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.
374 lines
13 KiB
Go
374 lines
13 KiB
Go
// 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)
|
|
}
|
|
}
|