Stitch any CI into Tangled
1package main
2
3// Tests for the SQLite store. We use t.TempDir() for an isolated database
4// per test, which both keeps tests independent and exercises the open +
5// migrate path on every run.
6//
7// Where the store doesn't expose a query method (it's intentionally
8// write-mostly today) we drop down to raw SQL via s.db to verify the
9// row is what we expect.
10
11import (
12 "context"
13 "path/filepath"
14 "testing"
15 "time"
16)
17
18// newTestStore opens a fresh store in a per-test temp dir and registers
19// cleanup. Centralized so test bodies stay focused on behavior.
20func newTestStore(t *testing.T) *store {
21 t.Helper()
22 path := filepath.Join(t.TempDir(), "tack.db")
23 s, err := openStore(path)
24 if err != nil {
25 t.Fatalf("openStore: %v", err)
26 }
27 t.Cleanup(func() {
28 if err := s.Close(); err != nil {
29 t.Errorf("close store: %v", err)
30 }
31 })
32 return s
33}
34
35// TestOpenStoreIdempotent makes sure re-opening an existing database
36// (which is what happens on every restart) succeeds and leaves the
37// schema intact.
38func TestOpenStoreIdempotent(t *testing.T) {
39 path := filepath.Join(t.TempDir(), "tack.db")
40 s1, err := openStore(path)
41 if err != nil {
42 t.Fatalf("first open: %v", err)
43 }
44 if err := s1.Close(); err != nil {
45 t.Fatalf("first close: %v", err)
46 }
47 s2, err := openStore(path)
48 if err != nil {
49 t.Fatalf("second open: %v", err)
50 }
51 defer s2.Close()
52
53 // Sanity check: the schema is in place by writing and reading back
54 // a cursor through the freshly re-opened handle.
55 ctx := context.Background()
56 if err := s2.SaveCursor(ctx, 42); err != nil {
57 t.Fatalf("save cursor: %v", err)
58 }
59 got, err := s2.LoadCursor(ctx)
60 if err != nil {
61 t.Fatalf("load cursor: %v", err)
62 }
63 if got == nil || *got != 42 {
64 t.Fatalf("cursor = %v, want 42", got)
65 }
66}
67
68// TestCursorRoundtrip covers the three states a cursor can be in:
69// missing (nil), present, and overwritten. Together they exercise the
70// INSERT and the ON CONFLICT branch of SaveCursor.
71func TestCursorRoundtrip(t *testing.T) {
72 s := newTestStore(t)
73 ctx := context.Background()
74
75 got, err := s.LoadCursor(ctx)
76 if err != nil {
77 t.Fatalf("load cursor (empty): %v", err)
78 }
79 if got != nil {
80 t.Fatalf("expected nil cursor on fresh store, got %d", *got)
81 }
82
83 if err := s.SaveCursor(ctx, 1234567890); err != nil {
84 t.Fatalf("save cursor: %v", err)
85 }
86 got, err = s.LoadCursor(ctx)
87 if err != nil {
88 t.Fatalf("load cursor: %v", err)
89 }
90 if got == nil || *got != 1234567890 {
91 t.Fatalf("cursor after save = %v, want 1234567890", got)
92 }
93
94 // Overwrite path — exercises ON CONFLICT DO UPDATE.
95 if err := s.SaveCursor(ctx, 9999); err != nil {
96 t.Fatalf("overwrite cursor: %v", err)
97 }
98 got, err = s.LoadCursor(ctx)
99 if err != nil {
100 t.Fatalf("load cursor (overwrite): %v", err)
101 }
102 if got == nil || *got != 9999 {
103 t.Fatalf("cursor after overwrite = %v, want 9999", got)
104 }
105}
106
107// TestSpindleMemberLifecycle covers insert, update-on-conflict, and
108// delete for spindle.member rows. We read back via raw SQL since the
109// store doesn't expose a query helper.
110func TestSpindleMemberLifecycle(t *testing.T) {
111 s := newTestStore(t)
112 ctx := context.Background()
113
114 const did, rkey = "did:plc:owner", "abc123"
115
116 if err := s.UpsertSpindleMember(ctx, did, rkey, "https://spindle.example", "did:plc:alice", "2026-01-01T00:00:00Z"); err != nil {
117 t.Fatalf("insert: %v", err)
118 }
119
120 var instance, subject, createdAt string
121 err := s.db.QueryRowContext(ctx,
122 `SELECT instance, subject, created_at FROM spindle_members WHERE did = ? AND rkey = ?`,
123 did, rkey,
124 ).Scan(&instance, &subject, &createdAt)
125 if err != nil {
126 t.Fatalf("query after insert: %v", err)
127 }
128 if instance != "https://spindle.example" || subject != "did:plc:alice" || createdAt != "2026-01-01T00:00:00Z" {
129 t.Fatalf("after insert got (%q,%q,%q)", instance, subject, createdAt)
130 }
131
132 // Update path — same primary key, different fields.
133 if err := s.UpsertSpindleMember(ctx, did, rkey, "https://spindle.example", "did:plc:bob", "2026-02-02T00:00:00Z"); err != nil {
134 t.Fatalf("update: %v", err)
135 }
136 err = s.db.QueryRowContext(ctx,
137 `SELECT subject, created_at FROM spindle_members WHERE did = ? AND rkey = ?`,
138 did, rkey,
139 ).Scan(&subject, &createdAt)
140 if err != nil {
141 t.Fatalf("query after update: %v", err)
142 }
143 if subject != "did:plc:bob" || createdAt != "2026-02-02T00:00:00Z" {
144 t.Fatalf("after update got (%q,%q)", subject, createdAt)
145 }
146
147 // Delete and verify the row is gone.
148 if err := s.DeleteSpindleMember(ctx, did, rkey); err != nil {
149 t.Fatalf("delete: %v", err)
150 }
151 if n := countRows(t, s, "spindle_members"); n != 0 {
152 t.Fatalf("after delete, spindle_members has %d rows, want 0", n)
153 }
154
155 // Deleting a row that doesn't exist must succeed silently — the
156 // jetstream stream can replay deletes after a restart.
157 if err := s.DeleteSpindleMember(ctx, did, rkey); err != nil {
158 t.Fatalf("delete missing: %v", err)
159 }
160}
161
162// TestRepoLifecycle exercises Repo upsert/delete and confirms that
163// optional fields (spindle, repo_did) round-trip as the empty string
164// rather than NULL — the store schema treats them uniformly.
165func TestRepoLifecycle(t *testing.T) {
166 s := newTestStore(t)
167 ctx := context.Background()
168
169 const did, rkey = "did:plc:owner", "repo1"
170
171 if err := s.UpsertRepo(ctx, did, rkey, "knot.example", "myrepo", "https://spindle.example", "did:plc:repo", "2026-01-01T00:00:00Z"); err != nil {
172 t.Fatalf("insert: %v", err)
173 }
174
175 var knot, name, spindle, repoDid string
176 err := s.db.QueryRowContext(ctx,
177 `SELECT knot, name, spindle, repo_did FROM repos WHERE did = ? AND rkey = ?`,
178 did, rkey,
179 ).Scan(&knot, &name, &spindle, &repoDid)
180 if err != nil {
181 t.Fatalf("query after insert: %v", err)
182 }
183 if knot != "knot.example" || name != "myrepo" || spindle != "https://spindle.example" || repoDid != "did:plc:repo" {
184 t.Fatalf("after insert got (%q,%q,%q,%q)", knot, name, spindle, repoDid)
185 }
186
187 // Upsert with cleared optional fields — verifies UPDATE actually
188 // overwrites them rather than preserving the old non-empty values.
189 if err := s.UpsertRepo(ctx, did, rkey, "knot.example", "myrepo", "", "", "2026-01-01T00:00:00Z"); err != nil {
190 t.Fatalf("update: %v", err)
191 }
192 err = s.db.QueryRowContext(ctx,
193 `SELECT spindle, repo_did FROM repos WHERE did = ? AND rkey = ?`,
194 did, rkey,
195 ).Scan(&spindle, &repoDid)
196 if err != nil {
197 t.Fatalf("query after update: %v", err)
198 }
199 if spindle != "" || repoDid != "" {
200 t.Fatalf("after update got (%q,%q), want both empty", spindle, repoDid)
201 }
202
203 if err := s.DeleteRepo(ctx, did, rkey); err != nil {
204 t.Fatalf("delete: %v", err)
205 }
206 if n := countRows(t, s, "repos"); n != 0 {
207 t.Fatalf("after delete, repos has %d rows, want 0", n)
208 }
209}
210
211// TestRepoCollaboratorLifecycle mirrors the repo and member tests for
212// the collaborator table.
213func TestRepoCollaboratorLifecycle(t *testing.T) {
214 s := newTestStore(t)
215 ctx := context.Background()
216
217 const did, rkey = "did:plc:owner", "collab1"
218
219 if err := s.UpsertRepoCollaborator(ctx, did, rkey, "myrepo", "did:plc:repo", "did:plc:carol", "2026-01-01T00:00:00Z"); err != nil {
220 t.Fatalf("insert: %v", err)
221 }
222
223 var repo, repoDid, subject string
224 err := s.db.QueryRowContext(ctx,
225 `SELECT repo, repo_did, subject FROM repo_collaborators WHERE did = ? AND rkey = ?`,
226 did, rkey,
227 ).Scan(&repo, &repoDid, &subject)
228 if err != nil {
229 t.Fatalf("query: %v", err)
230 }
231 if repo != "myrepo" || repoDid != "did:plc:repo" || subject != "did:plc:carol" {
232 t.Fatalf("got (%q,%q,%q)", repo, repoDid, subject)
233 }
234
235 if err := s.DeleteRepoCollaborator(ctx, did, rkey); err != nil {
236 t.Fatalf("delete: %v", err)
237 }
238 if n := countRows(t, s, "repo_collaborators"); n != 0 {
239 t.Fatalf("after delete, repo_collaborators has %d rows, want 0", n)
240 }
241}
242
243// TestKnotsForSpindle verifies the query returns only knots from repos
244// whose .spindle field matches the given hostname *and* whose publisher
245// is an authorized actor (owner or owner-vouched member). Duplicate
246// knots collapse to a single entry. Repos from non-members are excluded:
247// that's the gate that prevents an attacker-published repo record from
248// forcing us to dial an attacker-chosen knot.
249func TestKnotsForSpindle(t *testing.T) {
250 s := newTestStore(t)
251 ctx := context.Background()
252
253 const ours = "tack.example"
254 const other = "other.example"
255 const owner = "did:plc:owner"
256
257 // Vouch for did:plc:a and did:plc:b; did:plc:c is the owner so
258 // no membership grant is needed; did:plc:zzz is unvouched.
259 if err := s.UpsertSpindleMember(ctx, owner, "mk1", ours, "did:plc:a", "t"); err != nil {
260 t.Fatal(err)
261 }
262 if err := s.UpsertSpindleMember(ctx, owner, "mk2", ours, "did:plc:b", "t"); err != nil {
263 t.Fatal(err)
264 }
265
266 // Two member-published repos on the same knot pointing at us;
267 // should collapse to one entry.
268 if err := s.UpsertRepo(ctx, "did:plc:a", "rk1", "knot1.example", "repo-a", ours, "", "t"); err != nil {
269 t.Fatal(err)
270 }
271 if err := s.UpsertRepo(ctx, "did:plc:b", "rk2", "knot1.example", "repo-b", ours, "", "t"); err != nil {
272 t.Fatal(err)
273 }
274 // Owner-published repo on a second knot. Owner is implicitly
275 // authorized and must be included.
276 if err := s.UpsertRepo(ctx, owner, "rk3", "knot2.example", "repo-c", ours, "", "t"); err != nil {
277 t.Fatal(err)
278 }
279 // Repo pointing at a different spindle: must be excluded.
280 if err := s.UpsertRepo(ctx, "did:plc:a", "rk4", "knot3.example", "repo-d", other, "", "t"); err != nil {
281 t.Fatal(err)
282 }
283 // Repo with no spindle declared: must be excluded.
284 if err := s.UpsertRepo(ctx, "did:plc:a", "rk5", "knot4.example", "repo-e", "", "", "t"); err != nil {
285 t.Fatal(err)
286 }
287 // Repo published by an unvouched DID: must be excluded even
288 // though it points at us. This is the security-relevant case:
289 // without the membership filter, a stranger could pin us to
290 // "evil.example" just by publishing a sh.tangled.repo record.
291 if err := s.UpsertRepo(ctx, "did:plc:zzz", "rk6", "evil.example", "repo-z", ours, "", "t"); err != nil {
292 t.Fatal(err)
293 }
294
295 got, err := s.KnotsForSpindle(ctx, ours, owner)
296 if err != nil {
297 t.Fatalf("KnotsForSpindle: %v", err)
298 }
299 want := map[string]struct{}{"knot1.example": {}, "knot2.example": {}}
300 if len(got) != len(want) {
301 t.Fatalf("got %v, want %v", got, want)
302 }
303 for _, k := range got {
304 if _, ok := want[k]; !ok {
305 t.Fatalf("unexpected knot %q in %v", k, got)
306 }
307 }
308}
309
310// TestLookupBuildkiteBuildByTuplePicksMostRecent verifies that
311// LookupBuildkiteBuildByTuple returns the build with the largest
312// created_unix_ns even when text-ordering created_at would lie.
313//
314// The motivating bug: time.Format(RFC3339Nano) trims trailing zeros,
315// so an exact-second instant renders as "...:00Z" while one nanosecond
316// later renders as "...:00.000000001Z". Lex ordering puts ".000000001Z"
317// *before* "Z" because '.' (0x2E) < 'Z' (0x5A), so ORDER BY created_at
318// DESC would surface the *earlier* row as the latest build. Sorting
319// on created_unix_ns avoids that and is what /logs depends on to
320// resolve the right run.
321func TestLookupBuildkiteBuildByTuplePicksMostRecent(t *testing.T) {
322 s := newTestStore(t)
323 ctx := context.Background()
324
325 // Two rows for the same (knot, rkey, workflow). The "newer" one
326 // is one nanosecond later but its RFC3339Nano text sorts BEFORE
327 // the older row's, which is exactly the failure mode we're
328 // guarding against.
329 older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
330 newer := older.Add(time.Nanosecond)
331
332 insert := func(uuid string, ts time.Time) {
333 t.Helper()
334 _, err := s.db.ExecContext(ctx,
335 `INSERT INTO buildkite_builds (
336 build_uuid, build_number, pipeline_slug, org,
337 knot, pipeline_rkey, workflow,
338 pipeline_uri, created_at, created_unix_ns
339 ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?)`,
340 uuid, int64(1), "p",
341 "k", "r", "w", "at://x",
342 ts.Format(time.RFC3339Nano), ts.UnixNano(),
343 )
344 if err != nil {
345 t.Fatalf("insert %s: %v", uuid, err)
346 }
347 }
348 insert("older", older)
349 insert("newer", newer)
350
351 ref, err := s.LookupBuildkiteBuildByTuple(ctx, "k", "r", "w")
352 if err != nil {
353 t.Fatalf("lookup: %v", err)
354 }
355 if ref == nil {
356 t.Fatal("lookup returned nil; want a row")
357 }
358 if ref.BuildUUID != "newer" {
359 t.Fatalf("lookup picked %q; want %q (text-ordering bug regression)",
360 ref.BuildUUID, "newer")
361 }
362}
363
364// TestBuildkiteCreatedUnixNSBackfill simulates the upgrade path: rows
365// inserted before the column existed (created_unix_ns = 0) get
366// promoted to the parsed UnixNano of their created_at by the migration.
367// Without the backfill the lookup would tie on 0 across all legacy
368// rows and have to fall back to the (still-imperfect) text ordering.
369func TestBuildkiteCreatedUnixNSBackfill(t *testing.T) {
370 s := newTestStore(t)
371 ctx := context.Background()
372
373 // Pretend this row was written by an older binary: created_at
374 // is set, created_unix_ns is the post-ALTER default 0.
375 want := time.Date(2026, 3, 4, 5, 6, 7, 8, time.UTC)
376 if _, err := s.db.ExecContext(ctx,
377 `INSERT INTO buildkite_builds (
378 build_uuid, build_number, pipeline_slug, org,
379 knot, pipeline_rkey, workflow,
380 pipeline_uri, created_at, created_unix_ns
381 ) VALUES (?, 1, 'p', '', 'k', 'r', 'w', 'at://x', ?, 0)`,
382 "legacy", want.Format(time.RFC3339Nano),
383 ); err != nil {
384 t.Fatalf("seed legacy row: %v", err)
385 }
386
387 if err := s.backfillBuildkiteCreatedUnixNS(ctx); err != nil {
388 t.Fatalf("backfill: %v", err)
389 }
390
391 var got int64
392 if err := s.db.QueryRowContext(ctx,
393 `SELECT created_unix_ns FROM buildkite_builds WHERE build_uuid = ?`,
394 "legacy",
395 ).Scan(&got); err != nil {
396 t.Fatalf("read back: %v", err)
397 }
398 if got != want.UnixNano() {
399 t.Fatalf("created_unix_ns = %d; want %d", got, want.UnixNano())
400 }
401}
402
403// TestAuthorizePipelineActor exercises every gate of the
404// authorization helper end-to-end. The case table covers the matrix
405// of {repo claim present, member grant present, actor is owner} and
406// pins the negative-path failure reasons, which the knot consumer
407// surfaces verbatim into its denial logs.
408func TestAuthorizePipelineActor(t *testing.T) {
409 const (
410 hostname = "spindle.example"
411 knot = "knot.example"
412 owner = "did:plc:owner"
413 repoOwn = "did:plc:alice"
414 repoName = "myrepo"
415 )
416 ctx := context.Background()
417
418 t.Run("missing repo did", func(t *testing.T) {
419 s := newTestStore(t)
420 ok, reason, err := s.AuthorizePipelineActor(ctx,
421 hostname, knot, owner, "", repoName)
422 if err != nil || ok {
423 t.Fatalf("got ok=%v err=%v; want ok=false err=nil", ok, err)
424 }
425 if reason == "" {
426 t.Fatal("expected non-empty reason")
427 }
428 })
429
430 t.Run("repo not on this spindle/knot", func(t *testing.T) {
431 s := newTestStore(t)
432 // Repo exists but with the wrong knot: the trigger
433 // arrived on knot.example but the repo opted into a
434 // different host. Must be rejected.
435 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
436 "other.knot", repoName, hostname, "", "t"); err != nil {
437 t.Fatal(err)
438 }
439 ok, reason, err := s.AuthorizePipelineActor(ctx,
440 hostname, knot, owner, repoOwn, repoName)
441 if err != nil || ok {
442 t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason)
443 }
444 })
445
446 t.Run("repo on wrong spindle", func(t *testing.T) {
447 s := newTestStore(t)
448 // Right knot, but spindle is someone else.
449 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
450 knot, repoName, "other.spindle", "", "t"); err != nil {
451 t.Fatal(err)
452 }
453 ok, _, err := s.AuthorizePipelineActor(ctx,
454 hostname, knot, owner, repoOwn, repoName)
455 if err != nil || ok {
456 t.Fatalf("got ok=%v err=%v", ok, err)
457 }
458 })
459
460 t.Run("repo claims us but actor not a member", func(t *testing.T) {
461 s := newTestStore(t)
462 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
463 knot, repoName, hostname, "", "t"); err != nil {
464 t.Fatal(err)
465 }
466 ok, reason, err := s.AuthorizePipelineActor(ctx,
467 hostname, knot, owner, repoOwn, repoName)
468 if err != nil || ok {
469 t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason)
470 }
471 })
472
473 t.Run("membership granted by non-owner is ignored", func(t *testing.T) {
474 s := newTestStore(t)
475 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
476 knot, repoName, hostname, "", "t"); err != nil {
477 t.Fatal(err)
478 }
479 // A member record published by anyone other than the
480 // spindle owner must NOT count, otherwise self-grants
481 // would bypass authorization entirely.
482 if err := s.UpsertSpindleMember(ctx,
483 "did:plc:rando", "mk1", hostname, repoOwn, "t"); err != nil {
484 t.Fatal(err)
485 }
486 ok, _, err := s.AuthorizePipelineActor(ctx,
487 hostname, knot, owner, repoOwn, repoName)
488 if err != nil || ok {
489 t.Fatalf("got ok=%v err=%v", ok, err)
490 }
491 })
492
493 t.Run("owner-granted member is allowed", func(t *testing.T) {
494 s := newTestStore(t)
495 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
496 knot, repoName, hostname, "", "t"); err != nil {
497 t.Fatal(err)
498 }
499 if err := s.UpsertSpindleMember(ctx,
500 owner, "mk1", hostname, repoOwn, "t"); err != nil {
501 t.Fatal(err)
502 }
503 ok, _, err := s.AuthorizePipelineActor(ctx,
504 hostname, knot, owner, repoOwn, repoName)
505 if err != nil || !ok {
506 t.Fatalf("got ok=%v err=%v; want ok=true", ok, err)
507 }
508 })
509
510 t.Run("spindle owner triggers their own repo", func(t *testing.T) {
511 s := newTestStore(t)
512 // Owner needs no membership row to act on a repo they
513 // themselves published. Just the repo-claim gate.
514 if err := s.UpsertRepo(ctx, owner, "rk1",
515 knot, repoName, hostname, "", "t"); err != nil {
516 t.Fatal(err)
517 }
518 ok, _, err := s.AuthorizePipelineActor(ctx,
519 hostname, knot, owner, owner, repoName)
520 if err != nil || !ok {
521 t.Fatalf("got ok=%v err=%v; want ok=true", ok, err)
522 }
523 })
524}
525
526// countRows is a small SELECT COUNT(*) helper used by lifecycle tests
527// to verify deletes actually removed the row. Table name is interpolated
528// directly because callers pass a constant from the schema, not user
529// input — SQLite doesn't allow parameterized table names anyway.
530func countRows(t *testing.T, s *store, table string) int {
531 t.Helper()
532 var n int
533 if err := s.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
534 t.Fatalf("count %s: %v", table, err)
535 }
536 return n
537}