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.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent fe0cd4bf6d
commit 88d7d6d2b9
11 files changed
+2426

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"cmp"
"context"
"database/sql"
"fmt"
"log/slog"
"slices"
"time"
)
// operationGetContainers is the only audited operation
const operationGetContainers = "GET containers"
// AuditStore persists audit rows in the operations table
type AuditStore struct {
db *sql.DB
log *slog.Logger
now func() time.Time
}
// NewAuditStore creates an audit store using the given clock
func NewAuditStore(db *sql.DB, log *slog.Logger, now func() time.Time) *AuditStore {
return &AuditStore{db: db, log: log, now: now}
}
// RecordOperation inserts one operations row stamped with the current UTC time
func (a *AuditStore) RecordOperation(ctx context.Context, token, origin, operation string) error {
defer traceFunc(ctx, a.log, "AuditStore.RecordOperation")()
at := formatTime(a.now())
if _, err := a.db.ExecContext(ctx,
`INSERT INTO operations (token, origin, operation, time) VALUES (?, ?, ?, ?)`,
token, origin, operation, at); err != nil {
return fmt.Errorf("insert operation: %w", err)
}
trace(ctx, a.log, "row inserted", "table", "operations", "token_id", tokenID(token),
"origin", origin, "operation", operation, "time", at)
return nil
}
// Operation is one row of the operations table
type Operation struct {
RowID int64
Token string
Origin string
Operation string
Time time.Time // parsed, used for ordering and filtering
TimeText string // exactly as stored
}
// OperationQuery selects audit rows; the zero value selects every row
type OperationQuery struct {
Limit int // > 0: keep only the Limit latest matching rows
Since *time.Time // non-nil: keep only rows with time >= *Since
}
// compareOperations orders rows by parsed time, ties broken by insertion order (rowid)
func compareOperations(a, b Operation) int {
if c := a.Time.Compare(b.Time); c != 0 {
return c
}
return cmp.Compare(a.RowID, b.RowID)
}
// ListOperations returns the rows matching q in chronological order (oldest first)
// With a Limit, the returned rows are the Limit latest ones by (time, rowid), still oldest first
func (a *AuditStore) ListOperations(ctx context.Context, q OperationQuery) ([]Operation, error) {
defer traceFunc(ctx, a.log, "AuditStore.ListOperations")()
rows, err := a.db.QueryContext(ctx, `SELECT rowid, token, origin, operation, time FROM operations`)
if err != nil {
return nil, fmt.Errorf("query operations: %w", err)
}
defer rows.Close()
ops := []Operation{}
for rows.Next() {
var op Operation
if err := rows.Scan(&op.RowID, &op.Token, &op.Origin, &op.Operation, &op.TimeText); err != nil {
return nil, fmt.Errorf("read operation row: %w", err)
}
// A row whose time does not parse cannot be ordered: fail rather than misorder
if op.Time, err = parseTime(op.TimeText); err != nil {
return nil, fmt.Errorf("read operation row: %w", err)
}
trace(ctx, a.log, "row read", "table", "operations", "rowid", op.RowID, "token_id", tokenID(op.Token),
"origin", op.Origin, "operation", op.Operation, "time", op.TimeText)
if q.Since != nil && op.Time.Before(*q.Since) {
continue
}
ops = append(ops, op)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate operation rows: %w", err)
}
// Sort in Go: RFC3339Nano strings with variable fraction length do not sort lexically
slices.SortFunc(ops, compareOperations)
if q.Limit > 0 && len(ops) > q.Limit {
ops = ops[len(ops)-q.Limit:]
}
return ops, nil
}
+373
View File
@@ -0,0 +1,373 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"math"
"strings"
"testing"
"time"
sqlite3 "modernc.org/sqlite/lib"
)
type opRow struct {
token, origin, operation, time string
}
func readOperations(t *testing.T, store *AuditStore) []opRow {
t.Helper()
rows, err := store.db.Query(`SELECT token, origin, operation, time FROM operations ORDER BY rowid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []opRow
for rows.Next() {
var r opRow
if err := rows.Scan(&r.token, &r.origin, &r.operation, &r.time); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
return out
}
func newTestAudit(t *testing.T, now func() time.Time) *AuditStore {
t.Helper()
db := openTestDB(t)
mustExec(t, db, `INSERT INTO tokens VALUES ('tok', '', 1, '2026-01-01T00:00:00Z', NULL)`)
mustExec(t, db, `INSERT INTO tokens VALUES ('old', '', 0, '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')`)
return NewAuditStore(db, traceDiscard(), now)
}
func TestRecordOperationExactValues(t *testing.T) {
t.Parallel()
local := time.FixedZone("CET", 3600)
store := newTestAudit(t, func() time.Time { return time.Date(2026, 10, 5, 13, 14, 15, 160000000, local) })
if err := store.RecordOperation(context.Background(), "tok", "203.0.113.7", operationGetContainers); err != nil {
t.Fatal(err)
}
got := readOperations(t, store)
want := []opRow{{"tok", "203.0.113.7", "GET containers", "2026-10-05T12:14:15.16Z"}}
if len(got) != 1 || got[0] != want[0] {
t.Fatalf("got %+v, want %+v", got, want)
}
if !strings.HasSuffix(got[0].time, "Z") {
t.Fatal("time not UTC")
}
if _, err := time.Parse(time.RFC3339Nano, got[0].time); err != nil {
t.Fatal(err)
}
}
func TestRecordOperationMultipleRowsRetained(t *testing.T) {
t.Parallel()
store := newTestAudit(t, newFakeClock(t0, time.Second).Now)
inputs := []opRow{
{"tok", "127.0.0.1", operationGetContainers, ""},
{"tok", "::1", operationGetContainers, ""},
{"old", "10.0.0.1", operationGetContainers, ""}, // inactive tokens still satisfy the foreign key
{"tok", "127.0.0.1", operationGetContainers, ""},
}
for _, in := range inputs {
if err := store.RecordOperation(context.Background(), in.token, in.origin, in.operation); err != nil {
t.Fatal(err)
}
}
got := readOperations(t, store)
if len(got) != len(inputs) {
t.Fatalf("rows = %d", len(got))
}
for i, r := range got {
wantTime := formatTime(t0.Add(time.Duration(i) * time.Second))
if r.token != inputs[i].token || r.origin != inputs[i].origin || r.operation != "GET containers" || r.time != wantTime {
t.Errorf("row %d = %+v (want time %s)", i, r, wantTime)
}
}
}
func TestRecordOperationForeignKey(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
for _, tok := range []string{"missing", "TOK", "tok ", ""} {
err := store.RecordOperation(context.Background(), tok, "127.0.0.1", operationGetContainers)
if !isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY) || !strings.Contains(err.Error(), "insert operation") {
t.Errorf("token %q: expected foreign key error, got %v", tok, err)
}
}
if n := len(readOperations(t, store)); n != 0 {
t.Fatalf("rows = %d", n)
}
}
// insertOperations inserts raw operations rows (token, time) in the given order, origin 10.0.0.<i>
func insertOperations(t *testing.T, db *sql.DB, rows ...[2]string) {
t.Helper()
for i, r := range rows {
mustExec(t, db, `INSERT INTO operations (token, origin, operation, time) VALUES (?, ?, ?, ?)`,
r[0], fmt.Sprintf("10.0.0.%d", i+1), operationGetContainers, r[1])
}
}
// opOrigins renders the origins of ops (which identify the inserted rows) for compact assertions
func opOrigins(ops []Operation) string {
var out []string
for _, op := range ops {
out = append(out, strings.TrimPrefix(op.Origin, "10.0.0."))
}
return strings.Join(out, ",")
}
func listOps(t *testing.T, store *AuditStore, q OperationQuery) []Operation {
t.Helper()
ops, err := store.ListOperations(context.Background(), q)
if err != nil {
t.Fatal(err)
}
return ops
}
func TestListOperationsEmpty(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
since := t0
for _, q := range []OperationQuery{{}, {Limit: 10}, {Since: &since}, {Limit: 1, Since: &since}} {
ops := listOps(t, store, q)
if ops == nil || len(ops) != 0 {
t.Fatalf("%+v: expected empty non-nil slice, got %#v", q, ops)
}
}
}
// orderingRows are inserted out of chronological order, with fraction lengths that break lexical ordering,
// non-UTC offsets and equal instants written differently (ties are broken by insertion order)
var orderingRows = [][2]string{
{"tok", "2026-01-01T00:00:00.1Z"}, // 1
{"tok", "2026-01-01T00:00:00Z"}, // 2
{"old", "2026-01-01T00:00:00.12Z"}, // 3
{"tok", "2026-01-01T00:00:00.5Z"}, // 4
{"tok", "2026-01-01T01:00:00.5+01:00"}, // 5: same instant as 4
{"old", "2025-12-31T23:59:59.999999999Z"}, // 6
{"tok", "2026-01-01T00:00:00.500000000Z"}, // 7: same instant as 4
{"tok", "2026-01-01T00:00:00.000000001Z"}, // 8
{"tok", "2026-01-01T00:00:01Z"}, // 9
{"tok", "2026-01-01T00:00:00.99999999Z"}, // 10
{"tok", "2025-12-31T19:00:00.25-05:00"}, // 11: 2026-01-01T00:00:00.25Z
{"old", "2026-01-01T00:00:00.100000000Z"}, // 12: same instant as 1
{"tok", "2026-01-01T00:00:00.0000000009Z"}, // 13: sub-nanosecond digits are truncated by Go: ties with 2
{"tok", "2026-01-01T00:00:00.123456789012Z"}, // 14: 2026-01-01T00:00:00.123456789Z
}
// orderingSorted is the expected (time, rowid) order of orderingRows
const orderingSorted = "6,2,13,8,1,12,3,14,11,4,5,7,10,9"
func TestListOperationsOrdering(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
insertOperations(t, store.db, orderingRows...)
ops := listOps(t, store, OperationQuery{})
if got := opOrigins(ops); got != orderingSorted {
t.Fatalf("order = %s, want %s", got, orderingSorted)
}
// Values are returned exactly as stored, with the parsed time in UTC
for _, op := range ops {
i := int(op.RowID) - 1
if op.Token != orderingRows[i][0] || op.TimeText != orderingRows[i][1] || op.Operation != "GET containers" ||
op.Origin != fmt.Sprintf("10.0.0.%d", i+1) || op.Time.Location() != time.UTC {
t.Fatalf("row %d = %+v", op.RowID, op)
}
}
}
func TestListOperationsLimit(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
insertOperations(t, store.db, orderingRows...)
sorted := strings.Split(orderingSorted, ",")
for _, limit := range []int{1, 2, 3, 5, 13, 14, 15, 1000, math.MaxInt} {
want := sorted
if limit < len(sorted) {
want = sorted[len(sorted)-limit:]
}
if got := opOrigins(listOps(t, store, OperationQuery{Limit: limit})); got != strings.Join(want, ",") {
t.Errorf("limit %d: got %s, want %s", limit, got, strings.Join(want, ","))
}
}
}
func TestListOperationsSince(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
insertOperations(t, store.db, orderingRows...)
at := func(s string) *time.Time {
v, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
t.Fatal(err)
}
return &v
}
cases := []struct {
since *time.Time
limit int
want string
}{
// The boundary is inclusive, including for the same instant written with another offset or fraction length
{at("2026-01-01T00:00:00.5Z"), 0, "4,5,7,10,9"},
{at("2026-01-01T00:00:00.500000001Z"), 0, "10,9"},
{at("2026-01-01T00:00:00.499999999Z"), 0, "4,5,7,10,9"},
{at("2026-01-01T03:00:00.5+03:00"), 0, "4,5,7,10,9"},
{at("2026-01-01T00:00:01Z"), 0, "9"},
{at("2026-01-01T00:00:01.000000001Z"), 0, ""},
{at("2000-01-01T00:00:00Z"), 0, orderingSorted},
// Since and limit combine: the latest rows among those in the window
{at("2026-01-01T00:00:00.5Z"), 2, "10,9"},
{at("2026-01-01T00:00:00.5Z"), 100, "4,5,7,10,9"},
}
for _, c := range cases {
if got := opOrigins(listOps(t, store, OperationQuery{Since: c.since, Limit: c.limit})); got != c.want {
t.Errorf("since %s limit %d: got %q, want %q", c.since, c.limit, got, c.want)
}
}
}
func TestListOperationsMany(t *testing.T) {
t.Parallel()
store := newTestAudit(t, newFakeClock(t0, time.Millisecond).Now)
for i := 0; i < 300; i++ {
if err := store.RecordOperation(context.Background(), "tok", fmt.Sprintf("10.0.%d.%d", i/256, i%256), operationGetContainers); err != nil {
t.Fatal(err)
}
}
all := listOps(t, store, OperationQuery{})
last := listOps(t, store, OperationQuery{Limit: 10})
if len(all) != 300 || len(last) != 10 {
t.Fatalf("len = %d, %d", len(all), len(last))
}
for i, op := range all {
if op.RowID != int64(i+1) || op.TimeText != formatTime(t0.Add(time.Duration(i)*time.Millisecond)) {
t.Fatalf("row %d = %+v", i, op)
}
}
for i, op := range last {
if op.RowID != all[290+i].RowID || op.TimeText != all[290+i].TimeText {
t.Fatalf("latest %d = %+v, want %+v", i, op, all[290+i])
}
}
}
func TestListOperationsErrors(t *testing.T) {
t.Parallel()
// Query error: closed database
store := newTestAudit(t, time.Now)
store.db.Close()
if _, err := store.ListOperations(context.Background(), OperationQuery{}); err == nil || !strings.Contains(err.Error(), "query operations") {
t.Fatalf("got %v", err)
}
// Unparseable stored times (rows written by something else) are errors, never silently misordered
for _, bad := range []string{"garbage", "", "2026-01-01 00:00:00", "2026-01-01T00:00:00", "1767225600"} {
store := newTestAudit(t, time.Now)
insertOperations(t, store.db, [2]string{"tok", "2026-01-01T00:00:00Z"}, [2]string{"tok", bad})
for _, q := range []OperationQuery{{}, {Limit: 1}} {
if _, err := store.ListOperations(context.Background(), q); err == nil ||
!strings.HasPrefix(err.Error(), "read operation row: parse stored timestamp") {
t.Errorf("time %q: got %v", bad, err)
}
}
}
// Scan error: a NULL value in a table pre-created without constraints
_, db := newLegacySchemaStore(t, `CREATE TABLE operations (token TEXT, origin TEXT, operation TEXT, time TEXT)`)
mustExec(t, db, `INSERT INTO operations VALUES (NULL, '127.0.0.1', 'GET containers', '2026-01-01T00:00:00Z')`)
store = NewAuditStore(db, traceDiscard(), time.Now)
if _, err := store.ListOperations(context.Background(), OperationQuery{}); err == nil || !strings.Contains(err.Error(), "read operation row") {
t.Fatalf("scan: got %v", err)
}
}
func TestListOperationsIterationError(t *testing.T) {
t.Parallel()
db := openTestDB(t)
mustExec(t, db, `INSERT INTO tokens VALUES ('tok', '', 1, '2026-01-01T00:00:00Z', NULL)`)
for i := 0; i < 500; i++ {
mustExec(t, db, `INSERT INTO operations VALUES ('tok', '127.0.0.1', 'GET containers', '2026-01-01T00:00:00Z')`)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := NewAuditStore(db, slog.New(cancelOnRecord{msg: "row read", cancel: cancel}), time.Now)
if _, err := store.ListOperations(ctx, OperationQuery{}); err == nil || !strings.Contains(err.Error(), "iterate operation rows") {
t.Fatalf("got %v", err)
}
}
func TestListOperationsReadOnly(t *testing.T) {
t.Parallel()
store := newTestAudit(t, time.Now)
insertOperations(t, store.db, orderingRows...)
for _, table := range []string{"tokens", "operations"} {
for _, op := range []string{"INSERT", "UPDATE", "DELETE"} {
mustExec(t, store.db, fmt.Sprintf(`CREATE TRIGGER ro_%s_%s BEFORE %s ON %s BEGIN SELECT RAISE(ABORT, 'read-only'); END`,
op, table, op, table))
}
}
since := t0.AddDate(-1, 0, 0)
for _, q := range []OperationQuery{{}, {Limit: 3}, {Since: &since}} {
listOps(t, store, q)
}
}
func TestListOperationsTraceRedaction(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
const secret = "AUDIT_LIST_SECRET_0123456789abcdefghijklmno"
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, secret)
mustExec(t, db, `INSERT INTO operations VALUES (?, '192.0.2.1', 'GET containers', '2026-10-05T12:00:00.5Z')`, secret)
store := NewAuditStore(db, log, time.Now)
ops := listOps(t, store, OperationQuery{Limit: 1})
if len(ops) != 1 || ops[0].Token != secret {
t.Fatalf("got %+v", ops)
}
out := buf.String()
if strings.Contains(out, secret) || strings.Contains(out, secret[:12]) {
t.Fatalf("secret leaked:\n%s", out)
}
for _, want := range []string{
`level=TRACE msg="function entry" func=AuditStore.ListOperations`,
`level=TRACE msg="row read" table=operations rowid=1 token_id=` + tokenID(secret) +
` origin=192.0.2.1 operation="GET containers" time=2026-10-05T12:00:00.5Z`,
`level=TRACE msg="function exit" func=AuditStore.ListOperations`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
}
func TestRecordOperationTraceRedaction(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
const secret = "AUDIT_SECRET_0123456789abcdefghijklmnopqrst"
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, 'x', NULL)`, secret)
store := NewAuditStore(db, log, func() time.Time { return t0 })
if err := store.RecordOperation(context.Background(), secret, "192.0.2.1", operationGetContainers); err != nil {
t.Fatal(err)
}
out := buf.String()
if strings.Contains(out, secret) {
t.Fatalf("secret leaked:\n%s", out)
}
want := `msg="row inserted" table=operations token_id=` + tokenID(secret) +
` origin=192.0.2.1 operation="GET containers" time=2026-10-05T12:00:00.123456789Z`
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
+134
View File
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"errors"
"fmt"
"log/slog"
"net"
"strconv"
)
// Environment variable names
const (
envDBPath = "DB_PATH"
envHTTPAddr = "HTTP_ADDR"
envHTTPPort = "HTTP_PORT"
envLogLevel = "LOG_LEVEL"
)
// Default values for the optional environment variables
const (
defaultHTTPAddr = "0.0.0.0"
defaultHTTPPort = 80
defaultLogLevel = slog.LevelInfo
)
// errConfig marks configuration errors (exit code 2)
var errConfig = errors.New("configuration error")
// Config is the fully validated runtime configuration
type Config struct {
DBPath string
HTTPAddr string
HTTPPort int
LogLevel slog.Level
}
// ListenAddress returns the host:port pair the HTTP server binds to
func (c Config) ListenAddress() string {
return net.JoinHostPort(c.HTTPAddr, strconv.Itoa(c.HTTPPort))
}
// ListenNetwork returns "tcp4" for IPv4 addresses (so 0.0.0.0 binds IPv4 only)
// and "tcp" for IPv6 addresses (so :: binds dual-stack where the OS allows it)
func (c Config) ListenNetwork() string {
if ip := net.ParseIP(c.HTTPAddr); ip != nil && ip.To4() != nil {
return "tcp4"
}
return "tcp"
}
// configErrorf builds an error wrapping errConfig
func configErrorf(format string, args ...any) error {
return fmt.Errorf("%w: "+format, append([]any{errConfig}, args...)...)
}
// LoadConfig reads and validates the configuration from the given environment lookup function
// Variables are validated in the order DB_PATH, HTTP_ADDR, HTTP_PORT, LOG_LEVEL; the first error wins
func LoadConfig(lookupEnv func(string) (string, bool)) (Config, error) {
cfg := Config{
HTTPAddr: defaultHTTPAddr,
HTTPPort: defaultHTTPPort,
LogLevel: defaultLogLevel,
}
// DB_PATH is mandatory
v, ok := lookupEnv(envDBPath)
if !ok {
return Config{}, configErrorf("%s is required", envDBPath)
}
if v == "" {
return Config{}, configErrorf("%s must not be empty", envDBPath)
}
cfg.DBPath = v
// HTTP_ADDR must be a literal IPv4 or IPv6 address
if v, ok := lookupEnv(envHTTPAddr); ok {
if v == "" {
return Config{}, configErrorf("%s must not be empty", envHTTPAddr)
}
if net.ParseIP(v) == nil {
return Config{}, configErrorf("invalid %s %q: must be an IPv4 or IPv6 address", envHTTPAddr, v)
}
cfg.HTTPAddr = v
}
// HTTP_PORT must be a decimal integer in 1-65535
if v, ok := lookupEnv(envHTTPPort); ok {
if v == "" {
return Config{}, configErrorf("%s must not be empty", envHTTPPort)
}
port, err := parsePort(v)
if err != nil {
return Config{}, configErrorf("invalid %s %q: must be an integer between 1 and 65535", envHTTPPort, v)
}
cfg.HTTPPort = port
}
// LOG_LEVEL must be one of the five supported names
if v, ok := lookupEnv(envLogLevel); ok {
if v == "" {
return Config{}, configErrorf("%s must not be empty", envLogLevel)
}
level, err := ParseLogLevel(v)
if err != nil {
return Config{}, configErrorf("invalid %s %q: must be one of ERROR, WARN, INFO, DEBUG, TRACE", envLogLevel, v)
}
cfg.LogLevel = level
}
return cfg, nil
}
// parsePort accepts only ASCII decimal digits (no sign, no whitespace) in the range 1-65535
func parsePort(s string) (int, error) {
if s == "" {
return 0, errors.New("empty port")
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, errors.New("port is not a decimal integer")
}
}
port, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if port < 1 || port > 65535 {
return 0, errors.New("port out of range")
}
return port, nil
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"errors"
"log/slog"
"strings"
"testing"
)
func TestLoadConfigDefaults(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig(envMap(map[string]string{"DB_PATH": "/data/dpsapi.db"}))
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
want := Config{DBPath: "/data/dpsapi.db", HTTPAddr: "0.0.0.0", HTTPPort: 80, LogLevel: slog.LevelInfo}
if cfg != want {
t.Fatalf("got %+v, want %+v", cfg, want)
}
if got := cfg.ListenAddress(); got != "0.0.0.0:80" {
t.Fatalf("ListenAddress = %q", got)
}
if got := cfg.ListenNetwork(); got != "tcp4" {
t.Fatalf("ListenNetwork = %q", got)
}
}
func TestLoadConfigExplicitValues(t *testing.T) {
t.Parallel()
cfg, err := LoadConfig(envMap(map[string]string{
"DB_PATH": "rel/path.db",
"HTTP_ADDR": "::1",
"HTTP_PORT": "8080",
"LOG_LEVEL": "trace",
}))
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
want := Config{DBPath: "rel/path.db", HTTPAddr: "::1", HTTPPort: 8080, LogLevel: LevelTrace}
if cfg != want {
t.Fatalf("got %+v, want %+v", cfg, want)
}
if got := cfg.ListenAddress(); got != "[::1]:8080" {
t.Fatalf("ListenAddress = %q", got)
}
if got := cfg.ListenNetwork(); got != "tcp" {
t.Fatalf("ListenNetwork = %q", got)
}
}
func TestLoadConfigEveryLogLevel(t *testing.T) {
t.Parallel()
cases := map[string]slog.Level{
"ERROR": slog.LevelError, "error": slog.LevelError, "Error": slog.LevelError,
"WARN": slog.LevelWarn, "warn": slog.LevelWarn, "wArN": slog.LevelWarn,
"INFO": slog.LevelInfo, "info": slog.LevelInfo,
"DEBUG": slog.LevelDebug, "debug": slog.LevelDebug,
"TRACE": LevelTrace, "trace": LevelTrace, "TrAcE": LevelTrace,
}
for in, want := range cases {
cfg, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "LOG_LEVEL": in}))
if err != nil {
t.Errorf("LOG_LEVEL=%q: %v", in, err)
continue
}
if cfg.LogLevel != want {
t.Errorf("LOG_LEVEL=%q: got %v, want %v", in, cfg.LogLevel, want)
}
}
}
func TestLoadConfigInvalidLogLevels(t *testing.T) {
t.Parallel()
for _, in := range []string{
"WARNING", "ERR", "FATAL", "VERBOSE", "0", "-4", "INFO ", " INFO", "IN FO", "INFO\n",
"ınfo", // dotless i: Unicode case folding must not apply
"DEBUG-4", "trace1", "\x00", "INFO",
} {
_, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "LOG_LEVEL": in}))
if !errors.Is(err, errConfig) {
t.Errorf("LOG_LEVEL=%q: expected config error, got %v", in, err)
continue
}
if !strings.Contains(err.Error(), "invalid LOG_LEVEL") {
t.Errorf("LOG_LEVEL=%q: unexpected message %q", in, err)
}
}
}
func TestLoadConfigMissingDBPath(t *testing.T) {
t.Parallel()
_, err := LoadConfig(envMap(map[string]string{"HTTP_PORT": "8080"}))
if !errors.Is(err, errConfig) || err.Error() != "configuration error: DB_PATH is required" {
t.Fatalf("got %v", err)
}
}
func TestLoadConfigEmptyValues(t *testing.T) {
t.Parallel()
for _, name := range []string{"DB_PATH", "HTTP_ADDR", "HTTP_PORT", "LOG_LEVEL"} {
env := map[string]string{"DB_PATH": "x"}
env[name] = ""
_, err := LoadConfig(envMap(env))
want := "configuration error: " + name + " must not be empty"
if !errors.Is(err, errConfig) || err.Error() != want {
t.Errorf("%s empty: got %v, want %q", name, err, want)
}
}
}
func TestLoadConfigInvalidPorts(t *testing.T) {
t.Parallel()
for _, in := range []string{
"0", "-1", "-80", "+80", "65536", "70000", "99999999999999999999999", "abc", "80a", "8 0",
" 80", "80 ", "0x50", "1e3", "80.0", "80", "\t80",
} {
_, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "HTTP_PORT": in}))
if !errors.Is(err, errConfig) || !strings.Contains(err.Error(), "invalid HTTP_PORT") {
t.Errorf("HTTP_PORT=%q: expected invalid port error, got %v", in, err)
}
}
}
func TestLoadConfigValidEdgePorts(t *testing.T) {
t.Parallel()
cases := map[string]int{"1": 1, "80": 80, "1024": 1024, "65535": 65535, "0080": 80, "00001": 1}
for in, want := range cases {
cfg, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "HTTP_PORT": in}))
if err != nil {
t.Errorf("HTTP_PORT=%q: %v", in, err)
continue
}
if cfg.HTTPPort != want {
t.Errorf("HTTP_PORT=%q: got %d, want %d", in, cfg.HTTPPort, want)
}
}
}
func TestParsePortEmpty(t *testing.T) {
t.Parallel()
if _, err := parsePort(""); err == nil {
t.Fatal("expected error for empty port")
}
}
func TestLoadConfigAddresses(t *testing.T) {
t.Parallel()
valid := map[string]string{
"0.0.0.0": "tcp4",
"127.0.0.1": "tcp4",
"192.168.1.10": "tcp4",
"::": "tcp",
"::1": "tcp",
"fe80::1": "tcp",
"2001:db8::1": "tcp",
"::ffff:127.0.0.1": "tcp4",
}
for in, network := range valid {
cfg, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "HTTP_ADDR": in}))
if err != nil {
t.Errorf("HTTP_ADDR=%q: %v", in, err)
continue
}
if cfg.HTTPAddr != in || cfg.ListenNetwork() != network {
t.Errorf("HTTP_ADDR=%q: got addr %q network %q", in, cfg.HTTPAddr, cfg.ListenNetwork())
}
}
for _, in := range []string{
"localhost", "example.com", "256.0.0.1", "1.2.3", "01.2.3.4", " 127.0.0.1", "127.0.0.1 ",
"127.0.0.1:80", "[::1]", "fe80::1%eth0", "*", "0", "::g",
} {
_, err := LoadConfig(envMap(map[string]string{"DB_PATH": "x", "HTTP_ADDR": in}))
if !errors.Is(err, errConfig) || !strings.Contains(err.Error(), "invalid HTTP_ADDR") {
t.Errorf("HTTP_ADDR=%q: expected invalid address error, got %v", in, err)
}
}
}
func TestLoadConfigValidationOrder(t *testing.T) {
t.Parallel()
// Every variable invalid: DB_PATH is reported first, then HTTP_ADDR, HTTP_PORT, LOG_LEVEL
env := map[string]string{"HTTP_ADDR": "bad", "HTTP_PORT": "bad", "LOG_LEVEL": "bad"}
steps := []struct {
fix func()
want string
}{
{func() {}, "DB_PATH"},
{func() { env["DB_PATH"] = "x" }, "HTTP_ADDR"},
{func() { env["HTTP_ADDR"] = "127.0.0.1" }, "HTTP_PORT"},
{func() { env["HTTP_PORT"] = "1" }, "LOG_LEVEL"},
}
for _, step := range steps {
step.fix()
_, err := LoadConfig(envMap(env))
if err == nil || !strings.Contains(err.Error(), step.want) {
t.Fatalf("expected error about %s, got %v", step.want, err)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"log/slog"
"path/filepath"
"time"
// Pure-Go SQLite driver, registered as "sqlite"
"modernc.org/sqlite"
)
// schemaSQL holds the authoritative, idempotent table definitions
//
//go:embed schema.sql
var schemaSQL string
// dsnParams are applied by the driver to every pooled connection
// foreign_keys: enforce the operations -> tokens reference
// busy_timeout: wait instead of failing when the server and the CLI write concurrently
// journal_mode WAL: readers never block the writer
// _txlock=immediate: transactions take the write lock at BEGIN, so read-then-write never fails with SQLITE_BUSY
const dsnParams = "_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate"
// maxOpenConns bounds the SQLite connection pool
const maxOpenConns = 4
// sqliteDSN builds the driver DSN for a filesystem path
// The path is made absolute and percent-encoded so that spaces, '?', '#', '%' and other bytes are taken literally
func sqliteDSN(path string) (string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve database path: %w", err)
}
return "file://" + escapeURIPath(abs) + "?" + dsnParams, nil
}
// escapeURIPath percent-encodes every byte except RFC 3986 unreserved characters and '/'
func escapeURIPath(p string) string {
const hexDigits = "0123456789ABCDEF"
out := make([]byte, 0, len(p))
for i := 0; i < len(p); i++ {
c := p[i]
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9',
c == '-', c == '.', c == '_', c == '~', c == '/':
out = append(out, c)
default:
out = append(out, '%', hexDigits[c>>4], hexDigits[c&0x0f])
}
}
return string(out)
}
// OpenDatabase opens (creating if needed) the SQLite database at path and initializes the schema
func OpenDatabase(ctx context.Context, path string, log *slog.Logger) (*sql.DB, error) {
defer traceFunc(ctx, log, "OpenDatabase")()
// Build the DSN
dsn, err := sqliteDSN(path)
if err != nil {
return nil, err
}
// Open the connection pool (lazy: the file is opened on first use)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxOpenConns)
// Create the tables if needed
if err := InitializeSchema(ctx, db, log); err != nil {
db.Close()
return nil, err
}
log.DebugContext(ctx, "database opened", "path", path)
return db, nil
}
// InitializeSchema executes the embedded schema; every statement is idempotent
func InitializeSchema(ctx context.Context, db *sql.DB, log *slog.Logger) error {
defer traceFunc(ctx, log, "InitializeSchema")()
if _, err := db.ExecContext(ctx, schemaSQL); err != nil {
return fmt.Errorf("initialize database schema: %w", err)
}
log.DebugContext(ctx, "database schema initialized")
return nil
}
// isSQLiteCode reports whether err is a SQLite error with the given (extended) result code
func isSQLiteCode(err error, code int) bool {
var se *sqlite.Error
return errors.As(err, &se) && se.Code() == code
}
// formatTime renders a timestamp as stored in the database: UTC RFC3339Nano
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339Nano)
}
// parseTime parses a timestamp stored by formatTime
func parseTime(s string) (time.Time, error) {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}, fmt.Errorf("parse stored timestamp: %w", err)
}
return t.UTC(), nil
}
+345
View File
@@ -0,0 +1,345 @@
// 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)
}
}
+182
View File
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
"github.com/moby/moby/api/types/container"
)
// syncBuffer is a goroutine-safe bytes.Buffer for capturing logs and output
type syncBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
// newTestLogger returns a logger writing to a captured buffer
func newTestLogger(level slog.Level) (*slog.Logger, *syncBuffer) {
buf := &syncBuffer{}
return NewLogger(buf, level), buf
}
// traceDiscard is a TRACE-level logger whose output is dropped (exercises every log call)
func traceDiscard() *slog.Logger {
return NewLogger(io.Discard, LevelTrace)
}
// fakeClock returns a fixed time advancing by step on every call
type fakeClock struct {
mu sync.Mutex
t time.Time
step time.Duration
}
func newFakeClock(start time.Time, step time.Duration) *fakeClock {
return &fakeClock{t: start, step: step}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
t := c.t
c.t = c.t.Add(c.step)
return t
}
// seqGenerator returns the given tokens in order, then an error
func seqGenerator(values ...string) func() (string, error) {
var mu sync.Mutex
i := 0
return func() (string, error) {
mu.Lock()
defer mu.Unlock()
if i >= len(values) {
return "", errors.New("generator exhausted")
}
v := values[i]
i++
return v, nil
}
}
// testDBPath returns a fresh database path inside the test's temporary directory
func testDBPath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "dpsapi.db")
}
// openTestDB opens and initializes a fresh database
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := OpenDatabase(context.Background(), testDBPath(t), traceDiscard())
if err != nil {
t.Fatalf("OpenDatabase: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// mustExec executes a statement or fails the test
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("exec %q: %v", query, err)
}
}
// countRows returns the number of rows in a table
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
return n
}
// fakeDocker is an in-memory ContainerLister
type fakeDocker struct {
mu sync.Mutex
containers []container.Summary
err error
calls int
closed bool
onList func(ctx context.Context)
}
func (f *fakeDocker) ListRunningContainers(ctx context.Context) ([]container.Summary, error) {
f.mu.Lock()
f.calls++
hook := f.onList
f.mu.Unlock()
if hook != nil {
hook(ctx)
}
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return nil, f.err
}
return f.containers, nil
}
func (f *fakeDocker) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.closed = true
return nil
}
func (f *fakeDocker) Calls() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
// makeContainers builds n distinct container summaries
func makeContainers(n int) []container.Summary {
out := make([]container.Summary, 0, n)
for i := 0; i < n; i++ {
out = append(out, container.Summary{
ID: fmt.Sprintf("%064x", i+1),
Names: []string{fmt.Sprintf("/c%d", i)},
Image: "busybox:latest",
State: container.StateRunning,
Status: "Up 1 second",
Labels: map[string]string{"index": fmt.Sprint(i)},
})
}
return out
}
// envMap returns a lookupEnv function backed by a map
func envMap(m map[string]string) func(string) (string, bool) {
return func(k string) (string, bool) {
v, ok := m[k]
return v, ok
}
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
)
// LevelTrace is the custom level below DEBUG
const LevelTrace = slog.Level(-8)
// Names of the supported levels, as printed and as accepted by ParseLogLevel
var levelNames = map[slog.Level]string{
slog.LevelError: "ERROR",
slog.LevelWarn: "WARN",
slog.LevelInfo: "INFO",
slog.LevelDebug: "DEBUG",
LevelTrace: "TRACE",
}
// ParseLogLevel maps a level name (ASCII case-insensitive, exact match) to its slog level
func ParseLogLevel(s string) (slog.Level, error) {
for level, name := range levelNames {
if asciiEqualFold(s, name) {
return level, nil
}
}
return 0, errors.New("unknown log level")
}
// LevelName renders a level using the five supported names, falling back to the slog default
func LevelName(level slog.Level) string {
if name, ok := levelNames[level]; ok {
return name
}
return level.String()
}
// NewLogger builds the text logger writing to w with the given minimum level
func NewLogger(w io.Writer, level slog.Level) *slog.Logger {
return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{
Level: level,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) != 0 {
return a
}
switch a.Key {
case slog.LevelKey:
// Render the level with the custom names (TRACE instead of DEBUG-4)
if level, ok := a.Value.Any().(slog.Level); ok {
a.Value = slog.StringValue(LevelName(level))
}
case slog.TimeKey:
// Always log timestamps in UTC
if a.Value.Kind() == slog.KindTime {
a.Value = slog.TimeValue(a.Value.Time().UTC())
}
}
return a
},
}))
}
// trace emits a TRACE record
func trace(ctx context.Context, log *slog.Logger, msg string, args ...any) {
log.Log(ctx, LevelTrace, msg, args...)
}
// traceFunc emits a TRACE function-entry record and returns the matching function-exit emitter
// Usage: defer traceFunc(ctx, log, "Name")()
func traceFunc(ctx context.Context, log *slog.Logger, fn string) func() {
trace(ctx, log, "function entry", "func", fn)
return func() {
trace(ctx, log, "function exit", "func", fn)
}
}
// tokenID returns a stable, non-reversible identifier for a token, safe to log
// It is the first 8 hex characters of the SHA-256 digest of the token
func tokenID(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:4])
}
// asciiEqualFold compares two strings case-insensitively over ASCII letters only
// Non-ASCII bytes must match exactly, so Unicode case folding never applies
func asciiEqualFold(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
if asciiLower(a[i]) != asciiLower(b[i]) {
return false
}
}
return true
}
// asciiLower lowercases a single ASCII letter
func asciiLower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
+18
View File
@@ -0,0 +1,18 @@
-- SPDX-License-Identifier: GPL-2.0-or-later
-- Copyright (C) 2026 Bryan Joshua Pedini
CREATE TABLE IF NOT EXISTS tokens (
token TEXT PRIMARY KEY NOT NULL,
description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
deleted_at TEXT NULL
);
CREATE TABLE IF NOT EXISTS operations (
token TEXT NOT NULL,
origin TEXT NOT NULL,
operation TEXT NOT NULL,
time TEXT NOT NULL,
FOREIGN KEY (token) REFERENCES tokens(token)
);
+258
View File
@@ -0,0 +1,258 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"cmp"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"time"
sqlite3 "modernc.org/sqlite/lib"
)
// tokenBytes is the amount of random data in a token (256 bits)
const tokenBytes = 32
// tokenLength is the encoded token length: base64 RawURLEncoding of 32 bytes
const tokenLength = 43
// maxTokenAttempts bounds the retries on a primary-key collision
const maxTokenAttempts = 5
// Token store errors
var (
ErrTokenNotFound = errors.New("token not found")
ErrTokenInactive = errors.New("token already inactive")
ErrTokenCollision = errors.New("could not generate a unique token")
)
// Token is one row of the tokens table
type Token struct {
Token string
Description string
Active bool
CreatedAt time.Time
DeletedAt *time.Time
}
// GenerateToken returns a new random token from crypto/rand
func GenerateToken() (string, error) {
return generateTokenFrom(rand.Reader)
}
// generateTokenFrom reads tokenBytes from r and encodes them with base64 RawURLEncoding
func generateTokenFrom(r io.Reader) (string, error) {
buf := make([]byte, tokenBytes)
if _, err := io.ReadFull(r, buf); err != nil {
return "", fmt.Errorf("read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// TokenStore persists tokens in SQLite
type TokenStore struct {
db *sql.DB
log *slog.Logger
now func() time.Time
generate func() (string, error)
maxAttempts int
}
// NewTokenStore creates a token store using the given clock and token generator
func NewTokenStore(db *sql.DB, log *slog.Logger, now func() time.Time, generate func() (string, error)) *TokenStore {
return &TokenStore{
db: db,
log: log,
now: now,
generate: generate,
maxAttempts: maxTokenAttempts,
}
}
// CreateToken generates and stores a new active token with the given description
// On primary-key collision a new token is generated, up to maxAttempts times
func (s *TokenStore) CreateToken(ctx context.Context, description string) (Token, error) {
defer traceFunc(ctx, s.log, "TokenStore.CreateToken")()
for attempt := 1; attempt <= s.maxAttempts; attempt++ {
// Generate a candidate token
value, err := s.generate()
if err != nil {
return Token{}, fmt.Errorf("generate token: %w", err)
}
tok := Token{
Token: value,
Description: description,
Active: true,
CreatedAt: s.now().UTC().Round(0),
}
createdAt := formatTime(tok.CreatedAt)
// Insert it
_, err = s.db.ExecContext(ctx,
`INSERT INTO tokens (token, description, active, created_at, deleted_at) VALUES (?, ?, 1, ?, NULL)`,
value, description, createdAt)
if err == nil {
trace(ctx, s.log, "row inserted", "table", "tokens", "token_id", tokenID(value),
"description", description, "active", true, "created_at", createdAt, "deleted_at", nil)
return tok, nil
}
if isSQLiteCode(err, sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY) {
s.log.DebugContext(ctx, "generated token collided with an existing token, retrying", "attempt", attempt)
continue
}
return Token{}, fmt.Errorf("insert token: %w", err)
}
return Token{}, ErrTokenCollision
}
// rowScanner is implemented by *sql.Row and *sql.Rows
type rowScanner interface {
Scan(dest ...any) error
}
// tokenColumns is the column list matching scanToken
const tokenColumns = `token, description, active, created_at, deleted_at`
// scanToken reads one tokens row
func scanToken(sc rowScanner) (Token, error) {
var (
tok Token
active int64
createdAt string
deletedAt sql.NullString
)
if err := sc.Scan(&tok.Token, &tok.Description, &active, &createdAt, &deletedAt); err != nil {
return Token{}, err
}
tok.Active = active == 1
created, err := parseTime(createdAt)
if err != nil {
return Token{}, err
}
tok.CreatedAt = created
if deletedAt.Valid {
deleted, err := parseTime(deletedAt.String)
if err != nil {
return Token{}, err
}
tok.DeletedAt = &deleted
}
return tok, nil
}
// traceTokenRow emits the row-level TRACE record for a token row; the secret is replaced by token_id
func (s *TokenStore) traceTokenRow(ctx context.Context, msg string, tok Token) {
var deletedAt any
if tok.DeletedAt != nil {
deletedAt = formatTime(*tok.DeletedAt)
}
trace(ctx, s.log, msg, "table", "tokens", "token_id", tokenID(tok.Token), "description", tok.Description,
"active", tok.Active, "created_at", formatTime(tok.CreatedAt), "deleted_at", deletedAt)
}
// ListTokens returns the active tokens, or every token (active and inactive) when activeOnly is false,
// ordered by creation time, then by token
func (s *TokenStore) ListTokens(ctx context.Context, activeOnly bool) ([]Token, error) {
defer traceFunc(ctx, s.log, "TokenStore.ListTokens")()
query := `SELECT ` + tokenColumns + ` FROM tokens`
if activeOnly {
query += ` WHERE active = 1`
}
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("query tokens: %w", err)
}
defer rows.Close()
tokens := []Token{}
for rows.Next() {
tok, err := scanToken(rows)
if err != nil {
return nil, fmt.Errorf("read token row: %w", err)
}
s.traceTokenRow(ctx, "row read", tok)
tokens = append(tokens, tok)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate token rows: %w", err)
}
// Sort in Go: RFC3339Nano strings with variable fraction length do not sort lexically
slices.SortFunc(tokens, func(a, b Token) int {
if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
return c
}
return cmp.Compare(a.Token, b.Token)
})
return tokens, nil
}
// GetToken returns the token row matching value exactly, or ErrTokenNotFound
func (s *TokenStore) GetToken(ctx context.Context, value string) (Token, error) {
defer traceFunc(ctx, s.log, "TokenStore.GetToken")()
row := s.db.QueryRowContext(ctx, `SELECT `+tokenColumns+` FROM tokens WHERE token = ?`, value)
tok, err := scanToken(row)
if errors.Is(err, sql.ErrNoRows) {
trace(ctx, s.log, "row not found", "table", "tokens", "token_id", tokenID(value))
return Token{}, ErrTokenNotFound
}
if err != nil {
return Token{}, fmt.Errorf("query token: %w", err)
}
s.traceTokenRow(ctx, "row read", tok)
return tok, nil
}
// DeactivateToken sets active=0 and deleted_at=now in a single transaction
// It returns ErrTokenNotFound or ErrTokenInactive without modifying anything
func (s *TokenStore) DeactivateToken(ctx context.Context, value string) error {
defer traceFunc(ctx, s.log, "TokenStore.DeactivateToken")()
// Begin an immediate (write-locked) transaction
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
// Read the current state
var active int64
err = tx.QueryRowContext(ctx, `SELECT active FROM tokens WHERE token = ?`, value).Scan(&active)
if errors.Is(err, sql.ErrNoRows) {
trace(ctx, s.log, "row not found", "table", "tokens", "token_id", tokenID(value))
return ErrTokenNotFound
}
if err != nil {
return fmt.Errorf("query token: %w", err)
}
if active != 1 {
trace(ctx, s.log, "row already inactive", "table", "tokens", "token_id", tokenID(value), "active", false)
return ErrTokenInactive
}
// Flip the state
deletedAt := formatTime(s.now())
if _, err := tx.ExecContext(ctx,
`UPDATE tokens SET active = 0, deleted_at = ? WHERE token = ? AND active = 1`,
deletedAt, value); err != nil {
return fmt.Errorf("update token: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
trace(ctx, s.log, "row updated", "table", "tokens", "token_id", tokenID(value),
"active_from", true, "active_to", false, "deleted_at", deletedAt)
return nil
}
+577
View File
@@ -0,0 +1,577 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"regexp"
"strings"
"testing"
"time"
)
var tokenAlphabet = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
var t0 = time.Date(2026, 10, 5, 12, 0, 0, 123456789, time.UTC)
func TestGenerateToken(t *testing.T) {
t.Parallel()
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatal(err)
}
if tok == "" {
t.Fatal("empty token")
}
if len(tok) != tokenLength {
t.Fatalf("length %d, want %d", len(tok), tokenLength)
}
if !tokenAlphabet.MatchString(tok) {
t.Fatalf("token %q outside the URL-safe alphabet", tok)
}
if seen[tok] {
t.Fatalf("duplicate token after %d generations", i)
}
seen[tok] = true
}
}
func TestGenerateTokenFromDeterministicReader(t *testing.T) {
t.Parallel()
tok, err := generateTokenFrom(strings.NewReader(strings.Repeat("\xff", tokenBytes)))
if err != nil {
t.Fatal(err)
}
if tok != strings.Repeat("_", 42)+"8" {
t.Fatalf("unexpected encoding %q", tok)
}
}
func TestGenerateTokenReaderError(t *testing.T) {
t.Parallel()
if _, err := generateTokenFrom(strings.NewReader("short")); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected short-read error, got %v", err)
}
}
// newTestStore returns a store with a deterministic clock (1ms steps) and generator
func newTestStore(t *testing.T, gen func() (string, error)) (*TokenStore, *sql.DB) {
t.Helper()
db := openTestDB(t)
return NewTokenStore(db, traceDiscard(), newFakeClock(t0, time.Millisecond).Now, gen), db
}
func TestCreateTokenDescriptions(t *testing.T) {
t.Parallel()
descriptions := []string{
"",
"ci deploy token",
" leading and trailing ",
"quotes ' \" ` and $(rm -rf /); DROP TABLE tokens; --",
"line1\nline2\r\n\ttab",
"unicode 日本語 🚀 full",
"ctl \x01\x1b[31m\x7f",
string([]byte{0xff, 0xfe, 'x'}),
"nul\x00byte",
strings.Repeat("long", 25000),
}
store, db := newTestStore(t, GenerateToken)
for _, desc := range descriptions {
tok, err := store.CreateToken(context.Background(), desc)
if err != nil {
t.Fatalf("CreateToken(%q): %v", desc, err)
}
if !tok.Active || tok.DeletedAt != nil || tok.Description != desc || !tokenAlphabet.MatchString(tok.Token) {
t.Fatalf("unexpected token %+v", tok)
}
var stored string
if err := db.QueryRow(`SELECT description FROM tokens WHERE token = ?`, tok.Token).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != desc {
t.Fatalf("description not stored verbatim: %q != %q", stored, desc)
}
got, err := store.GetToken(context.Background(), tok.Token)
if err != nil || got.Description != desc {
t.Fatalf("GetToken description = %q, %v", got.Description, err)
}
}
if n := countRows(t, db, "tokens"); n != len(descriptions) {
t.Fatalf("rows = %d", n)
}
// The SQL-injection payload did nothing
if n := countRows(t, db, "operations"); n != 0 {
t.Fatalf("operations = %d", n)
}
}
func TestCreateTokenTimestamps(t *testing.T) {
t.Parallel()
local := time.FixedZone("UTC+5", 5*3600)
db := openTestDB(t)
store := NewTokenStore(db, traceDiscard(), func() time.Time { return time.Date(2026, 3, 4, 10, 0, 0, 500, local) }, seqGenerator("a"))
tok, err := store.CreateToken(context.Background(), "")
if err != nil {
t.Fatal(err)
}
var created string
if err := db.QueryRow(`SELECT created_at FROM tokens WHERE token = 'a'`).Scan(&created); err != nil {
t.Fatal(err)
}
if created != "2026-03-04T05:00:00.0000005Z" {
t.Fatalf("created_at = %q", created)
}
if tok.CreatedAt.Location() != time.UTC || !tok.CreatedAt.Equal(time.Date(2026, 3, 4, 5, 0, 0, 500, time.UTC)) {
t.Fatalf("CreatedAt = %v", tok.CreatedAt)
}
if _, err := time.Parse(time.RFC3339Nano, created); err != nil {
t.Fatalf("not RFC3339Nano: %v", err)
}
}
func TestCreateTokenCollisionRetry(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
store := NewTokenStore(db, log, newFakeClock(t0, time.Millisecond).Now, seqGenerator("same", "same", "same", "other"))
if _, err := store.CreateToken(context.Background(), "first"); err != nil {
t.Fatal(err)
}
tok, err := store.CreateToken(context.Background(), "second")
if err != nil {
t.Fatalf("expected retry to succeed: %v", err)
}
if tok.Token != "other" {
t.Fatalf("token = %q", tok.Token)
}
if c := strings.Count(buf.String(), "generated token collided"); c != 2 {
t.Fatalf("collision logs = %d", c)
}
// The colliding row was not modified
first, err := store.GetToken(context.Background(), "same")
if err != nil || first.Description != "first" {
t.Fatalf("existing row changed: %+v, %v", first, err)
}
}
func TestCreateTokenCollisionExhausted(t *testing.T) {
t.Parallel()
gen := func() (string, error) { return "fixed", nil }
store, db := newTestStore(t, gen)
if _, err := store.CreateToken(context.Background(), ""); err != nil {
t.Fatal(err)
}
if _, err := store.CreateToken(context.Background(), ""); !errors.Is(err, ErrTokenCollision) {
t.Fatalf("expected ErrTokenCollision, got %v", err)
}
if n := countRows(t, db, "tokens"); n != 1 {
t.Fatalf("rows = %d", n)
}
}
func TestCreateTokenGeneratorError(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, func() (string, error) { return "", errors.New("no entropy") })
if _, err := store.CreateToken(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "no entropy") {
t.Fatalf("got %v", err)
}
}
func TestCreateTokenInsertError(t *testing.T) {
t.Parallel()
store, db := newTestStore(t, GenerateToken)
db.Close()
if _, err := store.CreateToken(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "insert token") {
t.Fatalf("got %v", err)
}
}
func TestListTokensEmpty(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, GenerateToken)
for _, activeOnly := range []bool{false, true} {
tokens, err := store.ListTokens(context.Background(), activeOnly)
if err != nil {
t.Fatal(err)
}
if tokens == nil || len(tokens) != 0 {
t.Fatalf("activeOnly=%v: expected empty non-nil slice, got %#v", activeOnly, tokens)
}
}
}
func TestListTokensOne(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, seqGenerator("only"))
created, err := store.CreateToken(context.Background(), "d")
if err != nil {
t.Fatal(err)
}
tokens, err := store.ListTokens(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if len(tokens) != 1 || tokens[0].Token != "only" || tokens[0].Description != "d" || !tokens[0].Active ||
!tokens[0].CreatedAt.Equal(created.CreatedAt) || tokens[0].DeletedAt != nil {
t.Fatalf("got %+v", tokens)
}
}
func TestListTokensManyOrdering(t *testing.T) {
t.Parallel()
db := openTestDB(t)
store := NewTokenStore(db, traceDiscard(), time.Now, GenerateToken)
// Rows inserted out of order, with fraction lengths that break lexical ordering
// "y", "a" and "w" are inactive
rows := []struct {
token, created string
active int
}{
{"z", "2026-01-01T00:00:00.1Z", 1},
{"y", "2026-01-01T00:00:00Z", 0},
{"x", "2026-01-01T00:00:00.12Z", 1},
{"b", "2026-01-01T00:00:00.5Z", 1},
{"a", "2026-01-01T00:00:00.5Z", 0},
{"w", "2025-12-31T23:59:59.999999999Z", 0},
{"c", "2026-01-01T00:00:00.5Z", 1},
}
for _, r := range rows {
deleted := any(nil)
if r.active == 0 {
deleted = "2026-02-01T00:00:00Z"
}
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', ?, ?, ?)`, r.token, r.active, r.created, deleted)
}
for activeOnly, want := range map[bool]string{false: "w,y,z,x,a,b,c", true: "z,x,b,c"} {
tokens, err := store.ListTokens(context.Background(), activeOnly)
if err != nil {
t.Fatal(err)
}
var got []string
for _, tok := range tokens {
got = append(got, tok.Token)
}
if strings.Join(got, ",") != want {
t.Fatalf("activeOnly=%v: order = %v, want %s", activeOnly, got, want)
}
}
}
func TestListTokensMany(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, GenerateToken)
var want []string
for i := 0; i < 200; i++ {
tok, err := store.CreateToken(context.Background(), fmt.Sprint(i))
if err != nil {
t.Fatal(err)
}
want = append(want, tok.Token)
}
for i := 0; i < 200; i += 3 {
if err := store.DeactivateToken(context.Background(), want[i]); err != nil {
t.Fatal(err)
}
}
tokens, err := store.ListTokens(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if len(tokens) != 200 {
t.Fatalf("len = %d", len(tokens))
}
for i, tok := range tokens {
inactive := i%3 == 0
if tok.Token != want[i] || tok.Description != fmt.Sprint(i) || tok.Active == inactive || (tok.DeletedAt != nil) != inactive {
t.Fatalf("row %d = %+v", i, tok)
}
}
// Only the active ones, same relative order
active, err := store.ListTokens(context.Background(), true)
if err != nil {
t.Fatal(err)
}
var wantActive []Token
for i, tok := range tokens {
if i%3 != 0 {
wantActive = append(wantActive, tok)
}
}
if len(active) != len(wantActive) {
t.Fatalf("active len = %d, want %d", len(active), len(wantActive))
}
for i, tok := range active {
if tok.Token != wantActive[i].Token || !tok.Active || tok.DeletedAt != nil {
t.Fatalf("active row %d = %+v", i, tok)
}
}
}
func TestListTokensErrors(t *testing.T) {
t.Parallel()
// Query error: closed database
store, db := newTestStore(t, GenerateToken)
db.Close()
for _, activeOnly := range []bool{false, true} {
if _, err := store.ListTokens(context.Background(), activeOnly); err == nil || !strings.Contains(err.Error(), "query tokens") {
t.Fatalf("activeOnly=%v: got %v", activeOnly, err)
}
}
// Malformed rows written by something else
for name, row := range map[string][]any{
"bad created_at": {"t", "", 1, "yesterday", nil},
"bad deleted_at": {"t", "", 0, "2026-01-01T00:00:00Z", "tomorrow"},
} {
store, db := newTestStore(t, GenerateToken)
mustExec(t, db, `INSERT INTO tokens VALUES (?, ?, ?, ?, ?)`, row...)
if _, err := store.ListTokens(context.Background(), false); err == nil || !strings.Contains(err.Error(), "parse stored timestamp") {
t.Errorf("%s: got %v", name, err)
}
}
// The inactive malformed row is filtered out in SQL, before it is parsed
store, db = newTestStore(t, GenerateToken)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 0, '2026-01-01T00:00:00Z', 'tomorrow')`)
if tokens, err := store.ListTokens(context.Background(), true); err != nil || len(tokens) != 0 {
t.Fatalf("active only: got %v, %v", tokens, err)
}
}
// cancelOnRecord is a slog handler that cancels a context when it sees a record with the given message
type cancelOnRecord struct {
slog.Handler
msg string
cancel context.CancelFunc
}
func (h cancelOnRecord) Enabled(context.Context, slog.Level) bool { return true }
func (h cancelOnRecord) Handle(ctx context.Context, r slog.Record) error {
if r.Message == h.msg {
h.cancel()
// Give database/sql and the driver time to observe the cancellation
time.Sleep(50 * time.Millisecond)
}
return nil
}
func TestListTokensIterationError(t *testing.T) {
t.Parallel()
db := openTestDB(t)
for i := 0; i < 500; i++ {
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, fmt.Sprint(i))
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log := slog.New(cancelOnRecord{msg: "row read", cancel: cancel})
store := NewTokenStore(db, log, time.Now, GenerateToken)
if _, err := store.ListTokens(ctx, false); err == nil || !strings.Contains(err.Error(), "iterate token rows") {
t.Fatalf("got %v", err)
}
}
// newLegacySchemaStore creates a database whose tables were pre-created without constraints,
// so rows the real schema would reject can be used to exercise defensive error paths
func newLegacySchemaStore(t *testing.T, ddl string) (*TokenStore, *sql.DB) {
t.Helper()
path := testDBPath(t)
raw, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
mustExec(t, raw, ddl)
raw.Close()
db, err := OpenDatabase(context.Background(), path, traceDiscard())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return NewTokenStore(db, traceDiscard(), newFakeClock(t0, time.Millisecond).Now, GenerateToken), db
}
func TestTokenScanErrors(t *testing.T) {
t.Parallel()
store, db := newLegacySchemaStore(t, `CREATE TABLE tokens (token TEXT, description TEXT, active, created_at TEXT, deleted_at TEXT)`)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', NULL, 'not-a-number', '2026-01-01T00:00:00Z', NULL)`)
if _, err := store.ListTokens(context.Background(), false); err == nil || !strings.Contains(err.Error(), "read token row") {
t.Fatalf("ListTokens: got %v", err)
}
if _, err := store.GetToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "query token") {
t.Fatalf("GetToken: got %v", err)
}
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "query token") {
t.Fatalf("DeactivateToken: got %v", err)
}
}
func TestGetToken(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, seqGenerator("tok-a", "tok-b"))
a, _ := store.CreateToken(context.Background(), "A")
if _, err := store.CreateToken(context.Background(), "B"); err != nil {
t.Fatal(err)
}
// Existing
got, err := store.GetToken(context.Background(), "tok-a")
if err != nil || got.Token != "tok-a" || got.Description != "A" || !got.Active || !got.CreatedAt.Equal(a.CreatedAt) || got.DeletedAt != nil {
t.Fatalf("got %+v, %v", got, err)
}
// Unknown, near-match, case change, whitespace
for _, v := range []string{"", "tok-c", "TOK-A", "tok-a ", " tok-a", "tok-", "tok-a'--", "%"} {
if _, err := store.GetToken(context.Background(), v); !errors.Is(err, ErrTokenNotFound) {
t.Errorf("GetToken(%q) = %v", v, err)
}
}
// Inactive
if err := store.DeactivateToken(context.Background(), "tok-b"); err != nil {
t.Fatal(err)
}
got, err = store.GetToken(context.Background(), "tok-b")
if err != nil || got.Active || got.DeletedAt == nil {
t.Fatalf("inactive: %+v, %v", got, err)
}
// Query error
store.db.Close()
if _, err := store.GetToken(context.Background(), "tok-a"); err == nil || errors.Is(err, ErrTokenNotFound) {
t.Fatalf("closed db: %v", err)
}
}
func TestDeactivateToken(t *testing.T) {
t.Parallel()
store, db := newTestStore(t, seqGenerator("tok-a", "tok-b"))
if _, err := store.CreateToken(context.Background(), ""); err != nil { // created at t0
t.Fatal(err)
}
if _, err := store.CreateToken(context.Background(), ""); err != nil { // created at t0+1ms
t.Fatal(err)
}
if err := store.DeactivateToken(context.Background(), "tok-a"); err != nil { // deleted at t0+2ms
t.Fatal(err)
}
var active int
var deleted sql.NullString
if err := db.QueryRow(`SELECT active, deleted_at FROM tokens WHERE token = 'tok-a'`).Scan(&active, &deleted); err != nil {
t.Fatal(err)
}
if active != 0 || deleted.String != "2026-10-05T12:00:00.125456789Z" {
t.Fatalf("active=%d deleted_at=%+v", active, deleted)
}
// The other token is untouched
if err := db.QueryRow(`SELECT active, deleted_at FROM tokens WHERE token = 'tok-b'`).Scan(&active, &deleted); err != nil || active != 1 || deleted.Valid {
t.Fatalf("tok-b changed: %d %+v %v", active, deleted, err)
}
// Already inactive: error, and deleted_at keeps its original value
if err := store.DeactivateToken(context.Background(), "tok-a"); !errors.Is(err, ErrTokenInactive) {
t.Fatalf("second delete: %v", err)
}
if err := db.QueryRow(`SELECT deleted_at FROM tokens WHERE token = 'tok-a'`).Scan(&deleted); err != nil || deleted.String != "2026-10-05T12:00:00.125456789Z" {
t.Fatalf("deleted_at changed: %+v", deleted)
}
// Unknown
if err := store.DeactivateToken(context.Background(), "nope"); !errors.Is(err, ErrTokenNotFound) {
t.Fatalf("unknown: %v", err)
}
if n := countRows(t, db, "tokens"); n != 2 {
t.Fatalf("rows = %d", n)
}
}
func TestDeactivateTokenErrors(t *testing.T) {
t.Parallel()
// Begin error
store, db := newTestStore(t, GenerateToken)
db.Close()
if err := store.DeactivateToken(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "begin transaction") {
t.Fatalf("begin: %v", err)
}
// Update error: a trigger rejects the update
store, db = newTestStore(t, seqGenerator("t"))
if _, err := store.CreateToken(context.Background(), ""); err != nil {
t.Fatal(err)
}
mustExec(t, db, `CREATE TRIGGER no_update BEFORE UPDATE ON tokens BEGIN SELECT RAISE(ABORT, 'refused'); END`)
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "update token") {
t.Fatalf("update: %v", err)
}
var active int
if err := db.QueryRow(`SELECT active FROM tokens WHERE token = 't'`).Scan(&active); err != nil || active != 1 {
t.Fatalf("token changed after failed update: %d %v", active, err)
}
// Commit error: a deferred foreign key on deleted_at is violated only at COMMIT
store, db = newLegacySchemaStore(t, `
CREATE TABLE parents (id TEXT PRIMARY KEY);
CREATE TABLE tokens (
token TEXT PRIMARY KEY NOT NULL, description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL, created_at TEXT NOT NULL,
deleted_at TEXT NULL REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED)`)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 1, '2026-01-01T00:00:00Z', NULL)`)
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "commit transaction") {
t.Fatalf("commit: %v", err)
}
if err := db.QueryRow(`SELECT active FROM tokens WHERE token = 't'`).Scan(&active); err != nil || active != 1 {
t.Fatalf("token changed after failed commit: %d %v", active, err)
}
}
func TestTokenStoreTraceRedaction(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
const secret = "SECRET_TOKEN_VALUE_0123456789abcdefghijklmno"
store := NewTokenStore(db, log, newFakeClock(t0, time.Millisecond).Now, seqGenerator(secret))
if _, err := store.CreateToken(context.Background(), "my description"); err != nil {
t.Fatal(err)
}
if _, err := store.GetToken(context.Background(), secret); err != nil {
t.Fatal(err)
}
if _, err := store.ListTokens(context.Background(), false); err != nil {
t.Fatal(err)
}
if err := store.DeactivateToken(context.Background(), secret); err != nil {
t.Fatal(err)
}
_ = store.DeactivateToken(context.Background(), secret)
_, _ = store.GetToken(context.Background(), secret+"x")
_ = store.DeactivateToken(context.Background(), secret+"x")
out := buf.String()
if strings.Contains(out, secret) || strings.Contains(out, secret[:12]) {
t.Fatalf("token secret leaked into trace output:\n%s", out)
}
id := tokenID(secret)
for _, want := range []string{
`msg="function entry" func=TokenStore.CreateToken`,
`msg="function exit" func=TokenStore.CreateToken`,
`msg="row inserted" table=tokens token_id=` + id + ` description="my description" active=true created_at=2026-10-05T12:00:00.123456789Z deleted_at=<nil>`,
`msg="row read" table=tokens token_id=` + id,
`msg="row updated" table=tokens token_id=` + id + ` active_from=true active_to=false deleted_at=`,
`msg="row already inactive" table=tokens token_id=` + id,
`msg="row not found" table=tokens token_id=` + tokenID(secret+"x"),
`func=TokenStore.ListTokens`, `func=TokenStore.GetToken`, `func=TokenStore.DeactivateToken`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing trace record %q", want)
}
}
}