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:
1 parent
fe0cd4bf6d
commit
88d7d6d2b9
11 files changed
+2426
No files matched your search
+108
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, ¬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)
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user