This repository has no description
1package spindle
2
3import (
4 "context"
5 "database/sql"
6 "io"
7 "log/slog"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13
14 "tangled.org/core/spindle/db"
15 "tangled.org/core/spindle/secrets"
16)
17
18func seedTapDB(t *testing.T, path string) {
19 t.Helper()
20 tdb, err := sql.Open("sqlite3", path)
21 if err != nil {
22 t.Fatalf("open tap db: %v", err)
23 }
24 defer tdb.Close()
25 if _, err := tdb.Exec(`
26 create table repos (
27 did text primary key,
28 state text not null default 'pending',
29 status text not null default 'active',
30 handle text default '',
31 rev text default '',
32 prev_data text default '',
33 error_msg text default '',
34 retry_count integer not null default 0,
35 retry_after integer not null default 0
36 );
37 create table repo_records (
38 did text not null,
39 collection text not null,
40 rkey text not null,
41 cid text not null,
42 primary key (did, collection, rkey)
43 );
44 `); err != nil {
45 t.Fatalf("create tap tables: %v", err)
46 }
47}
48
49func tapRepoState(t *testing.T, path, did string) string {
50 t.Helper()
51 tdb, err := sql.Open("sqlite3", path)
52 if err != nil {
53 t.Fatalf("open tap db: %v", err)
54 }
55 defer tdb.Close()
56 var state string
57 if err := tdb.QueryRow(`select state from repos where did = ?`, did).Scan(&state); err != nil {
58 t.Fatalf("query state for %s: %v", did, err)
59 }
60 return state
61}
62
63func tapRecordCount(t *testing.T, path string) int {
64 t.Helper()
65 tdb, err := sql.Open("sqlite3", path)
66 if err != nil {
67 t.Fatalf("open tap db: %v", err)
68 }
69 defer tdb.Close()
70 var n int
71 if err := tdb.QueryRow(`select count(*) from repo_records`).Scan(&n); err != nil {
72 t.Fatalf("count repo_records: %v", err)
73 }
74 return n
75}
76
77func newTestSpindleDB(t *testing.T) *db.DB {
78 t.Helper()
79 d, err := db.Make(context.Background(), filepath.Join(t.TempDir(), "spindle.db"))
80 if err != nil {
81 t.Fatalf("db.Make: %v", err)
82 }
83 t.Cleanup(func() { d.Close() })
84 return d
85}
86
87func newTestVault(t *testing.T) *secrets.SqliteManager {
88 t.Helper()
89 vault, err := secrets.NewSQLiteManager(filepath.Join(t.TempDir(), "vault.db"))
90 if err != nil {
91 t.Fatalf("vault.New: %v", err)
92 }
93 return vault
94}
95
96func mustAddSecret(t *testing.T, vault secrets.Manager, repo, key, value string, createdAt time.Time, by string) {
97 t.Helper()
98 err := vault.AddSecret(context.Background(), secrets.UnlockedSecret{
99 Repo: secrets.RepoIdentifier(repo),
100 Key: key,
101 Value: value,
102 CreatedAt: createdAt,
103 CreatedBy: syntax.DID(by),
104 })
105 if err != nil {
106 t.Fatalf("AddSecret(%s/%s): %v", repo, key, err)
107 }
108}
109
110func TestMigrateLegacyRepoSecrets_NameCandidate(t *testing.T) {
111 ctx := context.Background()
112 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
113 d := newTestSpindleDB(t)
114 vault := newTestVault(t)
115
116 owner := syntax.DID("did:plc:akshay")
117 repoDid := syntax.DID("did:plc:boltless")
118 displayName := "myrepo"
119 rkey := syntax.RecordKey("3kspindlerkey00a")
120
121 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
122 oldNameKey := owner.String() + "/" + displayName
123
124 mustAddSecret(t, vault, oldNameKey, "API_KEY", "alpha", created, owner.String())
125 mustAddSecret(t, vault, oldNameKey, "DB_PASSWORD", "bravo", created.Add(1*time.Hour), owner.String())
126
127 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
128
129 copied, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
130 if err != nil {
131 t.Fatalf("GetSecretsUnlocked(new): %v", err)
132 }
133 if len(copied) != 2 {
134 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(copied))
135 }
136
137 want := map[string]struct {
138 value string
139 createdAt time.Time
140 }{
141 "API_KEY": {"alpha", created},
142 "DB_PASSWORD": {"bravo", created.Add(1 * time.Hour)},
143 }
144 for _, s := range copied {
145 w, ok := want[s.Key]
146 if !ok {
147 t.Errorf("unexpected key %q under %s", s.Key, repoDid)
148 continue
149 }
150 if s.Value != w.value {
151 t.Errorf("%s: value got %q, want %q", s.Key, s.Value, w.value)
152 }
153 if !s.CreatedAt.Equal(w.createdAt) {
154 t.Errorf("%s: CreatedAt got %s, want %s", s.Key, s.CreatedAt, w.createdAt)
155 }
156 if string(s.Repo) != repoDid.String() {
157 t.Errorf("%s: Repo got %s, want %s", s.Key, s.Repo, repoDid)
158 }
159 }
160
161 orig, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(oldNameKey))
162 if err != nil {
163 t.Fatalf("GetSecretsUnlocked(old): %v", err)
164 }
165 if len(orig) != 2 {
166 t.Errorf("expected old-key secrets preserved, got %d", len(orig))
167 }
168
169 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
170 again, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
171 if err != nil {
172 t.Fatalf("GetSecretsUnlocked(new) after re-run: %v", err)
173 }
174 if len(again) != 2 {
175 t.Errorf("re-run should not duplicate or drop secrets, got %d", len(again))
176 }
177
178 var marked int
179 if err := d.QueryRow(
180 `select count(*) from migrations where name = ?`,
181 "legacy-secret-copy:"+repoDid.String()+":"+rkey.String(),
182 ).Scan(&marked); err != nil {
183 t.Fatalf("query migrations: %v", err)
184 }
185 if marked != 1 {
186 t.Errorf("expected per-repo flag recorded exactly once, got %d", marked)
187 }
188}
189
190func TestMigrateLegacyRepoSecrets_RkeyCandidate(t *testing.T) {
191 ctx := context.Background()
192 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
193 d := newTestSpindleDB(t)
194 vault := newTestVault(t)
195
196 owner := syntax.DID("did:plc:akshay")
197 repoDid := syntax.DID("did:plc:boltless")
198 displayName := "myrepo"
199 rkey := syntax.RecordKey("3kspindlerkey00a")
200
201 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
202 oldRkeyKey := owner.String() + "/" + rkey.String()
203
204 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String())
205 mustAddSecret(t, vault, oldRkeyKey, "DB_PASSWORD", "bravo", created, owner.String())
206
207 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
208
209 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
210 if err != nil {
211 t.Fatalf("GetSecretsUnlocked: %v", err)
212 }
213 if len(got) != 2 {
214 t.Fatalf("expected 2 secrets copied via rkey candidate, got %d", len(got))
215 }
216}
217
218func TestMigrateLegacyRepoSecrets_BothCandidates(t *testing.T) {
219 ctx := context.Background()
220 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
221 d := newTestSpindleDB(t)
222 vault := newTestVault(t)
223
224 owner := syntax.DID("did:plc:akshay")
225 repoDid := syntax.DID("did:plc:boltless")
226 displayName := "myrepo"
227 rkey := syntax.RecordKey("3kspindlerkey00a")
228
229 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
230 oldNameKey := owner.String() + "/" + displayName
231 oldRkeyKey := owner.String() + "/" + rkey.String()
232
233 mustAddSecret(t, vault, oldNameKey, "FROM_NAME", "n", created, owner.String())
234 mustAddSecret(t, vault, oldRkeyKey, "FROM_RKEY", "r", created, owner.String())
235
236 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
237
238 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
239 if err != nil {
240 t.Fatalf("GetSecretsUnlocked: %v", err)
241 }
242 if len(got) != 2 {
243 t.Fatalf("expected 2 secrets merged from both candidates, got %d", len(got))
244 }
245 seen := map[string]string{}
246 for _, s := range got {
247 seen[s.Key] = s.Value
248 }
249 if seen["FROM_NAME"] != "n" {
250 t.Errorf("FROM_NAME missing or wrong value: %q", seen["FROM_NAME"])
251 }
252 if seen["FROM_RKEY"] != "r" {
253 t.Errorf("FROM_RKEY missing or wrong value: %q", seen["FROM_RKEY"])
254 }
255}
256
257func TestMigrateLegacyRepoSecrets_PreExistingTakesPriority(t *testing.T) {
258 ctx := context.Background()
259 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
260 d := newTestSpindleDB(t)
261 vault := newTestVault(t)
262
263 owner := syntax.DID("did:plc:akshay")
264 repoDid := syntax.DID("did:plc:boltless")
265 displayName := "myrepo"
266 rkey := syntax.RecordKey("3kspindlerkey00a")
267
268 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
269 oldKey := owner.String() + "/" + displayName
270
271 mustAddSecret(t, vault, oldKey, "API_KEY", "alpha", created, owner.String())
272 mustAddSecret(t, vault, oldKey, "DB_PASSWORD", "bravo", created, owner.String())
273 mustAddSecret(t, vault, repoDid.String(), "API_KEY", "pre-existing", created.Add(-24*time.Hour), owner.String())
274
275 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
276
277 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
278 if err != nil {
279 t.Fatalf("GetSecretsUnlocked: %v", err)
280 }
281 if len(got) != 2 {
282 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(got))
283 }
284 for _, s := range got {
285 if s.Key == "API_KEY" && s.Value != "pre-existing" {
286 t.Errorf("API_KEY should preserve pre-existing value, got %q", s.Value)
287 }
288 if s.Key == "DB_PASSWORD" && s.Value != "bravo" {
289 t.Errorf("DB_PASSWORD should be copied, got %q", s.Value)
290 }
291 }
292}
293
294func TestMigrateLegacyRepoSecrets_EmptyName(t *testing.T) {
295 ctx := context.Background()
296 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
297 d := newTestSpindleDB(t)
298 vault := newTestVault(t)
299
300 owner := syntax.DID("did:plc:akshay")
301 repoDid := syntax.DID("did:plc:boltless")
302 rkey := syntax.RecordKey("3kspindlerkey00a")
303
304 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
305 oldRkeyKey := owner.String() + "/" + rkey.String()
306 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String())
307
308 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", rkey, repoDid)
309
310 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
311 if err != nil {
312 t.Fatalf("GetSecretsUnlocked: %v", err)
313 }
314 if len(got) != 1 {
315 t.Errorf("expected 1 secret via rkey candidate when name empty, got %d", len(got))
316 }
317}
318
319func TestMigrateLegacyRepoSecrets_BothEmpty(t *testing.T) {
320 ctx := context.Background()
321 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
322 d := newTestSpindleDB(t)
323 vault := newTestVault(t)
324
325 owner := syntax.DID("did:plc:akshay")
326 repoDid := syntax.DID("did:plc:boltless")
327
328 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", "", repoDid)
329
330 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
331 if err != nil {
332 t.Fatalf("GetSecretsUnlocked: %v", err)
333 }
334 if len(got) != 0 {
335 t.Errorf("expected no work when both name and rkey empty, got %d secrets", len(got))
336 }
337
338 var marked int
339 if err := d.QueryRow(
340 `select count(*) from migrations where name like ?`,
341 "legacy-secret-copy:"+repoDid.String()+":%",
342 ).Scan(&marked); err != nil {
343 t.Fatalf("query migrations: %v", err)
344 }
345 if marked != 0 {
346 t.Errorf("empty inputs should not record flag, got %d", marked)
347 }
348}
349
350func TestNudgeTapForResync(t *testing.T) {
351 ctx := context.Background()
352 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
353 d := newTestSpindleDB(t)
354
355 tapPath := filepath.Join(t.TempDir(), "tap.db")
356 seedTapDB(t, tapPath)
357
358 tdb, err := sql.Open("sqlite3", tapPath)
359 if err != nil {
360 t.Fatalf("open tap db: %v", err)
361 }
362 if _, err := tdb.Exec(`insert into repos (did, state) values
363 ('did:plc:akshay', 'active'),
364 ('did:plc:boltless', 'error'),
365 ('did:plc:limpet', 'pending')
366 `); err != nil {
367 t.Fatalf("seed repos: %v", err)
368 }
369 if _, err := tdb.Exec(`insert into repo_records (did, collection, rkey, cid) values
370 ('did:plc:akshay', 'sh.tangled.repo', '3kspindlerkey00a', 'bafyone'),
371 ('did:plc:boltless', 'sh.tangled.repo', '3kspindlerkey00b', 'bafytwo')
372 `); err != nil {
373 t.Fatalf("seed records: %v", err)
374 }
375 tdb.Close()
376
377 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil {
378 t.Fatalf("nudgeTapForResync: %v", err)
379 }
380
381 if got := tapRecordCount(t, tapPath); got != 0 {
382 t.Errorf("expected repo_records cleared, got %d", got)
383 }
384 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "desynchronized" {
385 t.Errorf("active should flip to desynchronized, got %s", got)
386 }
387 if got := tapRepoState(t, tapPath, "did:plc:boltless"); got != "desynchronized" {
388 t.Errorf("error should flip to desynchronized, got %s", got)
389 }
390 if got := tapRepoState(t, tapPath, "did:plc:limpet"); got != "pending" {
391 t.Errorf("pending should not be touched, got %s", got)
392 }
393
394 tdb2, err := sql.Open("sqlite3", tapPath)
395 if err != nil {
396 t.Fatalf("reopen tap db: %v", err)
397 }
398 if _, err := tdb2.Exec(`update repos set state = 'active' where did = 'did:plc:akshay'`); err != nil {
399 t.Fatalf("reseed: %v", err)
400 }
401 tdb2.Close()
402
403 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil {
404 t.Fatalf("nudgeTapForResync second run: %v", err)
405 }
406 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "active" {
407 t.Errorf("idempotent re-run should not touch state, got %s", got)
408 }
409
410 var marked int
411 if err := d.QueryRow(
412 `select count(*) from migrations where name = ?`,
413 "force-tap-repo-resync-v1",
414 ).Scan(&marked); err != nil {
415 t.Fatalf("query migrations: %v", err)
416 }
417 if marked != 1 {
418 t.Errorf("expected flag recorded exactly once, got %d", marked)
419 }
420}
421
422func TestNudgeTapForResync_MissingDB(t *testing.T) {
423 ctx := context.Background()
424 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
425 d := newTestSpindleDB(t)
426
427 missing := filepath.Join(t.TempDir(), "absent.db")
428
429 if err := nudgeTapForResync(ctx, d, missing, logger); err != nil {
430 t.Fatalf("missing tap db should succeed: %v", err)
431 }
432
433 var marked int
434 if err := d.QueryRow(
435 `select count(*) from migrations where name = ?`,
436 "force-tap-repo-resync-v1",
437 ).Scan(&marked); err != nil {
438 t.Fatalf("query migrations: %v", err)
439 }
440 if marked != 1 {
441 t.Errorf("expected flag recorded even when tap db absent, got %d", marked)
442 }
443}
444
445func TestNudgeTapForResync_EmptyPath(t *testing.T) {
446 ctx := context.Background()
447 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
448 d := newTestSpindleDB(t)
449
450 if err := nudgeTapForResync(ctx, d, "", logger); err == nil {
451 t.Errorf("expected error for empty tap db path")
452 }
453
454 var marked int
455 if err := d.QueryRow(
456 `select count(*) from migrations where name = ?`,
457 "force-tap-repo-resync-v1",
458 ).Scan(&marked); err != nil {
459 t.Fatalf("query migrations: %v", err)
460 }
461 if marked != 0 {
462 t.Errorf("empty path should not mark flag, got %d", marked)
463 }
464}
465
466func TestRunStartupMigrations_NonEmbedSkipsTapNudge(t *testing.T) {
467 ctx := context.Background()
468 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
469 d := newTestSpindleDB(t)
470
471 if err := runStartupMigrations(ctx, d, false, "", logger); err != nil {
472 t.Fatalf("non-embed should not error on empty path: %v", err)
473 }
474
475 var marked int
476 if err := d.QueryRow(
477 `select count(*) from migrations where name = ?`,
478 "force-tap-repo-resync-v1",
479 ).Scan(&marked); err != nil {
480 t.Fatalf("query migrations: %v", err)
481 }
482 if marked != 0 {
483 t.Errorf("non-embed mode should skip tap nudge flag, got %d", marked)
484 }
485}
486
487func TestCleanupOrphanRepos_DeletesWhenSiblingExists(t *testing.T) {
488 ctx := context.Background()
489 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
490 d := newTestSpindleDB(t)
491
492 owner := "did:plc:akshay"
493 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
494 ('k', ?, 'legacy_name', null, null),
495 ('k', ?, '3kspindlerkey00a', 'did:plc:boltless', '2024-01-01T00:00:00Z')`,
496 owner, owner); err != nil {
497 t.Fatalf("seed: %v", err)
498 }
499
500 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
501 t.Fatalf("cleanupOrphanRepos: %v", err)
502 }
503
504 var nullCount int
505 if err := d.QueryRow(`select count(*) from repos where repo_did is null`).Scan(&nullCount); err != nil {
506 t.Fatalf("null count: %v", err)
507 }
508 if nullCount != 0 {
509 t.Errorf("orphan should be deleted when sibling exists, got %d remaining", nullCount)
510 }
511
512 var sibCount int
513 if err := d.QueryRow(`select count(*) from repos where repo_did is not null`).Scan(&sibCount); err != nil {
514 t.Fatalf("sibling count: %v", err)
515 }
516 if sibCount != 1 {
517 t.Errorf("sibling row should be preserved, got %d", sibCount)
518 }
519}
520
521func TestCleanupOrphanRepos_KeepsWhenAlone(t *testing.T) {
522 ctx := context.Background()
523 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
524 d := newTestSpindleDB(t)
525
526 owner := "did:plc:akshay"
527 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
528 ('k', ?, 'legacy_name', null, null)`, owner); err != nil {
529 t.Fatalf("seed: %v", err)
530 }
531
532 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
533 t.Fatalf("cleanupOrphanRepos: %v", err)
534 }
535
536 var remaining int
537 if err := d.QueryRow(`select count(*) from repos where owner = ?`, owner).Scan(&remaining); err != nil {
538 t.Fatalf("count: %v", err)
539 }
540 if remaining != 1 {
541 t.Errorf("orphan with no sibling should be kept (preserves owner registration), got %d", remaining)
542 }
543}
544
545func TestCleanupOrphanRepos_PerOwnerScope(t *testing.T) {
546 ctx := context.Background()
547 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
548 d := newTestSpindleDB(t)
549
550 ownerA := "did:plc:akshay"
551 ownerB := "did:plc:limpet"
552 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
553 ('k', ?, 'legacy_a', null, null),
554 ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z'),
555 ('k', ?, 'legacy_b', null, null)`,
556 ownerA, ownerA, ownerB); err != nil {
557 t.Fatalf("seed: %v", err)
558 }
559
560 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
561 t.Fatalf("cleanupOrphanRepos: %v", err)
562 }
563
564 var ownerARows, ownerBRows int
565 if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerA).Scan(&ownerARows); err != nil {
566 t.Fatalf("count A: %v", err)
567 }
568 if ownerARows != 1 {
569 t.Errorf("ownerA: orphan should be deleted (sibling exists), expected 1 row, got %d", ownerARows)
570 }
571 if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerB).Scan(&ownerBRows); err != nil {
572 t.Fatalf("count B: %v", err)
573 }
574 if ownerBRows != 1 {
575 t.Errorf("ownerB: orphan should be kept (no sibling), expected 1 row, got %d", ownerBRows)
576 }
577}
578
579func TestCleanupOrphanRepos_EmptyStringRepoDid(t *testing.T) {
580 ctx := context.Background()
581 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
582 d := newTestSpindleDB(t)
583
584 owner := "did:plc:akshay"
585 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
586 ('k', ?, 'legacy_empty', '', null),
587 ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z')`,
588 owner, owner); err != nil {
589 t.Fatalf("seed: %v", err)
590 }
591
592 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
593 t.Fatalf("cleanupOrphanRepos: %v", err)
594 }
595
596 var emptyCount int
597 if err := d.QueryRow(`select count(*) from repos where coalesce(repo_did, '') = ''`).Scan(&emptyCount); err != nil {
598 t.Fatalf("empty count: %v", err)
599 }
600 if emptyCount != 0 {
601 t.Errorf("empty-string repo_did orphan should be deleted when sibling exists, got %d remaining", emptyCount)
602 }
603}