A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package pds
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "atcr.io/pkg/atproto"
11)
12
13// TestNewHoldPDS_NewRepo tests creating a new hold PDS with fresh database
14func TestNewHoldPDS_NewRepo(t *testing.T) {
15 ctx := context.Background()
16 tmpDir := t.TempDir()
17
18 // Paths for database and key
19 dbPath := filepath.Join(tmpDir, "pds.db")
20 keyPath := filepath.Join(tmpDir, "signing-key")
21
22 // Create new hold PDS
23 did := "did:web:hold.example.com"
24 publicURL := "https://hold.example.com"
25
26 pds, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
27 if err != nil {
28 t.Fatalf("NewHoldPDS failed: %v", err)
29 }
30 defer pds.Close()
31
32 // Verify DID was set
33 if pds.DID() != did {
34 t.Errorf("Expected DID %s, got %s", did, pds.DID())
35 }
36
37 // Verify signing key was created
38 if pds.SigningKey() == nil {
39 t.Error("Expected signing key to be created")
40 }
41
42 // Verify key file exists
43 if _, err := os.Stat(keyPath); os.IsNotExist(err) {
44 t.Error("Expected signing key file to be created")
45 }
46
47 // Verify database file exists (SQLite creates db.sqlite3 inside the directory)
48 dbFile := filepath.Join(dbPath, "db.sqlite3")
49 if _, err := os.Stat(dbFile); os.IsNotExist(err) {
50 t.Errorf("Expected database file to be created at %s", dbFile)
51 }
52}
53
54// TestNewHoldPDS_ExistingRepo tests opening an existing hold PDS database
55func TestNewHoldPDS_ExistingRepo(t *testing.T) {
56 ctx := context.Background()
57 tmpDir := t.TempDir()
58
59 dbPath := filepath.Join(tmpDir, "pds.db")
60 keyPath := filepath.Join(tmpDir, "signing-key")
61 did := "did:web:hold.example.com"
62 publicURL := "https://hold.example.com"
63
64 // Create first PDS instance and bootstrap it
65 pds1, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
66 if err != nil {
67 t.Fatalf("First NewHoldPDS failed: %v", err)
68 }
69
70 // Bootstrap with a captain record
71 ownerDID := "did:plc:owner123"
72 if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
73 t.Fatalf("Bootstrap failed: %v", err)
74 }
75
76 // Verify captain record exists
77 _, captain1, err := pds1.GetCaptainRecord(ctx)
78 if err != nil {
79 t.Fatalf("GetCaptainRecord failed after bootstrap: %v", err)
80 }
81 if captain1.Owner != ownerDID {
82 t.Errorf("Expected owner %s, got %s", ownerDID, captain1.Owner)
83 }
84
85 // Close first instance
86 pds1.Close()
87
88 // Re-open the same database
89 pds2, err := NewHoldPDS(ctx, did, publicURL, dbPath, keyPath, false)
90 if err != nil {
91 t.Fatalf("Second NewHoldPDS failed: %v", err)
92 }
93 defer pds2.Close()
94
95 // Verify captain record still exists
96 _, captain2, err := pds2.GetCaptainRecord(ctx)
97 if err != nil {
98 t.Fatalf("GetCaptainRecord failed after reopening: %v", err)
99 }
100
101 // Verify captain data persisted
102 if captain2.Owner != ownerDID {
103 t.Errorf("Expected owner %s after reopen, got %s", ownerDID, captain2.Owner)
104 }
105 if !captain2.Public {
106 t.Error("Expected captain.Public to be true")
107 }
108 if captain2.AllowAllCrew {
109 t.Error("Expected captain.AllowAllCrew to be false")
110 }
111}
112
113// TestBootstrap_NewRepo tests bootstrap on a new repository
114func TestBootstrap_NewRepo(t *testing.T) {
115 ctx := context.Background()
116 tmpDir := t.TempDir()
117
118 dbPath := filepath.Join(tmpDir, "pds.db")
119 keyPath := filepath.Join(tmpDir, "signing-key")
120
121 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
122 if err != nil {
123 t.Fatalf("NewHoldPDS failed: %v", err)
124 }
125 defer pds.Close()
126
127 // Bootstrap with owner
128 ownerDID := "did:plc:alice123"
129 publicAccess := true
130 allowAllCrew := false
131
132 err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
133 if err != nil {
134 t.Fatalf("Bootstrap failed: %v", err)
135 }
136
137 // Verify captain record was created
138 _, captain, err := pds.GetCaptainRecord(ctx)
139 if err != nil {
140 t.Fatalf("GetCaptainRecord failed: %v", err)
141 }
142
143 // Verify captain fields
144 if captain.Owner != ownerDID {
145 t.Errorf("Expected owner %s, got %s", ownerDID, captain.Owner)
146 }
147 if captain.Public != publicAccess {
148 t.Errorf("Expected public=%v, got %v", publicAccess, captain.Public)
149 }
150 if captain.AllowAllCrew != allowAllCrew {
151 t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew)
152 }
153 if captain.Type != atproto.CaptainCollection {
154 t.Errorf("Expected type %s, got %s", atproto.CaptainCollection, captain.Type)
155 }
156 if captain.DeployedAt == "" {
157 t.Error("Expected deployedAt to be set")
158 }
159
160 // Verify owner was added as crew member
161 crewMembers, err := pds.ListCrewMembers(ctx)
162 if err != nil {
163 t.Fatalf("ListCrewMembers failed: %v", err)
164 }
165
166 if len(crewMembers) != 1 {
167 t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
168 }
169
170 crew := crewMembers[0]
171 if crew.Record.Member != ownerDID {
172 t.Errorf("Expected crew member %s, got %s", ownerDID, crew.Record.Member)
173 }
174 if crew.Record.Role != "admin" {
175 t.Errorf("Expected role admin, got %s", crew.Record.Role)
176 }
177
178 // Verify permissions
179 expectedPerms := []string{"blob:read", "blob:write", "crew:admin"}
180 if len(crew.Record.Permissions) != len(expectedPerms) {
181 t.Fatalf("Expected %d permissions, got %d", len(expectedPerms), len(crew.Record.Permissions))
182 }
183 for i, perm := range expectedPerms {
184 if crew.Record.Permissions[i] != perm {
185 t.Errorf("Expected permission[%d]=%s, got %s", i, perm, crew.Record.Permissions[i])
186 }
187 }
188}
189
190// TestBootstrap_Idempotent tests that bootstrap is idempotent
191func TestBootstrap_Idempotent(t *testing.T) {
192 ctx := context.Background()
193 tmpDir := t.TempDir()
194
195 dbPath := filepath.Join(tmpDir, "pds.db")
196 keyPath := filepath.Join(tmpDir, "signing-key")
197
198 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
199 if err != nil {
200 t.Fatalf("NewHoldPDS failed: %v", err)
201 }
202 defer pds.Close()
203
204 ownerDID := "did:plc:alice123"
205
206 // First bootstrap
207 err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
208 if err != nil {
209 t.Fatalf("First bootstrap failed: %v", err)
210 }
211
212 // Get captain CID after first bootstrap
213 cid1, captain1, err := pds.GetCaptainRecord(ctx)
214 if err != nil {
215 t.Fatalf("GetCaptainRecord failed: %v", err)
216 }
217
218 // Get crew count after first bootstrap
219 crew1, err := pds.ListCrewMembers(ctx)
220 if err != nil {
221 t.Fatalf("ListCrewMembers failed: %v", err)
222 }
223 crewCount1 := len(crew1)
224
225 // Second bootstrap (should be idempotent - skip creation)
226 err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
227 if err != nil {
228 t.Fatalf("Second bootstrap failed: %v", err)
229 }
230
231 // Verify captain record unchanged
232 cid2, captain2, err := pds.GetCaptainRecord(ctx)
233 if err != nil {
234 t.Fatalf("GetCaptainRecord failed after second bootstrap: %v", err)
235 }
236
237 if !cid1.Equals(cid2) {
238 t.Error("Expected captain CID to remain unchanged after second bootstrap")
239 }
240 if captain1.Owner != captain2.Owner {
241 t.Error("Expected captain owner to remain unchanged")
242 }
243
244 // Verify crew count unchanged (owner not added twice)
245 crew2, err := pds.ListCrewMembers(ctx)
246 if err != nil {
247 t.Fatalf("ListCrewMembers failed after second bootstrap: %v", err)
248 }
249 crewCount2 := len(crew2)
250
251 if crewCount1 != crewCount2 {
252 t.Errorf("Expected crew count to remain %d, got %d (owner may have been added twice)", crewCount1, crewCount2)
253 }
254}
255
256// TestBootstrap_EmptyOwner tests that bootstrap with empty owner is a no-op
257func TestBootstrap_EmptyOwner(t *testing.T) {
258 ctx := context.Background()
259 tmpDir := t.TempDir()
260
261 dbPath := filepath.Join(tmpDir, "pds.db")
262 keyPath := filepath.Join(tmpDir, "signing-key")
263
264 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
265 if err != nil {
266 t.Fatalf("NewHoldPDS failed: %v", err)
267 }
268 defer pds.Close()
269
270 // Bootstrap with empty owner DID (should be no-op)
271 err = pds.Bootstrap(ctx, nil, "", true, false, "")
272 if err != nil {
273 t.Fatalf("Bootstrap with empty owner should not error: %v", err)
274 }
275
276 // Verify captain record was NOT created
277 _, _, err = pds.GetCaptainRecord(ctx)
278 if err == nil {
279 t.Error("Expected GetCaptainRecord to fail (no captain record), but it succeeded")
280 }
281 // Verify it's a "not found" type error
282 if err != nil && !strings.Contains(err.Error(), "not found") && !strings.Contains(err.Error(), "failed to get captain record") {
283 t.Errorf("Expected 'not found' error, got: %v", err)
284 }
285}
286
287// TestLexiconTypeRegistration tests that captain and crew types are registered
288func TestLexiconTypeRegistration(t *testing.T) {
289 // The init() function in server.go registers types
290 // We can verify this by creating a PDS and doing a round-trip write/read
291 ctx := context.Background()
292 tmpDir := t.TempDir()
293
294 dbPath := filepath.Join(tmpDir, "pds.db")
295 keyPath := filepath.Join(tmpDir, "signing-key")
296
297 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
298 if err != nil {
299 t.Fatalf("NewHoldPDS failed: %v", err)
300 }
301 defer pds.Close()
302
303 // Bootstrap to create captain record
304 ownerDID := "did:plc:alice123"
305 if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
306 t.Fatalf("Bootstrap failed: %v", err)
307 }
308
309 // GetCaptainRecord uses type assertion to *atproto.CaptainRecord
310 // If the type wasn't registered, this would fail with type assertion error
311 _, captain, err := pds.GetCaptainRecord(ctx)
312 if err != nil {
313 t.Fatalf("GetCaptainRecord failed: %v", err)
314 }
315
316 // Verify we got the correct concrete type
317 if captain == nil {
318 t.Fatal("Expected non-nil captain record")
319 }
320 if captain.Type != atproto.CaptainCollection {
321 t.Errorf("Expected captain type %s, got %s", atproto.CaptainCollection, captain.Type)
322 }
323
324 // Do the same for crew record
325 crewMembers, err := pds.ListCrewMembers(ctx)
326 if err != nil {
327 t.Fatalf("ListCrewMembers failed: %v", err)
328 }
329 if len(crewMembers) == 0 {
330 t.Fatal("Expected at least one crew member")
331 }
332
333 crew := crewMembers[0].Record
334 if crew.Type != atproto.CrewCollection {
335 t.Errorf("Expected crew type %s, got %s", atproto.CrewCollection, crew.Type)
336 }
337}
338
339// TestBootstrap_DidWebOwner tests bootstrap with did:web owner
340func TestBootstrap_DidWebOwner(t *testing.T) {
341 ctx := context.Background()
342 tmpDir := t.TempDir()
343
344 dbPath := filepath.Join(tmpDir, "pds.db")
345 keyPath := filepath.Join(tmpDir, "signing-key")
346
347 pds, err := NewHoldPDS(ctx, "did:web:hold01.atcr.io", "https://hold01.atcr.io", dbPath, keyPath, false)
348 if err != nil {
349 t.Fatalf("NewHoldPDS failed: %v", err)
350 }
351 defer pds.Close()
352
353 // Bootstrap with did:web owner (not did:plc)
354 ownerDID := "did:web:alice.example.com"
355 publicAccess := true
356 allowAllCrew := false
357
358 err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
359 if err != nil {
360 t.Fatalf("Bootstrap failed with did:web owner: %v", err)
361 }
362
363 // Verify captain record was created with did:web owner
364 _, captain, err := pds.GetCaptainRecord(ctx)
365 if err != nil {
366 t.Fatalf("GetCaptainRecord failed: %v", err)
367 }
368
369 // Verify captain fields
370 if captain.Owner != ownerDID {
371 t.Errorf("Expected owner %s, got %s", ownerDID, captain.Owner)
372 }
373 if captain.Public != publicAccess {
374 t.Errorf("Expected public=%v, got %v", publicAccess, captain.Public)
375 }
376 if captain.AllowAllCrew != allowAllCrew {
377 t.Errorf("Expected allowAllCrew=%v, got %v", allowAllCrew, captain.AllowAllCrew)
378 }
379
380 // Verify owner was added as crew member
381 crewMembers, err := pds.ListCrewMembers(ctx)
382 if err != nil {
383 t.Fatalf("ListCrewMembers failed: %v", err)
384 }
385
386 if len(crewMembers) != 1 {
387 t.Fatalf("Expected 1 crew member, got %d", len(crewMembers))
388 }
389
390 crew := crewMembers[0]
391 if crew.Record.Member != ownerDID {
392 t.Errorf("Expected crew member %s, got %s", ownerDID, crew.Record.Member)
393 }
394 if crew.Record.Role != "admin" {
395 t.Errorf("Expected role admin, got %s", crew.Record.Role)
396 }
397}
398
399// TestBootstrap_MixedDIDs tests bootstrap with mixed DID types
400func TestBootstrap_MixedDIDs(t *testing.T) {
401 ctx := context.Background()
402 tmpDir := t.TempDir()
403
404 dbPath := filepath.Join(tmpDir, "pds.db")
405 keyPath := filepath.Join(tmpDir, "signing-key")
406
407 // Create hold with did:web
408 holdDID := "did:web:hold.example.com"
409 pds, err := NewHoldPDS(ctx, holdDID, "https://hold.example.com", dbPath, keyPath, false)
410 if err != nil {
411 t.Fatalf("NewHoldPDS failed: %v", err)
412 }
413 defer pds.Close()
414
415 // Bootstrap with did:plc owner
416 plcOwner := "did:plc:alice123"
417 err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "")
418 if err != nil {
419 t.Fatalf("Bootstrap failed: %v", err)
420 }
421
422 // Add did:web crew member
423 webMember := "did:web:bob.example.com"
424 _, err = pds.AddCrewMember(ctx, webMember, "writer", []string{"blob:read", "blob:write"})
425 if err != nil {
426 t.Fatalf("AddCrewMember failed with did:web: %v", err)
427 }
428
429 // Verify captain
430 _, captain, err := pds.GetCaptainRecord(ctx)
431 if err != nil {
432 t.Fatalf("GetCaptainRecord failed: %v", err)
433 }
434 if captain.Owner != plcOwner {
435 t.Errorf("Expected captain owner %s, got %s", plcOwner, captain.Owner)
436 }
437
438 // Verify crew members (should have both did:plc and did:web)
439 crewMembers, err := pds.ListCrewMembers(ctx)
440 if err != nil {
441 t.Fatalf("ListCrewMembers failed: %v", err)
442 }
443
444 if len(crewMembers) != 2 {
445 t.Fatalf("Expected 2 crew members, got %d", len(crewMembers))
446 }
447
448 // Verify both DIDs are present
449 foundPLC := false
450 foundWeb := false
451 for _, cm := range crewMembers {
452 if cm.Record.Member == plcOwner {
453 foundPLC = true
454 }
455 if cm.Record.Member == webMember {
456 foundWeb = true
457 }
458 }
459
460 if !foundPLC {
461 t.Errorf("Expected to find did:plc member %s", plcOwner)
462 }
463 if !foundWeb {
464 t.Errorf("Expected to find did:web member %s", webMember)
465 }
466}
467
468// TestBootstrap_CrewWithoutCaptain tests bootstrap when crew exists but captain doesn't
469// This edge case could happen if repo state is corrupted or partially initialized
470func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
471 ctx := context.Background()
472 tmpDir := t.TempDir()
473
474 dbPath := filepath.Join(tmpDir, "pds.db")
475 keyPath := filepath.Join(tmpDir, "signing-key")
476
477 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
478 if err != nil {
479 t.Fatalf("NewHoldPDS failed: %v", err)
480 }
481 defer pds.Close()
482
483 // Initialize repo manually
484 err = pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
485 if err != nil {
486 t.Fatalf("InitNewActor failed: %v", err)
487 }
488
489 // Create crew member WITHOUT captain (unusual state)
490 ownerDID := "did:plc:alice123"
491 _, err = pds.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})
492 if err != nil {
493 t.Fatalf("AddCrewMember failed: %v", err)
494 }
495
496 // Verify crew exists
497 crewBefore, err := pds.ListCrewMembers(ctx)
498 if err != nil {
499 t.Fatalf("ListCrewMembers failed: %v", err)
500 }
501 if len(crewBefore) != 1 {
502 t.Fatalf("Expected 1 crew member before bootstrap, got %d", len(crewBefore))
503 }
504
505 // Verify captain doesn't exist
506 _, _, err = pds.GetCaptainRecord(ctx)
507 if err == nil {
508 t.Fatal("Expected captain record to not exist before bootstrap")
509 }
510
511 // Bootstrap should create captain record
512 err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
513 if err != nil {
514 t.Fatalf("Bootstrap failed: %v", err)
515 }
516
517 // Verify captain was created
518 _, captain, err := pds.GetCaptainRecord(ctx)
519 if err != nil {
520 t.Fatalf("GetCaptainRecord failed after bootstrap: %v", err)
521 }
522 if captain.Owner != ownerDID {
523 t.Errorf("Expected captain owner %s, got %s", ownerDID, captain.Owner)
524 }
525
526 // Verify crew wasn't duplicated (Bootstrap adds owner as crew, but they already exist)
527 crewAfter, err := pds.ListCrewMembers(ctx)
528 if err != nil {
529 t.Fatalf("ListCrewMembers failed after bootstrap: %v", err)
530 }
531
532 // Should have 2 crew members now: original + one added by bootstrap
533 // (Bootstrap doesn't check for duplicates currently)
534 if len(crewAfter) != 2 {
535 t.Logf("Note: Bootstrap added owner as crew even though they already existed")
536 t.Logf("Crew count after bootstrap: %d", len(crewAfter))
537 }
538}
539
540// TestBootstrap_CaptainWithoutCrew tests bootstrap when captain exists but owner crew doesn't
541// This verifies that bootstrap properly adds the owner as crew if missing
542func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
543 ctx := context.Background()
544 tmpDir := t.TempDir()
545
546 dbPath := filepath.Join(tmpDir, "pds.db")
547 keyPath := filepath.Join(tmpDir, "signing-key")
548
549 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
550 if err != nil {
551 t.Fatalf("NewHoldPDS failed: %v", err)
552 }
553 defer pds.Close()
554
555 // Initialize repo manually
556 err = pds.repomgr.InitNewActor(ctx, pds.uid, "", pds.did, "", "", "")
557 if err != nil {
558 t.Fatalf("InitNewActor failed: %v", err)
559 }
560
561 // Create captain record WITHOUT crew (unusual state)
562 ownerDID := "did:plc:alice123"
563 _, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
564 if err != nil {
565 t.Fatalf("CreateCaptainRecord failed: %v", err)
566 }
567
568 // Verify captain exists
569 _, captain, err := pds.GetCaptainRecord(ctx)
570 if err != nil {
571 t.Fatalf("GetCaptainRecord failed: %v", err)
572 }
573 if captain.Owner != ownerDID {
574 t.Errorf("Expected captain owner %s, got %s", ownerDID, captain.Owner)
575 }
576
577 // Verify crew is empty
578 crewBefore, err := pds.ListCrewMembers(ctx)
579 if err != nil {
580 t.Fatalf("ListCrewMembers failed: %v", err)
581 }
582 if len(crewBefore) != 0 {
583 t.Fatalf("Expected 0 crew members before bootstrap, got %d", len(crewBefore))
584 }
585
586 // Bootstrap should be idempotent but notice missing crew
587 // Currently Bootstrap skips if captain exists, so crew won't be added
588 err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
589 if err != nil {
590 t.Fatalf("Bootstrap failed: %v", err)
591 }
592
593 // Verify crew after bootstrap
594 crewAfter, err := pds.ListCrewMembers(ctx)
595 if err != nil {
596 t.Fatalf("ListCrewMembers failed after bootstrap: %v", err)
597 }
598
599 // Bootstrap currently skips everything if captain exists
600 // This means crew won't be added in this case
601 if len(crewAfter) == 0 {
602 t.Logf("Note: Bootstrap skipped adding owner as crew because captain already exists")
603 t.Logf("This is current behavior - Bootstrap is fully idempotent and skips if captain exists")
604 } else {
605 // If we change Bootstrap to be smarter, it might add crew
606 t.Logf("Bootstrap added %d crew members", len(crewAfter))
607
608 // Verify owner was added
609 foundOwner := false
610 for _, cm := range crewAfter {
611 if cm.Record.Member == ownerDID {
612 foundOwner = true
613 if cm.Record.Role != "admin" {
614 t.Errorf("Expected owner role admin, got %s", cm.Record.Role)
615 }
616 }
617 }
618 if !foundOwner {
619 t.Error("Expected owner to be added as crew member")
620 }
621 }
622}
623
624// Tests for RecordsIndex feature
625
626// TestHoldPDS_RecordsIndex_Nil tests that RecordsIndex is nil for :memory: database
627func TestHoldPDS_RecordsIndex_Nil(t *testing.T) {
628 ctx := context.Background()
629 tmpDir := t.TempDir()
630 keyPath := filepath.Join(tmpDir, "signing-key")
631
632 // Create with :memory: database
633 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
634 if err != nil {
635 t.Fatalf("NewHoldPDS failed: %v", err)
636 }
637 defer pds.Close()
638
639 // RecordsIndex should be nil for :memory:
640 if pds.RecordsIndex() != nil {
641 t.Error("Expected RecordsIndex() to be nil for :memory: database")
642 }
643}
644
645// TestHoldPDS_RecordsIndex_NonNil tests that RecordsIndex is created for file database
646func TestHoldPDS_RecordsIndex_NonNil(t *testing.T) {
647 ctx := context.Background()
648 tmpDir := t.TempDir()
649 dbPath := filepath.Join(tmpDir, "pds.db")
650 keyPath := filepath.Join(tmpDir, "signing-key")
651
652 // Create with file database
653 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
654 if err != nil {
655 t.Fatalf("NewHoldPDS failed: %v", err)
656 }
657 defer pds.Close()
658
659 // RecordsIndex should be non-nil for file database
660 if pds.RecordsIndex() == nil {
661 t.Error("Expected RecordsIndex() to be non-nil for file database")
662 }
663}
664
665// TestHoldPDS_Carstore tests the Carstore getter
666func TestHoldPDS_Carstore(t *testing.T) {
667 ctx := context.Background()
668 tmpDir := t.TempDir()
669 keyPath := filepath.Join(tmpDir, "signing-key")
670
671 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
672 if err != nil {
673 t.Fatalf("NewHoldPDS failed: %v", err)
674 }
675 defer pds.Close()
676
677 if pds.Carstore() == nil {
678 t.Error("Expected Carstore() to be non-nil")
679 }
680}
681
682// TestHoldPDS_UID tests the UID getter
683func TestHoldPDS_UID(t *testing.T) {
684 ctx := context.Background()
685 tmpDir := t.TempDir()
686 keyPath := filepath.Join(tmpDir, "signing-key")
687
688 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
689 if err != nil {
690 t.Fatalf("NewHoldPDS failed: %v", err)
691 }
692 defer pds.Close()
693
694 // UID should be 1 for single-user PDS
695 if pds.UID() != 1 {
696 t.Errorf("Expected UID() to be 1, got %d", pds.UID())
697 }
698}
699
700// TestHoldPDS_CreateRecordsIndexEventHandler tests event handler wrapper
701func TestHoldPDS_CreateRecordsIndexEventHandler(t *testing.T) {
702 ctx := context.Background()
703 tmpDir := t.TempDir()
704 dbPath := filepath.Join(tmpDir, "pds.db")
705 keyPath := filepath.Join(tmpDir, "signing-key")
706
707 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
708 if err != nil {
709 t.Fatalf("NewHoldPDS failed: %v", err)
710 }
711 defer pds.Close()
712
713 // Track if broadcaster was called
714 broadcasterCalled := false
715 broadcasterHandler := func(ctx context.Context, event *RepoEvent) {
716 broadcasterCalled = true
717 }
718
719 // Create handler
720 handler := pds.CreateRecordsIndexEventHandler(broadcasterHandler)
721 if handler == nil {
722 t.Fatal("Expected handler to be non-nil")
723 }
724
725 // Create a test event with create operation
726 event := &RepoEvent{
727 Ops: []RepoOp{
728 {
729 Kind: EvtKindCreateRecord,
730 Collection: "io.atcr.hold.crew",
731 Rkey: "testrkey",
732 RecCid: nil, // Will be nil string
733 },
734 },
735 }
736
737 // Call handler
738 handler(ctx, event)
739
740 // Verify broadcaster was called
741 if !broadcasterCalled {
742 t.Error("Expected broadcaster handler to be called")
743 }
744
745 // Verify record was indexed
746 if pds.RecordsIndex() != nil {
747 count, err := pds.RecordsIndex().Count("io.atcr.hold.crew")
748 if err != nil {
749 t.Fatalf("Count() error = %v", err)
750 }
751 if count != 1 {
752 t.Errorf("Expected 1 indexed record, got %d", count)
753 }
754 }
755}
756
757// TestHoldPDS_CreateRecordsIndexEventHandler_Delete tests delete operation
758func TestHoldPDS_CreateRecordsIndexEventHandler_Delete(t *testing.T) {
759 ctx := context.Background()
760 tmpDir := t.TempDir()
761 dbPath := filepath.Join(tmpDir, "pds.db")
762 keyPath := filepath.Join(tmpDir, "signing-key")
763
764 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
765 if err != nil {
766 t.Fatalf("NewHoldPDS failed: %v", err)
767 }
768 defer pds.Close()
769
770 handler := pds.CreateRecordsIndexEventHandler(nil)
771
772 // First, create a record
773 createEvent := &RepoEvent{
774 Ops: []RepoOp{
775 {
776 Kind: EvtKindCreateRecord,
777 Collection: "io.atcr.hold.crew",
778 Rkey: "testrkey",
779 },
780 },
781 }
782 handler(ctx, createEvent)
783
784 // Verify it was indexed
785 count, _ := pds.RecordsIndex().Count("io.atcr.hold.crew")
786 if count != 1 {
787 t.Fatalf("Expected 1 record after create, got %d", count)
788 }
789
790 // Now delete it
791 deleteEvent := &RepoEvent{
792 Ops: []RepoOp{
793 {
794 Kind: EvtKindDeleteRecord,
795 Collection: "io.atcr.hold.crew",
796 Rkey: "testrkey",
797 },
798 },
799 }
800 handler(ctx, deleteEvent)
801
802 // Verify it was removed from index
803 count, _ = pds.RecordsIndex().Count("io.atcr.hold.crew")
804 if count != 0 {
805 t.Errorf("Expected 0 records after delete, got %d", count)
806 }
807}
808
809// TestHoldPDS_CreateRecordsIndexEventHandler_NilBroadcaster tests with nil broadcaster
810func TestHoldPDS_CreateRecordsIndexEventHandler_NilBroadcaster(t *testing.T) {
811 ctx := context.Background()
812 tmpDir := t.TempDir()
813 dbPath := filepath.Join(tmpDir, "pds.db")
814 keyPath := filepath.Join(tmpDir, "signing-key")
815
816 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
817 if err != nil {
818 t.Fatalf("NewHoldPDS failed: %v", err)
819 }
820 defer pds.Close()
821
822 // Create handler with nil broadcaster (should not panic)
823 handler := pds.CreateRecordsIndexEventHandler(nil)
824
825 event := &RepoEvent{
826 Ops: []RepoOp{
827 {
828 Kind: EvtKindCreateRecord,
829 Collection: "io.atcr.hold.crew",
830 Rkey: "testrkey",
831 },
832 },
833 }
834
835 // Should not panic
836 handler(ctx, event)
837
838 // Verify record was still indexed
839 count, _ := pds.RecordsIndex().Count("io.atcr.hold.crew")
840 if count != 1 {
841 t.Errorf("Expected 1 indexed record, got %d", count)
842 }
843}
844
845// TestHoldPDS_BackfillRecordsIndex tests backfilling the records index from MST
846func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
847 ctx := context.Background()
848 tmpDir := t.TempDir()
849 dbPath := filepath.Join(tmpDir, "pds.db")
850 keyPath := filepath.Join(tmpDir, "signing-key")
851
852 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
853 if err != nil {
854 t.Fatalf("NewHoldPDS failed: %v", err)
855 }
856 defer pds.Close()
857
858 // Bootstrap to create some records in MST (captain + crew)
859 ownerDID := "did:plc:testowner"
860 err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
861 if err != nil {
862 t.Fatalf("Bootstrap failed: %v", err)
863 }
864
865 // Clear the index to simulate out-of-sync state
866 _, err = pds.RecordsIndex().db.Exec("DELETE FROM records")
867 if err != nil {
868 t.Fatalf("Failed to clear index: %v", err)
869 }
870
871 // Verify index is empty
872 count, _ := pds.RecordsIndex().TotalCount()
873 if count != 0 {
874 t.Fatalf("Expected empty index, got %d", count)
875 }
876
877 // Backfill
878 err = pds.BackfillRecordsIndex(ctx)
879 if err != nil {
880 t.Fatalf("BackfillRecordsIndex failed: %v", err)
881 }
882
883 // Verify records were backfilled
884 // Bootstrap creates: 1 captain + 1 crew + 1 profile = 3 records
885 count, _ = pds.RecordsIndex().TotalCount()
886 if count < 2 {
887 t.Errorf("Expected at least 2 records after backfill (captain + crew), got %d", count)
888 }
889}
890
891// TestHoldPDS_BackfillRecordsIndex_NilIndex tests backfill with nil index
892func TestHoldPDS_BackfillRecordsIndex_NilIndex(t *testing.T) {
893 ctx := context.Background()
894 tmpDir := t.TempDir()
895 keyPath := filepath.Join(tmpDir, "signing-key")
896
897 // Use :memory: to get nil index
898 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
899 if err != nil {
900 t.Fatalf("NewHoldPDS failed: %v", err)
901 }
902 defer pds.Close()
903
904 // Backfill should be no-op and not error
905 err = pds.BackfillRecordsIndex(ctx)
906 if err != nil {
907 t.Errorf("BackfillRecordsIndex should not error with nil index, got: %v", err)
908 }
909}
910
911// TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced tests backfill skip when already synced
912func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
913 ctx := context.Background()
914 tmpDir := t.TempDir()
915 dbPath := filepath.Join(tmpDir, "pds.db")
916 keyPath := filepath.Join(tmpDir, "signing-key")
917
918 pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
919 if err != nil {
920 t.Fatalf("NewHoldPDS failed: %v", err)
921 }
922 defer pds.Close()
923
924 // Bootstrap to create records
925 err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "")
926 if err != nil {
927 t.Fatalf("Bootstrap failed: %v", err)
928 }
929
930 // Backfill once to sync
931 err = pds.BackfillRecordsIndex(ctx)
932 if err != nil {
933 t.Fatalf("First BackfillRecordsIndex failed: %v", err)
934 }
935
936 count1, _ := pds.RecordsIndex().TotalCount()
937
938 // Backfill again - should skip (counts match)
939 err = pds.BackfillRecordsIndex(ctx)
940 if err != nil {
941 t.Fatalf("Second BackfillRecordsIndex failed: %v", err)
942 }
943
944 count2, _ := pds.RecordsIndex().TotalCount()
945
946 // Count should be unchanged
947 if count1 != count2 {
948 t.Errorf("Expected count to remain %d after second backfill, got %d", count1, count2)
949 }
950}