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
19 kB 603 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/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}