// 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. 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", "١", "1", "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\"", "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 deactivate a token\n" + " dpsapi audit print the latest 10 audit rows\n" + " dpsapi audit print the latest n audit rows\n" + " dpsapi audit 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()) } }