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 that duplicate
245// knots collapse to a single entry.
246func TestKnotsForSpindle(t *testing.T) {
247 s := newTestStore(t)
248 ctx := context.Background()
249
250 const ours = "tack.example"
251 const other = "other.example"
252
253 // Two repos on the same knot pointing at us — should collapse to 1.
254 if err := s.UpsertRepo(ctx, "did:plc:a", "rk1", "knot1.example", "repo-a", ours, "", "t"); err != nil {
255 t.Fatal(err)
256 }
257 if err := s.UpsertRepo(ctx, "did:plc:b", "rk2", "knot1.example", "repo-b", ours, "", "t"); err != nil {
258 t.Fatal(err)
259 }
260 // A second knot pointing at us.
261 if err := s.UpsertRepo(ctx, "did:plc:c", "rk3", "knot2.example", "repo-c", ours, "", "t"); err != nil {
262 t.Fatal(err)
263 }
264 // A repo pointing at a different spindle — must be excluded.
265 if err := s.UpsertRepo(ctx, "did:plc:d", "rk4", "knot3.example", "repo-d", other, "", "t"); err != nil {
266 t.Fatal(err)
267 }
268 // A repo with no spindle declared — must be excluded.
269 if err := s.UpsertRepo(ctx, "did:plc:e", "rk5", "knot4.example", "repo-e", "", "", "t"); err != nil {
270 t.Fatal(err)
271 }
272
273 got, err := s.KnotsForSpindle(ctx, ours)
274 if err != nil {
275 t.Fatalf("KnotsForSpindle: %v", err)
276 }
277 want := map[string]struct{}{"knot1.example": {}, "knot2.example": {}}
278 if len(got) != len(want) {
279 t.Fatalf("got %v, want %v", got, want)
280 }
281 for _, k := range got {
282 if _, ok := want[k]; !ok {
283 t.Fatalf("unexpected knot %q in %v", k, got)
284 }
285 }
286}
287
288// TestLookupBuildkiteBuildByTuplePicksMostRecent verifies that
289// LookupBuildkiteBuildByTuple returns the build with the largest
290// created_unix_ns even when text-ordering created_at would lie.
291//
292// The motivating bug: time.Format(RFC3339Nano) trims trailing zeros,
293// so an exact-second instant renders as "...:00Z" while one nanosecond
294// later renders as "...:00.000000001Z". Lex ordering puts ".000000001Z"
295// *before* "Z" because '.' (0x2E) < 'Z' (0x5A), so ORDER BY created_at
296// DESC would surface the *earlier* row as the latest build. Sorting
297// on created_unix_ns avoids that and is what /logs depends on to
298// resolve the right run.
299func TestLookupBuildkiteBuildByTuplePicksMostRecent(t *testing.T) {
300 s := newTestStore(t)
301 ctx := context.Background()
302
303 // Two rows for the same (knot, rkey, workflow). The "newer" one
304 // is one nanosecond later but its RFC3339Nano text sorts BEFORE
305 // the older row's, which is exactly the failure mode we're
306 // guarding against.
307 older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
308 newer := older.Add(time.Nanosecond)
309
310 insert := func(uuid string, ts time.Time) {
311 t.Helper()
312 _, err := s.db.ExecContext(ctx,
313 `INSERT INTO buildkite_builds (
314 build_uuid, build_number, pipeline_slug, org,
315 knot, pipeline_rkey, workflow,
316 pipeline_uri, created_at, created_unix_ns
317 ) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?, ?)`,
318 uuid, int64(1), "p",
319 "k", "r", "w", "at://x",
320 ts.Format(time.RFC3339Nano), ts.UnixNano(),
321 )
322 if err != nil {
323 t.Fatalf("insert %s: %v", uuid, err)
324 }
325 }
326 insert("older", older)
327 insert("newer", newer)
328
329 ref, err := s.LookupBuildkiteBuildByTuple(ctx, "k", "r", "w")
330 if err != nil {
331 t.Fatalf("lookup: %v", err)
332 }
333 if ref == nil {
334 t.Fatal("lookup returned nil; want a row")
335 }
336 if ref.BuildUUID != "newer" {
337 t.Fatalf("lookup picked %q; want %q (text-ordering bug regression)",
338 ref.BuildUUID, "newer")
339 }
340}
341
342// TestBuildkiteCreatedUnixNSBackfill simulates the upgrade path: rows
343// inserted before the column existed (created_unix_ns = 0) get
344// promoted to the parsed UnixNano of their created_at by the migration.
345// Without the backfill the lookup would tie on 0 across all legacy
346// rows and have to fall back to the (still-imperfect) text ordering.
347func TestBuildkiteCreatedUnixNSBackfill(t *testing.T) {
348 s := newTestStore(t)
349 ctx := context.Background()
350
351 // Pretend this row was written by an older binary: created_at
352 // is set, created_unix_ns is the post-ALTER default 0.
353 want := time.Date(2026, 3, 4, 5, 6, 7, 8, time.UTC)
354 if _, err := s.db.ExecContext(ctx,
355 `INSERT INTO buildkite_builds (
356 build_uuid, build_number, pipeline_slug, org,
357 knot, pipeline_rkey, workflow,
358 pipeline_uri, created_at, created_unix_ns
359 ) VALUES (?, 1, 'p', '', 'k', 'r', 'w', 'at://x', ?, 0)`,
360 "legacy", want.Format(time.RFC3339Nano),
361 ); err != nil {
362 t.Fatalf("seed legacy row: %v", err)
363 }
364
365 if err := s.backfillBuildkiteCreatedUnixNS(ctx); err != nil {
366 t.Fatalf("backfill: %v", err)
367 }
368
369 var got int64
370 if err := s.db.QueryRowContext(ctx,
371 `SELECT created_unix_ns FROM buildkite_builds WHERE build_uuid = ?`,
372 "legacy",
373 ).Scan(&got); err != nil {
374 t.Fatalf("read back: %v", err)
375 }
376 if got != want.UnixNano() {
377 t.Fatalf("created_unix_ns = %d; want %d", got, want.UnixNano())
378 }
379}
380
381// TestAuthorizePipelineActor exercises every gate of the
382// authorization helper end-to-end. The case table covers the matrix
383// of {repo claim present, member grant present, actor is owner} and
384// pins the negative-path failure reasons, which the knot consumer
385// surfaces verbatim into its denial logs.
386func TestAuthorizePipelineActor(t *testing.T) {
387 const (
388 hostname = "spindle.example"
389 knot = "knot.example"
390 owner = "did:plc:owner"
391 repoOwn = "did:plc:alice"
392 repoName = "myrepo"
393 )
394 ctx := context.Background()
395
396 t.Run("missing repo did", func(t *testing.T) {
397 s := newTestStore(t)
398 ok, reason, err := s.AuthorizePipelineActor(ctx,
399 hostname, knot, owner, "", repoName)
400 if err != nil || ok {
401 t.Fatalf("got ok=%v err=%v; want ok=false err=nil", ok, err)
402 }
403 if reason == "" {
404 t.Fatal("expected non-empty reason")
405 }
406 })
407
408 t.Run("repo not on this spindle/knot", func(t *testing.T) {
409 s := newTestStore(t)
410 // Repo exists but with the wrong knot: the trigger
411 // arrived on knot.example but the repo opted into a
412 // different host. Must be rejected.
413 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
414 "other.knot", repoName, hostname, "", "t"); err != nil {
415 t.Fatal(err)
416 }
417 ok, reason, err := s.AuthorizePipelineActor(ctx,
418 hostname, knot, owner, repoOwn, repoName)
419 if err != nil || ok {
420 t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason)
421 }
422 })
423
424 t.Run("repo on wrong spindle", func(t *testing.T) {
425 s := newTestStore(t)
426 // Right knot, but spindle is someone else.
427 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
428 knot, repoName, "other.spindle", "", "t"); err != nil {
429 t.Fatal(err)
430 }
431 ok, _, err := s.AuthorizePipelineActor(ctx,
432 hostname, knot, owner, repoOwn, repoName)
433 if err != nil || ok {
434 t.Fatalf("got ok=%v err=%v", ok, err)
435 }
436 })
437
438 t.Run("repo claims us but actor not a member", func(t *testing.T) {
439 s := newTestStore(t)
440 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
441 knot, repoName, hostname, "", "t"); err != nil {
442 t.Fatal(err)
443 }
444 ok, reason, err := s.AuthorizePipelineActor(ctx,
445 hostname, knot, owner, repoOwn, repoName)
446 if err != nil || ok {
447 t.Fatalf("got ok=%v err=%v reason=%q", ok, err, reason)
448 }
449 })
450
451 t.Run("membership granted by non-owner is ignored", func(t *testing.T) {
452 s := newTestStore(t)
453 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
454 knot, repoName, hostname, "", "t"); err != nil {
455 t.Fatal(err)
456 }
457 // A member record published by anyone other than the
458 // spindle owner must NOT count, otherwise self-grants
459 // would bypass authorization entirely.
460 if err := s.UpsertSpindleMember(ctx,
461 "did:plc:rando", "mk1", hostname, repoOwn, "t"); err != nil {
462 t.Fatal(err)
463 }
464 ok, _, err := s.AuthorizePipelineActor(ctx,
465 hostname, knot, owner, repoOwn, repoName)
466 if err != nil || ok {
467 t.Fatalf("got ok=%v err=%v", ok, err)
468 }
469 })
470
471 t.Run("owner-granted member is allowed", func(t *testing.T) {
472 s := newTestStore(t)
473 if err := s.UpsertRepo(ctx, repoOwn, "rk1",
474 knot, repoName, hostname, "", "t"); err != nil {
475 t.Fatal(err)
476 }
477 if err := s.UpsertSpindleMember(ctx,
478 owner, "mk1", hostname, repoOwn, "t"); err != nil {
479 t.Fatal(err)
480 }
481 ok, _, err := s.AuthorizePipelineActor(ctx,
482 hostname, knot, owner, repoOwn, repoName)
483 if err != nil || !ok {
484 t.Fatalf("got ok=%v err=%v; want ok=true", ok, err)
485 }
486 })
487
488 t.Run("spindle owner triggers their own repo", func(t *testing.T) {
489 s := newTestStore(t)
490 // Owner needs no membership row to act on a repo they
491 // themselves published. Just the repo-claim gate.
492 if err := s.UpsertRepo(ctx, owner, "rk1",
493 knot, repoName, hostname, "", "t"); err != nil {
494 t.Fatal(err)
495 }
496 ok, _, err := s.AuthorizePipelineActor(ctx,
497 hostname, knot, owner, owner, repoName)
498 if err != nil || !ok {
499 t.Fatalf("got ok=%v err=%v; want ok=true", ok, err)
500 }
501 })
502}
503
504// countRows is a small SELECT COUNT(*) helper used by lifecycle tests
505// to verify deletes actually removed the row. Table name is interpolated
506// directly because callers pass a constant from the schema, not user
507// input — SQLite doesn't allow parameterized table names anyway.
508func countRows(t *testing.T, s *store, table string) int {
509 t.Helper()
510 var n int
511 if err := s.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
512 t.Fatalf("count %s: %v", table, err)
513 }
514 return n
515}