+7
-1
pkg/appview/storage/crew.go
+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
+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
+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