This repository has no description
1package spindle
2
3import (
4 "context"
5 "database/sql"
6 "io"
7 "log/slog"
8 "path/filepath"
9 "testing"
10 "time"
11
12 "github.com/bluesky-social/indigo/atproto/syntax"
13
14 "tangled.org/core/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}