302 lines
8.8 KiB
Go
302 lines
8.8 KiB
Go
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
|
|
}
|