init
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user