Files
dpsapi/database_test.go
bryanpedini 88d7d6d2b9 feat(core): add configuration, logging and sqlite persistence
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.
2026-10-06 10:52:25 +02:00

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, &notnull, &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)
}
}