Monorepo for Tangled
tangled.org
1package spindle
2
3import (
4 "context"
5 "database/sql"
6 "fmt"
7 "io"
8 "log/slog"
9 "path/filepath"
10 "testing"
11 "time"
12
13 "github.com/bluesky-social/indigo/atproto/syntax"
14
15 "tangled.org/core/rbac"
16 "tangled.org/core/spindle/db"
17 "tangled.org/core/spindle/secrets"
18)
19
20func seedTapDB(t *testing.T, path string) {
21 t.Helper()
22 tdb, err := sql.Open("sqlite3", path)
23 if err != nil {
24 t.Fatalf("open tap db: %v", err)
25 }
26 defer tdb.Close()
27 if _, err := tdb.Exec(`
28 create table repos (
29 did text primary key,
30 state text not null default 'pending',
31 status text not null default 'active',
32 handle text default '',
33 rev text default '',
34 prev_data text default '',
35 error_msg text default '',
36 retry_count integer not null default 0,
37 retry_after integer not null default 0
38 );
39 create table repo_records (
40 did text not null,
41 collection text not null,
42 rkey text not null,
43 cid text not null,
44 primary key (did, collection, rkey)
45 );
46 `); err != nil {
47 t.Fatalf("create tap tables: %v", err)
48 }
49}
50
51func tapRepoState(t *testing.T, path, did string) string {
52 t.Helper()
53 tdb, err := sql.Open("sqlite3", path)
54 if err != nil {
55 t.Fatalf("open tap db: %v", err)
56 }
57 defer tdb.Close()
58 var state string
59 if err := tdb.QueryRow(`select state from repos where did = ?`, did).Scan(&state); err != nil {
60 t.Fatalf("query state for %s: %v", did, err)
61 }
62 return state
63}
64
65func tapRecordCount(t *testing.T, path string) int {
66 t.Helper()
67 tdb, err := sql.Open("sqlite3", path)
68 if err != nil {
69 t.Fatalf("open tap db: %v", err)
70 }
71 defer tdb.Close()
72 var n int
73 if err := tdb.QueryRow(`select count(*) from repo_records`).Scan(&n); err != nil {
74 t.Fatalf("count repo_records: %v", err)
75 }
76 return n
77}
78
79func newTestSpindleDB(t *testing.T) (*db.DB, *rbac.Enforcer) {
80 t.Helper()
81 p := filepath.Join(t.TempDir(), "spindle.db")
82 d, err := db.Make(context.Background(), p)
83 if err != nil {
84 t.Fatalf("db.Make: %v", err)
85 }
86 t.Cleanup(func() { d.Close() })
87 e, err := rbac.NewEnforcer(p)
88 if err != nil {
89 t.Fatalf("rbac.NewEnforcer: %v", err)
90 }
91 e.E.EnableAutoSave(true)
92 return d, e
93}
94
95func newTestVault(t *testing.T) *secrets.SqliteManager {
96 t.Helper()
97 vault, err := secrets.NewSQLiteManager(filepath.Join(t.TempDir(), "vault.db"))
98 if err != nil {
99 t.Fatalf("vault.New: %v", err)
100 }
101 return vault
102}
103
104func mustAddSecret(t *testing.T, vault secrets.Manager, repo, key, value string, createdAt time.Time, by string) {
105 t.Helper()
106 err := vault.AddSecret(context.Background(), secrets.UnlockedSecret{
107 Repo: secrets.RepoIdentifier(repo),
108 Key: key,
109 Value: value,
110 CreatedAt: createdAt,
111 CreatedBy: syntax.DID(by),
112 })
113 if err != nil {
114 t.Fatalf("AddSecret(%s/%s): %v", repo, key, err)
115 }
116}
117
118func mustAddCollab(t *testing.T, d *db.DB, owner, rkey, subject, repoDid string) {
119 t.Helper()
120 if err := d.AddRepoCollaborator(db.RepoCollaborator{
121 OwnerDid: syntax.DID(owner),
122 Rkey: syntax.RecordKey(rkey),
123 Subject: syntax.DID(subject),
124 RepoDid: syntax.DID(repoDid),
125 }); err != nil {
126 t.Fatalf("AddRepoCollaborator(%s): %v", rkey, err)
127 }
128}
129
130func TestMigrateLegacyRepoSecrets_NameCandidate(t *testing.T) {
131 ctx := context.Background()
132 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
133 d, _ := newTestSpindleDB(t)
134 vault := newTestVault(t)
135
136 owner := syntax.DID("did:plc:akshay")
137 repoDid := syntax.DID("did:plc:boltless")
138 displayName := "myrepo"
139 rkey := syntax.RecordKey("3kspindlerkey00a")
140
141 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
142 oldNameKey := owner.String() + "/" + displayName
143
144 mustAddSecret(t, vault, oldNameKey, "API_KEY", "alpha", created, owner.String())
145 mustAddSecret(t, vault, oldNameKey, "DB_PASSWORD", "bravo", created.Add(1*time.Hour), owner.String())
146
147 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
148
149 copied, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
150 if err != nil {
151 t.Fatalf("GetSecretsUnlocked(new): %v", err)
152 }
153 if len(copied) != 2 {
154 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(copied))
155 }
156
157 want := map[string]struct {
158 value string
159 createdAt time.Time
160 }{
161 "API_KEY": {"alpha", created},
162 "DB_PASSWORD": {"bravo", created.Add(1 * time.Hour)},
163 }
164 for _, s := range copied {
165 w, ok := want[s.Key]
166 if !ok {
167 t.Errorf("unexpected key %q under %s", s.Key, repoDid)
168 continue
169 }
170 if s.Value != w.value {
171 t.Errorf("%s: value got %q, want %q", s.Key, s.Value, w.value)
172 }
173 if !s.CreatedAt.Equal(w.createdAt) {
174 t.Errorf("%s: CreatedAt got %s, want %s", s.Key, s.CreatedAt, w.createdAt)
175 }
176 if string(s.Repo) != repoDid.String() {
177 t.Errorf("%s: Repo got %s, want %s", s.Key, s.Repo, repoDid)
178 }
179 }
180
181 orig, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(oldNameKey))
182 if err != nil {
183 t.Fatalf("GetSecretsUnlocked(old): %v", err)
184 }
185 if len(orig) != 2 {
186 t.Errorf("expected old-key secrets preserved, got %d", len(orig))
187 }
188
189 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
190 again, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
191 if err != nil {
192 t.Fatalf("GetSecretsUnlocked(new) after re-run: %v", err)
193 }
194 if len(again) != 2 {
195 t.Errorf("re-run should not duplicate or drop secrets, got %d", len(again))
196 }
197
198 var marked int
199 if err := d.QueryRow(
200 `select count(*) from migrations where name = ?`,
201 "legacy-secret-copy:"+repoDid.String()+":"+rkey.String(),
202 ).Scan(&marked); err != nil {
203 t.Fatalf("query migrations: %v", err)
204 }
205 if marked != 1 {
206 t.Errorf("expected per-repo flag recorded exactly once, got %d", marked)
207 }
208}
209
210func TestMigrateLegacyRepoSecrets_RkeyCandidate(t *testing.T) {
211 ctx := context.Background()
212 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
213 d, _ := newTestSpindleDB(t)
214 vault := newTestVault(t)
215
216 owner := syntax.DID("did:plc:akshay")
217 repoDid := syntax.DID("did:plc:boltless")
218 displayName := "myrepo"
219 rkey := syntax.RecordKey("3kspindlerkey00a")
220
221 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
222 oldRkeyKey := owner.String() + "/" + rkey.String()
223
224 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String())
225 mustAddSecret(t, vault, oldRkeyKey, "DB_PASSWORD", "bravo", created, owner.String())
226
227 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
228
229 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
230 if err != nil {
231 t.Fatalf("GetSecretsUnlocked: %v", err)
232 }
233 if len(got) != 2 {
234 t.Fatalf("expected 2 secrets copied via rkey candidate, got %d", len(got))
235 }
236}
237
238func TestMigrateLegacyRepoSecrets_BothCandidates(t *testing.T) {
239 ctx := context.Background()
240 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
241 d, _ := newTestSpindleDB(t)
242 vault := newTestVault(t)
243
244 owner := syntax.DID("did:plc:akshay")
245 repoDid := syntax.DID("did:plc:boltless")
246 displayName := "myrepo"
247 rkey := syntax.RecordKey("3kspindlerkey00a")
248
249 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
250 oldNameKey := owner.String() + "/" + displayName
251 oldRkeyKey := owner.String() + "/" + rkey.String()
252
253 mustAddSecret(t, vault, oldNameKey, "FROM_NAME", "n", created, owner.String())
254 mustAddSecret(t, vault, oldRkeyKey, "FROM_RKEY", "r", created, owner.String())
255
256 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
257
258 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
259 if err != nil {
260 t.Fatalf("GetSecretsUnlocked: %v", err)
261 }
262 if len(got) != 2 {
263 t.Fatalf("expected 2 secrets merged from both candidates, got %d", len(got))
264 }
265 seen := map[string]string{}
266 for _, s := range got {
267 seen[s.Key] = s.Value
268 }
269 if seen["FROM_NAME"] != "n" {
270 t.Errorf("FROM_NAME missing or wrong value: %q", seen["FROM_NAME"])
271 }
272 if seen["FROM_RKEY"] != "r" {
273 t.Errorf("FROM_RKEY missing or wrong value: %q", seen["FROM_RKEY"])
274 }
275}
276
277func TestMigrateLegacyRepoSecrets_PreExistingTakesPriority(t *testing.T) {
278 ctx := context.Background()
279 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
280 d, _ := newTestSpindleDB(t)
281 vault := newTestVault(t)
282
283 owner := syntax.DID("did:plc:akshay")
284 repoDid := syntax.DID("did:plc:boltless")
285 displayName := "myrepo"
286 rkey := syntax.RecordKey("3kspindlerkey00a")
287
288 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
289 oldKey := owner.String() + "/" + displayName
290
291 mustAddSecret(t, vault, oldKey, "API_KEY", "alpha", created, owner.String())
292 mustAddSecret(t, vault, oldKey, "DB_PASSWORD", "bravo", created, owner.String())
293 mustAddSecret(t, vault, repoDid.String(), "API_KEY", "pre-existing", created.Add(-24*time.Hour), owner.String())
294
295 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid)
296
297 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
298 if err != nil {
299 t.Fatalf("GetSecretsUnlocked: %v", err)
300 }
301 if len(got) != 2 {
302 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(got))
303 }
304 for _, s := range got {
305 if s.Key == "API_KEY" && s.Value != "pre-existing" {
306 t.Errorf("API_KEY should preserve pre-existing value, got %q", s.Value)
307 }
308 if s.Key == "DB_PASSWORD" && s.Value != "bravo" {
309 t.Errorf("DB_PASSWORD should be copied, got %q", s.Value)
310 }
311 }
312}
313
314func TestMigrateLegacyRepoSecrets_EmptyName(t *testing.T) {
315 ctx := context.Background()
316 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
317 d, _ := newTestSpindleDB(t)
318 vault := newTestVault(t)
319
320 owner := syntax.DID("did:plc:akshay")
321 repoDid := syntax.DID("did:plc:boltless")
322 rkey := syntax.RecordKey("3kspindlerkey00a")
323
324 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC)
325 oldRkeyKey := owner.String() + "/" + rkey.String()
326 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String())
327
328 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", rkey, 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) != 1 {
335 t.Errorf("expected 1 secret via rkey candidate when name empty, got %d", len(got))
336 }
337}
338
339func TestMigrateLegacyRepoSecrets_BothEmpty(t *testing.T) {
340 ctx := context.Background()
341 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
342 d, _ := newTestSpindleDB(t)
343 vault := newTestVault(t)
344
345 owner := syntax.DID("did:plc:akshay")
346 repoDid := syntax.DID("did:plc:boltless")
347
348 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", "", repoDid)
349
350 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid))
351 if err != nil {
352 t.Fatalf("GetSecretsUnlocked: %v", err)
353 }
354 if len(got) != 0 {
355 t.Errorf("expected no work when both name and rkey empty, got %d secrets", len(got))
356 }
357
358 var marked int
359 if err := d.QueryRow(
360 `select count(*) from migrations where name like ?`,
361 "legacy-secret-copy:"+repoDid.String()+":%",
362 ).Scan(&marked); err != nil {
363 t.Fatalf("query migrations: %v", err)
364 }
365 if marked != 0 {
366 t.Errorf("empty inputs should not record flag, got %d", marked)
367 }
368}
369
370func TestMigrateLegacyRepoCasbin_NameCandidate(t *testing.T) {
371 ctx := context.Background()
372 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
373 d, e := newTestSpindleDB(t)
374
375 if err := e.AddSpindle(rbacDomain); err != nil {
376 t.Fatalf("AddSpindle: %v", err)
377 }
378
379 owner := "did:plc:akshay"
380 repoDid := "did:plc:boltless"
381 displayName := "myrepo"
382 rkey := "3kspindlerkey00a"
383 collab := "did:plc:limpet"
384 oldNameKey := owner + "/" + displayName
385 oldRkeyKey := owner + "/" + rkey
386
387 mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid)
388
389 if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil {
390 t.Fatalf("seed AddRepo at Name key: %v", err)
391 }
392 if err := e.AddCollaborator(collab, rbacDomain, oldNameKey); err != nil {
393 t.Fatalf("seed AddCollaborator at Name key: %v", err)
394 }
395
396 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
397
398 if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got {
399 t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err)
400 }
401 if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got {
402 t.Errorf("collab should have settings at new repoDid key, allowed=%v err=%v", got, err)
403 }
404 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got {
405 t.Errorf("owner Name-keyed policy should be removed, allowed=%v err=%v", got, err)
406 }
407 if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldNameKey); err != nil || got {
408 t.Errorf("collab Name-keyed policy should be removed, allowed=%v err=%v", got, err)
409 }
410 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got {
411 t.Errorf("owner rkey-keyed policy should be absent (never added), allowed=%v err=%v", got, err)
412 }
413
414 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
415
416 if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got {
417 t.Errorf("collab settings still expected after idempotent re-run, allowed=%v err=%v", got, err)
418 }
419
420 var marked int
421 if err := d.QueryRow(
422 `select count(*) from migrations where name = ?`,
423 "legacy-casbin-rekey:"+repoDid+":"+rkey,
424 ).Scan(&marked); err != nil {
425 t.Fatalf("query migrations: %v", err)
426 }
427 if marked != 1 {
428 t.Errorf("expected per-repo flag recorded exactly once, got %d", marked)
429 }
430}
431
432func TestMigrateLegacyRepoCasbin_RkeyCandidate(t *testing.T) {
433 ctx := context.Background()
434 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
435 d, e := newTestSpindleDB(t)
436
437 if err := e.AddSpindle(rbacDomain); err != nil {
438 t.Fatalf("AddSpindle: %v", err)
439 }
440
441 owner := "did:plc:akshay"
442 repoDid := "did:plc:boltless"
443 displayName := "myrepo"
444 rkey := "3kspindlerkey00a"
445 collab := "did:plc:limpet"
446 oldRkeyKey := owner + "/" + rkey
447
448 mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid)
449
450 if err := e.AddRepo(owner, rbacDomain, oldRkeyKey); err != nil {
451 t.Fatalf("seed AddRepo at rkey: %v", err)
452 }
453 if err := e.AddCollaborator(collab, rbacDomain, oldRkeyKey); err != nil {
454 t.Fatalf("seed AddCollaborator at rkey: %v", err)
455 }
456
457 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
458
459 if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got {
460 t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err)
461 }
462 if got, err := e.IsSettingsAllowed(collab, rbacDomain, repoDid); err != nil || !got {
463 t.Errorf("collab should have settings at new repoDid key, allowed=%v err=%v", got, err)
464 }
465 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got {
466 t.Errorf("owner rkey-keyed policy should be removed, allowed=%v err=%v", got, err)
467 }
468 if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldRkeyKey); err != nil || got {
469 t.Errorf("collab rkey-keyed policy should be removed, allowed=%v err=%v", got, err)
470 }
471}
472
473func TestMigrateLegacyRepoCasbin_BothCandidates(t *testing.T) {
474 ctx := context.Background()
475 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
476 d, e := newTestSpindleDB(t)
477
478 if err := e.AddSpindle(rbacDomain); err != nil {
479 t.Fatalf("AddSpindle: %v", err)
480 }
481
482 owner := "did:plc:akshay"
483 repoDid := "did:plc:boltless"
484 displayName := "myrepo"
485 rkey := "3kspindlerkey00a"
486 collab := "did:plc:limpet"
487 oldNameKey := owner + "/" + displayName
488 oldRkeyKey := owner + "/" + rkey
489
490 mustAddCollab(t, d, owner, "3kcollabrkey0001", collab, repoDid)
491
492 if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil {
493 t.Fatalf("seed AddRepo at Name key: %v", err)
494 }
495 if err := e.AddRepo(owner, rbacDomain, oldRkeyKey); err != nil {
496 t.Fatalf("seed AddRepo at rkey: %v", err)
497 }
498 if err := e.AddCollaborator(collab, rbacDomain, oldNameKey); err != nil {
499 t.Fatalf("seed AddCollaborator at Name key: %v", err)
500 }
501 if err := e.AddCollaborator(collab, rbacDomain, oldRkeyKey); err != nil {
502 t.Fatalf("seed AddCollaborator at rkey: %v", err)
503 }
504
505 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
506
507 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got {
508 t.Errorf("owner Name-keyed policy should be removed, allowed=%v err=%v", got, err)
509 }
510 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldRkeyKey); err != nil || got {
511 t.Errorf("owner rkey-keyed policy should be removed, allowed=%v err=%v", got, err)
512 }
513 if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldNameKey); err != nil || got {
514 t.Errorf("collab Name-keyed policy should be removed, allowed=%v err=%v", got, err)
515 }
516 if got, err := e.IsSettingsAllowed(collab, rbacDomain, oldRkeyKey); err != nil || got {
517 t.Errorf("collab rkey-keyed policy should be removed, allowed=%v err=%v", got, err)
518 }
519}
520
521func TestMigrateLegacyRepoCasbin_BothEmpty(t *testing.T) {
522 ctx := context.Background()
523 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
524 d, e := newTestSpindleDB(t)
525
526 if err := e.AddSpindle(rbacDomain); err != nil {
527 t.Fatalf("AddSpindle: %v", err)
528 }
529
530 owner := syntax.DID("did:plc:akshay")
531 repoDid := syntax.DID("did:plc:boltless")
532
533 migrateLegacyRepoCasbin(ctx, d, e, logger, owner, "", "", repoDid)
534
535 var marked int
536 if err := d.QueryRow(
537 `select count(*) from migrations where name like ?`,
538 "legacy-casbin-rekey:"+repoDid.String()+":%",
539 ).Scan(&marked); err != nil {
540 t.Fatalf("query migrations: %v", err)
541 }
542 if marked != 0 {
543 t.Errorf("empty inputs should not record flag, got %d", marked)
544 }
545}
546
547func TestNudgeTapForResync(t *testing.T) {
548 ctx := context.Background()
549 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
550 d, _ := newTestSpindleDB(t)
551
552 tapPath := filepath.Join(t.TempDir(), "tap.db")
553 seedTapDB(t, tapPath)
554
555 tdb, err := sql.Open("sqlite3", tapPath)
556 if err != nil {
557 t.Fatalf("open tap db: %v", err)
558 }
559 if _, err := tdb.Exec(`insert into repos (did, state) values
560 ('did:plc:akshay', 'active'),
561 ('did:plc:boltless', 'error'),
562 ('did:plc:limpet', 'pending')
563 `); err != nil {
564 t.Fatalf("seed repos: %v", err)
565 }
566 if _, err := tdb.Exec(`insert into repo_records (did, collection, rkey, cid) values
567 ('did:plc:akshay', 'sh.tangled.repo', '3kspindlerkey00a', 'bafyone'),
568 ('did:plc:boltless', 'sh.tangled.repo', '3kspindlerkey00b', 'bafytwo')
569 `); err != nil {
570 t.Fatalf("seed records: %v", err)
571 }
572 tdb.Close()
573
574 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil {
575 t.Fatalf("nudgeTapForResync: %v", err)
576 }
577
578 if got := tapRecordCount(t, tapPath); got != 0 {
579 t.Errorf("expected repo_records cleared, got %d", got)
580 }
581 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "desynchronized" {
582 t.Errorf("active should flip to desynchronized, got %s", got)
583 }
584 if got := tapRepoState(t, tapPath, "did:plc:boltless"); got != "desynchronized" {
585 t.Errorf("error should flip to desynchronized, got %s", got)
586 }
587 if got := tapRepoState(t, tapPath, "did:plc:limpet"); got != "pending" {
588 t.Errorf("pending should not be touched, got %s", got)
589 }
590
591 tdb2, err := sql.Open("sqlite3", tapPath)
592 if err != nil {
593 t.Fatalf("reopen tap db: %v", err)
594 }
595 if _, err := tdb2.Exec(`update repos set state = 'active' where did = 'did:plc:akshay'`); err != nil {
596 t.Fatalf("reseed: %v", err)
597 }
598 tdb2.Close()
599
600 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil {
601 t.Fatalf("nudgeTapForResync second run: %v", err)
602 }
603 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "active" {
604 t.Errorf("idempotent re-run should not touch state, got %s", got)
605 }
606
607 var marked int
608 if err := d.QueryRow(
609 `select count(*) from migrations where name = ?`,
610 "force-tap-repo-resync-v1",
611 ).Scan(&marked); err != nil {
612 t.Fatalf("query migrations: %v", err)
613 }
614 if marked != 1 {
615 t.Errorf("expected flag recorded exactly once, got %d", marked)
616 }
617}
618
619func TestNudgeTapForResync_MissingDB(t *testing.T) {
620 ctx := context.Background()
621 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
622 d, _ := newTestSpindleDB(t)
623
624 missing := filepath.Join(t.TempDir(), "absent.db")
625
626 if err := nudgeTapForResync(ctx, d, missing, logger); err != nil {
627 t.Fatalf("missing tap db should succeed: %v", err)
628 }
629
630 var marked int
631 if err := d.QueryRow(
632 `select count(*) from migrations where name = ?`,
633 "force-tap-repo-resync-v1",
634 ).Scan(&marked); err != nil {
635 t.Fatalf("query migrations: %v", err)
636 }
637 if marked != 1 {
638 t.Errorf("expected flag recorded even when tap db absent, got %d", marked)
639 }
640}
641
642func TestNudgeTapForResync_EmptyPath(t *testing.T) {
643 ctx := context.Background()
644 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
645 d, _ := newTestSpindleDB(t)
646
647 if err := nudgeTapForResync(ctx, d, "", logger); err == nil {
648 t.Errorf("expected error for empty tap db path")
649 }
650
651 var marked int
652 if err := d.QueryRow(
653 `select count(*) from migrations where name = ?`,
654 "force-tap-repo-resync-v1",
655 ).Scan(&marked); err != nil {
656 t.Fatalf("query migrations: %v", err)
657 }
658 if marked != 0 {
659 t.Errorf("empty path should not mark flag, got %d", marked)
660 }
661}
662
663func TestRunStartupMigrations_NonEmbedSkipsTapNudge(t *testing.T) {
664 ctx := context.Background()
665 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
666 d, _ := newTestSpindleDB(t)
667
668 if err := runStartupMigrations(ctx, d, false, "", logger); err != nil {
669 t.Fatalf("non-embed should not error on empty path: %v", err)
670 }
671
672 var marked int
673 if err := d.QueryRow(
674 `select count(*) from migrations where name = ?`,
675 "force-tap-repo-resync-v1",
676 ).Scan(&marked); err != nil {
677 t.Fatalf("query migrations: %v", err)
678 }
679 if marked != 0 {
680 t.Errorf("non-embed mode should skip tap nudge flag, got %d", marked)
681 }
682}
683
684func TestCleanupOrphanRepos_DeletesWhenSiblingExists(t *testing.T) {
685 ctx := context.Background()
686 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
687 d, _ := newTestSpindleDB(t)
688
689 owner := "did:plc:akshay"
690 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
691 ('k', ?, 'legacy_name', null, null),
692 ('k', ?, '3kspindlerkey00a', 'did:plc:boltless', '2024-01-01T00:00:00Z')`,
693 owner, owner); err != nil {
694 t.Fatalf("seed: %v", err)
695 }
696
697 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
698 t.Fatalf("cleanupOrphanRepos: %v", err)
699 }
700
701 var nullCount int
702 if err := d.QueryRow(`select count(*) from repos where repo_did is null`).Scan(&nullCount); err != nil {
703 t.Fatalf("null count: %v", err)
704 }
705 if nullCount != 0 {
706 t.Errorf("orphan should be deleted when sibling exists, got %d remaining", nullCount)
707 }
708
709 var sibCount int
710 if err := d.QueryRow(`select count(*) from repos where repo_did is not null`).Scan(&sibCount); err != nil {
711 t.Fatalf("sibling count: %v", err)
712 }
713 if sibCount != 1 {
714 t.Errorf("sibling row should be preserved, got %d", sibCount)
715 }
716}
717
718func TestCleanupOrphanRepos_KeepsWhenAlone(t *testing.T) {
719 ctx := context.Background()
720 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
721 d, _ := newTestSpindleDB(t)
722
723 owner := "did:plc:akshay"
724 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
725 ('k', ?, 'legacy_name', null, null)`, owner); err != nil {
726 t.Fatalf("seed: %v", err)
727 }
728
729 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
730 t.Fatalf("cleanupOrphanRepos: %v", err)
731 }
732
733 var remaining int
734 if err := d.QueryRow(`select count(*) from repos where owner = ?`, owner).Scan(&remaining); err != nil {
735 t.Fatalf("count: %v", err)
736 }
737 if remaining != 1 {
738 t.Errorf("orphan with no sibling should be kept (preserves owner registration), got %d", remaining)
739 }
740}
741
742func TestCleanupOrphanRepos_PerOwnerScope(t *testing.T) {
743 ctx := context.Background()
744 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
745 d, _ := newTestSpindleDB(t)
746
747 ownerA := "did:plc:akshay"
748 ownerB := "did:plc:limpet"
749 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
750 ('k', ?, 'legacy_a', null, null),
751 ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z'),
752 ('k', ?, 'legacy_b', null, null)`,
753 ownerA, ownerA, ownerB); err != nil {
754 t.Fatalf("seed: %v", err)
755 }
756
757 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
758 t.Fatalf("cleanupOrphanRepos: %v", err)
759 }
760
761 var ownerARows, ownerBRows int
762 if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerA).Scan(&ownerARows); err != nil {
763 t.Fatalf("count A: %v", err)
764 }
765 if ownerARows != 1 {
766 t.Errorf("ownerA: orphan should be deleted (sibling exists), expected 1 row, got %d", ownerARows)
767 }
768 if err := d.QueryRow(`select count(*) from repos where owner = ?`, ownerB).Scan(&ownerBRows); err != nil {
769 t.Fatalf("count B: %v", err)
770 }
771 if ownerBRows != 1 {
772 t.Errorf("ownerB: orphan should be kept (no sibling), expected 1 row, got %d", ownerBRows)
773 }
774}
775
776func TestCleanupOrphanRepos_EmptyStringRepoDid(t *testing.T) {
777 ctx := context.Background()
778 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
779 d, _ := newTestSpindleDB(t)
780
781 owner := "did:plc:akshay"
782 if _, err := d.Exec(`insert into repos (knot, owner, rkey, repo_did, created_at) values
783 ('k', ?, 'legacy_empty', '', null),
784 ('k', ?, '3krealkey', 'did:plc:boltless', '2024-01-01T00:00:00Z')`,
785 owner, owner); err != nil {
786 t.Fatalf("seed: %v", err)
787 }
788
789 if err := cleanupOrphanRepos(ctx, d, logger); err != nil {
790 t.Fatalf("cleanupOrphanRepos: %v", err)
791 }
792
793 var emptyCount int
794 if err := d.QueryRow(`select count(*) from repos where coalesce(repo_did, '') = ''`).Scan(&emptyCount); err != nil {
795 t.Fatalf("empty count: %v", err)
796 }
797 if emptyCount != 0 {
798 t.Errorf("empty-string repo_did orphan should be deleted when sibling exists, got %d remaining", emptyCount)
799 }
800}
801
802func TestMigrateLegacyRepoCasbin_MultipleCollabsAllRekeyed(t *testing.T) {
803 ctx := context.Background()
804 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
805 d, e := newTestSpindleDB(t)
806
807 if err := e.AddSpindle(rbacDomain); err != nil {
808 t.Fatalf("AddSpindle: %v", err)
809 }
810
811 owner := "did:plc:akshay"
812 repoDid := "did:plc:boltless"
813 displayName := "myrepo"
814 rkey := "3kspindlerkey00a"
815 oldNameKey := owner + "/" + displayName
816 collabs := []string{"did:plc:limpet", "did:plc:nautilus", "did:plc:whelk", "did:plc:cuttle"}
817
818 var addCollabRows func(rest []string, idx int)
819 addCollabRows = func(rest []string, idx int) {
820 if len(rest) == 0 {
821 return
822 }
823 mustAddCollab(t, d, owner, fmt.Sprintf("3kcollabrkey%04d", idx), rest[0], repoDid)
824 addCollabRows(rest[1:], idx+1)
825 }
826 addCollabRows(collabs, 0)
827
828 if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil {
829 t.Fatalf("seed owner: %v", err)
830 }
831 var seedAll func(rest []string) error
832 seedAll = func(rest []string) error {
833 if len(rest) == 0 {
834 return nil
835 }
836 if err := e.AddCollaborator(rest[0], rbacDomain, oldNameKey); err != nil {
837 return err
838 }
839 return seedAll(rest[1:])
840 }
841 if err := seedAll(collabs); err != nil {
842 t.Fatalf("seed collab policies: %v", err)
843 }
844
845 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
846
847 var assertEach func(rest []string)
848 assertEach = func(rest []string) {
849 if len(rest) == 0 {
850 return
851 }
852 c := rest[0]
853 if got, err := e.IsSettingsAllowed(c, rbacDomain, repoDid); err != nil || !got {
854 t.Errorf("collab %s should have settings at repoDid, allowed=%v err=%v", c, got, err)
855 }
856 if got, err := e.IsPushAllowed(c, rbacDomain, repoDid); err != nil || !got {
857 t.Errorf("collab %s should have push at repoDid, allowed=%v err=%v", c, got, err)
858 }
859 if got, err := e.IsSettingsAllowed(c, rbacDomain, oldNameKey); err != nil || got {
860 t.Errorf("collab %s old policy should be wiped, allowed=%v err=%v", c, got, err)
861 }
862 assertEach(rest[1:])
863 }
864 assertEach(collabs)
865}
866
867func TestMigrateLegacyRepoCasbin_RenameSiblingsEachWiped(t *testing.T) {
868 ctx := context.Background()
869 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
870 d, e := newTestSpindleDB(t)
871
872 if err := e.AddSpindle(rbacDomain); err != nil {
873 t.Fatalf("AddSpindle: %v", err)
874 }
875
876 owner := syntax.DID("did:plc:akshay")
877 repoDid := syntax.DID("did:plc:di4gol2smljyj6gjnjdu5qrg")
878 siblings := []string{"pre-rename-life", "i-renamed-this", "post-rename-rename", "post-rename-renamed-again"}
879
880 var seedAll func(rest []string) error
881 seedAll = func(rest []string) error {
882 if len(rest) == 0 {
883 return nil
884 }
885 if err := e.AddRepo(owner.String(), rbacDomain, owner.String()+"/"+rest[0]); err != nil {
886 return err
887 }
888 return seedAll(rest[1:])
889 }
890 if err := seedAll(siblings); err != nil {
891 t.Fatalf("seed siblings: %v", err)
892 }
893
894 var run func(rest []string)
895 run = func(rest []string) {
896 if len(rest) == 0 {
897 return
898 }
899 migrateLegacyRepoCasbin(ctx, d, e, logger, owner, "", syntax.RecordKey(rest[0]), repoDid)
900 run(rest[1:])
901 }
902 run(siblings)
903
904 var assertWiped func(rest []string)
905 assertWiped = func(rest []string) {
906 if len(rest) == 0 {
907 return
908 }
909 key := owner.String() + "/" + rest[0]
910 if got, err := e.IsSettingsAllowed(owner.String(), rbacDomain, key); err != nil || got {
911 t.Errorf("rename sibling %s should be wiped, allowed=%v err=%v", rest[0], got, err)
912 }
913 assertWiped(rest[1:])
914 }
915 assertWiped(siblings)
916
917 if got, err := e.IsSettingsAllowed(owner.String(), rbacDomain, repoDid.String()); err != nil || !got {
918 t.Errorf("owner should retain settings at repoDid, allowed=%v err=%v", got, err)
919 }
920}
921
922func TestMigrateLegacyRepoCasbin_StrandedCollabWiped(t *testing.T) {
923 ctx := context.Background()
924 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
925 d, e := newTestSpindleDB(t)
926
927 if err := e.AddSpindle(rbacDomain); err != nil {
928 t.Fatalf("AddSpindle: %v", err)
929 }
930
931 owner := "did:plc:akshay"
932 repoDid := "did:plc:boltless"
933 displayName := "myrepo"
934 rkey := "3kspindlerkey00a"
935 strandedCollab := "did:plc:nautilus"
936 oldNameKey := owner + "/" + displayName
937
938 if err := e.AddRepo(owner, rbacDomain, oldNameKey); err != nil {
939 t.Fatalf("seed AddRepo at Name key: %v", err)
940 }
941 if err := e.AddCollaborator(strandedCollab, rbacDomain, oldNameKey); err != nil {
942 t.Fatalf("seed stranded collab at Name key: %v", err)
943 }
944
945 migrateLegacyRepoCasbin(ctx, d, e, logger, syntax.DID(owner), displayName, syntax.RecordKey(rkey), syntax.DID(repoDid))
946
947 if got, err := e.IsSettingsAllowed(strandedCollab, rbacDomain, oldNameKey); err != nil || got {
948 t.Errorf("stranded collab should be wiped from old key, allowed=%v err=%v", got, err)
949 }
950 if got, err := e.IsSettingsAllowed(owner, rbacDomain, oldNameKey); err != nil || got {
951 t.Errorf("owner old policy should be wiped, allowed=%v err=%v", got, err)
952 }
953 if got, err := e.IsSettingsAllowed(owner, rbacDomain, repoDid); err != nil || !got {
954 t.Errorf("owner should have settings at new repoDid key, allowed=%v err=%v", got, err)
955 }
956}