DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
346 lines
10 KiB
Go
346 lines
10 KiB
Go
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
sqlite3 "modernc.org/sqlite/lib"
|
|
)
|
|
|
|
func TestOpenDatabaseCreatesFile(t *testing.T) {
|
|
t.Parallel()
|
|
path := testDBPath(t)
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Fatalf("file should not exist yet: %v", err)
|
|
}
|
|
log, buf := newTestLogger(LevelTrace)
|
|
db, err := OpenDatabase(context.Background(), path, log)
|
|
if err != nil {
|
|
t.Fatalf("OpenDatabase: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
|
|
t.Fatalf("database file not created: %v", err)
|
|
}
|
|
for _, want := range []string{
|
|
`level=TRACE msg="function entry" func=OpenDatabase`,
|
|
`level=DEBUG msg="database schema initialized"`,
|
|
`level=DEBUG msg="database opened"`,
|
|
`level=TRACE msg="function exit" func=OpenDatabase`,
|
|
} {
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("missing log %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseOddPaths(t *testing.T) {
|
|
t.Parallel()
|
|
dir := t.TempDir()
|
|
for _, name := range []string{
|
|
"with space.db", "q?mark.db", "hash#.db", "pct%20.db", "pct%.db", "amp&eq=.db",
|
|
"ünï©ødé.db", ":memory:", "semi;colon.db", "quote'\".db", "back\\slash.db",
|
|
} {
|
|
path := filepath.Join(dir, name)
|
|
db, err := OpenDatabase(context.Background(), path, traceDiscard())
|
|
if err != nil {
|
|
t.Errorf("%q: %v", name, err)
|
|
continue
|
|
}
|
|
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 1, 'x', NULL)`)
|
|
db.Close()
|
|
// The file exists under the exact literal name
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("%q: file not created literally: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseRelativePath(t *testing.T) {
|
|
t.Parallel()
|
|
dsn, err := sqliteDSN("rel/x y.db")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wd, _ := os.Getwd()
|
|
want := "file://" + escapeURIPath(filepath.Join(wd, "rel/x y.db")) + "?" + dsnParams
|
|
if dsn != want {
|
|
t.Fatalf("dsn = %q, want %q", dsn, want)
|
|
}
|
|
if !strings.Contains(dsn, "x%20y.db") {
|
|
t.Fatalf("space not escaped: %q", dsn)
|
|
}
|
|
}
|
|
|
|
func TestEscapeURIPath(t *testing.T) {
|
|
t.Parallel()
|
|
got := escapeURIPath("/a b/?#%&=~._-AZaz09/ü")
|
|
want := "/a%20b/%3F%23%25%26%3D~._-AZaz09/%C3%BC"
|
|
if got != want {
|
|
t.Fatalf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOpenDatabaseFailure(t *testing.T) {
|
|
t.Parallel()
|
|
// A directory cannot be opened as a database
|
|
if _, err := OpenDatabase(context.Background(), t.TempDir(), traceDiscard()); err == nil {
|
|
t.Fatal("expected error opening a directory")
|
|
}
|
|
// A missing parent directory is not created
|
|
path := filepath.Join(t.TempDir(), "missing", "x.db")
|
|
_, err := OpenDatabase(context.Background(), path, traceDiscard())
|
|
if err == nil || !strings.Contains(err.Error(), "initialize database schema") {
|
|
t.Fatalf("expected schema error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInitializeSchemaIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
path := testDBPath(t)
|
|
for i := 0; i < 3; i++ {
|
|
db, err := OpenDatabase(context.Background(), path, traceDiscard())
|
|
if err != nil {
|
|
t.Fatalf("open #%d: %v", i, err)
|
|
}
|
|
if i == 0 {
|
|
mustExec(t, db, `INSERT INTO tokens VALUES ('keep', 'd', 1, '2026-01-01T00:00:00Z', NULL)`)
|
|
}
|
|
if err := InitializeSchema(context.Background(), db, traceDiscard()); err != nil {
|
|
t.Fatalf("re-init #%d: %v", i, err)
|
|
}
|
|
if n := countRows(t, db, "tokens"); n != 1 {
|
|
t.Fatalf("re-init #%d lost data: %d rows", i, n)
|
|
}
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
func TestInitializeSchemaError(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := InitializeSchema(ctx, db, traceDiscard()); err == nil {
|
|
t.Fatal("expected error with cancelled context")
|
|
}
|
|
}
|
|
|
|
// tableColumns returns name -> "type|notnull|pk" for a table
|
|
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]string {
|
|
t.Helper()
|
|
rows, err := db.Query(`SELECT name, type, "notnull", pk FROM pragma_table_info(?)`, table)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer rows.Close()
|
|
cols := map[string]string{}
|
|
for rows.Next() {
|
|
var name, typ string
|
|
var notnull, pk int
|
|
if err := rows.Scan(&name, &typ, ¬null, &pk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cols[name] = strings.Join([]string{typ, map[int]string{0: "null", 1: "notnull"}[notnull], map[int]string{0: "", 1: "pk"}[pk]}, "|")
|
|
}
|
|
return cols
|
|
}
|
|
|
|
func TestSchemaColumns(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
tokens := tableColumns(t, db, "tokens")
|
|
wantTokens := map[string]string{
|
|
"token": "TEXT|notnull|pk",
|
|
"description": "TEXT|notnull|",
|
|
"active": "INTEGER|notnull|",
|
|
"created_at": "TEXT|notnull|",
|
|
"deleted_at": "TEXT|null|",
|
|
}
|
|
if !reflect.DeepEqual(tokens, wantTokens) {
|
|
t.Fatalf("tokens columns = %v", tokens)
|
|
}
|
|
ops := tableColumns(t, db, "operations")
|
|
wantOps := map[string]string{
|
|
"token": "TEXT|notnull|",
|
|
"origin": "TEXT|notnull|",
|
|
"operation": "TEXT|notnull|",
|
|
"time": "TEXT|notnull|",
|
|
}
|
|
if !reflect.DeepEqual(ops, wantOps) {
|
|
t.Fatalf("operations columns = %v", ops)
|
|
}
|
|
var table, from, to string
|
|
if err := db.QueryRow(`SELECT "table", "from", "to" FROM pragma_foreign_key_list('operations')`).Scan(&table, &from, &to); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if table != "tokens" || from != "token" || to != "token" {
|
|
t.Fatalf("foreign key = %s(%s) -> %s", table, from, to)
|
|
}
|
|
// The description default is the empty string
|
|
mustExec(t, db, `INSERT INTO tokens (token, active, created_at) VALUES ('d', 1, 'x')`)
|
|
var desc string
|
|
if err := db.QueryRow(`SELECT description FROM tokens WHERE token = 'd'`).Scan(&desc); err != nil || desc != "" {
|
|
t.Fatalf("default description = %q, %v", desc, err)
|
|
}
|
|
}
|
|
|
|
func TestSchemaPrimaryKey(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
mustExec(t, db, `INSERT INTO tokens VALUES ('dup', '', 1, 'x', NULL)`)
|
|
_, err := db.Exec(`INSERT INTO tokens VALUES ('dup', 'other', 1, 'y', NULL)`)
|
|
if !isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY) {
|
|
t.Fatalf("expected primary key violation, got %v", err)
|
|
}
|
|
// Case matters: the primary key is case-sensitive
|
|
mustExec(t, db, `INSERT INTO tokens VALUES ('DUP', '', 1, 'x', NULL)`)
|
|
// NULL token is rejected
|
|
if _, err := db.Exec(`INSERT INTO tokens VALUES (NULL, '', 1, 'x', NULL)`); err == nil {
|
|
t.Fatal("NULL token accepted")
|
|
}
|
|
}
|
|
|
|
func TestSchemaActiveCheck(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
for i, v := range []any{0, 1} {
|
|
if _, err := db.Exec(`INSERT INTO tokens VALUES (?, '', ?, 'x', NULL)`, "ok"+string(rune('0'+i)), v); err != nil {
|
|
t.Errorf("active=%v rejected: %v", v, err)
|
|
}
|
|
}
|
|
for i, v := range []any{2, -1, "yes", 0.5, nil} {
|
|
_, err := db.Exec(`INSERT INTO tokens VALUES (?, '', ?, 'x', NULL)`, "bad"+string(rune('0'+i)), v)
|
|
if err == nil {
|
|
t.Errorf("active=%v accepted", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSchemaForeignKeyEnforced(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
|
|
// The pragma is on for every pooled connection, not just one
|
|
var conns []*sql.Conn
|
|
for i := 0; i < maxOpenConns; i++ {
|
|
c, err := db.Conn(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conns = append(conns, c)
|
|
var fk int
|
|
if err := c.QueryRowContext(context.Background(), `PRAGMA foreign_keys`).Scan(&fk); err != nil || fk != 1 {
|
|
t.Fatalf("conn %d: foreign_keys = %d, %v", i, fk, err)
|
|
}
|
|
}
|
|
for _, c := range conns {
|
|
c.Close()
|
|
}
|
|
|
|
_, err := db.Exec(`INSERT INTO operations VALUES ('missing', '127.0.0.1', 'GET containers', 'x')`)
|
|
if !isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY) {
|
|
t.Fatalf("expected foreign key violation, got %v", err)
|
|
}
|
|
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 1, 'x', NULL)`)
|
|
mustExec(t, db, `INSERT INTO operations VALUES ('t', '127.0.0.1', 'GET containers', 'x')`)
|
|
// A referenced token cannot be removed
|
|
if _, err := db.Exec(`DELETE FROM tokens WHERE token = 't'`); !isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY) {
|
|
t.Fatalf("expected foreign key violation on delete, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDatabasePragmas(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
var mode string
|
|
var busy int
|
|
if err := db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil || mode != "wal" {
|
|
t.Fatalf("journal_mode = %q, %v", mode, err)
|
|
}
|
|
if err := db.QueryRow(`PRAGMA busy_timeout`).Scan(&busy); err != nil || busy != 5000 {
|
|
t.Fatalf("busy_timeout = %d, %v", busy, err)
|
|
}
|
|
}
|
|
|
|
func TestDeletedAtLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
db := openTestDB(t)
|
|
store := NewTokenStore(db, traceDiscard(), newFakeClock(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), time.Second).Now, seqGenerator("tok"))
|
|
if _, err := store.CreateToken(context.Background(), ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var deleted sql.NullString
|
|
if err := db.QueryRow(`SELECT deleted_at FROM tokens WHERE token = 'tok'`).Scan(&deleted); err != nil || deleted.Valid {
|
|
t.Fatalf("deleted_at should start NULL: %+v, %v", deleted, err)
|
|
}
|
|
if err := store.DeactivateToken(context.Background(), "tok"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.QueryRow(`SELECT deleted_at FROM tokens WHERE token = 'tok'`).Scan(&deleted); err != nil || deleted.String != "2026-01-02T03:04:06Z" {
|
|
t.Fatalf("deleted_at after delete = %+v, %v", deleted, err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentWritersAcrossPools(t *testing.T) {
|
|
t.Parallel()
|
|
// Two independent pools on the same file emulate the server and the CLI writing at once
|
|
path := testDBPath(t)
|
|
open := func() *sql.DB {
|
|
db, err := OpenDatabase(context.Background(), path, traceDiscard())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
return db
|
|
}
|
|
a, b := open(), open()
|
|
mustExec(t, a, `INSERT INTO tokens VALUES ('t', '', 1, 'x', NULL)`)
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 400)
|
|
for _, db := range []*sql.DB{a, b} {
|
|
audit := NewAuditStore(db, traceDiscard(), time.Now)
|
|
store := NewTokenStore(db, traceDiscard(), time.Now, GenerateToken)
|
|
for g := 0; g < 4; g++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < 25; i++ {
|
|
if err := audit.RecordOperation(context.Background(), "t", "127.0.0.1", operationGetContainers); err != nil {
|
|
errs <- err
|
|
}
|
|
tok, err := store.CreateToken(context.Background(), "c")
|
|
if err != nil {
|
|
errs <- err
|
|
continue
|
|
}
|
|
if err := store.DeactivateToken(context.Background(), tok.Token); err != nil {
|
|
errs <- err
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
for err := range errs {
|
|
t.Errorf("concurrent write failed: %v", err)
|
|
}
|
|
if n := countRows(t, a, "operations"); n != 200 {
|
|
t.Fatalf("operations = %d, want 200", n)
|
|
}
|
|
if n := countRows(t, a, "tokens"); n != 201 {
|
|
t.Fatalf("tokens = %d, want 201", n)
|
|
}
|
|
}
|