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.
1235 lines
51 KiB
Go
1235 lines
51 KiB
Go
// 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())
|
||
}
|
||
}
|