diff --git a/audit_store.go b/audit_store.go new file mode 100644 index 0000000..7747175 --- /dev/null +++ b/audit_store.go @@ -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 +} diff --git a/audit_store_test.go b/audit_store_test.go new file mode 100644 index 0000000..76ad963 --- /dev/null +++ b/audit_store_test.go @@ -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. +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) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..ebf9ab9 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..43b1220 --- /dev/null +++ b/config_test.go @@ -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) + } + } +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..41e744f --- /dev/null +++ b/database.go @@ -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 +} diff --git a/database_test.go b/database_test.go new file mode 100644 index 0000000..bfee9df --- /dev/null +++ b/database_test.go @@ -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) + } +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..527af72 --- /dev/null +++ b/helpers_test.go @@ -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 + } +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..6b650f1 --- /dev/null +++ b/logger.go @@ -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 +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0a99e62 --- /dev/null +++ b/schema.sql @@ -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) +); diff --git a/token_store.go b/token_store.go new file mode 100644 index 0000000..a6087bb --- /dev/null +++ b/token_store.go @@ -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 +} diff --git a/token_store_test.go b/token_store_test.go new file mode 100644 index 0000000..94344dc --- /dev/null +++ b/token_store_test.go @@ -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=`, + `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) + } + } +}