mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-29 05:25:35 +00:00
Webhook delivery was neither idempotent nor order-safe, and every failure
returned 400, which Stripe does not retry. A transient DB or hold error
therefore dropped a subscription change silently and permanently.
- New stripe_processed_events table: event_id as primary key dedups
redelivery, and event_created per customer drops stale out-of-order
deliveries.
- HandleWebhook distinguishes ErrWebhookSignature (400, no retry) from
ErrWebhookProcessing (500, Stripe redelivers). The event handlers
return errors instead of swallowing them. ErrBillingDisabled maps to
400: the route is mounted but billing is off, so redelivery can never
succeed and Stripe should stop rather than retry to exhaustion.
- Refuse to boot when billing is enabled with an empty
STRIPE_WEBHOOK_SECRET. Stripe HMACs with the empty key, so an
attacker can reproduce the signature and the endpoint is forgeable.
- UpdateCrewTierOnAllHolds retries each hold (3 attempts, linear
backoff, 5s per request) and returns a joined error so the webhook
can fail and let Stripe redeliver.
The fan-out contacts holds concurrently rather than in sequence. Serially,
one unreachable hold burns the caller's entire 10s budget on its own
retries (3 x 5s plus backoff) and the holds after it are never contacted;
because Stripe redelivers in the same order, a persistently-down first
hold means the rest are never updated at all.
On the hold, the signature-validated sub claim is now the source of truth
for updateCrewTier: a mismatched body userDid is rejected with 403 rather
than retargeting the grant to another DID. "Not crew on this hold" is a
200 no-op, since the appview fans updates out to every managed hold and a
subscriber is not crew everywhere.
That no-op has to be told apart from a storage failure. GetCrewMember
collapsed both into one generic error, so a CAR-store failure read as
"not a member", answered 200, and let the appview record the event as
processed — losing the tier grant permanently, which is exactly the
failure mode this commit exists to prevent. Missing records now carry an
ErrCrewMemberNotFound sentinel, and anything else returns 500 so Stripe
redelivers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
595 lines
15 KiB
Go
595 lines
15 KiB
Go
package pds
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
)
|
|
|
|
// TestAddCrewMember tests adding a single crew member
|
|
func TestAddCrewMember(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew member
|
|
memberDID := "did:plc:alice123"
|
|
role := "writer"
|
|
permissions := []string{"blob:read", "blob:write"}
|
|
|
|
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed: %v", err)
|
|
}
|
|
|
|
// Verify CID is defined
|
|
if !recordCID.Defined() {
|
|
t.Error("Expected defined CID")
|
|
}
|
|
|
|
// List crew members to verify
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 1 {
|
|
t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
|
|
}
|
|
|
|
crew := crewMembers[0]
|
|
if crew.Record.Member != memberDID {
|
|
t.Errorf("Expected member %s, got %s", memberDID, crew.Record.Member)
|
|
}
|
|
if crew.Record.Role != role {
|
|
t.Errorf("Expected role %s, got %s", role, crew.Record.Role)
|
|
}
|
|
if len(crew.Record.Permissions) != len(permissions) {
|
|
t.Fatalf("Expected %d permissions, got %d", len(permissions), len(crew.Record.Permissions))
|
|
}
|
|
for i, perm := range permissions {
|
|
if crew.Record.Permissions[i] != perm {
|
|
t.Errorf("Expected permission[%d]=%s, got %s", i, perm, crew.Record.Permissions[i])
|
|
}
|
|
}
|
|
if crew.Record.Type != atproto.CrewCollection {
|
|
t.Errorf("Expected type %s, got %s", atproto.CrewCollection, crew.Record.Type)
|
|
}
|
|
if crew.Record.AddedAt == "" {
|
|
t.Error("Expected addedAt to be set")
|
|
}
|
|
}
|
|
|
|
// TestGetCrewMember tests retrieving a crew member by rkey
|
|
func TestGetCrewMember(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew member
|
|
memberDID := "did:plc:bob456"
|
|
role := "reader"
|
|
permissions := []string{"blob:read"}
|
|
|
|
_, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed: %v", err)
|
|
}
|
|
|
|
// List to get the rkey
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) == 0 {
|
|
t.Fatal("Expected at least one crew member")
|
|
}
|
|
|
|
rkey := crewMembers[0].Rkey
|
|
|
|
// Get crew member by rkey
|
|
retrievedCID, crew, err := pds.GetCrewMember(ctx, rkey)
|
|
if err != nil {
|
|
t.Fatalf("GetCrewMember failed: %v", err)
|
|
}
|
|
|
|
// Verify CID matches
|
|
if !crewMembers[0].Cid.Equals(retrievedCID) {
|
|
t.Error("Expected retrieved CID to match")
|
|
}
|
|
|
|
// Verify crew data
|
|
if crew.Member != memberDID {
|
|
t.Errorf("Expected member %s, got %s", memberDID, crew.Member)
|
|
}
|
|
if crew.Role != role {
|
|
t.Errorf("Expected role %s, got %s", role, crew.Role)
|
|
}
|
|
if len(crew.Permissions) != len(permissions) {
|
|
t.Fatalf("Expected %d permissions, got %d", len(permissions), len(crew.Permissions))
|
|
}
|
|
}
|
|
|
|
// TestGetCrewMember_NotFound tests error handling for missing crew
|
|
func TestGetCrewMember_NotFound(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Try to get non-existent crew member
|
|
_, _, err := pds.GetCrewMember(ctx, "nonexistent-rkey")
|
|
if err == nil {
|
|
t.Fatal("Expected error when getting non-existent crew member")
|
|
}
|
|
|
|
// A missing record must be distinguishable from a storage failure. Callers
|
|
// that treat "not crew" as a benign no-op (updateCrewTier) would otherwise
|
|
// swallow a CAR-store error as a non-member and silently drop a tier grant.
|
|
if !errors.Is(err, ErrCrewMemberNotFound) {
|
|
t.Errorf("Expected ErrCrewMemberNotFound, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "nonexistent-rkey") {
|
|
t.Errorf("Expected the rkey in the error, got: %s", err)
|
|
}
|
|
}
|
|
|
|
// TestListCrewMembers_Empty tests listing when no crew exists
|
|
func TestListCrewMembers_Empty(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// List crew members (should be empty)
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 0 {
|
|
t.Errorf("Expected 0 crew members, got %d", len(crewMembers))
|
|
}
|
|
}
|
|
|
|
// TestListCrewMembers_Multiple tests listing with multiple crew members
|
|
func TestListCrewMembers_Multiple(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add multiple crew members
|
|
members := []struct {
|
|
did string
|
|
role string
|
|
permissions []string
|
|
}{
|
|
{
|
|
did: "did:plc:alice123",
|
|
role: "admin",
|
|
permissions: []string{"blob:read", "blob:write", "crew:admin"},
|
|
},
|
|
{
|
|
did: "did:plc:bob456",
|
|
role: "writer",
|
|
permissions: []string{"blob:read", "blob:write"},
|
|
},
|
|
{
|
|
did: "did:plc:charlie789",
|
|
role: "reader",
|
|
permissions: []string{"blob:read"},
|
|
},
|
|
}
|
|
|
|
for _, m := range members {
|
|
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
|
|
}
|
|
}
|
|
|
|
// List all crew members
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != len(members) {
|
|
t.Fatalf("Expected %d crew members, got %d", len(members), len(crewMembers))
|
|
}
|
|
|
|
// Verify each crew member (order may vary, so check by DID)
|
|
foundMembers := make(map[string]*CrewMemberWithKey)
|
|
for _, cm := range crewMembers {
|
|
foundMembers[cm.Record.Member] = cm
|
|
}
|
|
|
|
for _, m := range members {
|
|
crew, found := foundMembers[m.did]
|
|
if !found {
|
|
t.Errorf("Expected to find crew member %s", m.did)
|
|
continue
|
|
}
|
|
|
|
if crew.Record.Role != m.role {
|
|
t.Errorf("Expected role %s for %s, got %s", m.role, m.did, crew.Record.Role)
|
|
}
|
|
|
|
if len(crew.Record.Permissions) != len(m.permissions) {
|
|
t.Errorf("Expected %d permissions for %s, got %d", len(m.permissions), m.did, len(crew.Record.Permissions))
|
|
}
|
|
|
|
// Verify rkey is set
|
|
if crew.Rkey == "" {
|
|
t.Errorf("Expected non-empty rkey for %s", m.did)
|
|
}
|
|
|
|
// Verify CID is defined
|
|
if !crew.Cid.Defined() {
|
|
t.Errorf("Expected defined CID for %s", m.did)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRemoveCrewMember tests deleting a crew member
|
|
func TestRemoveCrewMember(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew member
|
|
memberDID := "did:plc:alice123"
|
|
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read", "blob:write"}, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed: %v", err)
|
|
}
|
|
|
|
// List to get the rkey
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 1 {
|
|
t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
|
|
}
|
|
|
|
rkey := crewMembers[0].Rkey
|
|
|
|
// Remove crew member
|
|
err = pds.RemoveCrewMember(ctx, rkey)
|
|
if err != nil {
|
|
t.Fatalf("RemoveCrewMember failed: %v", err)
|
|
}
|
|
|
|
// Verify crew member is gone
|
|
crewMembers, err = pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed after removal: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 0 {
|
|
t.Errorf("Expected 0 crew members after removal, got %d", len(crewMembers))
|
|
}
|
|
|
|
// Try to get removed crew member (should fail)
|
|
_, _, err = pds.GetCrewMember(ctx, rkey)
|
|
if err == nil {
|
|
t.Error("Expected error when getting removed crew member")
|
|
}
|
|
}
|
|
|
|
// TestRemoveCrewMember_NotFound tests removing non-existent crew member
|
|
func TestRemoveCrewMember_NotFound(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Try to remove non-existent crew member
|
|
err := pds.RemoveCrewMember(ctx, "nonexistent-rkey")
|
|
if err == nil {
|
|
t.Fatal("Expected error when removing non-existent crew member")
|
|
}
|
|
|
|
// Verify error message
|
|
errMsg := err.Error()
|
|
if !strings.Contains(errMsg, "failed to delete crew record") {
|
|
t.Errorf("Expected 'failed to delete crew record' in error, got: %s", errMsg)
|
|
}
|
|
}
|
|
|
|
// TestRemoveCrewMember_Multiple tests removing one crew member from many
|
|
func TestRemoveCrewMember_Multiple(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add multiple crew members
|
|
dids := []string{
|
|
"did:plc:alice123",
|
|
"did:plc:bob456",
|
|
"did:plc:charlie789",
|
|
}
|
|
|
|
for _, did := range dids {
|
|
_, err := pds.AddCrewMember(ctx, did, "writer", []string{"blob:read"}, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed for %s: %v", did, err)
|
|
}
|
|
}
|
|
|
|
// List crew members
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 3 {
|
|
t.Fatalf("Expected 3 crew members, got %d", len(crewMembers))
|
|
}
|
|
|
|
// Remove middle member
|
|
middleRkey := crewMembers[1].Rkey
|
|
middleDID := crewMembers[1].Record.Member
|
|
|
|
err = pds.RemoveCrewMember(ctx, middleRkey)
|
|
if err != nil {
|
|
t.Fatalf("RemoveCrewMember failed: %v", err)
|
|
}
|
|
|
|
// Verify only 2 remain
|
|
crewMembers, err = pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed after removal: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 2 {
|
|
t.Fatalf("Expected 2 crew members after removal, got %d", len(crewMembers))
|
|
}
|
|
|
|
// Verify removed member is not in list
|
|
for _, cm := range crewMembers {
|
|
if cm.Record.Member == middleDID {
|
|
t.Errorf("Expected %s to be removed, but still found in list", middleDID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCrewRecord_CBORRoundtrip tests CBOR marshal/unmarshal integrity
|
|
func TestCrewRecord_CBORRoundtrip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
record *atproto.CrewRecord
|
|
}{
|
|
{
|
|
name: "Basic crew member",
|
|
record: &atproto.CrewRecord{
|
|
Type: atproto.CrewCollection,
|
|
Member: "did:plc:alice123",
|
|
Role: "writer",
|
|
Permissions: []string{"blob:read", "blob:write"},
|
|
AddedAt: "2025-10-16T12:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "Admin crew member",
|
|
record: &atproto.CrewRecord{
|
|
Type: atproto.CrewCollection,
|
|
Member: "did:plc:bob456",
|
|
Role: "admin",
|
|
Permissions: []string{"blob:read", "blob:write", "crew:admin"},
|
|
AddedAt: "2025-10-16T13:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "Reader crew member",
|
|
record: &atproto.CrewRecord{
|
|
Type: atproto.CrewCollection,
|
|
Member: "did:plc:charlie789",
|
|
Role: "reader",
|
|
Permissions: []string{"blob:read"},
|
|
AddedAt: "2025-10-16T14:00:00Z",
|
|
},
|
|
},
|
|
{
|
|
name: "Crew member with empty permissions",
|
|
record: &atproto.CrewRecord{
|
|
Type: atproto.CrewCollection,
|
|
Member: "did:plc:dave012",
|
|
Role: "none",
|
|
Permissions: []string{},
|
|
AddedAt: "2025-10-16T15:00:00Z",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Marshal to CBOR
|
|
var buf bytes.Buffer
|
|
err := tt.record.MarshalCBOR(&buf)
|
|
if err != nil {
|
|
t.Fatalf("MarshalCBOR failed: %v", err)
|
|
}
|
|
|
|
cborBytes := buf.Bytes()
|
|
if len(cborBytes) == 0 {
|
|
t.Fatal("Expected non-empty CBOR bytes")
|
|
}
|
|
|
|
// Unmarshal from CBOR
|
|
var decoded atproto.CrewRecord
|
|
err = decoded.UnmarshalCBOR(bytes.NewReader(cborBytes))
|
|
if err != nil {
|
|
t.Fatalf("UnmarshalCBOR failed: %v", err)
|
|
}
|
|
|
|
// Verify all fields match
|
|
if decoded.Type != tt.record.Type {
|
|
t.Errorf("Type mismatch: expected %s, got %s", tt.record.Type, decoded.Type)
|
|
}
|
|
if decoded.Member != tt.record.Member {
|
|
t.Errorf("Member mismatch: expected %s, got %s", tt.record.Member, decoded.Member)
|
|
}
|
|
if decoded.Role != tt.record.Role {
|
|
t.Errorf("Role mismatch: expected %s, got %s", tt.record.Role, decoded.Role)
|
|
}
|
|
if decoded.AddedAt != tt.record.AddedAt {
|
|
t.Errorf("AddedAt mismatch: expected %s, got %s", tt.record.AddedAt, decoded.AddedAt)
|
|
}
|
|
|
|
// Verify permissions
|
|
if len(decoded.Permissions) != len(tt.record.Permissions) {
|
|
t.Fatalf("Permissions length mismatch: expected %d, got %d", len(tt.record.Permissions), len(decoded.Permissions))
|
|
}
|
|
for i, perm := range tt.record.Permissions {
|
|
if decoded.Permissions[i] != perm {
|
|
t.Errorf("Permission[%d] mismatch: expected %s, got %s", i, perm, decoded.Permissions[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCrewMemberWithKey_Structure tests the CrewMemberWithKey struct
|
|
func TestCrewMemberWithKey_Structure(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew member
|
|
memberDID := "did:plc:alice123"
|
|
_, err := pds.AddCrewMember(ctx, memberDID, "writer", []string{"blob:read"}, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed: %v", err)
|
|
}
|
|
|
|
// List crew members
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 1 {
|
|
t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
|
|
}
|
|
|
|
cm := crewMembers[0]
|
|
|
|
// Verify CrewMemberWithKey structure
|
|
if cm.Rkey == "" {
|
|
t.Error("Expected non-empty Rkey")
|
|
}
|
|
if !cm.Cid.Defined() {
|
|
t.Error("Expected defined Cid")
|
|
}
|
|
if cm.Record == nil {
|
|
t.Fatal("Expected non-nil Record")
|
|
}
|
|
if cm.Record.Member != memberDID {
|
|
t.Errorf("Expected member %s, got %s", memberDID, cm.Record.Member)
|
|
}
|
|
}
|
|
|
|
// TestAddCrewMember_DidWeb tests adding crew members with did:web DIDs
|
|
func TestAddCrewMember_DidWeb(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew member with did:web
|
|
memberDID := "did:web:alice.example.com"
|
|
role := "writer"
|
|
permissions := []string{"blob:read", "blob:write"}
|
|
|
|
recordCID, err := pds.AddCrewMember(ctx, memberDID, role, permissions, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed with did:web: %v", err)
|
|
}
|
|
|
|
if !recordCID.Defined() {
|
|
t.Error("Expected defined CID")
|
|
}
|
|
|
|
// Verify crew member was added
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != 1 {
|
|
t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
|
|
}
|
|
|
|
crew := crewMembers[0]
|
|
if crew.Record.Member != memberDID {
|
|
t.Errorf("Expected member %s, got %s", memberDID, crew.Record.Member)
|
|
}
|
|
|
|
// Verify we can get it by rkey
|
|
_, retrievedCrew, err := pds.GetCrewMember(ctx, crew.Rkey)
|
|
if err != nil {
|
|
t.Fatalf("GetCrewMember failed for did:web: %v", err)
|
|
}
|
|
|
|
if retrievedCrew.Member != memberDID {
|
|
t.Errorf("Expected member %s, got %s", memberDID, retrievedCrew.Member)
|
|
}
|
|
}
|
|
|
|
// TestListCrewMembers_MixedDIDs tests listing crew members with mixed DID types
|
|
func TestListCrewMembers_MixedDIDs(t *testing.T) {
|
|
pds, ctx := setupTestPDS(t)
|
|
defer pds.Close()
|
|
|
|
// Add crew members with different DID types
|
|
members := []struct {
|
|
did string
|
|
role string
|
|
permissions []string
|
|
}{
|
|
{
|
|
did: "did:plc:alice123",
|
|
role: "admin",
|
|
permissions: []string{"blob:read", "blob:write", "crew:admin"},
|
|
},
|
|
{
|
|
did: "did:web:bob.example.com",
|
|
role: "writer",
|
|
permissions: []string{"blob:read", "blob:write"},
|
|
},
|
|
{
|
|
did: "did:web:charlie.example.org",
|
|
role: "reader",
|
|
permissions: []string{"blob:read"},
|
|
},
|
|
}
|
|
|
|
for _, m := range members {
|
|
_, err := pds.AddCrewMember(ctx, m.did, m.role, m.permissions, "")
|
|
if err != nil {
|
|
t.Fatalf("AddCrewMember failed for %s: %v", m.did, err)
|
|
}
|
|
}
|
|
|
|
// List all crew members
|
|
crewMembers, err := pds.ListCrewMembers(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListCrewMembers failed: %v", err)
|
|
}
|
|
|
|
if len(crewMembers) != len(members) {
|
|
t.Fatalf("Expected %d crew members, got %d", len(members), len(crewMembers))
|
|
}
|
|
|
|
// Verify each crew member exists (order may vary)
|
|
foundMembers := make(map[string]*CrewMemberWithKey)
|
|
for _, cm := range crewMembers {
|
|
foundMembers[cm.Record.Member] = cm
|
|
}
|
|
|
|
for _, m := range members {
|
|
crew, found := foundMembers[m.did]
|
|
if !found {
|
|
t.Errorf("Expected to find crew member %s", m.did)
|
|
continue
|
|
}
|
|
|
|
if crew.Record.Role != m.role {
|
|
t.Errorf("Expected role %s for %s, got %s", m.role, m.did, crew.Record.Role)
|
|
}
|
|
}
|
|
}
|