Monorepo for Tangled tangled.org
3

Configure Feed

Select the types of activity you want to include in your feed.

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}