Stitch any CI into Tangled
3

Configure Feed

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

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}