Stitch any CI into Tangled
2

Configure Feed

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

at main 18 kB View raw
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}