This repository has no description
0

Configure Feed

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

core / spindle / startup_migrations_test.go
15 kB 492 lines
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/rbac/v2" 15 "tangled.org/core/spindle/db" 16 "tangled.org/core/spindle/secrets" 17) 18 19func seedTapDB(t *testing.T, path string) { 20 t.Helper() 21 tdb, err := sql.Open("sqlite3", path) 22 if err != nil { 23 t.Fatalf("open tap db: %v", err) 24 } 25 defer tdb.Close() 26 if _, err := tdb.Exec(` 27 create table repos ( 28 did text primary key, 29 state text not null default 'pending', 30 status text not null default 'active', 31 handle text default '', 32 rev text default '', 33 prev_data text default '', 34 error_msg text default '', 35 retry_count integer not null default 0, 36 retry_after integer not null default 0 37 ); 38 create table repo_records ( 39 did text not null, 40 collection text not null, 41 rkey text not null, 42 cid text not null, 43 primary key (did, collection, rkey) 44 ); 45 `); err != nil { 46 t.Fatalf("create tap tables: %v", err) 47 } 48} 49 50func tapRepoState(t *testing.T, path, did string) string { 51 t.Helper() 52 tdb, err := sql.Open("sqlite3", path) 53 if err != nil { 54 t.Fatalf("open tap db: %v", err) 55 } 56 defer tdb.Close() 57 var state string 58 if err := tdb.QueryRow(`select state from repos where did = ?`, did).Scan(&state); err != nil { 59 t.Fatalf("query state for %s: %v", did, err) 60 } 61 return state 62} 63 64func tapRecordCount(t *testing.T, path string) int { 65 t.Helper() 66 tdb, err := sql.Open("sqlite3", path) 67 if err != nil { 68 t.Fatalf("open tap db: %v", err) 69 } 70 defer tdb.Close() 71 var n int 72 if err := tdb.QueryRow(`select count(*) from repo_records`).Scan(&n); err != nil { 73 t.Fatalf("count repo_records: %v", err) 74 } 75 return n 76} 77 78func newTestSpindleDB(t *testing.T) (*db.DB, *rbac.Enforcer) { 79 t.Helper() 80 p := filepath.Join(t.TempDir(), "spindle.db") 81 d, err := db.Make(context.Background(), p) 82 if err != nil { 83 t.Fatalf("db.Make: %v", err) 84 } 85 t.Cleanup(func() { d.Close() }) 86 e, err := rbac.NewEnforcer(p) 87 if err != nil { 88 t.Fatalf("rbac.NewEnforcer: %v", err) 89 } 90 e.EnableAutoSave(true) 91 return d, e 92} 93 94func newTestVault(t *testing.T) *secrets.SqliteManager { 95 t.Helper() 96 vault, err := secrets.NewSQLiteManager(filepath.Join(t.TempDir(), "vault.db")) 97 if err != nil { 98 t.Fatalf("vault.New: %v", err) 99 } 100 return vault 101} 102 103func mustAddSecret(t *testing.T, vault secrets.Manager, repo, key, value string, createdAt time.Time, by string) { 104 t.Helper() 105 err := vault.AddSecret(context.Background(), secrets.UnlockedSecret{ 106 Repo: secrets.RepoIdentifier(repo), 107 Key: key, 108 Value: value, 109 CreatedAt: createdAt, 110 CreatedBy: syntax.DID(by), 111 }) 112 if err != nil { 113 t.Fatalf("AddSecret(%s/%s): %v", repo, key, err) 114 } 115} 116 117func TestMigrateLegacyRepoSecrets_NameCandidate(t *testing.T) { 118 ctx := context.Background() 119 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 120 d, _ := newTestSpindleDB(t) 121 vault := newTestVault(t) 122 123 owner := syntax.DID("did:plc:akshay") 124 repoDid := syntax.DID("did:plc:boltless") 125 displayName := "myrepo" 126 rkey := syntax.RecordKey("3kspindlerkey00a") 127 128 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 129 oldNameKey := owner.String() + "/" + displayName 130 131 mustAddSecret(t, vault, oldNameKey, "API_KEY", "alpha", created, owner.String()) 132 mustAddSecret(t, vault, oldNameKey, "DB_PASSWORD", "bravo", created.Add(1*time.Hour), owner.String()) 133 134 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid) 135 136 copied, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 137 if err != nil { 138 t.Fatalf("GetSecretsUnlocked(new): %v", err) 139 } 140 if len(copied) != 2 { 141 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(copied)) 142 } 143 144 want := map[string]struct { 145 value string 146 createdAt time.Time 147 }{ 148 "API_KEY": {"alpha", created}, 149 "DB_PASSWORD": {"bravo", created.Add(1 * time.Hour)}, 150 } 151 for _, s := range copied { 152 w, ok := want[s.Key] 153 if !ok { 154 t.Errorf("unexpected key %q under %s", s.Key, repoDid) 155 continue 156 } 157 if s.Value != w.value { 158 t.Errorf("%s: value got %q, want %q", s.Key, s.Value, w.value) 159 } 160 if !s.CreatedAt.Equal(w.createdAt) { 161 t.Errorf("%s: CreatedAt got %s, want %s", s.Key, s.CreatedAt, w.createdAt) 162 } 163 if string(s.Repo) != repoDid.String() { 164 t.Errorf("%s: Repo got %s, want %s", s.Key, s.Repo, repoDid) 165 } 166 } 167 168 orig, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(oldNameKey)) 169 if err != nil { 170 t.Fatalf("GetSecretsUnlocked(old): %v", err) 171 } 172 if len(orig) != 2 { 173 t.Errorf("expected old-key secrets preserved, got %d", len(orig)) 174 } 175 176 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid) 177 again, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 178 if err != nil { 179 t.Fatalf("GetSecretsUnlocked(new) after re-run: %v", err) 180 } 181 if len(again) != 2 { 182 t.Errorf("re-run should not duplicate or drop secrets, got %d", len(again)) 183 } 184 185 var marked int 186 if err := d.QueryRow( 187 `select count(*) from migrations where name = ?`, 188 "legacy-secret-copy:"+repoDid.String()+":"+rkey.String(), 189 ).Scan(&marked); err != nil { 190 t.Fatalf("query migrations: %v", err) 191 } 192 if marked != 1 { 193 t.Errorf("expected per-repo flag recorded exactly once, got %d", marked) 194 } 195} 196 197func TestMigrateLegacyRepoSecrets_RkeyCandidate(t *testing.T) { 198 ctx := context.Background() 199 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 200 d, _ := newTestSpindleDB(t) 201 vault := newTestVault(t) 202 203 owner := syntax.DID("did:plc:akshay") 204 repoDid := syntax.DID("did:plc:boltless") 205 displayName := "myrepo" 206 rkey := syntax.RecordKey("3kspindlerkey00a") 207 208 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 209 oldRkeyKey := owner.String() + "/" + rkey.String() 210 211 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String()) 212 mustAddSecret(t, vault, oldRkeyKey, "DB_PASSWORD", "bravo", created, owner.String()) 213 214 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid) 215 216 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 217 if err != nil { 218 t.Fatalf("GetSecretsUnlocked: %v", err) 219 } 220 if len(got) != 2 { 221 t.Fatalf("expected 2 secrets copied via rkey candidate, got %d", len(got)) 222 } 223} 224 225func TestMigrateLegacyRepoSecrets_BothCandidates(t *testing.T) { 226 ctx := context.Background() 227 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 228 d, _ := newTestSpindleDB(t) 229 vault := newTestVault(t) 230 231 owner := syntax.DID("did:plc:akshay") 232 repoDid := syntax.DID("did:plc:boltless") 233 displayName := "myrepo" 234 rkey := syntax.RecordKey("3kspindlerkey00a") 235 236 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 237 oldNameKey := owner.String() + "/" + displayName 238 oldRkeyKey := owner.String() + "/" + rkey.String() 239 240 mustAddSecret(t, vault, oldNameKey, "FROM_NAME", "n", created, owner.String()) 241 mustAddSecret(t, vault, oldRkeyKey, "FROM_RKEY", "r", created, owner.String()) 242 243 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid) 244 245 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 246 if err != nil { 247 t.Fatalf("GetSecretsUnlocked: %v", err) 248 } 249 if len(got) != 2 { 250 t.Fatalf("expected 2 secrets merged from both candidates, got %d", len(got)) 251 } 252 seen := map[string]string{} 253 for _, s := range got { 254 seen[s.Key] = s.Value 255 } 256 if seen["FROM_NAME"] != "n" { 257 t.Errorf("FROM_NAME missing or wrong value: %q", seen["FROM_NAME"]) 258 } 259 if seen["FROM_RKEY"] != "r" { 260 t.Errorf("FROM_RKEY missing or wrong value: %q", seen["FROM_RKEY"]) 261 } 262} 263 264func TestMigrateLegacyRepoSecrets_PreExistingTakesPriority(t *testing.T) { 265 ctx := context.Background() 266 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 267 d, _ := newTestSpindleDB(t) 268 vault := newTestVault(t) 269 270 owner := syntax.DID("did:plc:akshay") 271 repoDid := syntax.DID("did:plc:boltless") 272 displayName := "myrepo" 273 rkey := syntax.RecordKey("3kspindlerkey00a") 274 275 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 276 oldKey := owner.String() + "/" + displayName 277 278 mustAddSecret(t, vault, oldKey, "API_KEY", "alpha", created, owner.String()) 279 mustAddSecret(t, vault, oldKey, "DB_PASSWORD", "bravo", created, owner.String()) 280 mustAddSecret(t, vault, repoDid.String(), "API_KEY", "pre-existing", created.Add(-24*time.Hour), owner.String()) 281 282 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, displayName, rkey, repoDid) 283 284 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 285 if err != nil { 286 t.Fatalf("GetSecretsUnlocked: %v", err) 287 } 288 if len(got) != 2 { 289 t.Fatalf("expected 2 secrets under repo_did key, got %d", len(got)) 290 } 291 for _, s := range got { 292 if s.Key == "API_KEY" && s.Value != "pre-existing" { 293 t.Errorf("API_KEY should preserve pre-existing value, got %q", s.Value) 294 } 295 if s.Key == "DB_PASSWORD" && s.Value != "bravo" { 296 t.Errorf("DB_PASSWORD should be copied, got %q", s.Value) 297 } 298 } 299} 300 301func TestMigrateLegacyRepoSecrets_EmptyName(t *testing.T) { 302 ctx := context.Background() 303 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 304 d, _ := newTestSpindleDB(t) 305 vault := newTestVault(t) 306 307 owner := syntax.DID("did:plc:akshay") 308 repoDid := syntax.DID("did:plc:boltless") 309 rkey := syntax.RecordKey("3kspindlerkey00a") 310 311 created := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) 312 oldRkeyKey := owner.String() + "/" + rkey.String() 313 mustAddSecret(t, vault, oldRkeyKey, "API_KEY", "alpha", created, owner.String()) 314 315 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", rkey, repoDid) 316 317 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 318 if err != nil { 319 t.Fatalf("GetSecretsUnlocked: %v", err) 320 } 321 if len(got) != 1 { 322 t.Errorf("expected 1 secret via rkey candidate when name empty, got %d", len(got)) 323 } 324} 325 326func TestMigrateLegacyRepoSecrets_BothEmpty(t *testing.T) { 327 ctx := context.Background() 328 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 329 d, _ := newTestSpindleDB(t) 330 vault := newTestVault(t) 331 332 owner := syntax.DID("did:plc:akshay") 333 repoDid := syntax.DID("did:plc:boltless") 334 335 migrateLegacyRepoSecrets(ctx, d, vault, logger, owner, "", "", repoDid) 336 337 got, err := vault.GetSecretsUnlocked(ctx, secrets.RepoIdentifier(repoDid)) 338 if err != nil { 339 t.Fatalf("GetSecretsUnlocked: %v", err) 340 } 341 if len(got) != 0 { 342 t.Errorf("expected no work when both name and rkey empty, got %d secrets", len(got)) 343 } 344 345 var marked int 346 if err := d.QueryRow( 347 `select count(*) from migrations where name like ?`, 348 "legacy-secret-copy:"+repoDid.String()+":%", 349 ).Scan(&marked); err != nil { 350 t.Fatalf("query migrations: %v", err) 351 } 352 if marked != 0 { 353 t.Errorf("empty inputs should not record flag, got %d", marked) 354 } 355} 356 357func TestNudgeTapForResync(t *testing.T) { 358 ctx := context.Background() 359 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 360 d, _ := newTestSpindleDB(t) 361 362 tapPath := filepath.Join(t.TempDir(), "tap.db") 363 seedTapDB(t, tapPath) 364 365 tdb, err := sql.Open("sqlite3", tapPath) 366 if err != nil { 367 t.Fatalf("open tap db: %v", err) 368 } 369 if _, err := tdb.Exec(`insert into repos (did, state) values 370 ('did:plc:akshay', 'active'), 371 ('did:plc:boltless', 'error'), 372 ('did:plc:limpet', 'pending') 373 `); err != nil { 374 t.Fatalf("seed repos: %v", err) 375 } 376 if _, err := tdb.Exec(`insert into repo_records (did, collection, rkey, cid) values 377 ('did:plc:akshay', 'sh.tangled.repo', '3kspindlerkey00a', 'bafyone'), 378 ('did:plc:boltless', 'sh.tangled.repo', '3kspindlerkey00b', 'bafytwo') 379 `); err != nil { 380 t.Fatalf("seed records: %v", err) 381 } 382 tdb.Close() 383 384 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil { 385 t.Fatalf("nudgeTapForResync: %v", err) 386 } 387 388 if got := tapRecordCount(t, tapPath); got != 0 { 389 t.Errorf("expected repo_records cleared, got %d", got) 390 } 391 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "desynchronized" { 392 t.Errorf("active should flip to desynchronized, got %s", got) 393 } 394 if got := tapRepoState(t, tapPath, "did:plc:boltless"); got != "desynchronized" { 395 t.Errorf("error should flip to desynchronized, got %s", got) 396 } 397 if got := tapRepoState(t, tapPath, "did:plc:limpet"); got != "pending" { 398 t.Errorf("pending should not be touched, got %s", got) 399 } 400 401 tdb2, err := sql.Open("sqlite3", tapPath) 402 if err != nil { 403 t.Fatalf("reopen tap db: %v", err) 404 } 405 if _, err := tdb2.Exec(`update repos set state = 'active' where did = 'did:plc:akshay'`); err != nil { 406 t.Fatalf("reseed: %v", err) 407 } 408 tdb2.Close() 409 410 if err := nudgeTapForResync(ctx, d, tapPath, logger); err != nil { 411 t.Fatalf("nudgeTapForResync second run: %v", err) 412 } 413 if got := tapRepoState(t, tapPath, "did:plc:akshay"); got != "active" { 414 t.Errorf("idempotent re-run should not touch state, got %s", got) 415 } 416 417 var marked int 418 if err := d.QueryRow( 419 `select count(*) from migrations where name = ?`, 420 "force-tap-repo-resync-v1", 421 ).Scan(&marked); err != nil { 422 t.Fatalf("query migrations: %v", err) 423 } 424 if marked != 1 { 425 t.Errorf("expected flag recorded exactly once, got %d", marked) 426 } 427} 428 429func TestNudgeTapForResync_MissingDB(t *testing.T) { 430 ctx := context.Background() 431 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 432 d, _ := newTestSpindleDB(t) 433 434 missing := filepath.Join(t.TempDir(), "absent.db") 435 436 if err := nudgeTapForResync(ctx, d, missing, logger); err != nil { 437 t.Fatalf("missing tap db should succeed: %v", err) 438 } 439 440 var marked int 441 if err := d.QueryRow( 442 `select count(*) from migrations where name = ?`, 443 "force-tap-repo-resync-v1", 444 ).Scan(&marked); err != nil { 445 t.Fatalf("query migrations: %v", err) 446 } 447 if marked != 1 { 448 t.Errorf("expected flag recorded even when tap db absent, got %d", marked) 449 } 450} 451 452func TestNudgeTapForResync_EmptyPath(t *testing.T) { 453 ctx := context.Background() 454 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 455 d, _ := newTestSpindleDB(t) 456 457 if err := nudgeTapForResync(ctx, d, "", logger); err == nil { 458 t.Errorf("expected error for empty tap db path") 459 } 460 461 var marked int 462 if err := d.QueryRow( 463 `select count(*) from migrations where name = ?`, 464 "force-tap-repo-resync-v1", 465 ).Scan(&marked); err != nil { 466 t.Fatalf("query migrations: %v", err) 467 } 468 if marked != 0 { 469 t.Errorf("empty path should not mark flag, got %d", marked) 470 } 471} 472 473func TestRunStartupMigrations_NonEmbedSkipsTapNudge(t *testing.T) { 474 ctx := context.Background() 475 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) 476 d, _ := newTestSpindleDB(t) 477 478 if err := runStartupMigrations(ctx, d, false, "", logger); err != nil { 479 t.Fatalf("non-embed should not error on empty path: %v", err) 480 } 481 482 var marked int 483 if err := d.QueryRow( 484 `select count(*) from migrations where name = ?`, 485 "force-tap-repo-resync-v1", 486 ).Scan(&marked); err != nil { 487 t.Fatalf("query migrations: %v", err) 488 } 489 if marked != 0 { 490 t.Errorf("non-embed mode should skip tap nudge flag, got %d", marked) 491 } 492}