Files
dpsapi/token_store_test.go
T
bryanpedini 88d7d6d2b9 feat(core): add configuration, logging and sqlite persistence
DB_PATH, HTTP_ADDR, HTTP_PORT and LOG_LEVEL parsing with validation; slog text logger with a TRACE level and token redaction; embedded idempotent schema on CGO-free SQLite (WAL, busy timeout); token store (create, list, deactivate, authenticate) and append-only audit store with ordered, limited and windowed queries; shared unit test helpers.
2026-10-06 10:52:25 +02:00

578 lines
19 KiB
Go

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"regexp"
"strings"
"testing"
"time"
)
var tokenAlphabet = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
var t0 = time.Date(2026, 10, 5, 12, 0, 0, 123456789, time.UTC)
func TestGenerateToken(t *testing.T) {
t.Parallel()
seen := map[string]bool{}
for i := 0; i < 1000; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatal(err)
}
if tok == "" {
t.Fatal("empty token")
}
if len(tok) != tokenLength {
t.Fatalf("length %d, want %d", len(tok), tokenLength)
}
if !tokenAlphabet.MatchString(tok) {
t.Fatalf("token %q outside the URL-safe alphabet", tok)
}
if seen[tok] {
t.Fatalf("duplicate token after %d generations", i)
}
seen[tok] = true
}
}
func TestGenerateTokenFromDeterministicReader(t *testing.T) {
t.Parallel()
tok, err := generateTokenFrom(strings.NewReader(strings.Repeat("\xff", tokenBytes)))
if err != nil {
t.Fatal(err)
}
if tok != strings.Repeat("_", 42)+"8" {
t.Fatalf("unexpected encoding %q", tok)
}
}
func TestGenerateTokenReaderError(t *testing.T) {
t.Parallel()
if _, err := generateTokenFrom(strings.NewReader("short")); !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("expected short-read error, got %v", err)
}
}
// newTestStore returns a store with a deterministic clock (1ms steps) and generator
func newTestStore(t *testing.T, gen func() (string, error)) (*TokenStore, *sql.DB) {
t.Helper()
db := openTestDB(t)
return NewTokenStore(db, traceDiscard(), newFakeClock(t0, time.Millisecond).Now, gen), db
}
func TestCreateTokenDescriptions(t *testing.T) {
t.Parallel()
descriptions := []string{
"",
"ci deploy token",
" leading and trailing ",
"quotes ' \" ` and $(rm -rf /); DROP TABLE tokens; --",
"line1\nline2\r\n\ttab",
"unicode 日本語 🚀 full",
"ctl \x01\x1b[31m\x7f",
string([]byte{0xff, 0xfe, 'x'}),
"nul\x00byte",
strings.Repeat("long", 25000),
}
store, db := newTestStore(t, GenerateToken)
for _, desc := range descriptions {
tok, err := store.CreateToken(context.Background(), desc)
if err != nil {
t.Fatalf("CreateToken(%q): %v", desc, err)
}
if !tok.Active || tok.DeletedAt != nil || tok.Description != desc || !tokenAlphabet.MatchString(tok.Token) {
t.Fatalf("unexpected token %+v", tok)
}
var stored string
if err := db.QueryRow(`SELECT description FROM tokens WHERE token = ?`, tok.Token).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored != desc {
t.Fatalf("description not stored verbatim: %q != %q", stored, desc)
}
got, err := store.GetToken(context.Background(), tok.Token)
if err != nil || got.Description != desc {
t.Fatalf("GetToken description = %q, %v", got.Description, err)
}
}
if n := countRows(t, db, "tokens"); n != len(descriptions) {
t.Fatalf("rows = %d", n)
}
// The SQL-injection payload did nothing
if n := countRows(t, db, "operations"); n != 0 {
t.Fatalf("operations = %d", n)
}
}
func TestCreateTokenTimestamps(t *testing.T) {
t.Parallel()
local := time.FixedZone("UTC+5", 5*3600)
db := openTestDB(t)
store := NewTokenStore(db, traceDiscard(), func() time.Time { return time.Date(2026, 3, 4, 10, 0, 0, 500, local) }, seqGenerator("a"))
tok, err := store.CreateToken(context.Background(), "")
if err != nil {
t.Fatal(err)
}
var created string
if err := db.QueryRow(`SELECT created_at FROM tokens WHERE token = 'a'`).Scan(&created); err != nil {
t.Fatal(err)
}
if created != "2026-03-04T05:00:00.0000005Z" {
t.Fatalf("created_at = %q", created)
}
if tok.CreatedAt.Location() != time.UTC || !tok.CreatedAt.Equal(time.Date(2026, 3, 4, 5, 0, 0, 500, time.UTC)) {
t.Fatalf("CreatedAt = %v", tok.CreatedAt)
}
if _, err := time.Parse(time.RFC3339Nano, created); err != nil {
t.Fatalf("not RFC3339Nano: %v", err)
}
}
func TestCreateTokenCollisionRetry(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
store := NewTokenStore(db, log, newFakeClock(t0, time.Millisecond).Now, seqGenerator("same", "same", "same", "other"))
if _, err := store.CreateToken(context.Background(), "first"); err != nil {
t.Fatal(err)
}
tok, err := store.CreateToken(context.Background(), "second")
if err != nil {
t.Fatalf("expected retry to succeed: %v", err)
}
if tok.Token != "other" {
t.Fatalf("token = %q", tok.Token)
}
if c := strings.Count(buf.String(), "generated token collided"); c != 2 {
t.Fatalf("collision logs = %d", c)
}
// The colliding row was not modified
first, err := store.GetToken(context.Background(), "same")
if err != nil || first.Description != "first" {
t.Fatalf("existing row changed: %+v, %v", first, err)
}
}
func TestCreateTokenCollisionExhausted(t *testing.T) {
t.Parallel()
gen := func() (string, error) { return "fixed", nil }
store, db := newTestStore(t, gen)
if _, err := store.CreateToken(context.Background(), ""); err != nil {
t.Fatal(err)
}
if _, err := store.CreateToken(context.Background(), ""); !errors.Is(err, ErrTokenCollision) {
t.Fatalf("expected ErrTokenCollision, got %v", err)
}
if n := countRows(t, db, "tokens"); n != 1 {
t.Fatalf("rows = %d", n)
}
}
func TestCreateTokenGeneratorError(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, func() (string, error) { return "", errors.New("no entropy") })
if _, err := store.CreateToken(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "no entropy") {
t.Fatalf("got %v", err)
}
}
func TestCreateTokenInsertError(t *testing.T) {
t.Parallel()
store, db := newTestStore(t, GenerateToken)
db.Close()
if _, err := store.CreateToken(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "insert token") {
t.Fatalf("got %v", err)
}
}
func TestListTokensEmpty(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, GenerateToken)
for _, activeOnly := range []bool{false, true} {
tokens, err := store.ListTokens(context.Background(), activeOnly)
if err != nil {
t.Fatal(err)
}
if tokens == nil || len(tokens) != 0 {
t.Fatalf("activeOnly=%v: expected empty non-nil slice, got %#v", activeOnly, tokens)
}
}
}
func TestListTokensOne(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, seqGenerator("only"))
created, err := store.CreateToken(context.Background(), "d")
if err != nil {
t.Fatal(err)
}
tokens, err := store.ListTokens(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if len(tokens) != 1 || tokens[0].Token != "only" || tokens[0].Description != "d" || !tokens[0].Active ||
!tokens[0].CreatedAt.Equal(created.CreatedAt) || tokens[0].DeletedAt != nil {
t.Fatalf("got %+v", tokens)
}
}
func TestListTokensManyOrdering(t *testing.T) {
t.Parallel()
db := openTestDB(t)
store := NewTokenStore(db, traceDiscard(), time.Now, GenerateToken)
// Rows inserted out of order, with fraction lengths that break lexical ordering
// "y", "a" and "w" are inactive
rows := []struct {
token, created string
active int
}{
{"z", "2026-01-01T00:00:00.1Z", 1},
{"y", "2026-01-01T00:00:00Z", 0},
{"x", "2026-01-01T00:00:00.12Z", 1},
{"b", "2026-01-01T00:00:00.5Z", 1},
{"a", "2026-01-01T00:00:00.5Z", 0},
{"w", "2025-12-31T23:59:59.999999999Z", 0},
{"c", "2026-01-01T00:00:00.5Z", 1},
}
for _, r := range rows {
deleted := any(nil)
if r.active == 0 {
deleted = "2026-02-01T00:00:00Z"
}
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', ?, ?, ?)`, r.token, r.active, r.created, deleted)
}
for activeOnly, want := range map[bool]string{false: "w,y,z,x,a,b,c", true: "z,x,b,c"} {
tokens, err := store.ListTokens(context.Background(), activeOnly)
if err != nil {
t.Fatal(err)
}
var got []string
for _, tok := range tokens {
got = append(got, tok.Token)
}
if strings.Join(got, ",") != want {
t.Fatalf("activeOnly=%v: order = %v, want %s", activeOnly, got, want)
}
}
}
func TestListTokensMany(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, GenerateToken)
var want []string
for i := 0; i < 200; i++ {
tok, err := store.CreateToken(context.Background(), fmt.Sprint(i))
if err != nil {
t.Fatal(err)
}
want = append(want, tok.Token)
}
for i := 0; i < 200; i += 3 {
if err := store.DeactivateToken(context.Background(), want[i]); err != nil {
t.Fatal(err)
}
}
tokens, err := store.ListTokens(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if len(tokens) != 200 {
t.Fatalf("len = %d", len(tokens))
}
for i, tok := range tokens {
inactive := i%3 == 0
if tok.Token != want[i] || tok.Description != fmt.Sprint(i) || tok.Active == inactive || (tok.DeletedAt != nil) != inactive {
t.Fatalf("row %d = %+v", i, tok)
}
}
// Only the active ones, same relative order
active, err := store.ListTokens(context.Background(), true)
if err != nil {
t.Fatal(err)
}
var wantActive []Token
for i, tok := range tokens {
if i%3 != 0 {
wantActive = append(wantActive, tok)
}
}
if len(active) != len(wantActive) {
t.Fatalf("active len = %d, want %d", len(active), len(wantActive))
}
for i, tok := range active {
if tok.Token != wantActive[i].Token || !tok.Active || tok.DeletedAt != nil {
t.Fatalf("active row %d = %+v", i, tok)
}
}
}
func TestListTokensErrors(t *testing.T) {
t.Parallel()
// Query error: closed database
store, db := newTestStore(t, GenerateToken)
db.Close()
for _, activeOnly := range []bool{false, true} {
if _, err := store.ListTokens(context.Background(), activeOnly); err == nil || !strings.Contains(err.Error(), "query tokens") {
t.Fatalf("activeOnly=%v: got %v", activeOnly, err)
}
}
// Malformed rows written by something else
for name, row := range map[string][]any{
"bad created_at": {"t", "", 1, "yesterday", nil},
"bad deleted_at": {"t", "", 0, "2026-01-01T00:00:00Z", "tomorrow"},
} {
store, db := newTestStore(t, GenerateToken)
mustExec(t, db, `INSERT INTO tokens VALUES (?, ?, ?, ?, ?)`, row...)
if _, err := store.ListTokens(context.Background(), false); err == nil || !strings.Contains(err.Error(), "parse stored timestamp") {
t.Errorf("%s: got %v", name, err)
}
}
// The inactive malformed row is filtered out in SQL, before it is parsed
store, db = newTestStore(t, GenerateToken)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 0, '2026-01-01T00:00:00Z', 'tomorrow')`)
if tokens, err := store.ListTokens(context.Background(), true); err != nil || len(tokens) != 0 {
t.Fatalf("active only: got %v, %v", tokens, err)
}
}
// cancelOnRecord is a slog handler that cancels a context when it sees a record with the given message
type cancelOnRecord struct {
slog.Handler
msg string
cancel context.CancelFunc
}
func (h cancelOnRecord) Enabled(context.Context, slog.Level) bool { return true }
func (h cancelOnRecord) Handle(ctx context.Context, r slog.Record) error {
if r.Message == h.msg {
h.cancel()
// Give database/sql and the driver time to observe the cancellation
time.Sleep(50 * time.Millisecond)
}
return nil
}
func TestListTokensIterationError(t *testing.T) {
t.Parallel()
db := openTestDB(t)
for i := 0; i < 500; i++ {
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, fmt.Sprint(i))
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
log := slog.New(cancelOnRecord{msg: "row read", cancel: cancel})
store := NewTokenStore(db, log, time.Now, GenerateToken)
if _, err := store.ListTokens(ctx, false); err == nil || !strings.Contains(err.Error(), "iterate token rows") {
t.Fatalf("got %v", err)
}
}
// newLegacySchemaStore creates a database whose tables were pre-created without constraints,
// so rows the real schema would reject can be used to exercise defensive error paths
func newLegacySchemaStore(t *testing.T, ddl string) (*TokenStore, *sql.DB) {
t.Helper()
path := testDBPath(t)
raw, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
mustExec(t, raw, ddl)
raw.Close()
db, err := OpenDatabase(context.Background(), path, traceDiscard())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return NewTokenStore(db, traceDiscard(), newFakeClock(t0, time.Millisecond).Now, GenerateToken), db
}
func TestTokenScanErrors(t *testing.T) {
t.Parallel()
store, db := newLegacySchemaStore(t, `CREATE TABLE tokens (token TEXT, description TEXT, active, created_at TEXT, deleted_at TEXT)`)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', NULL, 'not-a-number', '2026-01-01T00:00:00Z', NULL)`)
if _, err := store.ListTokens(context.Background(), false); err == nil || !strings.Contains(err.Error(), "read token row") {
t.Fatalf("ListTokens: got %v", err)
}
if _, err := store.GetToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "query token") {
t.Fatalf("GetToken: got %v", err)
}
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "query token") {
t.Fatalf("DeactivateToken: got %v", err)
}
}
func TestGetToken(t *testing.T) {
t.Parallel()
store, _ := newTestStore(t, seqGenerator("tok-a", "tok-b"))
a, _ := store.CreateToken(context.Background(), "A")
if _, err := store.CreateToken(context.Background(), "B"); err != nil {
t.Fatal(err)
}
// Existing
got, err := store.GetToken(context.Background(), "tok-a")
if err != nil || got.Token != "tok-a" || got.Description != "A" || !got.Active || !got.CreatedAt.Equal(a.CreatedAt) || got.DeletedAt != nil {
t.Fatalf("got %+v, %v", got, err)
}
// Unknown, near-match, case change, whitespace
for _, v := range []string{"", "tok-c", "TOK-A", "tok-a ", " tok-a", "tok-", "tok-a'--", "%"} {
if _, err := store.GetToken(context.Background(), v); !errors.Is(err, ErrTokenNotFound) {
t.Errorf("GetToken(%q) = %v", v, err)
}
}
// Inactive
if err := store.DeactivateToken(context.Background(), "tok-b"); err != nil {
t.Fatal(err)
}
got, err = store.GetToken(context.Background(), "tok-b")
if err != nil || got.Active || got.DeletedAt == nil {
t.Fatalf("inactive: %+v, %v", got, err)
}
// Query error
store.db.Close()
if _, err := store.GetToken(context.Background(), "tok-a"); err == nil || errors.Is(err, ErrTokenNotFound) {
t.Fatalf("closed db: %v", err)
}
}
func TestDeactivateToken(t *testing.T) {
t.Parallel()
store, db := newTestStore(t, seqGenerator("tok-a", "tok-b"))
if _, err := store.CreateToken(context.Background(), ""); err != nil { // created at t0
t.Fatal(err)
}
if _, err := store.CreateToken(context.Background(), ""); err != nil { // created at t0+1ms
t.Fatal(err)
}
if err := store.DeactivateToken(context.Background(), "tok-a"); err != nil { // deleted at t0+2ms
t.Fatal(err)
}
var active int
var deleted sql.NullString
if err := db.QueryRow(`SELECT active, deleted_at FROM tokens WHERE token = 'tok-a'`).Scan(&active, &deleted); err != nil {
t.Fatal(err)
}
if active != 0 || deleted.String != "2026-10-05T12:00:00.125456789Z" {
t.Fatalf("active=%d deleted_at=%+v", active, deleted)
}
// The other token is untouched
if err := db.QueryRow(`SELECT active, deleted_at FROM tokens WHERE token = 'tok-b'`).Scan(&active, &deleted); err != nil || active != 1 || deleted.Valid {
t.Fatalf("tok-b changed: %d %+v %v", active, deleted, err)
}
// Already inactive: error, and deleted_at keeps its original value
if err := store.DeactivateToken(context.Background(), "tok-a"); !errors.Is(err, ErrTokenInactive) {
t.Fatalf("second delete: %v", err)
}
if err := db.QueryRow(`SELECT deleted_at FROM tokens WHERE token = 'tok-a'`).Scan(&deleted); err != nil || deleted.String != "2026-10-05T12:00:00.125456789Z" {
t.Fatalf("deleted_at changed: %+v", deleted)
}
// Unknown
if err := store.DeactivateToken(context.Background(), "nope"); !errors.Is(err, ErrTokenNotFound) {
t.Fatalf("unknown: %v", err)
}
if n := countRows(t, db, "tokens"); n != 2 {
t.Fatalf("rows = %d", n)
}
}
func TestDeactivateTokenErrors(t *testing.T) {
t.Parallel()
// Begin error
store, db := newTestStore(t, GenerateToken)
db.Close()
if err := store.DeactivateToken(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "begin transaction") {
t.Fatalf("begin: %v", err)
}
// Update error: a trigger rejects the update
store, db = newTestStore(t, seqGenerator("t"))
if _, err := store.CreateToken(context.Background(), ""); err != nil {
t.Fatal(err)
}
mustExec(t, db, `CREATE TRIGGER no_update BEFORE UPDATE ON tokens BEGIN SELECT RAISE(ABORT, 'refused'); END`)
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "update token") {
t.Fatalf("update: %v", err)
}
var active int
if err := db.QueryRow(`SELECT active FROM tokens WHERE token = 't'`).Scan(&active); err != nil || active != 1 {
t.Fatalf("token changed after failed update: %d %v", active, err)
}
// Commit error: a deferred foreign key on deleted_at is violated only at COMMIT
store, db = newLegacySchemaStore(t, `
CREATE TABLE parents (id TEXT PRIMARY KEY);
CREATE TABLE tokens (
token TEXT PRIMARY KEY NOT NULL, description TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL, created_at TEXT NOT NULL,
deleted_at TEXT NULL REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED)`)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 1, '2026-01-01T00:00:00Z', NULL)`)
if err := store.DeactivateToken(context.Background(), "t"); err == nil || !strings.Contains(err.Error(), "commit transaction") {
t.Fatalf("commit: %v", err)
}
if err := db.QueryRow(`SELECT active FROM tokens WHERE token = 't'`).Scan(&active); err != nil || active != 1 {
t.Fatalf("token changed after failed commit: %d %v", active, err)
}
}
func TestTokenStoreTraceRedaction(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
db := openTestDB(t)
const secret = "SECRET_TOKEN_VALUE_0123456789abcdefghijklmno"
store := NewTokenStore(db, log, newFakeClock(t0, time.Millisecond).Now, seqGenerator(secret))
if _, err := store.CreateToken(context.Background(), "my description"); err != nil {
t.Fatal(err)
}
if _, err := store.GetToken(context.Background(), secret); err != nil {
t.Fatal(err)
}
if _, err := store.ListTokens(context.Background(), false); err != nil {
t.Fatal(err)
}
if err := store.DeactivateToken(context.Background(), secret); err != nil {
t.Fatal(err)
}
_ = store.DeactivateToken(context.Background(), secret)
_, _ = store.GetToken(context.Background(), secret+"x")
_ = store.DeactivateToken(context.Background(), secret+"x")
out := buf.String()
if strings.Contains(out, secret) || strings.Contains(out, secret[:12]) {
t.Fatalf("token secret leaked into trace output:\n%s", out)
}
id := tokenID(secret)
for _, want := range []string{
`msg="function entry" func=TokenStore.CreateToken`,
`msg="function exit" func=TokenStore.CreateToken`,
`msg="row inserted" table=tokens token_id=` + id + ` description="my description" active=true created_at=2026-10-05T12:00:00.123456789Z deleted_at=<nil>`,
`msg="row read" table=tokens token_id=` + id,
`msg="row updated" table=tokens token_id=` + id + ` active_from=true active_to=false deleted_at=`,
`msg="row already inactive" table=tokens token_id=` + id,
`msg="row not found" table=tokens token_id=` + tokenID(secret+"x"),
`func=TokenStore.ListTokens`, `func=TokenStore.GetToken`, `func=TokenStore.DeactivateToken`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing trace record %q", want)
}
}
}