···33import (
44 "context"
55 "fmt"
66+ "io"
67 "log/slog"
78 "net/http"
89···7576 defer resp.Body.Close()
76777778 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
7878- return fmt.Errorf("requestCrew failed with status %d", resp.StatusCode)
7979+ // Read response body to capture actual error message from hold
8080+ body, readErr := io.ReadAll(resp.Body)
8181+ if readErr != nil {
8282+ return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr)
8383+ }
8484+ return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body))
7985 }
80868187 return nil
+4-2
pkg/hold/pds/crew.go
···33import (
44 "bytes"
55 "context"
66+ "errors"
67 "fmt"
78 "strings"
89 "time"
···122123123124 if err != nil {
124125 // ErrDoneIterating is expected when we stop walking early
125125- if err == repo.ErrDoneIterating {
126126+ // Use errors.Is to handle wrapped errors (indigo wraps with %w in MST walk)
127127+ if errors.Is(err, repo.ErrDoneIterating) {
126128 // Successfully stopped at collection boundary
127127- } else if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") {
129129+ } else if strings.Contains(err.Error(), "not found") {
128130 // If the collection doesn't exist yet (empty repo or no records created),
129131 // return empty list instead of error
130132 return []*CrewMemberWithKey{}, nil
+195
pkg/hold/pds/xrpc_test.go
···12891289 t.Skip("Method validation is now handled by chi router, not individual handlers")
12901290}
1291129112921292+// TestHandleRequestCrew_WithAuth tests successful crew membership request with authenticated user
12931293+// This test exercises the complete flow including the ListCrewMembers path with empty crew list,
12941294+// which previously caused a 500 error due to improper handling of wrapped ErrDoneIterating
12951295+func TestHandleRequestCrew_WithAuth(t *testing.T) {
12961296+ handler, ctx := setupTestXRPCHandler(t)
12971297+12981298+ // Update captain record to allow all crew
12991299+ _, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) // public=true, allowAllCrew=true
13001300+ if err != nil {
13011301+ t.Fatalf("Failed to update captain record: %v", err)
13021302+ }
13031303+13041304+ // Remove the bootstrap-created owner crew member to get an empty crew list
13051305+ // (Bootstrap automatically adds the owner as a crew admin)
13061306+ crewBefore, err := handler.pds.ListCrewMembers(ctx)
13071307+ if err != nil {
13081308+ t.Fatalf("Failed to list crew members before test: %v", err)
13091309+ }
13101310+ for _, member := range crewBefore {
13111311+ if err := handler.pds.RemoveCrewMember(ctx, member.Rkey); err != nil {
13121312+ t.Fatalf("Failed to remove bootstrap crew member: %v", err)
13131313+ }
13141314+ }
13151315+13161316+ // Verify crew list is now empty (this is the critical condition that triggers the bug)
13171317+ crewAfterCleanup, err := handler.pds.ListCrewMembers(ctx)
13181318+ if err != nil {
13191319+ t.Fatalf("Failed to list crew members after cleanup: %v", err)
13201320+ }
13211321+ if len(crewAfterCleanup) != 0 {
13221322+ t.Fatalf("Expected empty crew list after cleanup, got %d members", len(crewAfterCleanup))
13231323+ }
13241324+13251325+ // Create authenticated user (injected into context to bypass auth middleware)
13261326+ testUserDID := "did:plc:newuser123"
13271327+ user := &ValidatedUser{
13281328+ DID: testUserDID,
13291329+ Handle: "newuser.test",
13301330+ PDS: "https://pds.test",
13311331+ Authorized: true,
13321332+ }
13331333+13341334+ // Create request with user in context
13351335+ body := map[string]any{
13361336+ "role": "member",
13371337+ "permissions": []string{"blob:read", "blob:write"},
13381338+ }
13391339+ bodyBytes, _ := json.Marshal(body)
13401340+ req := httptest.NewRequest(http.MethodPost, atproto.HoldRequestCrew, bytes.NewReader(bodyBytes))
13411341+ req.Header.Set("Content-Type", "application/json")
13421342+13431343+ // Inject user into request context
13441344+ reqCtx := context.WithValue(req.Context(), contextKeyUser, user)
13451345+ req = req.WithContext(reqCtx)
13461346+13471347+ w := httptest.NewRecorder()
13481348+13491349+ // Call handler - this should NOT return 500 even with empty crew list
13501350+ handler.HandleRequestCrew(w, req)
13511351+13521352+ // Verify successful response
13531353+ if w.Code != http.StatusCreated {
13541354+ t.Errorf("Expected status 201 Created, got %d", w.Code)
13551355+ t.Logf("Response body: %s", w.Body.String())
13561356+ }
13571357+13581358+ // Verify response structure
13591359+ var response map[string]any
13601360+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
13611361+ t.Fatalf("Failed to parse response JSON: %v", err)
13621362+ }
13631363+13641364+ // Check response fields
13651365+ if cid, ok := response["cid"].(string); !ok || cid == "" {
13661366+ t.Error("Expected cid string in response")
13671367+ }
13681368+13691369+ if status, ok := response["status"].(string); !ok || status != "created" {
13701370+ t.Errorf("Expected status='created', got %v", response["status"])
13711371+ }
13721372+13731373+ // Verify the crew member was actually added
13741374+ crewAfter, err := handler.pds.ListCrewMembers(ctx)
13751375+ if err != nil {
13761376+ t.Fatalf("Failed to list crew members after request: %v", err)
13771377+ }
13781378+13791379+ if len(crewAfter) != 1 {
13801380+ t.Fatalf("Expected 1 crew member after request, got %d", len(crewAfter))
13811381+ }
13821382+13831383+ // Verify it's the correct user
13841384+ if crewAfter[0].Record.Member != testUserDID {
13851385+ t.Errorf("Expected crew member DID %s, got %s", testUserDID, crewAfter[0].Record.Member)
13861386+ }
13871387+}
13881388+13891389+// TestHandleRequestCrew_AlreadyMember tests requesting crew membership when already a member
13901390+// Should return success without creating duplicate records
13911391+func TestHandleRequestCrew_AlreadyMember(t *testing.T) {
13921392+ handler, ctx := setupTestXRPCHandler(t)
13931393+13941394+ // Update captain record to allow all crew
13951395+ _, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false)
13961396+ if err != nil {
13971397+ t.Fatalf("Failed to update captain record: %v", err)
13981398+ }
13991399+14001400+ // Pre-add the user as a crew member
14011401+ testUserDID := "did:plc:existinguser123"
14021402+ _, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"})
14031403+ if err != nil {
14041404+ t.Fatalf("Failed to pre-add crew member: %v", err)
14051405+ }
14061406+14071407+ // Verify crew list has our test user (+ the bootstrap owner)
14081408+ crewBefore, err := handler.pds.ListCrewMembers(ctx)
14091409+ if err != nil {
14101410+ t.Fatalf("Failed to list crew members: %v", err)
14111411+ }
14121412+14131413+ // Find our test user in the crew list
14141414+ var foundTestUser bool
14151415+ var testUserCountBefore int
14161416+ for _, member := range crewBefore {
14171417+ if member.Record.Member == testUserDID {
14181418+ foundTestUser = true
14191419+ testUserCountBefore++
14201420+ }
14211421+ }
14221422+ if !foundTestUser {
14231423+ t.Fatalf("Expected to find test user %s in crew list", testUserDID)
14241424+ }
14251425+ if testUserCountBefore != 1 {
14261426+ t.Fatalf("Expected test user to appear once in crew list, got %d times", testUserCountBefore)
14271427+ }
14281428+14291429+ // Create authenticated user (same DID as pre-added member)
14301430+ user := &ValidatedUser{
14311431+ DID: testUserDID,
14321432+ Handle: "existinguser.test",
14331433+ PDS: "https://pds.test",
14341434+ Authorized: true,
14351435+ }
14361436+14371437+ // Create request
14381438+ req := httptest.NewRequest(http.MethodPost, atproto.HoldRequestCrew, nil)
14391439+ reqCtx := context.WithValue(req.Context(), contextKeyUser, user)
14401440+ req = req.WithContext(reqCtx)
14411441+14421442+ w := httptest.NewRecorder()
14431443+14441444+ // Call handler - should return success with "already_member" status
14451445+ handler.HandleRequestCrew(w, req)
14461446+14471447+ // Verify successful response (200 OK for already member)
14481448+ if w.Code != http.StatusOK {
14491449+ t.Errorf("Expected status 200 OK, got %d", w.Code)
14501450+ t.Logf("Response body: %s", w.Body.String())
14511451+ }
14521452+14531453+ // Verify response indicates already a member
14541454+ var response map[string]any
14551455+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
14561456+ t.Fatalf("Failed to parse response JSON: %v", err)
14571457+ }
14581458+14591459+ if status, ok := response["status"].(string); !ok || status != "already_member" {
14601460+ t.Errorf("Expected status='already_member', got %v", response["status"])
14611461+ }
14621462+14631463+ // Verify no duplicate was created
14641464+ crewAfter, err := handler.pds.ListCrewMembers(ctx)
14651465+ if err != nil {
14661466+ t.Fatalf("Failed to list crew members after request: %v", err)
14671467+ }
14681468+14691469+ // Count how many times our test user appears
14701470+ var testUserCountAfter int
14711471+ for _, member := range crewAfter {
14721472+ if member.Record.Member == testUserDID {
14731473+ testUserCountAfter++
14741474+ }
14751475+ }
14761476+14771477+ if testUserCountAfter != 1 {
14781478+ t.Errorf("Expected test user to appear exactly once (no duplicate), got %d times", testUserCountAfter)
14791479+ }
14801480+14811481+ // Verify crew list size didn't change (no duplicates added)
14821482+ if len(crewAfter) != len(crewBefore) {
14831483+ t.Errorf("Expected crew list size to stay the same (%d), got %d", len(crewBefore), len(crewAfter))
14841484+ }
14851485+}
14861486+12921487// Tests for DID document endpoints
1293148812941489// TestHandleDIDDocument tests /.well-known/did.json endpoint