Files
bryanpedini 0a6023e942 feat(cli): add help, token and audit commands
Entry point and dispatch, token list [all] / new / delete, audit [all | n | n<unit>] with padded table output, help, usage and exit codes; unit and e2e tests for commands, configuration precedence, logging and token lifecycle.
2026-10-06 10:52:25 +02:00

1235 lines
51 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
)
// cliResult captures one CLI invocation
type cliResult struct {
code int
stdout string
stderr string
}
// cliEnv describes the environment of a CLI invocation
type cliEnv struct {
env map[string]string
gen func() (string, error)
now func() time.Time
ctx context.Context
out interface{ Write([]byte) (int, error) }
setup func(a *app)
}
func runApp(t *testing.T, e cliEnv, args ...string) cliResult {
t.Helper()
stdout, stderr := &syncBuffer{}, &syncBuffer{}
a := newApp(envMap(e.env), stdout, stderr)
if e.out != nil {
a.stdout = e.out
}
if e.gen != nil {
a.generateToken = e.gen
}
if e.now != nil {
a.now = e.now
}
if e.setup != nil {
e.setup(a)
}
ctx := e.ctx
if ctx == nil {
ctx = context.Background()
}
code := a.run(ctx, args)
return cliResult{code: code, stdout: stdout.String(), stderr: stderr.String()}
}
// cli runs a command against the database at path with a deterministic clock
func cli(t *testing.T, path string, gen func() (string, error), clock *fakeClock, args ...string) cliResult {
t.Helper()
e := cliEnv{env: map[string]string{"DB_PATH": path}, gen: gen}
if clock != nil {
e.now = clock.Now
}
return runApp(t, e, args...)
}
func expect(t *testing.T, got cliResult, code int, stdout, stderr string) {
t.Helper()
if got.code != code || got.stdout != stdout || got.stderr != stderr {
t.Fatalf("got code=%d stdout=%q stderr=%q\nwant code=%d stdout=%q stderr=%q",
got.code, got.stdout, got.stderr, code, stdout, stderr)
}
}
// emptyTokenList is the whole `token list` output without rows: the header cells padded by their own widths
const emptyTokenList = "TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n"
// emptyAuditList is the whole `audit` output without rows
const emptyAuditList = "TIME TOKEN ORIGIN OPERATION\n"
// padTable renders lines the way `token list` and `audit` print them, independently of text/tabwriter:
// every column but the last is padded with spaces to its widest cell in runes plus 2, the last cell is never padded
func padTable(lines ...[]string) string {
widths := make([]int, len(lines[0])-1)
for _, line := range lines {
for i, cell := range line[:len(line)-1] {
widths[i] = max(widths[i], utf8.RuneCountInString(cell)+2)
}
}
var b strings.Builder
for _, line := range lines {
for i, cell := range line[:len(line)-1] {
b.WriteString(cell + strings.Repeat(" ", widths[i]-utf8.RuneCountInString(cell)))
}
b.WriteString(line[len(line)-1] + "\n")
}
return b.String()
}
func TestCLITokenListEmpty(t *testing.T) {
t.Parallel()
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}} {
path := testDBPath(t)
expect(t, cli(t, path, nil, nil, args...), 0, emptyTokenList, "")
if _, err := os.Stat(path); err != nil {
t.Fatalf("%q: database not created: %v", args, err)
}
}
}
func TestCLITokenListOne(t *testing.T) {
t.Parallel()
path := testDBPath(t)
clock := newFakeClock(t0, time.Millisecond)
expect(t, cli(t, path, seqGenerator("tok-1"), clock, "token", "new", "first token"), 0, "tok-1\n", "")
want := "" +
"TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n" +
"tok-1 \"first token\" true 2026-10-05T12:00:00.123456789Z -\n"
expect(t, cli(t, path, nil, nil, "token", "list"), 0, want, "")
expect(t, cli(t, path, nil, nil, "token", "list", "all"), 0, want, "")
// Once deactivated it is only listed with "all"
expect(t, cli(t, path, nil, clock, "token", "delete", "tok-1"), 0, "", "")
expect(t, cli(t, path, nil, nil, "token", "list"), 0, emptyTokenList, "")
want = "" +
"TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n" +
"tok-1 \"first token\" false 2026-10-05T12:00:00.123456789Z 2026-10-05T12:00:00.124456789Z\n"
expect(t, cli(t, path, nil, nil, "token", "list", "all"), 0, want, "")
}
func TestCLITokenListMany(t *testing.T) {
t.Parallel()
path := testDBPath(t)
clock := newFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), 500*time.Millisecond)
gen := seqGenerator("c", "b", "a", "d")
expect(t, cli(t, path, gen, clock, "token", "new"), 0, "c\n", "") // 00.000
expect(t, cli(t, path, gen, clock, "token", "new", "tab\there"), 0, "b\n", "") // 00.5
expect(t, cli(t, path, gen, clock, "token", "new", "multi\nline \"q\" é"), 0, "a\n", "") // 01.0
expect(t, cli(t, path, nil, clock, "token", "delete", "b"), 0, "", "") // 01.5
expect(t, cli(t, path, gen, clock, "token", "new", "ÃŒnïcödé — 日本語 🚀 «quoted»"), 0, "d\n", "") // 02.0
// Widths count runes: the quoted multi-byte description is 26 runes but 43 bytes, and the CJK runes count as 1 (not double-width)
wantAll := "" +
"TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n" +
"c \"\" true 2026-01-01T00:00:00Z -\n" +
"b \"tab\\there\" false 2026-01-01T00:00:00.5Z 2026-01-01T00:00:01.5Z\n" +
"a \"multi\\nline \\\"q\\\" é\" true 2026-01-01T00:00:01Z -\n" +
"d \"ÃŒnïcödé — 日本語 🚀 «quoted»\" true 2026-01-01T00:00:02Z -\n"
expect(t, cli(t, path, nil, nil, "token", "list", "all"), 0, wantAll, "")
// Without "all" the inactive row is hidden and the widths are computed over the printed lines only
wantActive := "" +
"TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n" +
"c \"\" true 2026-01-01T00:00:00Z -\n" +
"a \"multi\\nline \\\"q\\\" é\" true 2026-01-01T00:00:01Z -\n" +
"d \"ÃŒnïcödé — 日本語 🚀 «quoted»\" true 2026-01-01T00:00:02Z -\n"
expect(t, cli(t, path, nil, nil, "token", "list"), 0, wantActive, "")
// The independent renderer agrees with the literal tables
cells := func(tok, desc, active, created, deleted string) []string {
return []string{tok, strconv.Quote(desc), active, created, deleted}
}
c := cells("c", "", "true", "2026-01-01T00:00:00Z", "-")
b := cells("b", "tab\there", "false", "2026-01-01T00:00:00.5Z", "2026-01-01T00:00:01.5Z")
a := cells("a", "multi\nline \"q\" é", "true", "2026-01-01T00:00:01Z", "-")
d := cells("d", "ÃŒnïcödé — 日本語 🚀 «quoted»", "true", "2026-01-01T00:00:02Z", "-")
if padTable(tokenListHeader, c, b, a, d) != wantAll || padTable(tokenListHeader, c, a, d) != wantActive {
t.Fatal("padTable disagrees with the literal tables")
}
}
func TestCLITokenListManyGenerated(t *testing.T) {
t.Parallel()
path := testDBPath(t)
clock := newFakeClock(t0, 1500*time.Microsecond)
descriptions := []string{"", "x", "日本語", "with quoted spaces", "line\nbreak", "tab\t", "\x00\xff", "🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀🚀", "'single' \"double\""}
var all, active [][]string
for i := 0; i < 30; i++ {
desc := descriptions[i%len(descriptions)]
res := cli(t, path, nil, clock, "token", "new", desc)
tok := strings.TrimSuffix(res.stdout, "\n")
if res.code != 0 || !tokenAlphabet.MatchString(tok) {
t.Fatalf("%+v", res)
}
created := formatTime(t0.Add(time.Duration(i) * 1500 * time.Microsecond))
row := []string{tok, strconv.Quote(desc), "true", created, "-"}
if i%4 == 1 {
expect(t, cli(t, path, nil, nil, "token", "delete", tok), 0, "", "")
}
all = append(all, row)
}
for i, row := range all {
if i%4 == 1 {
continue
}
active = append(active, row)
}
// Deleted rows: read their deleted_at from the full listing (real clock) and check the rest exactly
full := cli(t, path, nil, nil, "token", "list", "all")
lines := strings.Split(strings.TrimSuffix(full.stdout, "\n"), "\n")
if full.code != 0 || full.stderr != "" || len(lines) != 31 {
t.Fatalf("%+v", full)
}
for i := range all {
if i%4 == 1 {
fields := strings.Fields(lines[i+1])
all[i][2], all[i][4] = "false", fields[len(fields)-1]
}
}
expect(t, cli(t, path, nil, nil, "token", "list", "all"), 0, padTable(append([][]string{tokenListHeader}, all...)...), "")
want := padTable(append([][]string{tokenListHeader}, active...)...)
expect(t, cli(t, path, nil, nil, "token", "list"), 0, want, "")
// Every token is 43 runes: the TOKEN column is always 45 wide; no line has trailing whitespace
for _, line := range strings.Split(strings.TrimSuffix(want, "\n"), "\n")[1:] {
if line[43:45] != " " || line[45] == ' ' || strings.TrimRight(line, " ") != line {
t.Fatalf("line %q", line)
}
}
}
func TestCLITokenNew(t *testing.T) {
t.Parallel()
path := testDBPath(t)
descriptions := []string{
"", "simple", "with quoted spaces", "'single' \"double\" `back` $HOME $(id) ; | & < > *",
"line\nbreak", "日本語 🚀", "-dash-leading", "--", "x'); DROP TABLE tokens; --",
}
for _, desc := range descriptions {
res := cli(t, path, nil, nil, "token", "new", desc)
if res.code != 0 || res.stderr != "" || !strings.HasSuffix(res.stdout, "\n") {
t.Fatalf("%q: %+v", desc, res)
}
tok := strings.TrimSuffix(res.stdout, "\n")
if !tokenAlphabet.MatchString(tok) {
t.Fatalf("printed token %q", tok)
}
db, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
var stored string
var active int
err = db.QueryRow(`SELECT description, active FROM tokens WHERE token = ?`, tok).Scan(&stored, &active)
db.Close()
if err != nil || stored != desc || active != 1 {
t.Fatalf("%q: stored %q active=%d err=%v", desc, stored, active, err)
}
}
// Without a description argument at all
res := cli(t, path, seqGenerator("nodesc"), nil, "token", "new")
expect(t, res, 0, "nodesc\n", "")
if !regexp.MustCompile(`(?m)^nodesc +"" +true +\S+ +-$`).MatchString(cli(t, path, nil, nil, "token", "list").stdout) {
t.Fatal("empty description not listed")
}
}
func TestCLITokenDelete(t *testing.T) {
t.Parallel()
path := testDBPath(t)
expect(t, cli(t, path, seqGenerator("victim"), nil, "token", "new"), 0, "victim\n", "")
expect(t, cli(t, path, nil, nil, "token", "delete", "victim"), 0, "", "")
expect(t, cli(t, path, nil, nil, "token", "delete", "victim"), 1, "", "dpsapi: token already inactive\n")
expect(t, cli(t, path, nil, nil, "token", "delete", "unknown-secret"), 1, "", "dpsapi: token not found\n")
expect(t, cli(t, path, nil, nil, "token", "delete", ""), 1, "", "dpsapi: token not found\n")
expect(t, cli(t, path, nil, nil, "token", "delete", "VICTIM"), 1, "", "dpsapi: token not found\n")
expect(t, cli(t, path, nil, nil, "token", "list"), 0, emptyTokenList, "")
list := cli(t, path, nil, nil, "token", "list", "all").stdout
if !regexp.MustCompile(`\Avictim +"" +false +\S+ +\S+\n\z`).MatchString(strings.SplitN(list, "\n", 2)[1]) ||
strings.Contains(list, " -\n") {
t.Fatalf("list after delete:\n%s", list)
}
}
func TestCLIDeleteDoesNotEchoToken(t *testing.T) {
t.Parallel()
path := testDBPath(t)
const secret = "DoNotEchoMe_0123456789abcdefghijklmnopqrstu"
e := cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "TRACE"}}
res := runApp(t, e, "token", "delete", secret)
if res.code != 1 || strings.Contains(res.stderr, secret) || strings.Contains(res.stdout, secret) {
t.Fatalf("token echoed: %+v", res)
}
if !strings.HasSuffix(res.stderr, "dpsapi: token not found\n") {
t.Fatalf("stderr = %q", res.stderr)
}
}
func TestCLIUsageErrors(t *testing.T) {
t.Parallel()
cases := []struct {
args []string
msg string
}{
{[]string{"foo"}, "unknown command"},
{[]string{"tokens"}, "unknown command"},
{[]string{"-h"}, "unknown command"},
{[]string{"--help"}, "unknown command"},
{[]string{"Help"}, "unknown command"},
{[]string{"HELP"}, "unknown command"},
{[]string{"help "}, "unknown command"},
{[]string{"-help"}, "unknown command"},
{[]string{"help", "x"}, "help takes no arguments"},
{[]string{"help", ""}, "help takes no arguments"},
{[]string{"help", "help"}, "help takes no arguments"},
{[]string{"help", "token", "list"}, "help takes no arguments"},
{[]string{"token", "help"}, "unknown token subcommand"},
{[]string{""}, "unknown command"},
{[]string{"TOKEN", "list"}, "unknown command"},
{[]string{"token"}, "missing token subcommand"},
{[]string{"token", "lst"}, "unknown token subcommand"},
{[]string{"token", "LIST"}, "unknown token subcommand"},
{[]string{"token", ""}, "unknown token subcommand"},
{[]string{"token", "list", "extra"}, "unknown token list argument"},
{[]string{"token", "list", ""}, "unknown token list argument"},
{[]string{"token", "list", "ALL"}, "unknown token list argument"},
{[]string{"token", "list", "All"}, "unknown token list argument"},
{[]string{"token", "list", "--all"}, "unknown token list argument"},
{[]string{"token", "list", "-a"}, "unknown token list argument"},
{[]string{"token", "list", " all"}, "unknown token list argument"},
{[]string{"token", "list", "all "}, "unknown token list argument"},
{[]string{"token", "list", "active"}, "unknown token list argument"},
{[]string{"token", "list", "help"}, "unknown token list argument"},
{[]string{"token", "list", "all", "all"}, "token list takes at most one argument"},
{[]string{"token", "list", "x", "y"}, "token list takes at most one argument"},
{[]string{"token", "list", "", ""}, "token list takes at most one argument"},
{[]string{"token", "list", "all", "x", "y"}, "token list takes at most one argument"},
{[]string{"token", "new", "a", "b"}, "token new takes at most one argument"},
{[]string{"token", "new", "a", ""}, "token new takes at most one argument"},
{[]string{"token", "delete"}, "token delete requires exactly one argument"},
{[]string{"token", "delete", "a", "b"}, "token delete requires exactly one argument"},
}
for _, c := range cases {
path := testDBPath(t)
res := cli(t, path, nil, nil, c.args...)
expect(t, res, 2, "", "dpsapi: usage error: "+c.msg+"\n"+usageText)
// Usage errors never touch the database
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%q: database created on usage error", c.args)
}
}
}
func TestCLIConfigErrors(t *testing.T) {
t.Parallel()
cases := []struct {
env map[string]string
msg string
}{
{map[string]string{}, "DB_PATH is required"},
{map[string]string{"DB_PATH": ""}, "DB_PATH must not be empty"},
{map[string]string{"DB_PATH": "x", "HTTP_PORT": "0"}, `invalid HTTP_PORT "0": must be an integer between 1 and 65535`},
{map[string]string{"DB_PATH": "x", "HTTP_ADDR": "localhost"}, `invalid HTTP_ADDR "localhost": must be an IPv4 or IPv6 address`},
{map[string]string{"DB_PATH": "x", "LOG_LEVEL": "verbose"}, `invalid LOG_LEVEL "verbose": must be one of ERROR, WARN, INFO, DEBUG, TRACE`},
{map[string]string{"DB_PATH": "x", "LOG_LEVEL": "a\nb"}, `invalid LOG_LEVEL "a\nb": must be one of ERROR, WARN, INFO, DEBUG, TRACE`},
}
for _, c := range cases {
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}, {"token", "list", "x"}, {}, {"bogus"}, {"help"}, {"help", "x"}} {
res := runApp(t, cliEnv{env: c.env}, args...)
// Configuration is validated first, so config errors win over usage errors
expect(t, res, 2, "", "dpsapi: configuration error: "+c.msg+"\n")
}
}
}
func TestCLIPersistenceError(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}, {"token", "new"}, {"token", "delete", "x"}} {
res := cli(t, dir, nil, nil, args...)
if res.code != 1 || res.stdout != "" || !strings.HasPrefix(res.stderr, "dpsapi: initialize database schema: ") ||
strings.Count(res.stderr, "\n") != 1 {
t.Fatalf("%q: %+v", args, res)
}
}
// List failing after the database opened (bad stored row)
path := testDBPath(t)
expect(t, cli(t, path, nil, nil, "token", "list"), 0, emptyTokenList, "")
db, _ := sql.Open("sqlite", "file:"+path)
mustExec(t, db, `INSERT INTO tokens VALUES ('t', '', 1, 'garbage', NULL)`)
db.Close()
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}} {
res := cli(t, path, nil, nil, args...)
if res.code != 1 || res.stdout != "" || !strings.HasPrefix(res.stderr, "dpsapi: read token row: parse stored timestamp") {
t.Fatalf("%q: %+v", args, res)
}
}
}
func TestCLIGeneratorFailure(t *testing.T) {
t.Parallel()
path := testDBPath(t)
res := cli(t, path, func() (string, error) { return "", errors.New("entropy\nexhausted") }, nil, "token", "new")
// Multi-line error text is folded onto one line
expect(t, res, 1, "", "dpsapi: generate token: entropy exhausted\n")
}
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("broken pipe") }
func TestCLIStdoutWriteFailure(t *testing.T) {
t.Parallel()
path := testDBPath(t)
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}, {"token", "new"}} {
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path}, out: failingWriter{}}, args...)
expect(t, res, 1, "", "dpsapi: write output: broken pipe\n")
}
// A table larger than the output buffer fails while the table is flushed: still reported once
for i := 0; i < 200; i++ {
expect(t, cli(t, path, seqGenerator(fmt.Sprintf("big-%03d-%s", i, strings.Repeat("x", 35))), nil, "token", "new", "description"),
0, fmt.Sprintf("big-%03d-%s\n", i, strings.Repeat("x", 35)), "")
}
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path}, out: failingWriter{}}, "token", "list")
expect(t, res, 1, "", "dpsapi: write output: broken pipe\n")
}
func TestCLILoggingLevels(t *testing.T) {
t.Parallel()
path := testDBPath(t)
// INFO (default) and above: a successful command writes nothing to stderr
for _, level := range []string{"ERROR", "WARN", "INFO"} {
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": level}}, "token", "new", "x")
if res.code != 0 || res.stderr != "" {
t.Fatalf("%s: %+v", level, res)
}
}
// DEBUG: lifecycle records, no TRACE
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "DEBUG"}, gen: seqGenerator("dbg-secret-value")}, "token", "new", "x")
for _, want := range []string{
`level=DEBUG msg="configuration resolved" db_path=` + path + ` http_addr=0.0.0.0 http_port=80 log_level=DEBUG`,
`level=DEBUG msg=dispatch mode=cli`,
`level=DEBUG msg="cli dispatch" command="token new"`,
`level=DEBUG msg="database schema initialized"`,
`level=DEBUG msg="database opened"`,
`level=DEBUG msg="token created" token_id=` + tokenID("dbg-secret-value"),
`level=DEBUG msg="cli command finished" command="token new" ok=true`,
`level=DEBUG msg="database closed"`,
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("DEBUG: missing %q in:\n%s", want, res.stderr)
}
}
if strings.Contains(res.stderr, "level=TRACE") || strings.Contains(res.stderr, "dbg-secret-value") {
t.Fatalf("DEBUG output leaked trace or secret:\n%s", res.stderr)
}
// TRACE: function entry/exit and row records, token redacted
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "trace"}, gen: seqGenerator("trace-secret-value")}, "token", "new", "described")
if res.stdout != "trace-secret-value\n" {
t.Fatalf("stdout = %q", res.stdout)
}
for _, want := range []string{
`level=TRACE msg="function entry" func=app.execute`,
`level=TRACE msg="function entry" func=app.runTokenCommand`,
`level=TRACE msg="function entry" func=app.tokenNew`,
`level=TRACE msg="function entry" func=TokenStore.CreateToken`,
`level=TRACE msg="row inserted" table=tokens token_id=` + tokenID("trace-secret-value") + ` description=described`,
`level=TRACE msg="function exit" func=app.execute`,
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("TRACE: missing %q", want)
}
}
if strings.Contains(res.stderr, "trace-secret-value") {
t.Fatalf("secret in trace output:\n%s", res.stderr)
}
// TRACE for list and delete
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "TRACE"}}, "token", "delete", "trace-secret-value")
if res.code != 0 || !strings.Contains(res.stderr, `msg="row updated" table=tokens token_id=`+tokenID("trace-secret-value")) ||
!strings.Contains(res.stderr, `level=DEBUG msg="token deactivated"`) || strings.Contains(res.stderr, "trace-secret-value") {
t.Fatalf("delete trace: %+v", res)
}
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "TRACE"}}, "token", "list", "all")
if res.code != 0 || !strings.Contains(res.stderr, `func=app.tokenList`) || strings.Contains(res.stderr, "trace-secret-value") ||
!strings.Contains(res.stderr, `msg="row read" table=tokens token_id=`+tokenID("trace-secret-value")) {
t.Fatalf("list trace: %+v", res)
}
if !strings.Contains(res.stdout, "trace-secret-value") {
t.Fatal("token list all must print full tokens on stdout")
}
// DEBUG for list: both forms dispatch as "token list"; the deactivated token is not even read without "all"
for _, args := range [][]string{{"token", "list"}, {"token", "list", "all"}} {
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "TRACE"}}, args...)
if res.code != 0 || !strings.Contains(res.stderr, `level=DEBUG msg="cli dispatch" command="token list"`+"\n") ||
!strings.Contains(res.stderr, `level=DEBUG msg="cli command finished" command="token list" ok=true`) ||
strings.Contains(res.stderr, "trace-secret-value") {
t.Fatalf("%q: %+v", args, res)
}
read := strings.Contains(res.stderr, `msg="row read" table=tokens token_id=`+tokenID("trace-secret-value"))
if listed := strings.Contains(res.stdout, "trace-secret-value"); read != (len(args) == 3) || listed != read {
t.Fatalf("%q: inactive token read=%v listed=%v", args, read, listed)
}
}
}
func TestRunEntryPoint(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "entry.db")
stdout, stderr := &syncBuffer{}, &syncBuffer{}
code := run([]string{"token", "list"}, envMap(map[string]string{"DB_PATH": path}), stdout, stderr)
if code != 0 || stdout.String() != emptyTokenList || stderr.String() != "" {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
code = run([]string{"nope"}, envMap(map[string]string{"DB_PATH": path}), stdout, stderr)
if code != 2 {
t.Fatalf("code=%d", code)
}
}
func TestFormatTokenRow(t *testing.T) {
t.Parallel()
deleted := time.Date(2026, 2, 3, 4, 5, 6, 7, time.UTC)
got := formatTokenRow(Token{Token: "t", Description: "a\tb\x00\xff", Active: false, CreatedAt: t0, DeletedAt: &deleted})
want := []string{"t", `"a\tb\x00\xff"`, "false", "2026-10-05T12:00:00.123456789Z", "2026-02-03T04:05:06.000000007Z"}
if !slices.Equal(got, want) {
t.Fatalf("got %q\nwant %q", got, want)
}
got = formatTokenRow(Token{Token: "u", Description: "日本語 é", Active: true, CreatedAt: t0})
want = []string{"u", `"日本語 é"`, "true", "2026-10-05T12:00:00.123456789Z", "-"}
if !slices.Equal(got, want) {
t.Fatalf("got %q\nwant %q", got, want)
}
}
// seedAudit creates the database at path with the tokens "tok" (active) and "old" (inactive)
// and the given operations rows (token, time), inserted in order with origin 10.0.0.<i>
func seedAudit(t *testing.T, path string, rows ...[2]string) *sql.DB {
t.Helper()
db := openTestDBAt(t, path)
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')`)
insertOperations(t, db, rows...)
return db
}
// auditOut renders the expected `audit` output for the given 1-based indexes into rows
func auditOut(rows [][2]string, idx ...int) string {
lines := [][]string{auditListHeader}
for _, i := range idx {
lines = append(lines, []string{rows[i-1][1], rows[i-1][0], fmt.Sprintf("10.0.0.%d", i), "GET containers"})
}
return padTable(lines...)
}
// auditCLI runs `audit args...` against path with a fixed clock
func auditCLI(t *testing.T, path string, now time.Time, args ...string) cliResult {
t.Helper()
e := cliEnv{env: map[string]string{"DB_PATH": path}, now: func() time.Time { return now }}
return runApp(t, e, append([]string{"audit"}, args...)...)
}
// indexes parses a comma-separated list of 1-based row indexes
func indexes(s string) []int {
var out []int
for _, f := range strings.Split(s, ",") {
n, _ := strconv.Atoi(f)
out = append(out, n)
}
return out
}
func TestCLIAuditEmpty(t *testing.T) {
t.Parallel()
for _, args := range [][]string{{}, {"1"}, {"5"}, {"1s"}, {"1h"}, {"1w"}, {"all"}} {
// Fresh database: created, schema initialized, header only
path := testDBPath(t)
expect(t, auditCLI(t, path, t0, args...), 0, emptyAuditList, "")
if _, err := os.Stat(path); err != nil {
t.Fatalf("%q: database not created: %v", args, err)
}
// Existing database with tokens but no audit rows
path = testDBPath(t)
seedAudit(t, path)
expect(t, auditCLI(t, path, t0, args...), 0, emptyAuditList, "")
}
}
func TestCLIAuditCount(t *testing.T) {
t.Parallel()
path := testDBPath(t)
var rows [][2]string
for i := 0; i < 15; i++ {
tok := "tok"
if i%2 == 1 {
tok = "old"
}
rows = append(rows, [2]string{tok, formatTime(t0.Add(time.Duration(i) * time.Second))})
}
seedAudit(t, path, rows...)
all := indexes("1,2,3,4,5,6,7,8,9,10,11,12,13,14,15")
cases := []struct {
args []string
want []int
}{
{nil, all[5:]}, // default: the latest 10
{[]string{"10"}, all[5:]},
{[]string{"010"}, all[5:]}, // leading zeros
{[]string{"0000000000000000000000000000010"}, all[5:]},
{[]string{"1"}, all[14:]},
{[]string{"01"}, all[14:]},
{[]string{"2"}, all[13:]},
{[]string{"14"}, all[1:]},
{[]string{"15"}, all},
{[]string{"16"}, all}, // more than available: everything
{[]string{"1000"}, all},
{[]string{"9223372036854775807"}, all}, // math.MaxInt
{[]string{"all"}, all}, // every row, not the latest 10
}
for _, c := range cases {
expect(t, auditCLI(t, path, t0, c.args...), 0, auditOut(rows, c.want...), "")
}
}
func TestCLIAuditOrdering(t *testing.T) {
t.Parallel()
path := testDBPath(t)
seedAudit(t, path, orderingRows...)
now := time.Date(2026, 1, 1, 0, 0, 1, 0, time.UTC)
cases := []struct {
args []string
want string
}{
{[]string{"100"}, orderingSorted},
{nil, "1,12,3,14,11,4,5,7,10,9"},
{[]string{"3"}, "7,10,9"},
// Ties (same instant, different spelling) keep insertion order; the cut falls between tied rows
{[]string{"4"}, "5,7,10,9"},
{[]string{"1s"}, "2,13,8,1,12,3,14,11,4,5,7,10,9"},
{[]string{"60s"}, orderingSorted},
{[]string{"all"}, orderingSorted},
}
for _, c := range cases {
expect(t, auditCLI(t, path, now, c.args...), 0, auditOut(orderingRows, indexes(c.want)...), "")
}
}
func TestCLIAuditAll(t *testing.T) {
t.Parallel()
// The clock plays no part: rows far in the past and in the future are all printed
clocks := []time.Time{t0, time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC)}
// No rows: header only
path := testDBPath(t)
seedAudit(t, path)
for _, now := range clocks {
expect(t, auditCLI(t, path, now, "all"), 0, emptyAuditList, "")
}
// One row
path = testDBPath(t)
one := [][2]string{{"old", "2026-01-01T00:00:00Z"}}
seedAudit(t, path, one...)
for _, now := range clocks {
expect(t, auditCLI(t, path, now, "all"), 0, auditOut(one, 1), "")
}
// More than 10 rows inserted newest first, spanning decades: every row, oldest first
path = testDBPath(t)
var rows [][2]string
var want []int
for i := 0; i < 25; i++ {
rows = append(rows, [2]string{"tok", formatTime(t0.Add(-time.Duration(i) * 400 * 24 * time.Hour))})
want = append([]int{i + 1}, want...)
}
seedAudit(t, path, rows...)
for _, now := range clocks {
expect(t, auditCLI(t, path, now, "all"), 0, auditOut(rows, want...), "")
}
// Mixed fraction lengths, zones and ties: (time, rowid) order, more than 10 rows
path = testDBPath(t)
seedAudit(t, path, orderingRows...)
for _, now := range clocks {
expect(t, auditCLI(t, path, now, "all"), 0, auditOut(orderingRows, indexes(orderingSorted)...), "")
}
}
func TestCLIAuditWindowUnits(t *testing.T) {
t.Parallel()
// The clock is not UTC: the window is an instant comparison, independent of zones
now := time.Date(2026, 10, 6, 14, 0, 0, 0, time.FixedZone("CEST", 2*3600))
cases := []struct {
arg string
dur time.Duration
}{
{"1s", time.Second},
{"45s", 45 * time.Second},
{"1m", time.Minute},
{"90m", 90 * time.Minute},
{"1h", time.Hour},
{"01h", time.Hour},
{"36h", 36 * time.Hour},
{"1d", 24 * time.Hour},
{"10d", 240 * time.Hour},
{"1w", 7 * 24 * time.Hour},
{"0002w", 14 * 24 * time.Hour},
}
for _, c := range cases {
since := now.Add(-c.dur)
rows := [][2]string{
{"tok", formatTime(since.Add(-time.Nanosecond))}, // just outside
{"old", formatTime(since)}, // boundary: included
{"tok", formatTime(since.Add(time.Nanosecond))},
{"tok", formatTime(now)},
{"tok", formatTime(now.Add(time.Hour))}, // later than now: still >= since
{"tok", formatTime(since.Add(-c.dur))}, // far outside
}
path := testDBPath(t)
seedAudit(t, path, rows...)
expect(t, auditCLI(t, path, now, c.arg), 0, auditOut(rows, 2, 3, 4, 5), "")
}
}
func TestCLIAuditLargestValues(t *testing.T) {
t.Parallel()
path := testDBPath(t)
rows := [][2]string{{"tok", "1700-01-01T00:00:00Z"}, {"tok", "1740-01-01T00:00:00Z"}, {"tok", "2026-01-01T00:00:00Z"}}
seedAudit(t, path, rows...)
// The largest window per unit that fits a time.Duration (about 292 years)
for _, arg := range []string{"9223372036s", "153722867m", "2562047h", "106751d", "15250w"} {
expect(t, auditCLI(t, path, t0, arg), 0, auditOut(rows, 2, 3), "")
}
}
func TestCLIAuditUsageErrors(t *testing.T) {
t.Parallel()
const many = "audit takes at most one argument"
const invalid = "invalid audit argument"
cases := []struct {
args []string
msg string
}{
{[]string{"AUDIT"}, "unknown command"},
{[]string{"Audit"}, "unknown command"},
{[]string{"audits"}, "unknown command"},
{[]string{"audit", "1", "2"}, many},
{[]string{"audit", "10", "1h"}, many},
{[]string{"audit", "", ""}, many},
{[]string{"audit", "1", ""}, many},
{[]string{"audit", "all", "x"}, many},
{[]string{"audit", "all", "all"}, many},
{[]string{"audit", "all", ""}, many},
{[]string{"audit", "1", "all"}, many},
}
for _, arg := range []string{
// zero
"0", "00", "0s", "0m", "0h", "0d", "0w", "000w",
// signs and whitespace
"-1", "+1", "-1h", "+1h", "--1", " 1", "1 ", "\t1", "1\n", "1h ", "1 h", " ", "1\x00",
// empty, unit only, unknown/uppercase/multi-letter units
"", "s", "h", "d", "1H", "1D", "1W", "1S", "1M", "1y", "1ms", "1us", "1ns", "1hh", "1h1", "h1", "1x",
// other number syntaxes
"1.5", "1.5h", "1e3", "1_000", "0x10", "0b1", "1,000", "Ù¡", "", "one", "-h", "--help", "help", "audit",
// overflow: count beyond int, window beyond time.Duration
"9223372036854775808", "99999999999999999999999999", "9223372036854775808s",
"9223372037s", "153722868m", "2562048h", "106752d", "15251w", "9223372036854775807w",
// SQL/shell payloads
"1; DROP TABLE operations", "1' OR '1'='1", "$(id)",
// all is exact and lowercase only
"ALL", "All", "aLL", "all1", "1all", "allh", " all", "all ", "\tall", "all\n", "alls", "al", "a", "--all", "-all", "-a",
"all\x00", "\"all\"", "", "every",
} {
cases = append(cases, struct {
args []string
msg string
}{[]string{"audit", arg}, invalid})
}
for _, c := range cases {
path := testDBPath(t)
res := cli(t, path, nil, nil, c.args...)
// The exact stderr proves the argument is never echoed
expect(t, res, 2, "", "dpsapi: usage error: "+c.msg+"\n"+usageText)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%q: database created on usage error", c.args)
}
}
}
func TestParseAuditArgs(t *testing.T) {
t.Parallel()
cases := []struct {
args []string
want auditSelection
}{
{nil, auditSelection{limit: 10}},
{[]string{}, auditSelection{limit: 10}},
{[]string{"7"}, auditSelection{limit: 7}},
{[]string{"007"}, auditSelection{limit: 7}},
{[]string{"9223372036854775807"}, auditSelection{limit: math.MaxInt}},
{[]string{"3s"}, auditSelection{window: 3 * time.Second}},
{[]string{"3m"}, auditSelection{window: 3 * time.Minute}},
{[]string{"3h"}, auditSelection{window: 3 * time.Hour}},
{[]string{"3d"}, auditSelection{window: 72 * time.Hour}},
{[]string{"3w"}, auditSelection{window: 504 * time.Hour}},
{[]string{"9223372036s"}, auditSelection{window: 9223372036 * time.Second}},
{[]string{"all"}, auditSelection{all: true}},
}
for _, c := range cases {
got, err := parseAuditArgs(c.args)
if err != nil || got != c.want {
t.Errorf("parseAuditArgs(%q) = %+v, %v; want %+v", c.args, got, err, c.want)
}
}
for _, arg := range []string{"0", "ALL", "all1"} {
if _, err := parseAuditArgs([]string{arg}); !errors.Is(err, errUsage) {
t.Fatalf("%q: expected usage error, got %v", arg, err)
}
}
}
func TestCLIAuditConfigErrorsFirst(t *testing.T) {
t.Parallel()
for _, args := range [][]string{{"audit"}, {"audit", "1h"}, {"audit", "0"}, {"audit", "1", "2"}, {"audit", "all"}, {"audit", "ALL"}} {
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": "x", "LOG_LEVEL": "loud"}}, args...)
expect(t, res, 2, "", "dpsapi: configuration error: invalid LOG_LEVEL \"loud\": must be one of ERROR, WARN, INFO, DEBUG, TRACE\n")
}
}
func TestCLIAuditPersistenceErrors(t *testing.T) {
t.Parallel()
dir := t.TempDir()
for _, args := range [][]string{{"audit"}, {"audit", "5"}, {"audit", "1h"}, {"audit", "all"}} {
res := cli(t, dir, nil, nil, args...)
if res.code != 1 || res.stdout != "" || !strings.HasPrefix(res.stderr, "dpsapi: initialize database schema: ") ||
strings.Count(res.stderr, "\n") != 1 {
t.Fatalf("%q: %+v", args, res)
}
}
// A stored time that does not parse fails the command instead of misordering; nothing reaches stdout
path := testDBPath(t)
seedAudit(t, path, [2]string{"tok", "2026-01-01T00:00:00Z"}, [2]string{"tok", "yesterday"})
for _, args := range [][]string{{}, {"1"}, {"1w"}, {"all"}} {
res := auditCLI(t, path, t0, args...)
if res.code != 1 || res.stdout != "" || !strings.HasPrefix(res.stderr, "dpsapi: read operation row: parse stored timestamp") ||
strings.Count(res.stderr, "\n") != 1 || strings.Contains(res.stderr, "usage") {
t.Fatalf("%q: %+v", args, res)
}
}
}
func TestCLIAuditStdoutWriteFailure(t *testing.T) {
t.Parallel()
path := testDBPath(t)
seedAudit(t, path, [2]string{"tok", "2026-01-01T00:00:00Z"})
for _, args := range [][]string{{"audit"}, {"audit", "1"}, {"audit", "1w"}, {"audit", "all"}} {
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path}, out: failingWriter{}}, args...)
expect(t, res, 1, "", "dpsapi: write output: broken pipe\n")
}
}
// tableDump renders every row of tokens and operations (with rowids) for before/after comparisons
func tableDump(t *testing.T, db *sql.DB) string {
t.Helper()
var b strings.Builder
for _, q := range []string{
`SELECT rowid, token, description, active, created_at, IFNULL(deleted_at, 'NULL') FROM tokens ORDER BY rowid`,
`SELECT rowid, token, origin, operation, time FROM operations ORDER BY rowid`,
} {
rows, err := db.Query(q)
if err != nil {
t.Fatal(err)
}
cols, _ := rows.Columns()
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
t.Fatal(err)
}
fmt.Fprintln(&b, vals...)
}
rows.Close()
}
return b.String()
}
func TestCLIAuditReadOnly(t *testing.T) {
t.Parallel()
path := testDBPath(t)
db := seedAudit(t, path, orderingRows...)
// Any write to either table would abort the command
for _, table := range []string{"tokens", "operations"} {
for _, op := range []string{"INSERT", "UPDATE", "DELETE"} {
mustExec(t, db, fmt.Sprintf(`CREATE TRIGGER ro_%s_%s BEFORE %s ON %s BEGIN SELECT RAISE(ABORT, 'read-only'); END`,
op, table, op, table))
}
}
before := tableDump(t, db)
for _, args := range [][]string{{}, {"1"}, {"100"}, {"1s"}, {"1w"}, {"all"}, {"0"}, {"x"}, {"ALL"}} {
res := auditCLI(t, path, t0, args...)
if res.code == 1 {
t.Fatalf("%q: %+v", args, res)
}
}
if after := tableDump(t, db); after != before {
t.Fatalf("tables changed:\n%s\nvs\n%s", before, after)
}
// On a fresh database only the schema is created: both tables stay empty
path = testDBPath(t)
expect(t, auditCLI(t, path, t0), 0, emptyAuditList, "")
fresh := openTestDBAt(t, path)
if countRows(t, fresh, "tokens") != 0 || countRows(t, fresh, "operations") != 0 {
t.Fatal("audit wrote rows")
}
}
func TestCLIAuditLogging(t *testing.T) {
t.Parallel()
const secret = "AuditCliSecret_0123456789abcdefghijklmnopqr"
path := testDBPath(t)
db := seedAudit(t, path)
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.9', 'GET containers', '2026-10-05T11:59:00Z')`, secret)
mustExec(t, db, `INSERT INTO operations VALUES ('tok', '192.0.2.8', 'GET containers', '2026-10-05T10:00:00Z')`)
wantOut := "" +
"TIME TOKEN ORIGIN OPERATION\n" +
"2026-10-05T11:59:00Z " + secret + " 192.0.2.9 GET containers\n"
env := func(level string) cliEnv {
return cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": level}, now: func() time.Time { return t0 }}
}
// INFO and above: a successful audit writes nothing to stderr; the full token is on stdout only
for _, level := range []string{"ERROR", "WARN", "INFO"} {
expect(t, runApp(t, env(level), "audit", "1"), 0, wantOut, "")
}
// DEBUG: dispatch, selection, result count, no TRACE, no secret
res := runApp(t, env("DEBUG"), "audit", "1h")
if res.code != 0 || res.stdout != wantOut {
t.Fatalf("%+v", res)
}
for _, want := range []string{
`level=DEBUG msg=dispatch mode=cli`,
`level=DEBUG msg="cli dispatch" command=audit window=1h0m0s`,
`level=DEBUG msg="database opened"`,
`level=DEBUG msg="audit window resolved" since=2026-10-05T11:00:00.123456789Z`,
`level=DEBUG msg="audit rows listed" rows=1`,
`level=DEBUG msg="cli command finished" command=audit ok=true`,
`level=DEBUG msg="database closed"`,
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("DEBUG: missing %q in:\n%s", want, res.stderr)
}
}
if strings.Contains(res.stderr, "level=TRACE") || strings.Contains(res.stderr, secret) {
t.Fatalf("DEBUG output leaked trace or secret:\n%s", res.stderr)
}
res = runApp(t, env("DEBUG"), "audit")
if !strings.Contains(res.stderr, `level=DEBUG msg="cli dispatch" command=audit limit=10`) ||
!strings.Contains(res.stderr, `level=DEBUG msg="audit rows listed" rows=2`) || strings.Contains(res.stderr, "window") {
t.Fatalf("DEBUG default:\n%s", res.stderr)
}
res = runApp(t, env("DEBUG"), "audit", "all")
wantAll := "" +
"TIME TOKEN ORIGIN OPERATION\n" +
"2026-10-05T10:00:00Z tok 192.0.2.8 GET containers\n" +
"2026-10-05T11:59:00Z " + secret + " 192.0.2.9 GET containers\n"
if res.code != 0 || res.stdout != wantAll ||
!strings.Contains(res.stderr, `level=DEBUG msg="cli dispatch" command=audit all=true`+"\n") ||
!strings.Contains(res.stderr, `level=DEBUG msg="audit rows listed" rows=2`) ||
!strings.Contains(res.stderr, `level=DEBUG msg="cli command finished" command=audit ok=true`) ||
strings.Contains(res.stderr, "limit=") || strings.Contains(res.stderr, "window") ||
strings.Contains(res.stderr, "level=TRACE") || strings.Contains(res.stderr, secret) || strings.Contains(res.stderr, secret[:12]) {
t.Fatalf("DEBUG all: %+v", res)
}
// TRACE: function path and every row read, token redacted to token_id
res = runApp(t, env("TRACE"), "audit", "1")
if res.code != 0 || res.stdout != wantOut {
t.Fatalf("%+v", res)
}
for _, want := range []string{
`level=TRACE msg="function entry" func=app.execute`,
`level=TRACE msg="function entry" func=app.runAuditCommand`,
`level=TRACE msg="function entry" func=app.auditList`,
`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.9 operation="GET containers" time=2026-10-05T11:59:00Z`,
`level=TRACE msg="row read" table=operations rowid=2 token_id=` + tokenID("tok") + ` origin=192.0.2.8`,
`level=TRACE msg="function exit" func=AuditStore.ListOperations`,
`level=TRACE msg="function exit" func=app.runAuditCommand`,
`level=DEBUG msg="cli dispatch" command=audit limit=1`,
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("TRACE: missing %q in:\n%s", want, res.stderr)
}
}
if strings.Contains(res.stderr, secret) || strings.Contains(res.stderr, secret[:12]) {
t.Fatalf("secret in trace output:\n%s", res.stderr)
}
// Failures: the error is the last stderr line, still without the secret, stdout empty
mustExec(t, db, `INSERT INTO operations VALUES (?, '192.0.2.9', 'GET containers', 'bogus')`, secret)
for _, args := range [][]string{{"audit"}, {"audit", "all"}} {
res = runApp(t, env("TRACE"), args...)
if res.code != 1 || res.stdout != "" || strings.Contains(res.stderr, secret) ||
!strings.Contains(res.stderr, `level=DEBUG msg="cli command finished" command=audit ok=false`) ||
!strings.HasSuffix(res.stderr, "dpsapi: read operation row: parse stored timestamp: parsing time \"bogus\" as \"2006-01-02T15:04:05.999999999Z07:00\": cannot parse \"bogus\" as \"2006\"\n") {
t.Fatalf("%q: %+v", args, res)
}
}
}
func TestCLIAuditAfterServerRequests(t *testing.T) {
t.Parallel()
path := testDBPath(t)
tok := strings.TrimSpace(cli(t, path, nil, nil, "token", "new", "auditor").stdout)
h := startServer(t, map[string]string{"DB_PATH": path}, nil)
for i := 0; i < 3; i++ {
if resp, _ := h.get("/api/v1/containers", bearer(tok)); resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
}
h.get("/api/v1/containers", nil) // 401: not audited
h.get("/api/v1/containers", bearer(tok+"x")) // 401: not audited
// The CLI reads the live database while the server is running, with the real clock
for _, args := range [][]string{{"audit"}, {"audit", "3"}, {"audit", "1h"}, {"audit", "all"}, {"audit", "2"}} {
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path}}, args...)
lines := strings.Split(strings.TrimSuffix(res.stdout, "\n"), "\n")
want := 4
if args[len(args)-1] == "2" {
want = 3
}
if res.code != 0 || res.stderr != "" || len(lines) != want || !slices.Equal(strings.Fields(lines[0]), auditListHeader) {
t.Fatalf("%q: %+v", args, res)
}
// The columns are aligned: the token starts where the TOKEN header starts, at the TIME width
at := strings.Index(lines[0], "TOKEN")
var prev time.Time
for _, line := range lines[1:] {
f := strings.Fields(line)
if len(f) != 5 || f[1] != tok || f[2] != "127.0.0.1" || f[3]+" "+f[4] != "GET containers" ||
line[at:at+len(tok)] != tok || line[at-2:at] != " " || !strings.HasSuffix(line, " 127.0.0.1 GET containers") {
t.Fatalf("%q: line %q", args, line)
}
at, err := time.Parse(time.RFC3339Nano, f[0])
if err != nil || at.Before(prev) || !strings.HasSuffix(f[0], "Z") {
t.Fatalf("%q: time %q (prev %v): %v", args, f[0], prev, err)
}
prev = at
}
}
h.cancel()
if code := h.wait(); code != 0 {
t.Fatalf("server exit %d", code)
}
}
func TestFormatOperationRow(t *testing.T) {
t.Parallel()
op := Operation{RowID: 7, Token: "tok-full-value", Origin: "::1", Operation: operationGetContainers,
Time: time.Date(2026, 1, 1, 0, 0, 0, 500000000, time.UTC), TimeText: "2026-01-01T01:00:00.5+01:00"}
if got, want := formatOperationRow(op), []string{"2026-01-01T01:00:00.5+01:00", "tok-full-value", "::1", "GET containers"}; !slices.Equal(got, want) {
t.Fatalf("got %q\nwant %q", got, want)
}
}
func TestCLIAuditPadded(t *testing.T) {
t.Parallel()
const long = "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdE"
path := testDBPath(t)
db := seedAudit(t, path)
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, long)
for _, r := range [][3]string{
{"tok", "10.0.0.1", "2026-10-05T11:00:00Z"},
{"old", "2001:db8::1234:5678", "2026-10-05T12:00:00.123456789Z"},
{long, "::1", "2026-10-05T13:00:00.5+01:00"},
} {
mustExec(t, db, `INSERT INTO operations VALUES (?, ?, 'GET containers', ?)`, r[0], r[1], r[2])
}
now := time.Date(2026, 10, 5, 12, 30, 0, 0, time.UTC)
// No rows: the header cells padded by their own widths
expect(t, auditCLI(t, path, now, "1s"), 0, emptyAuditList, "")
// One row: the times are printed as stored; OPERATION is the last column and contains a space
one := "" +
"TIME TOKEN ORIGIN OPERATION\n" +
"2026-10-05T13:00:00.5+01:00 " + long + " ::1 GET containers\n"
expect(t, auditCLI(t, path, now, "1"), 0, one, "")
// Many rows: every column but the last is as wide as its widest cell plus 2
many := "" +
"TIME TOKEN ORIGIN OPERATION\n" +
"2026-10-05T11:00:00Z tok 10.0.0.1 GET containers\n" +
"2026-10-05T12:00:00.123456789Z old 2001:db8::1234:5678 GET containers\n" +
"2026-10-05T13:00:00.5+01:00 " + long + " ::1 GET containers\n"
expect(t, auditCLI(t, path, now), 0, many, "")
expect(t, auditCLI(t, path, now, "3"), 0, many, "")
expect(t, auditCLI(t, path, now, "all"), 0, many, "")
// Window selection: the widths follow the printed rows only
window := "" +
"TIME TOKEN ORIGIN OPERATION\n" +
"2026-10-05T12:00:00.123456789Z old 2001:db8::1234:5678 GET containers\n" +
"2026-10-05T13:00:00.5+01:00 " + long + " ::1 GET containers\n"
expect(t, auditCLI(t, path, now, "1h"), 0, window, "")
expect(t, auditCLI(t, path, now, "2"), 0, window, "")
}
func TestWriteTable(t *testing.T) {
t.Parallel()
cases := []struct {
header []string
rows [][]string
want string
}{
{[]string{"A"}, nil, "A\n"},
{[]string{"A", "B"}, nil, "A B\n"},
{[]string{"A", "B"}, [][]string{{"", ""}}, "A B\n \n"},
{[]string{"LONG", "B", "C"}, [][]string{{"x", "yyyy", "last cell with spaces"}}, "LONG B C\nx yyyy last cell with spaces\n"},
// Runes, not bytes and not display width
{[]string{"K", "V"}, [][]string{{"日本語", "1"}, {"é", "2"}, {"🚀🚀", "3"}}, "K V\n日本語 1\né 2\n🚀🚀 3\n"},
}
for _, c := range cases {
var b strings.Builder
if err := writeTable(&b, c.header, c.rows); err != nil || b.String() != c.want {
t.Errorf("%q %q: got %q, %v; want %q", c.header, c.rows, b.String(), err, c.want)
}
if padTable(append([][]string{c.header}, c.rows...)...) != c.want {
t.Errorf("%q %q: padTable disagrees", c.header, c.rows)
}
}
if err := writeTable(failingWriter{}, auditListHeader, nil); err == nil || err.Error() != "write output: broken pipe" {
t.Fatalf("got %v", err)
}
}
func TestUsageText(t *testing.T) {
t.Parallel()
const want = "usage:\n" +
" dpsapi start the HTTP server\n" +
" dpsapi help print this usage text\n" +
" dpsapi token list [all] list the active tokens, or every token with \"all\"\n" +
" dpsapi token new [description] create a token and print it\n" +
" dpsapi token delete <token> deactivate a token\n" +
" dpsapi audit print the latest 10 audit rows\n" +
" dpsapi audit <n> print the latest n audit rows\n" +
" dpsapi audit <n><unit> print the audit rows of the last n units (s, m, h, d, w)\n" +
" dpsapi audit all print every audit row\n"
if usageText != want {
t.Fatalf("usageText = %q", usageText)
}
}
func TestCLIHelp(t *testing.T) {
t.Parallel()
const secret = "HelpSecret_0123456789abcdefghijklmnopqrstuv"
// Default and quiet levels: usage text on stdout only, the database is never created
for _, level := range []string{"", "ERROR", "WARN", "INFO"} {
path := testDBPath(t)
env := map[string]string{"DB_PATH": path}
if level != "" {
env["LOG_LEVEL"] = level
}
res := runApp(t, cliEnv{env: env}, "help")
expect(t, res, 0, usageText, "")
if !strings.HasSuffix(res.stdout, "\n dpsapi audit all print every audit row\n") {
t.Fatalf("%q: help lacks audit all: %q", level, res.stdout)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("%q: database created by help", level)
}
}
// An existing database is not opened either: its rows never reach the output or the logs
path := testDBPath(t)
db := openTestDBAt(t, path)
mustExec(t, db, `INSERT INTO tokens VALUES (?, '', 1, '2026-01-01T00:00:00Z', NULL)`, secret)
expect(t, cli(t, path, nil, nil, "help"), 0, usageText, "")
// DEBUG: dispatch records only
res := runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "DEBUG"}}, "help")
if res.code != 0 || res.stdout != usageText {
t.Fatalf("%+v", res)
}
dispatch := strings.Index(res.stderr, `level=DEBUG msg=dispatch mode=cli`+"\n")
command := strings.Index(res.stderr, `level=DEBUG msg="cli dispatch" command=help`+"\n")
if dispatch < 0 || command < dispatch || strings.Contains(res.stderr, "database") || strings.Contains(res.stderr, "level=TRACE") ||
strings.Contains(res.stderr, secret) || strings.Contains(res.stderr, "token_id") || strings.Count(res.stderr, "\n") != 3 {
t.Fatalf("DEBUG:\n%s", res.stderr)
}
// TRACE: function path, still no database and no token
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "TRACE"}}, "help")
for _, want := range []string{
`level=TRACE msg="function entry" func=app.runHelpCommand`,
`level=TRACE msg="function exit" func=app.runHelpCommand`,
`level=DEBUG msg="cli dispatch" command=help`,
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("TRACE: missing %q in:\n%s", want, res.stderr)
}
}
if res.code != 0 || res.stdout != usageText || strings.Contains(res.stderr, "database") || strings.Contains(res.stderr, secret) {
t.Fatalf("TRACE: %+v", res)
}
// A usage error never logs the help dispatch
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path, "LOG_LEVEL": "DEBUG"}}, "help", secret)
if res.code != 2 || res.stdout != "" || strings.Contains(res.stderr, "command=help") || strings.Contains(res.stderr, secret) ||
!strings.HasSuffix(res.stderr, "dpsapi: usage error: help takes no arguments\n"+usageText) {
t.Fatalf("help with argument: %+v", res)
}
// Write failures are reported like every other command
res = runApp(t, cliEnv{env: map[string]string{"DB_PATH": path}, out: failingWriter{}}, "help")
expect(t, res, 1, "", "dpsapi: write output: broken pipe\n")
// The process entry point
stdout, stderr := &syncBuffer{}, &syncBuffer{}
if code := run([]string{"help"}, envMap(map[string]string{"DB_PATH": path}), stdout, stderr); code != 0 ||
stdout.String() != usageText || stderr.String() != "" {
t.Fatalf("run help: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}