A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix ListCrewMembers

evan.jarrett.net 2f27f226 2b0501a4

verified
Changed files
+206 -3
pkg
appview
storage
hold
+7 -1
pkg/appview/storage/crew.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "io" 6 7 "log/slog" 7 8 "net/http" 8 9 ··· 75 76 defer resp.Body.Close() 76 77 77 78 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 78 - return fmt.Errorf("requestCrew failed with status %d", resp.StatusCode) 79 + // Read response body to capture actual error message from hold 80 + body, readErr := io.ReadAll(resp.Body) 81 + if readErr != nil { 82 + return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr) 83 + } 84 + return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body)) 79 85 } 80 86 81 87 return nil
+4 -2
pkg/hold/pds/crew.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 6 7 "fmt" 7 8 "strings" 8 9 "time" ··· 122 123 123 124 if err != nil { 124 125 // ErrDoneIterating is expected when we stop walking early 125 - if err == repo.ErrDoneIterating { 126 + // Use errors.Is to handle wrapped errors (indigo wraps with %w in MST walk) 127 + if errors.Is(err, repo.ErrDoneIterating) { 126 128 // Successfully stopped at collection boundary 127 - } else if err.Error() == "mst: not found" || strings.Contains(err.Error(), "not found") { 129 + } else if strings.Contains(err.Error(), "not found") { 128 130 // If the collection doesn't exist yet (empty repo or no records created), 129 131 // return empty list instead of error 130 132 return []*CrewMemberWithKey{}, nil
+195
pkg/hold/pds/xrpc_test.go
··· 1289 1289 t.Skip("Method validation is now handled by chi router, not individual handlers") 1290 1290 } 1291 1291 1292 + // TestHandleRequestCrew_WithAuth tests successful crew membership request with authenticated user 1293 + // This test exercises the complete flow including the ListCrewMembers path with empty crew list, 1294 + // which previously caused a 500 error due to improper handling of wrapped ErrDoneIterating 1295 + func TestHandleRequestCrew_WithAuth(t *testing.T) { 1296 + handler, ctx := setupTestXRPCHandler(t) 1297 + 1298 + // Update captain record to allow all crew 1299 + _, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) // public=true, allowAllCrew=true 1300 + if err != nil { 1301 + t.Fatalf("Failed to update captain record: %v", err) 1302 + } 1303 + 1304 + // Remove the bootstrap-created owner crew member to get an empty crew list 1305 + // (Bootstrap automatically adds the owner as a crew admin) 1306 + crewBefore, err := handler.pds.ListCrewMembers(ctx) 1307 + if err != nil { 1308 + t.Fatalf("Failed to list crew members before test: %v", err) 1309 + } 1310 + for _, member := range crewBefore { 1311 + if err := handler.pds.RemoveCrewMember(ctx, member.Rkey); err != nil { 1312 + t.Fatalf("Failed to remove bootstrap crew member: %v", err) 1313 + } 1314 + } 1315 + 1316 + // Verify crew list is now empty (this is the critical condition that triggers the bug) 1317 + crewAfterCleanup, err := handler.pds.ListCrewMembers(ctx) 1318 + if err != nil { 1319 + t.Fatalf("Failed to list crew members after cleanup: %v", err) 1320 + } 1321 + if len(crewAfterCleanup) != 0 { 1322 + t.Fatalf("Expected empty crew list after cleanup, got %d members", len(crewAfterCleanup)) 1323 + } 1324 + 1325 + // Create authenticated user (injected into context to bypass auth middleware) 1326 + testUserDID := "did:plc:newuser123" 1327 + user := &ValidatedUser{ 1328 + DID: testUserDID, 1329 + Handle: "newuser.test", 1330 + PDS: "https://pds.test", 1331 + Authorized: true, 1332 + } 1333 + 1334 + // Create request with user in context 1335 + body := map[string]any{ 1336 + "role": "member", 1337 + "permissions": []string{"blob:read", "blob:write"}, 1338 + } 1339 + bodyBytes, _ := json.Marshal(body) 1340 + req := httptest.NewRequest(http.MethodPost, atproto.HoldRequestCrew, bytes.NewReader(bodyBytes)) 1341 + req.Header.Set("Content-Type", "application/json") 1342 + 1343 + // Inject user into request context 1344 + reqCtx := context.WithValue(req.Context(), contextKeyUser, user) 1345 + req = req.WithContext(reqCtx) 1346 + 1347 + w := httptest.NewRecorder() 1348 + 1349 + // Call handler - this should NOT return 500 even with empty crew list 1350 + handler.HandleRequestCrew(w, req) 1351 + 1352 + // Verify successful response 1353 + if w.Code != http.StatusCreated { 1354 + t.Errorf("Expected status 201 Created, got %d", w.Code) 1355 + t.Logf("Response body: %s", w.Body.String()) 1356 + } 1357 + 1358 + // Verify response structure 1359 + var response map[string]any 1360 + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { 1361 + t.Fatalf("Failed to parse response JSON: %v", err) 1362 + } 1363 + 1364 + // Check response fields 1365 + if cid, ok := response["cid"].(string); !ok || cid == "" { 1366 + t.Error("Expected cid string in response") 1367 + } 1368 + 1369 + if status, ok := response["status"].(string); !ok || status != "created" { 1370 + t.Errorf("Expected status='created', got %v", response["status"]) 1371 + } 1372 + 1373 + // Verify the crew member was actually added 1374 + crewAfter, err := handler.pds.ListCrewMembers(ctx) 1375 + if err != nil { 1376 + t.Fatalf("Failed to list crew members after request: %v", err) 1377 + } 1378 + 1379 + if len(crewAfter) != 1 { 1380 + t.Fatalf("Expected 1 crew member after request, got %d", len(crewAfter)) 1381 + } 1382 + 1383 + // Verify it's the correct user 1384 + if crewAfter[0].Record.Member != testUserDID { 1385 + t.Errorf("Expected crew member DID %s, got %s", testUserDID, crewAfter[0].Record.Member) 1386 + } 1387 + } 1388 + 1389 + // TestHandleRequestCrew_AlreadyMember tests requesting crew membership when already a member 1390 + // Should return success without creating duplicate records 1391 + func TestHandleRequestCrew_AlreadyMember(t *testing.T) { 1392 + handler, ctx := setupTestXRPCHandler(t) 1393 + 1394 + // Update captain record to allow all crew 1395 + _, err := handler.pds.UpdateCaptainRecord(ctx, true, true, false) 1396 + if err != nil { 1397 + t.Fatalf("Failed to update captain record: %v", err) 1398 + } 1399 + 1400 + // Pre-add the user as a crew member 1401 + testUserDID := "did:plc:existinguser123" 1402 + _, err = handler.pds.AddCrewMember(ctx, testUserDID, "member", []string{"blob:read", "blob:write"}) 1403 + if err != nil { 1404 + t.Fatalf("Failed to pre-add crew member: %v", err) 1405 + } 1406 + 1407 + // Verify crew list has our test user (+ the bootstrap owner) 1408 + crewBefore, err := handler.pds.ListCrewMembers(ctx) 1409 + if err != nil { 1410 + t.Fatalf("Failed to list crew members: %v", err) 1411 + } 1412 + 1413 + // Find our test user in the crew list 1414 + var foundTestUser bool 1415 + var testUserCountBefore int 1416 + for _, member := range crewBefore { 1417 + if member.Record.Member == testUserDID { 1418 + foundTestUser = true 1419 + testUserCountBefore++ 1420 + } 1421 + } 1422 + if !foundTestUser { 1423 + t.Fatalf("Expected to find test user %s in crew list", testUserDID) 1424 + } 1425 + if testUserCountBefore != 1 { 1426 + t.Fatalf("Expected test user to appear once in crew list, got %d times", testUserCountBefore) 1427 + } 1428 + 1429 + // Create authenticated user (same DID as pre-added member) 1430 + user := &ValidatedUser{ 1431 + DID: testUserDID, 1432 + Handle: "existinguser.test", 1433 + PDS: "https://pds.test", 1434 + Authorized: true, 1435 + } 1436 + 1437 + // Create request 1438 + req := httptest.NewRequest(http.MethodPost, atproto.HoldRequestCrew, nil) 1439 + reqCtx := context.WithValue(req.Context(), contextKeyUser, user) 1440 + req = req.WithContext(reqCtx) 1441 + 1442 + w := httptest.NewRecorder() 1443 + 1444 + // Call handler - should return success with "already_member" status 1445 + handler.HandleRequestCrew(w, req) 1446 + 1447 + // Verify successful response (200 OK for already member) 1448 + if w.Code != http.StatusOK { 1449 + t.Errorf("Expected status 200 OK, got %d", w.Code) 1450 + t.Logf("Response body: %s", w.Body.String()) 1451 + } 1452 + 1453 + // Verify response indicates already a member 1454 + var response map[string]any 1455 + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { 1456 + t.Fatalf("Failed to parse response JSON: %v", err) 1457 + } 1458 + 1459 + if status, ok := response["status"].(string); !ok || status != "already_member" { 1460 + t.Errorf("Expected status='already_member', got %v", response["status"]) 1461 + } 1462 + 1463 + // Verify no duplicate was created 1464 + crewAfter, err := handler.pds.ListCrewMembers(ctx) 1465 + if err != nil { 1466 + t.Fatalf("Failed to list crew members after request: %v", err) 1467 + } 1468 + 1469 + // Count how many times our test user appears 1470 + var testUserCountAfter int 1471 + for _, member := range crewAfter { 1472 + if member.Record.Member == testUserDID { 1473 + testUserCountAfter++ 1474 + } 1475 + } 1476 + 1477 + if testUserCountAfter != 1 { 1478 + t.Errorf("Expected test user to appear exactly once (no duplicate), got %d times", testUserCountAfter) 1479 + } 1480 + 1481 + // Verify crew list size didn't change (no duplicates added) 1482 + if len(crewAfter) != len(crewBefore) { 1483 + t.Errorf("Expected crew list size to stay the same (%d), got %d", len(crewBefore), len(crewAfter)) 1484 + } 1485 + } 1486 + 1292 1487 // Tests for DID document endpoints 1293 1488 1294 1489 // TestHandleDIDDocument tests /.well-known/did.json endpoint