package store import ( "context" "database/sql" "io" "log/slog" "path/filepath" "strings" "testing" ) func testDB(t *testing.T) *DB { t.Helper() log := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := Open(context.Background(), filepath.Join(t.TempDir(), "pages.db"), log) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { db.Close() }) return db } func TestOpenAppliesMigrations(t *testing.T) { db := testDB(t) v, err := db.SchemaVersion(context.Background()) if err != nil { t.Fatal(err) } if v != 1 { t.Errorf("schema version = %d, want 1", v) } for _, table := range []string{"projects", "api_keys", "deployments", "blobs", "deployment_files"} { var n int if err := db.Reader().QueryRow( `SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Errorf("table %s missing", table) } } } func TestMigrateIsIdempotent(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "pages.db") log := slog.New(slog.NewTextHandler(io.Discard, nil)) db, err := Open(ctx, path, log) if err != nil { t.Fatal(err) } if _, err := db.w.ExecContext(ctx, `INSERT INTO projects (name, created_at, updated_at) VALUES ('demo', 1, 1)`); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } // Reopening must not re-run migrations, and must not lose data. db2, err := Open(ctx, path, log) if err != nil { t.Fatalf("reopen: %v", err) } defer db2.Close() var name string if err := db2.Reader().QueryRow(`SELECT name FROM projects`).Scan(&name); err != nil { t.Fatal(err) } if name != "demo" { t.Errorf("project name = %q", name) } } func TestMigrateDetectsChecksumDrift(t *testing.T) { ctx := context.Background() db := testDB(t) if _, err := db.w.ExecContext(ctx, `UPDATE schema_version SET checksum = 'tampered' WHERE version = 1`); err != nil { t.Fatal(err) } err := db.migrate(ctx) if err == nil { t.Fatal("editing an applied migration must be detected") } if !strings.Contains(err.Error(), "modified after it was applied") { t.Errorf("error = %v", err) } } func TestMigrateRejectsUnknownFutureVersion(t *testing.T) { ctx := context.Background() db := testDB(t) if _, err := db.w.ExecContext(ctx, `INSERT INTO schema_version (version, checksum, applied_at) VALUES (99, 'x', 1)`); err != nil { t.Fatal(err) } if err := db.migrate(ctx); err == nil { t.Fatal("a database from a newer build must be refused") } } // The partial unique index is what actually guarantees "one active deployment // per project"; the application layer only has to avoid fighting it. func TestOneActiveDeploymentPerProject(t *testing.T) { ctx := context.Background() db := testDB(t) if err := db.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil { return err } _, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at) VALUES ('dpl_a', 1, 'ready', 1, 1)`) return err }); err != nil { t.Fatal(err) } err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at) VALUES ('dpl_b', 1, 'ready', 1, 2)`) return err }) if err == nil { t.Fatal("a second active deployment must be rejected by the database") } if !IsConstraint(err) { t.Errorf("want a constraint violation, got %v", err) } // Demoting the old one first is the supported path. if err := db.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`UPDATE deployments SET active = 0 WHERE project_id = 1 AND active = 1`); err != nil { return err } _, err := tx.Exec(`INSERT INTO deployments (public_id, project_id, state, active, created_at) VALUES ('dpl_b', 1, 'ready', 1, 2)`) return err }); err != nil { t.Fatalf("demote-then-promote must be allowed: %v", err) } } func TestRefcountTriggers(t *testing.T) { ctx := context.Background() db := testDB(t) digest := make([]byte, 32) digest[0] = 0xab setup := func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil { return err } if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at) VALUES (1, 'dpl_a', 1, 'uploading', 1), (2, 'dpl_b', 1, 'uploading', 2)`); err != nil { return err } if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at) VALUES (?, 10, 0, 1, 1)`, digest); err != nil { return err } _, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (1, 'index.html', ?, 10), (2, 'index.html', ?, 10)`, digest, digest) return err } if err := db.Tx(ctx, setup); err != nil { t.Fatal(err) } refcount := func() int { t.Helper() var n int if err := db.Reader().QueryRow(`SELECT refcount FROM blobs WHERE digest = ?`, digest).Scan(&n); err != nil { t.Fatal(err) } return n } if got := refcount(); got != 2 { t.Fatalf("refcount after 2 inserts = %d, want 2", got) } if err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`DELETE FROM deployment_files WHERE deployment_id = 1`) return err }); err != nil { t.Fatal(err) } if got := refcount(); got != 1 { t.Errorf("refcount after explicit delete = %d, want 1", got) } // The cascade path: deleting the deployment row must also decrement, which // only holds because recursive_triggers is ON. if err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`DELETE FROM deployments WHERE id = 2`) return err }); err != nil { t.Fatal(err) } if got := refcount(); got != 0 { t.Errorf("refcount after cascade = %d, want 0 (recursive_triggers not in effect?)", got) } } // A blob may not be dropped while a manifest still points at it. func TestBlobDeleteRestricted(t *testing.T) { ctx := context.Background() db := testDB(t) digest := make([]byte, 32) if err := db.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`); err != nil { return err } if _, err := tx.Exec(`INSERT INTO deployments (id, public_id, project_id, state, created_at) VALUES (1, 'dpl_a', 1, 'ready', 1)`); err != nil { return err } if _, err := tx.Exec(`INSERT INTO blobs (digest, size, present, created_at, last_ref_at) VALUES (?, 1, 1, 1, 1)`, digest); err != nil { return err } _, err := tx.Exec(`INSERT INTO deployment_files (deployment_id, path, digest, size) VALUES (1, 'a', ?, 1)`, digest) return err }); err != nil { t.Fatal(err) } err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`DELETE FROM blobs WHERE digest = ?`, digest) return err }) if err == nil { t.Fatal("deleting a referenced blob must fail") } } func TestScopeCheckConstraint(t *testing.T) { ctx := context.Background() db := testDB(t) cases := []struct { name string scope string proj any ok bool }{ {"admin without project", "admin", nil, true}, {"admin with project", "admin", int64(1), false}, {"project without project", "project", nil, false}, {"project with project", "project", int64(1), true}, {"unknown scope", "root", nil, false}, } if err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec(`INSERT INTO projects (id, name, created_at, updated_at) VALUES (1, 'demo', 1, 1)`) return err }); err != nil { t.Fatal(err) } for i, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := db.Tx(ctx, func(tx *sql.Tx) error { _, err := tx.Exec( `INSERT INTO api_keys (id, secret_hash, scope, project_id, created_at) VALUES (?, ?, ?, ?, 1)`, "key"+string(rune('a'+i)), make([]byte, 32), tc.scope, tc.proj) return err }) if tc.ok && err != nil { t.Errorf("insert should have been accepted: %v", err) } if !tc.ok && err == nil { t.Error("insert should have been rejected") } }) } } // The write pool is capped at one connection, so a transaction that never // returns would deadlock the server. This asserts that a plain read does not // need the write pool. func TestReadsDoNotBlockOnWriter(t *testing.T) { ctx := context.Background() db := testDB(t) done := make(chan struct{}) go func() { defer close(done) _ = db.Tx(ctx, func(tx *sql.Tx) error { if _, err := tx.Exec(`INSERT INTO projects (name, created_at, updated_at) VALUES ('slow', 1, 1)`); err != nil { return err } var n int // While this transaction is open, a reader must still make progress. if err := db.Reader().QueryRow(`SELECT count(*) FROM projects`).Scan(&n); err != nil { return err } return nil }) }() <-done }