feat(cli): add help, token and audit commands
Entry point and dispatch, token list [all] / new / delete, audit [all | n | n<unit>] with padded table output, help, usage and exit codes; unit and e2e tests for commands, configuration precedence, logging and token lifecycle.
This commit is contained in:
1 parent
b792ad256e
commit
0a6023e942
10 files changed
+4138
No files matched your search
@@ -0,0 +1,313 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenListHeader holds the header cells of `token list`
|
||||||
|
var tokenListHeader = []string{"TOKEN", "DESCRIPTION", "ACTIVE", "CREATED_AT", "DELETED_AT"}
|
||||||
|
|
||||||
|
// auditListHeader holds the header cells of `audit`
|
||||||
|
var auditListHeader = []string{"TIME", "TOKEN", "ORIGIN", "OPERATION"}
|
||||||
|
|
||||||
|
// tokenListAll is the only argument accepted by `token list` (case-sensitive)
|
||||||
|
const tokenListAll = "all"
|
||||||
|
|
||||||
|
// auditAll is the only word accepted by `audit` (case-sensitive): print every row
|
||||||
|
const auditAll = "all"
|
||||||
|
|
||||||
|
// defaultAuditLimit is the number of rows printed by `audit` without arguments
|
||||||
|
const defaultAuditLimit = 10
|
||||||
|
|
||||||
|
// auditUnits maps the window unit letters (lowercase only) to their duration
|
||||||
|
var auditUnits = map[byte]time.Duration{
|
||||||
|
's': time.Second,
|
||||||
|
'm': time.Minute,
|
||||||
|
'h': time.Hour,
|
||||||
|
'd': 24 * time.Hour,
|
||||||
|
'w': 7 * 24 * time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditSelection is a parsed audit argument: exactly one of all, limit > 0 and window > 0 holds
|
||||||
|
type auditSelection struct {
|
||||||
|
all bool // print every row
|
||||||
|
limit int // latest rows to print
|
||||||
|
window time.Duration // print the rows with time >= now - window
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenCommand is a parsed token subcommand ready to run against a store
|
||||||
|
type tokenCommand func(ctx context.Context, store *TokenStore) error
|
||||||
|
|
||||||
|
// parseTokenCommand validates the arguments following "token" without touching the database
|
||||||
|
func (a *app) parseTokenCommand(args []string) (string, tokenCommand, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "", nil, usageErrorf("missing token subcommand")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
if len(args) > 2 {
|
||||||
|
return "", nil, usageErrorf("token list takes at most one argument")
|
||||||
|
}
|
||||||
|
if len(args) == 2 && args[1] != tokenListAll {
|
||||||
|
return "", nil, usageErrorf("unknown token list argument")
|
||||||
|
}
|
||||||
|
activeOnly := len(args) == 1
|
||||||
|
return "token list", func(ctx context.Context, store *TokenStore) error {
|
||||||
|
return a.tokenList(ctx, store, activeOnly)
|
||||||
|
}, nil
|
||||||
|
case "new":
|
||||||
|
if len(args) > 2 {
|
||||||
|
return "", nil, usageErrorf("token new takes at most one argument")
|
||||||
|
}
|
||||||
|
description := ""
|
||||||
|
if len(args) == 2 {
|
||||||
|
description = args[1]
|
||||||
|
}
|
||||||
|
return "token new", func(ctx context.Context, store *TokenStore) error {
|
||||||
|
return a.tokenNew(ctx, store, description)
|
||||||
|
}, nil
|
||||||
|
case "delete":
|
||||||
|
if len(args) != 2 {
|
||||||
|
return "", nil, usageErrorf("token delete requires exactly one argument")
|
||||||
|
}
|
||||||
|
value := args[1]
|
||||||
|
return "token delete", func(ctx context.Context, store *TokenStore) error {
|
||||||
|
return a.tokenDelete(ctx, store, value)
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return "", nil, usageErrorf("unknown token subcommand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTokenCommand parses and executes a token subcommand
|
||||||
|
func (a *app) runTokenCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) (err error) {
|
||||||
|
defer traceFunc(ctx, log, "app.runTokenCommand")()
|
||||||
|
|
||||||
|
// Parse first: usage errors never touch the database
|
||||||
|
name, cmd, err := a.parseTokenCommand(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.DebugContext(ctx, "cli dispatch", "command", name)
|
||||||
|
|
||||||
|
return a.withDatabase(ctx, cfg, log, name, func(db *sql.DB) error {
|
||||||
|
return cmd(ctx, NewTokenStore(db, log, a.now, a.generateToken))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDatabase opens the database, runs the named CLI command against it and closes it
|
||||||
|
func (a *app) withDatabase(ctx context.Context, cfg Config, log *slog.Logger, name string, cmd func(db *sql.DB) error) (err error) {
|
||||||
|
// Open the database
|
||||||
|
db, err := OpenDatabase(ctx, cfg.DBPath, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeDatabase(ctx, db, log, &err)
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
err = cmd(db)
|
||||||
|
log.DebugContext(ctx, "cli command finished", "command", name, "ok", err == nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTable prints the header and the rows as a column-aligned table
|
||||||
|
// Every column but the last is padded with spaces to its widest cell (in runes) plus 2; lines carry no trailing whitespace
|
||||||
|
func writeTable(out io.Writer, header []string, rows [][]string) error {
|
||||||
|
w := bufio.NewWriter(out)
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, strings.Join(header, "\t"))
|
||||||
|
for _, row := range rows {
|
||||||
|
fmt.Fprintln(tw, strings.Join(row, "\t"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write errors are reported once, keeping the first one
|
||||||
|
err := tw.Flush()
|
||||||
|
if ferr := w.Flush(); err == nil {
|
||||||
|
err = ferr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatTokenRow renders the cells of one `token list` line
|
||||||
|
// The description is Go-quoted so every row is exactly one line whatever its content
|
||||||
|
func formatTokenRow(tok Token) []string {
|
||||||
|
deletedAt := "-"
|
||||||
|
if tok.DeletedAt != nil {
|
||||||
|
deletedAt = formatTime(*tok.DeletedAt)
|
||||||
|
}
|
||||||
|
return []string{tok.Token, strconv.Quote(tok.Description), strconv.FormatBool(tok.Active), formatTime(tok.CreatedAt), deletedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenList prints the header and the active tokens, or every token when activeOnly is false
|
||||||
|
func (a *app) tokenList(ctx context.Context, store *TokenStore, activeOnly bool) error {
|
||||||
|
defer traceFunc(ctx, store.log, "app.tokenList")()
|
||||||
|
|
||||||
|
tokens, err := store.ListTokens(ctx, activeOnly)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(tokens))
|
||||||
|
for _, tok := range tokens {
|
||||||
|
rows = append(rows, formatTokenRow(tok))
|
||||||
|
}
|
||||||
|
return writeTable(a.stdout, tokenListHeader, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenNew creates a token and prints it
|
||||||
|
func (a *app) tokenNew(ctx context.Context, store *TokenStore, description string) error {
|
||||||
|
defer traceFunc(ctx, store.log, "app.tokenNew")()
|
||||||
|
|
||||||
|
tok, err := store.CreateToken(ctx, description)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.log.DebugContext(ctx, "token created", "token_id", tokenID(tok.Token))
|
||||||
|
if _, err := fmt.Fprintln(a.stdout, tok.Token); err != nil {
|
||||||
|
return fmt.Errorf("write output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenDelete deactivates a token; nothing is printed on success
|
||||||
|
func (a *app) tokenDelete(ctx context.Context, store *TokenStore, value string) error {
|
||||||
|
defer traceFunc(ctx, store.log, "app.tokenDelete")()
|
||||||
|
|
||||||
|
if err := store.DeactivateToken(ctx, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.log.DebugContext(ctx, "token deactivated", "token_id", tokenID(value))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAuditArgs validates the arguments following "audit" without touching the database
|
||||||
|
// Accepted: nothing (latest 10 rows), all (every row), <n> (latest n rows), <n><unit> (rows of the last n units)
|
||||||
|
// all is exact and case-sensitive; n is ASCII decimal digits only (leading zeros allowed) and at least 1; the argument is never echoed
|
||||||
|
func parseAuditArgs(args []string) (auditSelection, error) {
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
return auditSelection{limit: defaultAuditLimit}, nil
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return auditSelection{}, usageErrorf("audit takes at most one argument")
|
||||||
|
}
|
||||||
|
if args[0] == auditAll {
|
||||||
|
return auditSelection{all: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split an optional trailing unit letter
|
||||||
|
digits, unit := args[0], time.Duration(0)
|
||||||
|
if n := len(digits); n > 0 {
|
||||||
|
if u, ok := auditUnits[digits[n-1]]; ok {
|
||||||
|
digits, unit = digits[:n-1], u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The count must be a positive decimal integer that fits an int
|
||||||
|
n, err := parsePositiveDecimal(digits)
|
||||||
|
if err != nil {
|
||||||
|
return auditSelection{}, usageErrorf("invalid audit argument")
|
||||||
|
}
|
||||||
|
if unit == 0 {
|
||||||
|
return auditSelection{limit: n}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The window must fit a time.Duration
|
||||||
|
if int64(n) > math.MaxInt64/int64(unit) {
|
||||||
|
return auditSelection{}, usageErrorf("invalid audit argument")
|
||||||
|
}
|
||||||
|
return auditSelection{window: time.Duration(n) * unit}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePositiveDecimal accepts only ASCII decimal digits (no sign, no whitespace) with a value >= 1 that fits an int
|
||||||
|
func parsePositiveDecimal(s string) (int, error) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, errors.New("empty number")
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < '0' || s[i] > '9' {
|
||||||
|
return 0, errors.New("not a decimal integer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
return 0, errors.New("number must be at least 1")
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAuditCommand parses and executes the audit command
|
||||||
|
func (a *app) runAuditCommand(ctx context.Context, cfg Config, log *slog.Logger, args []string) error {
|
||||||
|
defer traceFunc(ctx, log, "app.runAuditCommand")()
|
||||||
|
|
||||||
|
// Parse first: usage errors never touch the database
|
||||||
|
sel, err := parseAuditArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sel.all:
|
||||||
|
log.DebugContext(ctx, "cli dispatch", "command", "audit", "all", true)
|
||||||
|
case sel.window > 0:
|
||||||
|
log.DebugContext(ctx, "cli dispatch", "command", "audit", "window", sel.window)
|
||||||
|
default:
|
||||||
|
log.DebugContext(ctx, "cli dispatch", "command", "audit", "limit", sel.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.withDatabase(ctx, cfg, log, "audit", func(db *sql.DB) error {
|
||||||
|
return a.auditList(ctx, NewAuditStore(db, log, a.now), sel)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOperationRow renders the cells of one `audit` line
|
||||||
|
// Time and token are printed exactly as stored; origin and operation are written by the server and contain no tabs or newlines
|
||||||
|
func formatOperationRow(op Operation) []string {
|
||||||
|
return []string{op.TimeText, op.Token, op.Origin, op.Operation}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditList prints the header and the selected audit rows, oldest first
|
||||||
|
func (a *app) auditList(ctx context.Context, store *AuditStore, sel auditSelection) error {
|
||||||
|
defer traceFunc(ctx, store.log, "app.auditList")()
|
||||||
|
|
||||||
|
// Resolve the selection against the clock; the zero query selects every row
|
||||||
|
var q OperationQuery
|
||||||
|
switch {
|
||||||
|
case sel.all:
|
||||||
|
case sel.window > 0:
|
||||||
|
since := a.now().Add(-sel.window)
|
||||||
|
q.Since = &since
|
||||||
|
store.log.DebugContext(ctx, "audit window resolved", "since", formatTime(since))
|
||||||
|
default:
|
||||||
|
q.Limit = sel.limit
|
||||||
|
}
|
||||||
|
|
||||||
|
ops, err := store.ListOperations(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.log.DebugContext(ctx, "audit rows listed", "rows", len(ops))
|
||||||
|
rows := make([][]string, 0, len(ops))
|
||||||
|
for _, op := range ops {
|
||||||
|
rows = append(rows, formatOperationRow(op))
|
||||||
|
}
|
||||||
|
return writeTable(a.stdout, auditListHeader, rows)
|
||||||
|
}
|
||||||
+1234
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,733 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 17: `audit`, `audit all`, `audit <n>` and `audit <n><unit>` through the
|
||||||
|
* compiled binary — header, exact rows and chronological order against the `operations` rows read
|
||||||
|
* directly from SQLite, the default of 10, every row with `all` (also more than 10, and ties on
|
||||||
|
* equal stored times in insertion order), counts larger than the row count, every unit, a real
|
||||||
|
* time window and its boundary band, operation while the server runs (also under concurrent load
|
||||||
|
* and across restarts), the read-only guarantee (byte-identical database), every usage-error class
|
||||||
|
* (including the near misses of `all`), configuration-error precedence, log levels, an
|
||||||
|
* unparseable stored time, and a population of 4000 rows produced by real authenticated requests.
|
||||||
|
*
|
||||||
|
* Rows only ever come from real authenticated GETs against FakeDocker. Only two scenarios write
|
||||||
|
* to the database themselves (server stopped, test-owned connection): the unparseable-time test
|
||||||
|
* and the equal-timestamps ordering test of `audit all`, which can only get identical stored
|
||||||
|
* times that way.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
AUDIT_COLUMNS,
|
||||||
|
EMPTY_AUDIT,
|
||||||
|
LOG_LEVELS,
|
||||||
|
USAGE_TEXT,
|
||||||
|
assertDbInvariants,
|
||||||
|
buildRawRequest,
|
||||||
|
expectCliFailure,
|
||||||
|
expectCliSuccess,
|
||||||
|
expectContainersResponse,
|
||||||
|
fileDigest,
|
||||||
|
formatAuditOutput,
|
||||||
|
levelRank,
|
||||||
|
mapLimit,
|
||||||
|
msToNanos,
|
||||||
|
parseAuditOutput,
|
||||||
|
parseLogs,
|
||||||
|
parseTokenList,
|
||||||
|
padTable,
|
||||||
|
pollUntil,
|
||||||
|
printsTokens,
|
||||||
|
readOperations,
|
||||||
|
rfc3339ToNanos,
|
||||||
|
RFC3339NANO_UTC_RE,
|
||||||
|
tokenId,
|
||||||
|
visibleLevels,
|
||||||
|
type AuditRow,
|
||||||
|
type CliRunner,
|
||||||
|
type HttpClient,
|
||||||
|
type LogLevel,
|
||||||
|
type ServerHandle,
|
||||||
|
} from '../../harness';
|
||||||
|
import {
|
||||||
|
AUDIT_ALL_EXTRA_ARGS,
|
||||||
|
AUDIT_ALL_NEAR_MISSES,
|
||||||
|
AUDIT_MAX_WINDOW,
|
||||||
|
AUDIT_UNITS,
|
||||||
|
auditArgsOutcome,
|
||||||
|
chronological,
|
||||||
|
expectAuditWindow,
|
||||||
|
expectedAuditStdout,
|
||||||
|
expectNoTokenFragments,
|
||||||
|
stateDigest,
|
||||||
|
} from '../../harness/personas';
|
||||||
|
|
||||||
|
const SECOND = AUDIT_UNITS.s;
|
||||||
|
const nowNs = (skewMs = 0) => msToNanos(Date.now() + skewMs);
|
||||||
|
|
||||||
|
/** One authenticated GET per token in `plan`, sequentially (Playwright request); all must be 200. */
|
||||||
|
async function sequential(http: HttpClient, server: ServerHandle, plan: string[]): Promise<void> {
|
||||||
|
for (const t of plan) expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real authenticated GETs pipelined over keep-alive connections (`perConn` requests each,
|
||||||
|
* `concurrency` connections at a time) — a fast way to produce thousands of audit rows.
|
||||||
|
*/
|
||||||
|
async function pipelined(http: HttpClient, server: ServerHandle, plan: string[], { perConn = 50, concurrency = 8 } = {}): Promise<void> {
|
||||||
|
const chunks: string[][] = [];
|
||||||
|
for (let i = 0; i < plan.length; i += perConn) chunks.push(plan.slice(i, i + perConn));
|
||||||
|
await mapLimit(chunks, concurrency, async (chunk, ci) => {
|
||||||
|
const data = chunk.map((t, i) => buildRawRequest({ target: '/api/v1/containers', headers: [['Authorization', `Bearer ${t}`]], close: i === chunk.length - 1 })).join('');
|
||||||
|
const r = await http.raw({ host: server.connectHost, port: server.port, data, expectResponses: chunk.length, timeoutMs: 30_000 });
|
||||||
|
expect(r.responses.map((x) => x.status), `pipelined connection ${ci}: every request answered 200`).toEqual(chunk.map(() => 200));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `audit ...args` against `dbPath`: exit 0, empty stderr, strictly parseable stdout that equals
|
||||||
|
* the oracle's selection over the stored rows byte for byte (windows: no stored row may lie in
|
||||||
|
* the boundary band, so the expectation is exact). No writer may run concurrently.
|
||||||
|
*/
|
||||||
|
async function expectAudit(cli: CliRunner, dbPath: string, args: string[], label = `audit ${JSON.stringify(args)}`): Promise<AuditRow[]> {
|
||||||
|
const parsed = auditArgsOutcome(args);
|
||||||
|
if (!parsed.ok) throw new Error(`${label}: not a valid audit argument list`);
|
||||||
|
const t0 = nowNs(-1);
|
||||||
|
const r = await cli.run(['audit', ...args], { env: { DB_PATH: dbPath } });
|
||||||
|
const t1 = nowNs(1);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
const rows = parseAuditOutput(r.stdout);
|
||||||
|
const ops = readOperations(dbPath);
|
||||||
|
const sel = parsed.selection;
|
||||||
|
if ('windowNs' in sel) expect(expectAuditWindow(rows, ops, sel.windowNs, t0, t1, label), `${label}: no stored row near the window boundary`).toBe(0);
|
||||||
|
else expect(r.stdout, `${label}: stdout equals the stored rows`).toBe(expectedAuditStdout(ops, sel));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('audit selection forms', () => {
|
||||||
|
test('fresh database: header only for every form; the database and the empty schema are created, as by the token commands', async ({ cli, ws }) => {
|
||||||
|
const forms = [[], ['all'], ['1'], ['10'], ['9223372036854775807'], ['1s'], ['1m'], ['1h'], ['1d'], ['1w'], ['15250w']];
|
||||||
|
await mapLimit(forms, 5, async (args, i) => {
|
||||||
|
const db = ws.path(`fresh-${i}.db`);
|
||||||
|
expect(fs.existsSync(db)).toBe(false);
|
||||||
|
const r = await cli.run(['audit', ...args], { env: { DB_PATH: db } });
|
||||||
|
expectCliSuccess(r, { stdout: EMPTY_AUDIT, stderr: '' });
|
||||||
|
expect(fs.existsSync(db), `audit ${args.join(' ')}: database created`).toBe(true);
|
||||||
|
assertDbInvariants(db, { tokenCount: 0, operationCount: 0 });
|
||||||
|
});
|
||||||
|
// tokens but no operations yet: still only the header
|
||||||
|
await cli.tokenNew('no requests yet');
|
||||||
|
for (const args of forms) expectCliSuccess(await cli.run(['audit', ...args]), { stdout: EMPTY_AUDIT, stderr: '' });
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default 10 and explicit counts with fewer than, exactly and more than 10 rows: exact rows and order from SQLite (server running)', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const [a, b, c] = [await cli.tokenNew('a'), await cli.tokenNew('b'), await cli.tokenNew('c')];
|
||||||
|
const server = await startServer();
|
||||||
|
const plan: string[] = [];
|
||||||
|
const add = async (tokens: string[]) => {
|
||||||
|
await sequential(http, server, tokens);
|
||||||
|
plan.push(...tokens);
|
||||||
|
expect(readOperations(ws.dbPath).map((o) => o.token), 'one row per request, in order').toEqual(plan);
|
||||||
|
};
|
||||||
|
|
||||||
|
await test.step('3 rows (fewer than 10)', async () => {
|
||||||
|
await add([a, b, c]);
|
||||||
|
expect(await expectAudit(cli, ws.dbPath, [])).toHaveLength(3);
|
||||||
|
for (const n of ['1', '2', '3', '4', '10']) await expectAudit(cli, ws.dbPath, [n]);
|
||||||
|
});
|
||||||
|
await test.step('exactly 10 rows', async () => {
|
||||||
|
await add([a, a, b, c, c, a, b]);
|
||||||
|
const rows = await expectAudit(cli, ws.dbPath, []);
|
||||||
|
expect(rows.map((r) => r.token)).toEqual(plan);
|
||||||
|
for (const n of ['9', '10', '11']) await expectAudit(cli, ws.dbPath, [n]);
|
||||||
|
});
|
||||||
|
await test.step('25 rows (more than 10)', async () => {
|
||||||
|
await add([c, b, a, a, a, c, b, b, c, a, b, c, a, c, b]);
|
||||||
|
const rows = await expectAudit(cli, ws.dbPath, []);
|
||||||
|
expect(rows.map((r) => r.token), 'the latest 10, oldest first').toEqual(plan.slice(-10));
|
||||||
|
for (const n of ['1', '24', '25', '26', '1000', '010', '0000000000000000000000000000025', '9223372036854775807', '00009223372036854775807']) {
|
||||||
|
await expectAudit(cli, ws.dbPath, [n]);
|
||||||
|
}
|
||||||
|
const all = await expectAudit(cli, ws.dbPath, ['1000']);
|
||||||
|
expect(all.map((r) => r.token)).toEqual(plan);
|
||||||
|
expect(all.every((r) => r.origin === '127.0.0.1' && r.operation === 'GET containers')).toBe(true);
|
||||||
|
});
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 3, operationCount: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every unit s/m/h/d/w, leading zeros and the largest window per unit select every recent row', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const t = await cli.tokenNew('units');
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [t, t, t, t, t, t]);
|
||||||
|
const forms = ['120s', '3600s', '2m', '60m', '1h', '01h', '0001d', '1d', '1w', '00001w'];
|
||||||
|
for (const u of Object.keys(AUDIT_MAX_WINDOW) as Array<keyof typeof AUDIT_MAX_WINDOW>) forms.push(`${AUDIT_MAX_WINDOW[u]}${u}`);
|
||||||
|
for (const f of forms) expect(await expectAudit(cli, ws.dbPath, [f]), f).toHaveLength(6);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
for (const f of ['1h', '15250w']) expect(await expectAudit(cli, ws.dbPath, [f]), `${f} after the server stopped`).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real time window: after a 2 s gap `audit 2s` prints exactly the later rows; every window is a chronological suffix with no row on the wrong side of its boundary band', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const [early, late] = [await cli.tokenNew('early'), await cli.tokenNew('late')];
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [early, early, early, early]);
|
||||||
|
const lastEarly = rfc3339ToNanos(chronological(readOperations(ws.dbPath)).at(-1)!.time);
|
||||||
|
// poll the clock until the early rows are more than 2 s old (no fixed sleep)
|
||||||
|
await pollUntil('the early rows to be older than 2 s', () => (nowNs(-30) > lastEarly + 2n * SECOND ? true : undefined), { timeoutMs: 10_000, intervalMs: 10 });
|
||||||
|
await sequential(http, server, [late, late, late]);
|
||||||
|
|
||||||
|
const t0 = nowNs(-1);
|
||||||
|
const r = await cli.run(['audit', '2s']);
|
||||||
|
const t1 = nowNs(1);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
const rows = parseAuditOutput(r.stdout);
|
||||||
|
expect(expectAuditWindow(rows, ops, 2n * SECOND, t0, t1, 'audit 2s'), 'no stored row inside the boundary band').toBe(0);
|
||||||
|
expect(r.stdout, 'exactly the rows written after the gap').toBe(formatAuditOutput(ops.slice(4)));
|
||||||
|
expect(rows.map((x) => x.token)).toEqual([late, late, late]);
|
||||||
|
|
||||||
|
// boundary sanity for a range of windows: each output is a chronological suffix, every row at or
|
||||||
|
// after now - window is printed and none before; the selections are nested
|
||||||
|
let previous = -1;
|
||||||
|
for (const w of ['1s', '2s', '3s', '5s', '30s', '1m', '1h']) {
|
||||||
|
const s0 = nowNs(-1);
|
||||||
|
const x = await cli.run(['audit', w]);
|
||||||
|
const s1 = nowNs(1);
|
||||||
|
expectCliSuccess(x, { stderr: '' });
|
||||||
|
const xs = parseAuditOutput(x.stdout);
|
||||||
|
const sel = auditArgsOutcome([w]);
|
||||||
|
if (!sel.ok || !('windowNs' in sel.selection)) throw new Error('window expected');
|
||||||
|
expectAuditWindow(xs, ops, sel.selection.windowNs, s0, s1, `audit ${w}`);
|
||||||
|
expect(xs.length >= previous, `audit ${w} includes the rows of the smaller windows`).toBe(true);
|
||||||
|
previous = xs.length;
|
||||||
|
}
|
||||||
|
expect(previous, 'audit 1h: every row').toBe(7);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('audit all', () => {
|
||||||
|
test('empty database: `audit all` prints exactly the padded header, exit 0, empty stderr (fresh file and tokens without requests)', async ({ cli, ws }) => {
|
||||||
|
expect(EMPTY_AUDIT, 'the header-only table').toBe(padTable([AUDIT_COLUMNS]));
|
||||||
|
expect(EMPTY_AUDIT).toBe('TIME TOKEN ORIGIN OPERATION\n');
|
||||||
|
const r = await cli.run(['audit', 'all']);
|
||||||
|
expectCliSuccess(r, { stdout: EMPTY_AUDIT, stderr: '' });
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 0, operationCount: 0 });
|
||||||
|
await cli.tokenNew('never used');
|
||||||
|
await cli.tokenDelete(await cli.tokenNew('deleted, never used'));
|
||||||
|
expect(await cli.audit('all')).toEqual([]);
|
||||||
|
expectCliSuccess(await cli.run(['audit', 'all']), { stdout: EMPTY_AUDIT, stderr: '' });
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every row, oldest first, as the exact padded table with 1, exactly 10, 11 and 25 rows (more than the default 10); tokens printed are registered secrets', async ({ cli, ws, startServer, http, secrets }) => {
|
||||||
|
expect(printsTokens(['audit', 'all']), 'audit all stdout is scanned for tokens').toBe(true);
|
||||||
|
const [a, b, c] = [await cli.tokenNew('all a'), await cli.tokenNew('all b'), await cli.tokenNew('all c')];
|
||||||
|
const server = await startServer();
|
||||||
|
const plan: string[] = [];
|
||||||
|
for (const batch of [[a], [b, c, a, a, b, c, c, b, a], [c], [a, b, c, a, b, c, a, b, c, a, b, c, a, b]]) {
|
||||||
|
await sequential(http, server, batch);
|
||||||
|
plan.push(...batch);
|
||||||
|
await test.step(`${plan.length} rows`, async () => {
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
expect(ops.map((o) => o.token), 'one row per request, in order').toEqual(plan);
|
||||||
|
const r = await cli.run(['audit', 'all']);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
// exact stdout: the padded table of every stored row, oldest first
|
||||||
|
const expected = padTable([[...AUDIT_COLUMNS], ...chronological(ops).map((o) => [o.time, o.token, o.origin, o.operation])]);
|
||||||
|
expect(r.stdout, 'stdout is the padded table of every stored row').toBe(expected);
|
||||||
|
expect(r.stdout, 'the oracle agrees').toBe(expectedAuditStdout(ops, { all: true }));
|
||||||
|
const rows = parseAuditOutput(r.stdout);
|
||||||
|
expect(rows.map((x) => x.token), 'every row, oldest first').toEqual(plan);
|
||||||
|
expect(rows.map((x) => x.time), 'TIME exactly as stored').toEqual(ops.map((o) => o.time));
|
||||||
|
const times = rows.map((x) => rfc3339ToNanos(x.time));
|
||||||
|
expect(times.every((t, k) => k === 0 || times[k - 1] <= t), 'chronological').toBe(true);
|
||||||
|
// the same bytes as a count that covers every row; the default still stops at 10
|
||||||
|
expectCliSuccess(await cli.run(['audit', String(plan.length)]), { stdout: r.stdout, stderr: '' });
|
||||||
|
expect((await cli.audit()).map((x) => x.token), 'plain audit: the latest 10').toEqual(plan.slice(-10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(plan).toHaveLength(25);
|
||||||
|
for (const t of [a, b, c]) expect(secrets.has(t)).toBe(true);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(formatAuditOutput(await cli.audit('all')), 'with the server stopped').toBe(expectedAuditStdout(readOperations(ws.dbPath), { all: true }));
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 3, operationCount: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equal stored times are printed in insertion order (rowid), after the earlier times — also for rows inserted later with an earlier time', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const [a, b] = [await cli.tokenNew('ties a'), await cli.tokenNew('ties b')];
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [a, a, b, b, a, b]);
|
||||||
|
// no server may use the database while the test writes to it
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
expect(ops).toHaveLength(6);
|
||||||
|
const [r0, r1, r2, r3, r4, r5] = ops;
|
||||||
|
|
||||||
|
// Test-owned write (the only way to get identical stored times): r1, r4, r5 share the latest
|
||||||
|
// time, r3 the earliest one (tie with r0, inserted later). Every new value is an existing,
|
||||||
|
// valid stored time after the tokens' created_at, so the database invariants still hold.
|
||||||
|
const assign: Array<[number, string]> = [
|
||||||
|
[r1.rowid, r5.time],
|
||||||
|
[r3.rowid, r0.time],
|
||||||
|
[r4.rowid, r5.time],
|
||||||
|
];
|
||||||
|
const db = new DatabaseSync(ws.dbPath, { timeout: 5000 });
|
||||||
|
try {
|
||||||
|
for (const [rowid, time] of assign) expect(Number(db.prepare('UPDATE operations SET time = ? WHERE rowid = ?').run(time, rowid).changes)).toBe(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 6 });
|
||||||
|
const tied = readOperations(ws.dbPath);
|
||||||
|
const byRowid = new Map(tied.map((o) => [o.rowid, o]));
|
||||||
|
const order = [r0, r3, r2, r1, r4, r5].map((o) => byRowid.get(o.rowid)!);
|
||||||
|
expect(chronological(tied).map((o) => o.rowid), 'the oracle orders by time, then rowid').toEqual(order.map((o) => o.rowid));
|
||||||
|
|
||||||
|
const r = await cli.run(['audit', 'all']);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
expect(r.stdout, 'time, then insertion order').toBe(formatAuditOutput(order));
|
||||||
|
expect(r.stdout).toBe(expectedAuditStdout(tied, { all: true }));
|
||||||
|
expect(parseAuditOutput(r.stdout).map((x) => x.token)).toEqual([a, b, b, a, a, b]);
|
||||||
|
// the other forms agree with the same order
|
||||||
|
expectCliSuccess(await cli.run(['audit', '100']), { stdout: r.stdout, stderr: '' });
|
||||||
|
expectCliSuccess(await cli.run(['audit', '3']), { stdout: formatAuditOutput(order.slice(-3)), stderr: '' });
|
||||||
|
// stable across invocations
|
||||||
|
for (let i = 0; i < 3; i++) expectCliSuccess(await cli.run(['audit', 'all']), { stdout: r.stdout, stderr: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('near misses of `all` (case, digits, blanks, options, extra arguments): exact usage error, nothing echoed, no database created, an existing one untouched', async ({ cli, ws }) => {
|
||||||
|
const cases: Array<[string[], string]> = [
|
||||||
|
...[...new Set(['ALL', 'All', 'all1', '1all', ' all', 'alls', '--all', ...AUDIT_ALL_NEAR_MISSES.map(([v]) => v)])].map((v): [string[], string] => [[v], INVALID]),
|
||||||
|
...AUDIT_ALL_EXTRA_ARGS.map((args): [string[], string] => [[...args], TOO_MANY]),
|
||||||
|
];
|
||||||
|
expect(cases.map(([a]) => JSON.stringify(a)), 'the required near misses are covered').toEqual(expect.arrayContaining(['["ALL"]', '["All"]', '["all1"]', '["1all"]', '[" all"]', '["alls"]', '["--all"]', '["all","x"]']));
|
||||||
|
for (const [args, msg] of cases) expect(auditArgsOutcome(args), JSON.stringify(args)).toEqual({ ok: false, message: msg });
|
||||||
|
const run = () =>
|
||||||
|
mapLimit(cases, 8, async ([args, msg]) => {
|
||||||
|
const r = await cli.run(['audit', ...args]);
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
expect(r.stderr, `${JSON.stringify(args)}: exact stderr, the argument is never echoed`).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
|
||||||
|
});
|
||||||
|
await run();
|
||||||
|
expect(fs.readdirSync(ws.dir), 'no database created').toEqual([]);
|
||||||
|
await cli.tokenNew('existing');
|
||||||
|
const file = fileDigest(ws.dbPath);
|
||||||
|
const state = stateDigest(ws.dbPath);
|
||||||
|
await run();
|
||||||
|
expect(fileDigest(ws.dbPath)).toBe(file);
|
||||||
|
expect(stateDigest(ws.dbPath)).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('audit against live and changing state', () => {
|
||||||
|
test('output equals the operations rows in SQLite (exact time, full token, origin, operation, order) across concurrent writers, token deletion and server restarts', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const tokens = await Promise.all(['ci', 'ops', 'dash', 'backup'].map((d) => cli.tokenNew(d)));
|
||||||
|
const server = await startServer();
|
||||||
|
// concurrent writers: interleaved times, variable-length fractions (trailing zeros dropped)
|
||||||
|
await mapLimit(Array.from({ length: 40 }, (_, i) => tokens[i % 4]), 8, async (t, i) => {
|
||||||
|
expectContainersResponse(i % 5 === 0 ? await http.curl({ url: server.url(), token: t }) : await http.get(server.url(), { token: t }));
|
||||||
|
});
|
||||||
|
const check = async (label: string, expected: number) => {
|
||||||
|
const all = await expectAudit(cli, ws.dbPath, ['100000'], `${label}: audit 100000`);
|
||||||
|
expect(all, label).toHaveLength(expected);
|
||||||
|
expect(await expectAudit(cli, ws.dbPath, ['all'], `${label}: audit all`), `${label}: audit all = audit 100000`).toEqual(all);
|
||||||
|
await expectAudit(cli, ws.dbPath, [], `${label}: audit`);
|
||||||
|
await expectAudit(cli, ws.dbPath, ['7'], `${label}: audit 7`);
|
||||||
|
await expectAudit(cli, ws.dbPath, ['1h'], `${label}: audit 1h`);
|
||||||
|
// every printed token is a full token known to `token list all` (active or not)
|
||||||
|
const listed = new Set((await cli.tokenListAll()).map((r) => r.token));
|
||||||
|
expect(all.every((r) => listed.has(r.token)), `${label}: tokens correlate with token list all`).toBe(true);
|
||||||
|
return all;
|
||||||
|
};
|
||||||
|
const first = await check('after 40 concurrent requests', 40);
|
||||||
|
// lexical and chronological order differ whenever a fraction lost trailing zeros: informative only
|
||||||
|
const lexicalInversions = first.filter((r, i) => i > 0 && first[i - 1].time > r.time).length;
|
||||||
|
test.info().annotations.push({ type: 'audit ordering', description: `${lexicalInversions} adjacent row pair(s) whose TIME text sorts differently from the instant` });
|
||||||
|
|
||||||
|
await test.step('deleted tokens keep their rows, printed with the full token', async () => {
|
||||||
|
await cli.tokenDelete(tokens[1]);
|
||||||
|
await cli.tokenDelete(tokens[2]);
|
||||||
|
const all = await check('after deleting two tokens', 40);
|
||||||
|
expect(all.filter((r) => r.token === tokens[1])).toHaveLength(10);
|
||||||
|
expect(all.filter((r) => r.token === tokens[2])).toHaveLength(10);
|
||||||
|
});
|
||||||
|
await test.step('across a restart', async () => {
|
||||||
|
await server.restart();
|
||||||
|
await check('after a restart', 40);
|
||||||
|
await sequential(http, server, [tokens[0], tokens[3], tokens[0]]);
|
||||||
|
const all = await check('after requests on the new generation', 43);
|
||||||
|
expect(all.slice(0, 40)).toEqual(first);
|
||||||
|
expect(all.slice(40).map((r) => r.token)).toEqual([tokens[0], tokens[3], tokens[0]]);
|
||||||
|
});
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
await check('with the server stopped', 43);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 4, operationCount: 43 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('while the server is under concurrent load: every output is a complete table and a consistent snapshot of the history', async ({ cli, ws, startServer, http }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const tokens = await Promise.all([1, 2, 3, 4].map((i) => cli.tokenNew(`load ${i}`)));
|
||||||
|
const server = await startServer();
|
||||||
|
const PER_TOKEN = 60;
|
||||||
|
let started = 0;
|
||||||
|
let completed = 0;
|
||||||
|
let failed = false;
|
||||||
|
const writer = async (t: string) => {
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < PER_TOKEN; i++) {
|
||||||
|
started++;
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||||
|
completed++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failed = true; // stop the reader too: a failed writer must not turn into a hang
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const forms = [['1000000'], [], ['all'], ['1h'], ['01d'], ['5']];
|
||||||
|
const snaps: Array<{ args: string[]; stdout: string; before: number; after: number }> = [];
|
||||||
|
const reader = async () => {
|
||||||
|
for (let k = 0; !failed && completed < tokens.length * PER_TOKEN; k++) {
|
||||||
|
const args = forms[k % forms.length];
|
||||||
|
const before = completed;
|
||||||
|
const r = await cli.run(['audit', ...args]);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
snaps.push({ args, stdout: r.stdout, before, after: started });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([...tokens.map(writer), reader()]);
|
||||||
|
expect(snaps.length, 'audit ran repeatedly during the load').toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const final = chronological(readOperations(ws.dbPath));
|
||||||
|
expect(final).toHaveLength(tokens.length * PER_TOKEN);
|
||||||
|
// compared by cells: the padding of a line depends on the widest cells of that invocation
|
||||||
|
const cells = (r: { time: string; token: string; origin: string; operation: string }) => [r.time, r.token, r.origin, r.operation].join('\t');
|
||||||
|
const finalLines = final.map(cells);
|
||||||
|
const isSubsequence = (lines: string[]) => {
|
||||||
|
let j = 0;
|
||||||
|
for (const l of lines) {
|
||||||
|
while (j < finalLines.length && finalLines[j] !== l) j++;
|
||||||
|
if (j++ >= finalLines.length) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for (const [i, s] of snaps.entries()) {
|
||||||
|
const label = `snapshot ${i} (audit ${s.args.join(' ')}, ${s.before} rows committed before it started, ${s.after} requests started before it ended)`;
|
||||||
|
const rows = parseAuditOutput(s.stdout); // strict: a torn line or header fails here
|
||||||
|
const times = rows.map((r) => rfc3339ToNanos(r.time));
|
||||||
|
expect(times.every((t, k) => k === 0 || times[k - 1] <= t), `${label}: chronological`).toBe(true);
|
||||||
|
expect(isSubsequence(rows.map(cells)), `${label}: every row is a stored row, in the final relative order`).toBe(true);
|
||||||
|
expect(s.stdout, `${label}: padded table of its own rows`).toBe(formatAuditOutput(rows));
|
||||||
|
const limit = s.args[0] === '5' ? 5 : s.args.length === 0 ? 10 : Infinity;
|
||||||
|
expect(rows.length, `${label}: lower bound`).toBeGreaterThanOrEqual(Math.min(limit, s.before));
|
||||||
|
expect(rows.length, `${label}: upper bound`).toBeLessThanOrEqual(Math.min(limit, s.after));
|
||||||
|
}
|
||||||
|
await expectAudit(cli, ws.dbPath, ['1000000'], 'after the load');
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 4, operationCount: tokens.length * PER_TOKEN });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read-only: database file and WAL byte-identical, logical state unchanged and no audit row created, for every form at every log level', async ({ cli, ws, startServer, http }) => {
|
||||||
|
const t = await cli.tokenNew('read-only');
|
||||||
|
const dead = await cli.tokenNew('deleted');
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [t, dead, t, dead, t]);
|
||||||
|
await cli.tokenDelete(dead);
|
||||||
|
const wal = `${ws.dbPath}-wal`;
|
||||||
|
const before = { db: fileDigest(ws.dbPath), wal: fileDigest(wal), state: stateDigest(ws.dbPath), ops: readOperations(ws.dbPath) };
|
||||||
|
expect(before.wal, 'the running server keeps a WAL file').toBeDefined();
|
||||||
|
for (const level of LOG_LEVELS) {
|
||||||
|
for (const args of [[], ['all'], ['3'], ['1000'], ['1h'], ['1w']]) {
|
||||||
|
const r = await cli.run(['audit', ...args], { env: { LOG_LEVEL: level } });
|
||||||
|
expectCliSuccess(r);
|
||||||
|
const label = `audit ${args.join(' ')} at ${level}`;
|
||||||
|
expect(fileDigest(ws.dbPath), `${label}: database file bytes`).toBe(before.db);
|
||||||
|
expect(fileDigest(wal), `${label}: WAL bytes`).toBe(before.wal);
|
||||||
|
expect(stateDigest(ws.dbPath), `${label}: logical state`).toBe(before.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(readOperations(ws.dbPath), 'running audit created no audit row').toEqual(before.ops);
|
||||||
|
expect(server.logs().requests(), 'audit never talks to the server').toHaveLength(5);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
|
||||||
|
// closed database (no server): still nothing written
|
||||||
|
const closed = { db: fileDigest(ws.dbPath), state: stateDigest(ws.dbPath) };
|
||||||
|
for (const args of [[], ['all'], ['2'], ['1d']]) {
|
||||||
|
expectCliSuccess(await cli.run(['audit', ...args]), { stderr: '' });
|
||||||
|
expect(fileDigest(ws.dbPath), `audit ${args.join(' ')} on a closed database: file bytes`).toBe(closed.db);
|
||||||
|
expect(stateDigest(ws.dbPath)).toBe(closed.state);
|
||||||
|
}
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const INVALID = 'invalid audit argument';
|
||||||
|
const TOO_MANY = 'audit takes at most one argument';
|
||||||
|
const invalid = (...values: string[]): Array<[string[], string]> => values.map((v) => [[v], INVALID]);
|
||||||
|
|
||||||
|
/** Every class of invalid `audit` argument (README "audit", PLAN §3). */
|
||||||
|
const USAGE_ERRORS: Array<[string[], string]> = [
|
||||||
|
// more than one argument
|
||||||
|
[['10', 'h'], TOO_MANY],
|
||||||
|
[['1', '2'], TOO_MANY],
|
||||||
|
[['10', 'hours'], TOO_MANY],
|
||||||
|
[['', ''], TOO_MANY],
|
||||||
|
[['1h', '--json'], TOO_MANY],
|
||||||
|
[['1', '2', '3'], TOO_MANY],
|
||||||
|
// zero in any form
|
||||||
|
...invalid('0', '000', '0s', '0m', '00h', '0d', '0000w'),
|
||||||
|
// signs
|
||||||
|
...invalid('-5', '+5', '-0', '-1h', '+1h', '−5'),
|
||||||
|
// whitespace
|
||||||
|
...invalid(' 5', '5 ', '5\n', '\t5', '5 h', '1 0', ' ', '5 ', ' 5', '5h\r'),
|
||||||
|
// empty argument
|
||||||
|
...invalid(''),
|
||||||
|
// uppercase and unknown units
|
||||||
|
...invalid('5S', '5M', '5H', '5D', '5W', '5y', '5ms', '5us', '5µs', '5ns', '5sec', '30min', '1hour', '5x', '5ss', '5hh', '5ѕ', '5s'),
|
||||||
|
// unit without digits
|
||||||
|
...invalid('s', 'm', 'h', 'd', 'w', 'hh'),
|
||||||
|
// other number syntaxes
|
||||||
|
...invalid('1.5', '1.5h', '1e3', '0x10', '0b1', '1_000', '1,000', '½', '10', '٣', 'ten', 'NaN', 'Infinity', '1h30m', 's5', '5d5'),
|
||||||
|
// count overflowing int64, window overflowing time.Duration
|
||||||
|
...invalid('9223372036854775808', '18446744073709551616', '99999999999999999999999999', '9223372037s', '153722868m', '2562048h', '106752d', '15251w', '99999999999999999999w'),
|
||||||
|
// looks like an option
|
||||||
|
...invalid('--help', '-n', '--tail=10'),
|
||||||
|
// near misses of `all` (exact, lowercase only) and `all` with extra arguments
|
||||||
|
...invalid(...new Set(['ALL', 'All', 'all1', '1all', ' all', 'alls', '--all', ...AUDIT_ALL_NEAR_MISSES.map(([v]) => v)])),
|
||||||
|
...AUDIT_ALL_EXTRA_ARGS.map((args): [string[], string] => [[...args], TOO_MANY]),
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('audit usage and configuration errors', () => {
|
||||||
|
test(`${USAGE_ERRORS.length} invalid argument lists → exit 2, exact error line + usage text, nothing echoed, no database created / existing one untouched`, async ({ cli, ws, startServer, http }) => {
|
||||||
|
// the oracle agrees with the table (and the table with the README)
|
||||||
|
for (const [args, msg] of USAGE_ERRORS) expect(auditArgsOutcome(args), JSON.stringify(args)).toEqual({ ok: false, message: msg });
|
||||||
|
const run = async (dbPath: string) =>
|
||||||
|
mapLimit(USAGE_ERRORS, 8, async ([args, msg]) => {
|
||||||
|
const r = await cli.run(['audit', ...args], { env: { DB_PATH: dbPath } });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
// exact stderr: the argument is never echoed
|
||||||
|
expect(r.stderr, JSON.stringify(args)).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('fresh workspace: the database is never created', async () => {
|
||||||
|
const never = ws.path('never.db');
|
||||||
|
await run(never);
|
||||||
|
expect(fs.readdirSync(ws.dir), 'nothing written to the workspace').toEqual([]);
|
||||||
|
});
|
||||||
|
await test.step('existing database with audit rows: byte-identical afterwards', async () => {
|
||||||
|
const t = await cli.tokenNew('existing');
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [t, t, t]);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const file = fileDigest(ws.dbPath);
|
||||||
|
const state = stateDigest(ws.dbPath);
|
||||||
|
await run(ws.dbPath);
|
||||||
|
expect(fileDigest(ws.dbPath)).toBe(file);
|
||||||
|
expect(stateDigest(ws.dbPath)).toBe(state);
|
||||||
|
expect(await expectAudit(cli, ws.dbPath, [])).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the largest accepted count and windows are valid (exit 0)', async ({ cli, ws }) => {
|
||||||
|
const edges = ['9223372036854775807', '00009223372036854775807', '9223372036s', '153722867m', '2562047h', '106751d', '15250w', '1s', '01h', '010', '1'];
|
||||||
|
for (const e of edges) expect(auditArgsOutcome([e]).ok, e).toBe(true);
|
||||||
|
await mapLimit(edges, 4, async (e) => expectCliSuccess(await cli.run(['audit', e]), { stdout: EMPTY_AUDIT, stderr: '' }));
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 0, operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configuration errors take precedence over audit usage errors (no usage text, no database)', async ({ cli, ws }) => {
|
||||||
|
const cases: Array<[Record<string, string | undefined>, string[], string]> = [
|
||||||
|
[{ DB_PATH: undefined }, ['0'], 'configuration error: DB_PATH is required'],
|
||||||
|
[{ DB_PATH: '' }, ['1', 'h'], 'configuration error: DB_PATH must not be empty'],
|
||||||
|
[{ HTTP_ADDR: 'localhost' }, ['-1'], 'configuration error: invalid HTTP_ADDR "localhost": must be an IPv4 or IPv6 address'],
|
||||||
|
[{ HTTP_PORT: '0' }, ['1H'], 'configuration error: invalid HTTP_PORT "0": must be an integer between 1 and 65535'],
|
||||||
|
[{ LOG_LEVEL: 'verbose' }, ['10', 'hours'], 'configuration error: invalid LOG_LEVEL "verbose": must be one of ERROR, WARN, INFO, DEBUG, TRACE'],
|
||||||
|
// a valid audit invocation is rejected the same way: CLI mode validates every variable
|
||||||
|
[{ HTTP_PORT: '70000' }, [], 'configuration error: invalid HTTP_PORT "70000": must be an integer between 1 and 65535'],
|
||||||
|
[{ LOG_LEVEL: '' }, ['1h'], 'configuration error: LOG_LEVEL must not be empty'],
|
||||||
|
];
|
||||||
|
for (const [env, args, message] of cases) {
|
||||||
|
const r = await cli.run(['audit', ...args], { env });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message, usage: false });
|
||||||
|
}
|
||||||
|
expect(fs.existsSync(ws.dbPath)).toBe(false);
|
||||||
|
expect(fs.readdirSync(ws.dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('log levels: stdout byte-identical at every level, stderr carries exactly the records the level allows, never a token (only token_id)', async ({ cli, ws, startServer, http, secrets }) => {
|
||||||
|
const a = await cli.tokenNew('log a');
|
||||||
|
const b = await cli.tokenNew('log b');
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [a, b, a, b, a, a]);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
await cli.tokenDelete(b);
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
const FUNCS = ['app.execute', 'app.runAuditCommand', 'OpenDatabase', 'InitializeSchema', 'app.auditList', 'AuditStore.ListOperations'];
|
||||||
|
const forms: Array<{ args: string[]; dispatch: Record<string, string>; rows: number }> = [
|
||||||
|
{ args: [], dispatch: { command: 'audit', limit: '10' }, rows: 6 },
|
||||||
|
{ args: ['3'], dispatch: { command: 'audit', limit: '3' }, rows: 3 },
|
||||||
|
{ args: ['all'], dispatch: { command: 'audit', all: 'true' }, rows: 6 },
|
||||||
|
{ args: ['2h'], dispatch: { command: 'audit', window: '2h0m0s' }, rows: 6 },
|
||||||
|
{ args: ['0090m'], dispatch: { command: 'audit', window: '1h30m0s' }, rows: 6 },
|
||||||
|
];
|
||||||
|
for (const f of forms) {
|
||||||
|
const baseline = await cli.run(['audit', ...f.args]);
|
||||||
|
expectCliSuccess(baseline, { stderr: '' });
|
||||||
|
expect(parseAuditOutput(baseline.stdout)).toHaveLength(f.rows);
|
||||||
|
expect(baseline.stdout.includes(a) && baseline.stdout.includes(b), 'stdout carries the full tokens').toBe(true);
|
||||||
|
for (const level of LOG_LEVELS) {
|
||||||
|
await test.step(`audit ${f.args.join(' ')} at ${level}`, async () => {
|
||||||
|
const t0 = nowNs(-1);
|
||||||
|
const r = await cli.run(['audit', ...f.args], { env: { LOG_LEVEL: level } });
|
||||||
|
const t1 = nowNs(1);
|
||||||
|
expectCliSuccess(r);
|
||||||
|
expect(r.stdout, 'stdout identical at every level').toBe(baseline.stdout);
|
||||||
|
// no token, nor any 12-character fragment of one, in stderr — although stdout has them
|
||||||
|
expect(r.stderr.includes(a) || r.stderr.includes(b)).toBe(false);
|
||||||
|
expectNoTokenFragments(r.stderr, secrets, `stderr at ${level}`);
|
||||||
|
const logs = parseLogs(r.stderr);
|
||||||
|
expect(logs.unparsed, 'stderr consists of slog records only').toEqual([]);
|
||||||
|
const allowed = visibleLevels(level as LogLevel);
|
||||||
|
expect(logs.records.filter((x) => !allowed.includes(x.level as LogLevel)).map((x) => x.raw)).toEqual([]);
|
||||||
|
if (levelRank(level) >= levelRank('INFO')) expect(r.stderr, 'successful CLI commands are silent at INFO and above').toBe('');
|
||||||
|
|
||||||
|
if (levelRank(level) <= levelRank('DEBUG')) {
|
||||||
|
expect(logs.find({ msg: 'configuration resolved' })!.attrs.log_level).toBe(level);
|
||||||
|
expect(logs.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'cli' });
|
||||||
|
expect(logs.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([f.dispatch]);
|
||||||
|
expect(logs.filter({ msg: 'audit rows listed' }).map((x) => x.attrs)).toEqual([{ rows: String(f.rows) }]);
|
||||||
|
expect(logs.filter({ msg: 'cli command finished' }).map((x) => x.attrs)).toEqual([{ command: 'audit', ok: 'true' }]);
|
||||||
|
for (const m of ['database schema initialized', 'database opened', 'database closed']) expect(logs.count({ level: 'DEBUG', msg: m }), m).toBe(1);
|
||||||
|
const resolved = logs.filter({ msg: 'audit window resolved' });
|
||||||
|
if ('window' in f.dispatch) {
|
||||||
|
expect(resolved).toHaveLength(1);
|
||||||
|
const since = resolved[0].attrs.since;
|
||||||
|
expect(since).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
const w = auditArgsOutcome(f.args);
|
||||||
|
const windowNs = w.ok && 'windowNs' in w.selection ? w.selection.windowNs : 0n;
|
||||||
|
expect(rfc3339ToNanos(since) >= t0 - windowNs && rfc3339ToNanos(since) <= t1 - windowNs, `since=${since} is now - window`).toBe(true);
|
||||||
|
} else expect(resolved, 'no window resolved for a count').toEqual([]);
|
||||||
|
} else expect(logs.count({ level: 'DEBUG' })).toBe(0);
|
||||||
|
|
||||||
|
if (level === 'TRACE') {
|
||||||
|
for (const fn of FUNCS) {
|
||||||
|
expect(logs.count({ msg: 'function entry', attrs: { func: fn } }), `entry ${fn}`).toBe(1);
|
||||||
|
expect(logs.count({ msg: 'function exit', attrs: { func: fn } }), `exit ${fn}`).toBe(1);
|
||||||
|
}
|
||||||
|
// one `row read` per stored row (also outside the selection), token redacted to token_id
|
||||||
|
const reads = logs.filter({ level: 'TRACE', msg: 'row read', attrs: { table: 'operations' } });
|
||||||
|
expect(reads.map((x) => x.attrs)).toEqual(ops.map((o) => ({ table: 'operations', rowid: String(o.rowid), token_id: tokenId(o.token), origin: o.origin, operation: o.operation, time: o.time })));
|
||||||
|
expect(logs.records.some((x) => 'token' in x.attrs), 'no record has a token attribute').toBe(false);
|
||||||
|
} else expect(logs.count({ level: 'TRACE' })).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unparseable stored time fails every form with exit 1 and nothing on stdout; restoring the row restores the output', async ({ cli, ws, startServer, http, secrets }) => {
|
||||||
|
const t = await cli.tokenNew('corrupt');
|
||||||
|
const server = await startServer();
|
||||||
|
await sequential(http, server, [t, t, t]);
|
||||||
|
// no server may use the database while the test writes to it
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
const good = formatAuditOutput(ops);
|
||||||
|
const victim = ops[1];
|
||||||
|
|
||||||
|
// Test-owned write: the only scenario that writes to the application database. It replaces one
|
||||||
|
// row's time and finally restores it; nothing else is touched.
|
||||||
|
const setTime = (value: string) => {
|
||||||
|
const db = new DatabaseSync(ws.dbPath, { timeout: 5000 });
|
||||||
|
try {
|
||||||
|
const res = db.prepare('UPDATE operations SET time = ? WHERE rowid = ?').run(value, victim.rowid);
|
||||||
|
expect(Number(res.changes)).toBe(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const goParse = (v: string) => `read operation row: parse stored timestamp: parsing time ${JSON.stringify(v)} as "2006-01-02T15:04:05.999999999Z07:00": cannot parse ${JSON.stringify(v)} as "2006"`;
|
||||||
|
const corrupt: Array<[string, string | RegExp]> = [
|
||||||
|
['not-a-time', goParse('not-a-time')],
|
||||||
|
['', goParse('')],
|
||||||
|
['2026-13-01T00:00:00Z', /^read operation row: parse stored timestamp: parsing time "2026-13-01T00:00:00Z": month out of range$/],
|
||||||
|
['2026-10-05 22:00:00Z', /^read operation row: parse stored timestamp: parsing time "2026-10-05 22:00:00Z" as "2006-01-02T15:04:05\.999999999Z07:00": cannot parse /],
|
||||||
|
['1759700000', /^read operation row: parse stored timestamp: parsing time "1759700000" as "2006-01-02T15:04:05\.999999999Z07:00": cannot parse /],
|
||||||
|
];
|
||||||
|
for (const [value, message] of corrupt) {
|
||||||
|
await test.step(`time = ${JSON.stringify(value)}`, async () => {
|
||||||
|
setTime(value);
|
||||||
|
// The harness invariant check flags the deliberately corrupt row — asserted here explicitly
|
||||||
|
// (the check itself stays enabled everywhere else).
|
||||||
|
expect(() => assertDbInvariants(ws.dbPath), 'assertDbInvariants flags the corrupt row').toThrow(/database invariants/);
|
||||||
|
const state = stateDigest(ws.dbPath);
|
||||||
|
for (const args of [[], ['all'], ['1'], ['1000'], ['1w'], ['15250w']]) {
|
||||||
|
const r = await cli.run(['audit', ...args]);
|
||||||
|
expectCliFailure(r, { exitCode: 1, message, usage: false });
|
||||||
|
expect(r.stdout, 'no partial output').toBe('');
|
||||||
|
}
|
||||||
|
const trace = await cli.run(['audit'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||||
|
expectCliFailure(trace, { exitCode: 1, message, usage: false, exactStderr: false });
|
||||||
|
expect(parseLogs(trace.stderr).unparsed.map((u) => u.text), 'only the final error line is not a log record').toHaveLength(1);
|
||||||
|
secrets.assertNoSecretsIn(trace.stderr, 'TRACE stderr of the failing audit');
|
||||||
|
expect(stateDigest(ws.dbPath), 'the failing audit changed nothing').toBe(state);
|
||||||
|
// the token table is unaffected
|
||||||
|
expect(parseTokenList((await cli.run(['token', 'list'])).stdout).map((r) => r.token)).toEqual([t]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTime(victim.time);
|
||||||
|
expectCliSuccess(await cli.run(['audit']), { stdout: good, stderr: '' });
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 3 });
|
||||||
|
expect(readOperations(ws.dbPath)).toEqual(ops);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('population: 4000 audit rows from real authenticated requests over 40 tokens (pipelined keep-alive connections) — every form equals SQLite', async ({ cli, ws, startServer, http, rng, secrets }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
const tokens = await mapLimit(Array.from({ length: 40 }, (_, i) => `population ${i}`), 16, (d) => cli.tokenNew(d));
|
||||||
|
const server = await startServer({ logLevel: 'ERROR' });
|
||||||
|
const N = 4000;
|
||||||
|
const plan = Array.from({ length: N }, () => rng.pick(tokens));
|
||||||
|
const t0 = performance.now();
|
||||||
|
await pipelined(http, server, plan, { perConn: 50, concurrency: 10 });
|
||||||
|
const requestMs = performance.now() - t0;
|
||||||
|
for (const victim of tokens.slice(0, 8)) await cli.tokenDelete(victim);
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
expect(ops).toHaveLength(N);
|
||||||
|
const perToken = new Map<string, number>();
|
||||||
|
for (const t of plan) perToken.set(t, (perToken.get(t) ?? 0) + 1);
|
||||||
|
for (const [t, n] of perToken) expect(ops.filter((o) => o.token === t).length, `rows of ${tokenId(t)}`).toBe(n);
|
||||||
|
|
||||||
|
const timings: Record<string, number> = {};
|
||||||
|
for (const args of [[], ['all'], ['1'], ['10'], ['3999'], ['4000'], ['4001'], ['100000'], ['0100'], ['1h'], ['1d'], ['1w']]) {
|
||||||
|
const s = performance.now();
|
||||||
|
const rows = await expectAudit(cli, ws.dbPath, args);
|
||||||
|
timings[args.join(' ') || '(none)'] = Math.round(performance.now() - s);
|
||||||
|
const sel = auditArgsOutcome(args);
|
||||||
|
if (sel.ok && 'limit' in sel.selection) expect(rows).toHaveLength(Math.min(N, Number(sel.selection.limit)));
|
||||||
|
else expect(rows).toHaveLength(N);
|
||||||
|
}
|
||||||
|
expect(Math.max(...Object.values(timings)), 'audit over 4000 rows stays fast').toBeLessThan(5000);
|
||||||
|
|
||||||
|
// TRACE: one `row read` per row, identical stdout, no token in 4000+ log lines
|
||||||
|
const full = await cli.run(['audit', '100000']);
|
||||||
|
expectCliSuccess(await cli.run(['audit', 'all']), { stdout: full.stdout, stderr: '' });
|
||||||
|
const traced = await cli.run(['audit', 'all'], { env: { LOG_LEVEL: 'TRACE' } });
|
||||||
|
expectCliSuccess(traced);
|
||||||
|
expect(traced.stdout).toBe(full.stdout);
|
||||||
|
const logs = parseLogs(traced.stderr);
|
||||||
|
expect(logs.count({ msg: 'row read', attrs: { table: 'operations' } })).toBe(N);
|
||||||
|
secrets.assertNoSecretsIn(traced.stderr, 'TRACE stderr');
|
||||||
|
|
||||||
|
// the server serves on: the latest row printed is the next request
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: tokens[39] }));
|
||||||
|
const last = await expectAudit(cli, ws.dbPath, ['1']);
|
||||||
|
expect(last.map((r) => r.token)).toEqual([tokens[39]]);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 40, operationCount: N + 1 });
|
||||||
|
test.info().annotations.push({ type: 'audit population', description: JSON.stringify({ rows: N, requestMs: Math.round(requestMs), auditMs: timings }) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core items 4, 5, 11 (CLI side): token creation with every description family (no-shell
|
||||||
|
* argv and through a real shell), verbatim storage, deterministic `token list` (active tokens) and
|
||||||
|
* `token list all` (every token) as padded tables, their usage errors, deletion.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
countRows,
|
||||||
|
expectCliFailure,
|
||||||
|
expectCliSuccess,
|
||||||
|
goQuote,
|
||||||
|
msToNanos,
|
||||||
|
parseTokenList,
|
||||||
|
readOperations,
|
||||||
|
readToken,
|
||||||
|
readTokens,
|
||||||
|
rfc3339ToNanos,
|
||||||
|
RFC3339NANO_UTC_RE,
|
||||||
|
shellQuote,
|
||||||
|
EMPTY_TOKEN_LIST,
|
||||||
|
USAGE_TEXT,
|
||||||
|
fileDigest,
|
||||||
|
formatTokenList,
|
||||||
|
padTable,
|
||||||
|
runeLength,
|
||||||
|
type CliRunner,
|
||||||
|
type TokenListRow,
|
||||||
|
} from '../../harness';
|
||||||
|
import { expectedTokenListStdout, stateDigest } from '../../harness/personas';
|
||||||
|
|
||||||
|
const hex = (s: string | Buffer) => Buffer.from(s as string).toString('hex').toUpperCase();
|
||||||
|
|
||||||
|
/** Creates a token, asserting created_at lies within the invocation window. */
|
||||||
|
async function createChecked(cli: CliRunner, dbPath: string, description: string | undefined): Promise<string> {
|
||||||
|
const before = msToNanos(Date.now() - 1);
|
||||||
|
const token = await cli.tokenNew(description);
|
||||||
|
const after = msToNanos(Date.now() + 1);
|
||||||
|
const row = readToken(dbPath, token)!;
|
||||||
|
expect(row, 'row exists').toBeTruthy();
|
||||||
|
expect(row.descriptionHex, 'description stored byte-for-byte').toBe(hex(description ?? ''));
|
||||||
|
expect(row.active).toBe(1);
|
||||||
|
expect(row.deleted_at).toBeNull();
|
||||||
|
expect(row.created_at).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
const created = rfc3339ToNanos(row.created_at);
|
||||||
|
expect(created >= before && created <= after, `created_at ${row.created_at} within the invocation window`).toBe(true);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DESCRIPTIONS: Array<[string, string | undefined]> = [
|
||||||
|
['omitted (no argument)', undefined],
|
||||||
|
['explicit empty argument', ''],
|
||||||
|
['normal words', 'ci pipeline'],
|
||||||
|
['whitespace only', ' \t '],
|
||||||
|
['leading/trailing spaces', ' padded value '],
|
||||||
|
['newlines, tabs, CR', 'line1\nline2\tcol\r\nend'],
|
||||||
|
['ASCII punctuation', `!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~`],
|
||||||
|
['Unicode letters, emoji, RTL, combining', 'café ☕ 日本語 עברית 🚀 é Ωmega'],
|
||||||
|
['non-printable Unicode (NBSP, ZWSP, LS, NEL, tag)', ' nbz
x\u0085y\u{E0001}'],
|
||||||
|
['ASCII control characters and ESC sequences', '\x01\x07\x08\x1b[31mred\x1b[0m\x7f'],
|
||||||
|
['SQL injection payload', `'); DROP TABLE tokens; --`],
|
||||||
|
['SQL quote games', `x' OR '1'='1`],
|
||||||
|
['shell metacharacters', '$(touch /tmp/pwned) `id` ; rm -rf / && echo $HOME | cat > /dev/null &'],
|
||||||
|
['looks like an option', '--help'],
|
||||||
|
['looks like a subcommand', 'delete'],
|
||||||
|
['Go format verbs', '%s %d %v %!x(MISSING) %%'],
|
||||||
|
['backslash escapes kept literal', '\\n \\t \\x00 \\u0041'],
|
||||||
|
['very long ASCII (100 000 bytes)', 'x'.repeat(100_000)],
|
||||||
|
['long multi-byte (30 000 × 日 = 90 000 bytes)', '日'.repeat(30_000)],
|
||||||
|
['maximum single argv string (131 071 bytes)', 'a'.repeat(131_071)],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('token new: description stored verbatim (argv, no shell)', () => {
|
||||||
|
for (const [name, description] of DESCRIPTIONS) {
|
||||||
|
test(name, async ({ cli, ws }) => {
|
||||||
|
const token = await createChecked(cli, ws.dbPath, description);
|
||||||
|
const rows = await cli.tokenList();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].token).toBe(token);
|
||||||
|
expect(rows[0].descriptionQuoted, 'DESCRIPTION column is Go-quoted').toBe(goQuote(description ?? ''));
|
||||||
|
expect(rows[0].descriptionBytes.equals(Buffer.from(description ?? '')), 'list round-trips the bytes').toBe(true);
|
||||||
|
expect(rows[0].active).toBe(true);
|
||||||
|
expect(rows[0].deletedAt).toBeNull();
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('one argument larger than the kernel limit is refused by the OS, not the app', async ({ cli }) => {
|
||||||
|
// MAX_ARG_STRLEN is 131072 bytes including the terminating NUL: exec fails before dpsapi runs.
|
||||||
|
await expect(cli.run(['token', 'new', 'a'.repeat(131_072)])).rejects.toThrow(/harness:spawn|E2BIG/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('token new through a real shell (quoting)', () => {
|
||||||
|
const tricky = [
|
||||||
|
'two words',
|
||||||
|
`it's "quoted"`,
|
||||||
|
'$HOME and $(id) and `id`',
|
||||||
|
'semi;colon|pipe&>redirect<in',
|
||||||
|
'glob * ? [a-z] ~',
|
||||||
|
'new\nline',
|
||||||
|
"'",
|
||||||
|
'\\',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('single-quoted descriptions reach the binary byte-exact', async ({ cli, ws }) => {
|
||||||
|
for (const d of tricky) {
|
||||||
|
await test.step(JSON.stringify(d), async () => {
|
||||||
|
const r = await cli.shell(`"$DPSAPI_BIN" token new ${shellQuote(d)}`);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
expect(r.stdout).toMatch(/^[A-Za-z0-9_-]{43}\n$/);
|
||||||
|
expect(readToken(ws.dbPath, r.stdout.trim())!.descriptionHex).toBe(hex(d));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = await cli.tokenList();
|
||||||
|
expect(rows.map((r) => r.description).sort()).toEqual([...tricky].sort());
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: tricky.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unquoted multi-word description is a usage error and creates nothing', async ({ cli, ws }) => {
|
||||||
|
const r = await cli.shell('"$DPSAPI_BIN" token new ci pipeline');
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: 'usage error: token new takes at most one argument' });
|
||||||
|
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double quotes expand variables in the shell (the app stores what it receives)', async ({ cli, ws }) => {
|
||||||
|
const r = await cli.shell('"$DPSAPI_BIN" token new "home=$HOME_FOR_TEST"', { env: { HOME_FOR_TEST: '/home/someone' } });
|
||||||
|
expectCliSuccess(r);
|
||||||
|
expect(readToken(ws.dbPath, r.stdout.trim())!.description).toBe('home=/home/someone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid UTF-8 bytes from the shell are stored exactly and listed with \\x escapes', async ({ cli, ws }) => {
|
||||||
|
const r = await cli.shell(`"$DPSAPI_BIN" token new "$(printf 'bad\\377byte\\300\\200')"`);
|
||||||
|
expectCliSuccess(r);
|
||||||
|
const token = r.stdout.trim();
|
||||||
|
expect(readToken(ws.dbPath, token)!.descriptionHex).toBe('626164FF62797465C080');
|
||||||
|
const rows = await cli.tokenList();
|
||||||
|
expect(rows[0].descriptionQuoted).toBe('"bad\\xffbyte\\xc0\\x80"');
|
||||||
|
expect(rows[0].descriptionBytes.toString('hex').toUpperCase()).toBe('626164FF62797465C080');
|
||||||
|
assertDbInvariants(ws.dbPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('token list', () => {
|
||||||
|
test('fresh database: padded header only (list and list all), database and schema created', async ({ cli, ws }) => {
|
||||||
|
expect(EMPTY_TOKEN_LIST).toBe('TOKEN DESCRIPTION ACTIVE CREATED_AT DELETED_AT\n');
|
||||||
|
for (const args of [['token', 'list'], ['token', 'list', 'all']]) {
|
||||||
|
const db = ws.path(`fresh-${args.length}.db`);
|
||||||
|
expect(fs.existsSync(db)).toBe(false);
|
||||||
|
const r = await cli.run(args, { env: { DB_PATH: db } });
|
||||||
|
expectCliSuccess(r, { stdout: EMPTY_TOKEN_LIST, stderr: '' });
|
||||||
|
expect(fs.existsSync(db), `${args.join(' ')}: database created`).toBe(true);
|
||||||
|
assertDbInvariants(db, { tokenCount: 0, operationCount: 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only inactive tokens: token list prints the header only, token list all prints them', async ({ cli, ws }) => {
|
||||||
|
const a = await cli.tokenNew('gone a');
|
||||||
|
const b = await cli.tokenNew('gone b');
|
||||||
|
await cli.tokenDelete(a);
|
||||||
|
await cli.tokenDelete(b);
|
||||||
|
expectCliSuccess(await cli.run(['token', 'list']), { stdout: EMPTY_TOKEN_LIST, stderr: '' });
|
||||||
|
const all = await cli.tokenListAll();
|
||||||
|
expect(all.map((r) => [r.token, r.active, r.deletedAt !== null])).toEqual([
|
||||||
|
[a, false, true],
|
||||||
|
[b, false, true],
|
||||||
|
]);
|
||||||
|
expect((await cli.run(['token', 'list', 'all'])).stdout).toBe(expectedTokenListStdout(ws.dbPath, 'all'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deterministic, ordered by created_at then token, exact values for active and inactive rows', async ({ cli, ws }) => {
|
||||||
|
test.slow();
|
||||||
|
const tokens: string[] = [];
|
||||||
|
// sequential and concurrent creations (concurrent ones may share timestamps closely)
|
||||||
|
for (let i = 0; i < 6; i++) tokens.push(await cli.tokenNew(`seq ${i}`));
|
||||||
|
tokens.push(...(await Promise.all(Array.from({ length: 12 }, (_, i) => cli.tokenNew(`par ${i}`)))));
|
||||||
|
const deleted = [tokens[1], tokens[7], tokens[12]];
|
||||||
|
for (const t of deleted) await cli.tokenDelete(t);
|
||||||
|
|
||||||
|
const a = await cli.run(['token', 'list', 'all']);
|
||||||
|
const b = await cli.run(['token', 'list', 'all']);
|
||||||
|
expectCliSuccess(a, { stderr: '' });
|
||||||
|
expect(b.stdout, 'two consecutive listings are byte-identical').toBe(a.stdout);
|
||||||
|
expect(a.stdout, 'token list all equals the stored rows (padded table)').toBe(expectedTokenListStdout(ws.dbPath, 'all'));
|
||||||
|
const rows = parseTokenList(a.stdout);
|
||||||
|
expect(rows).toHaveLength(tokens.length);
|
||||||
|
expect(new Set(rows.map((r) => r.token))).toEqual(new Set(tokens));
|
||||||
|
|
||||||
|
// plain `token list`: exactly the active rows, same order, its own (narrower or equal) widths
|
||||||
|
const active = await cli.run(['token', 'list']);
|
||||||
|
expectCliSuccess(active, { stderr: '' });
|
||||||
|
expect(active.stdout, 'token list equals the stored active rows').toBe(expectedTokenListStdout(ws.dbPath, 'active'));
|
||||||
|
expect(parseTokenList(active.stdout).map((r) => r.token)).toEqual(rows.filter((r) => r.active).map((r) => r.token));
|
||||||
|
|
||||||
|
// ordering: created_at (as time, not string), then token (bytewise)
|
||||||
|
const key = (r: TokenListRow) => [rfc3339ToNanos(r.createdAt), r.token] as const;
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const [ta, ka] = key(rows[i - 1]);
|
||||||
|
const [tb, kb] = key(rows[i]);
|
||||||
|
expect(ta < tb || (ta === tb && ka < kb), `row ${i} ordered after row ${i - 1}`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// every row equals the database
|
||||||
|
const db = new Map(readTokens(ws.dbPath).map((t) => [t.token, t]));
|
||||||
|
for (const r of rows) {
|
||||||
|
const d = db.get(r.token)!;
|
||||||
|
expect(r.createdAt).toBe(d.created_at);
|
||||||
|
expect(r.deletedAt).toBe(d.deleted_at);
|
||||||
|
expect(r.active).toBe(d.active === 1);
|
||||||
|
expect(r.descriptionQuoted).toBe(goQuote(d.description));
|
||||||
|
expect(r.active).toBe(!deleted.includes(r.token));
|
||||||
|
if (!r.active) {
|
||||||
|
expect(r.deletedAt).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
expect(rfc3339ToNanos(r.deletedAt!) >= rfc3339ToNanos(r.createdAt)).toBe(true);
|
||||||
|
expect(r.line).toMatch(new RegExp(` false ${r.createdAt.replace(/\./g, '\\.')} {2,}${r.deletedAt!.replace(/\./g, '\\.')}$`));
|
||||||
|
} else {
|
||||||
|
expect(r.line).toMatch(new RegExp(` true ${r.createdAt.replace(/\./g, '\\.')} {2,}-$`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 18, operationCount: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('token list [all]: active by default, every token with "all", padded table', () => {
|
||||||
|
test('default hides inactive tokens; all shows them with ACTIVE false and DELETED_AT set; both byte-exact padded tables', async ({ cli, ws }) => {
|
||||||
|
const keep = await cli.tokenNew('keep');
|
||||||
|
// the longest description belongs to a token that gets deleted: widths differ between the two listings
|
||||||
|
const long = await cli.tokenNew('a much longer description that only token list all prints');
|
||||||
|
const other = await cli.tokenNew('other');
|
||||||
|
await cli.tokenDelete(long);
|
||||||
|
const active = await cli.run(['token', 'list']);
|
||||||
|
const all = await cli.run(['token', 'list', 'all']);
|
||||||
|
expectCliSuccess(active, { stderr: '' });
|
||||||
|
expectCliSuccess(all, { stderr: '' });
|
||||||
|
const db = new Map(readTokens(ws.dbPath).map((t) => [t.token, t]));
|
||||||
|
const entry = (t: string) => {
|
||||||
|
const d = db.get(t)!;
|
||||||
|
return { token: t, description: d.description, active: d.active === 1, createdAt: d.created_at, deletedAt: d.deleted_at };
|
||||||
|
};
|
||||||
|
expect(active.stdout, 'token list: active rows only').toBe(formatTokenList([keep, other].map(entry)));
|
||||||
|
expect(all.stdout, 'token list all: every row').toBe(formatTokenList([keep, long, other].map(entry)));
|
||||||
|
// explicit layout of the default listing (widths computed over the printed lines only)
|
||||||
|
const k = entry(keep);
|
||||||
|
const o = entry(other);
|
||||||
|
const cw = 2 + Math.max(10, k.createdAt.length, o.createdAt.length);
|
||||||
|
expect(active.stdout).toBe(
|
||||||
|
'TOKEN' + ' '.repeat(40) + 'DESCRIPTION ACTIVE ' + 'CREATED_AT'.padEnd(cw) + 'DELETED_AT\n' +
|
||||||
|
`${keep} "keep" true ${k.createdAt.padEnd(cw)}-\n` +
|
||||||
|
`${other} "other" true ${o.createdAt.padEnd(cw)}-\n`,
|
||||||
|
);
|
||||||
|
const deleted = parseTokenList(all.stdout).find((r) => r.token === long)!;
|
||||||
|
expect(deleted.active).toBe(false);
|
||||||
|
expect(deleted.deletedAt).toBe(db.get(long)!.deleted_at);
|
||||||
|
expect(deleted.deletedAt).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
expect(active.stdout.includes(long), 'the inactive token is not printed by token list').toBe(false);
|
||||||
|
expect(active.stdout.split('\n')[0].length, 'narrower DESCRIPTION column without the long inactive row').toBeLessThan(all.stdout.split('\n')[0].length);
|
||||||
|
for (const out of [active.stdout, all.stdout]) expect(out.split('\n').filter((l) => / $/.test(l)), 'no trailing whitespace').toEqual([]);
|
||||||
|
expect(readOperations(ws.dbPath)).toEqual([]);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 3, operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descriptions with runs of spaces, escaped quotes, multi-byte runes and escapes: columns aligned in runes, parser round-trips', async ({ cli, ws }) => {
|
||||||
|
const descriptions = ['', 'two spaces three', 'say "hi" \\ back', '日本語', '🚀🚀', 'e\u0301 combining', 'tab\there', '\x01\x7f\xff', '"', ' ', 'x'.repeat(70)];
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const d of descriptions) tokens.push(await cli.tokenNew(d));
|
||||||
|
await cli.tokenDelete(tokens[3]);
|
||||||
|
for (const scope of ['active', 'all'] as const) {
|
||||||
|
const r = await cli.run(scope === 'all' ? ['token', 'list', 'all'] : ['token', 'list']);
|
||||||
|
expectCliSuccess(r, { stderr: '' });
|
||||||
|
expect(r.stdout, `${scope}: exact padded table`).toBe(expectedTokenListStdout(ws.dbPath, scope));
|
||||||
|
const rows = parseTokenList(r.stdout);
|
||||||
|
const want = scope === 'all' ? descriptions : descriptions.filter((_, i) => i !== 3);
|
||||||
|
expect(rows.map((x) => x.description).sort()).toEqual([...want].sort());
|
||||||
|
expect(rows.every((x) => x.descriptionQuoted === goQuote(x.descriptionBytes))).toBe(true);
|
||||||
|
// every cell of a column starts at the same rune offset (display width is irrelevant to the app)
|
||||||
|
const lines = r.stdout.slice(0, -1).split('\n');
|
||||||
|
const offset = (l: string, cell: string) => runeLength(l.slice(0, l.indexOf(cell)));
|
||||||
|
const hdr = lines[0];
|
||||||
|
for (const x of rows) {
|
||||||
|
expect(offset(x.line, x.descriptionQuoted), 'DESCRIPTION column').toBe(offset(hdr, 'DESCRIPTION'));
|
||||||
|
expect(runeLength(x.line) - runeLength(x.line.slice(x.line.lastIndexOf(x.deletedAt ?? '-'))), 'DELETED_AT column').toBe(offset(hdr, 'DELETED_AT'));
|
||||||
|
}
|
||||||
|
expect(padTable([lines[0].split(/ +/), ...rows.map((x) => [x.token, x.descriptionQuoted, String(x.active), x.createdAt, x.deletedAt ?? '-'])])).toBe(r.stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const UNKNOWN_ARGUMENT = 'unknown token list argument';
|
||||||
|
const TOO_MANY = 'token list takes at most one argument';
|
||||||
|
const LIST_USAGE: Array<[string[], string]> = [
|
||||||
|
[['ALL'], UNKNOWN_ARGUMENT],
|
||||||
|
[['All'], UNKNOWN_ARGUMENT],
|
||||||
|
[[''], UNKNOWN_ARGUMENT],
|
||||||
|
[['--all'], UNKNOWN_ARGUMENT],
|
||||||
|
[['-a'], UNKNOWN_ARGUMENT],
|
||||||
|
[[' all'], UNKNOWN_ARGUMENT],
|
||||||
|
[['all '], UNKNOWN_ARGUMENT],
|
||||||
|
[['all\n'], UNKNOWN_ARGUMENT],
|
||||||
|
[['\u0430ll'], UNKNOWN_ARGUMENT],
|
||||||
|
[['active'], UNKNOWN_ARGUMENT],
|
||||||
|
[['inactive'], UNKNOWN_ARGUMENT],
|
||||||
|
[['extra'], UNKNOWN_ARGUMENT],
|
||||||
|
[['all', 'all'], TOO_MANY],
|
||||||
|
[['all', ''], TOO_MANY],
|
||||||
|
[['', 'all'], TOO_MANY],
|
||||||
|
[['ALL', 'x'], TOO_MANY],
|
||||||
|
[['a', 'b', 'c'], TOO_MANY],
|
||||||
|
];
|
||||||
|
|
||||||
|
test(`${LIST_USAGE.length} invalid token list arguments → exit 2, exact stderr (nothing echoed), no database created, existing one byte-identical`, async ({ cli, ws }) => {
|
||||||
|
const run = async (dbPath: string) => {
|
||||||
|
for (const [args, msg] of LIST_USAGE) {
|
||||||
|
const r = await cli.run(['token', 'list', ...args], { env: { DB_PATH: dbPath } });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
expect(r.stderr, JSON.stringify(args)).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await run(ws.dbPath);
|
||||||
|
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
|
||||||
|
expect(fs.readdirSync(ws.dir)).toEqual([]);
|
||||||
|
|
||||||
|
const t = await cli.tokenNew('existing');
|
||||||
|
await cli.tokenDelete(t);
|
||||||
|
await cli.tokenNew('still active');
|
||||||
|
const file = fileDigest(ws.dbPath);
|
||||||
|
const state = stateDigest(ws.dbPath);
|
||||||
|
await run(ws.dbPath);
|
||||||
|
expect(fileDigest(ws.dbPath)).toBe(file);
|
||||||
|
expect(stateDigest(ws.dbPath)).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('token delete', () => {
|
||||||
|
test('deactivates silently; repeated/unknown/near-match deletions fail without changing state', async ({ cli, ws }) => {
|
||||||
|
const keep = await cli.tokenNew('keep');
|
||||||
|
const victim = await cli.tokenNew('victim');
|
||||||
|
const before = msToNanos(Date.now() - 1);
|
||||||
|
const r = await cli.run(['token', 'delete', victim]);
|
||||||
|
const after = msToNanos(Date.now() + 1);
|
||||||
|
expectCliSuccess(r, { stdout: '', stderr: '' });
|
||||||
|
|
||||||
|
const row = readToken(ws.dbPath, victim)!;
|
||||||
|
expect(row.active).toBe(0);
|
||||||
|
expect(row.deleted_at).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
const del = rfc3339ToNanos(row.deleted_at!);
|
||||||
|
expect(del >= before && del <= after, 'deleted_at within the delete invocation').toBe(true);
|
||||||
|
expect(readToken(ws.dbPath, keep)!.active).toBe(1);
|
||||||
|
const snapshot = JSON.stringify(readTokens(ws.dbPath));
|
||||||
|
|
||||||
|
const flip = (s: string) => s.replace(/[a-zA-Z]/, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
|
||||||
|
const failures: Array<[string, string, string]> = [
|
||||||
|
['already inactive', victim, 'token already inactive'],
|
||||||
|
['unknown', 'A'.repeat(43), 'token not found'],
|
||||||
|
['case changed', flip(keep), 'token not found'],
|
||||||
|
['leading space', ` ${keep}`, 'token not found'],
|
||||||
|
['trailing newline', `${keep}\n`, 'token not found'],
|
||||||
|
['prefix', keep.slice(0, 42), 'token not found'],
|
||||||
|
['extended', keep + 'x', 'token not found'],
|
||||||
|
['empty string', '', 'token not found'],
|
||||||
|
['SQL wildcard', '%', 'token not found'],
|
||||||
|
['SQL injection', `' OR '1'='1`, 'token not found'],
|
||||||
|
];
|
||||||
|
for (const [name, arg, message] of failures) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const f = await cli.run(['token', 'delete', arg]);
|
||||||
|
expectCliFailure(f, { exitCode: 1, message });
|
||||||
|
if (arg.length > 0) expect(f.stderr.includes(arg), 'supplied token never echoed').toBe(false);
|
||||||
|
expect(JSON.stringify(readTokens(ws.dbPath)), 'no token row changed').toBe(snapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = await cli.tokenListAll();
|
||||||
|
expect(rows.map((x) => [x.token, x.active])).toEqual([
|
||||||
|
[keep, true],
|
||||||
|
[victim, false],
|
||||||
|
]);
|
||||||
|
expect((await cli.tokenList()).map((x) => x.token), 'token list hides the deleted token').toEqual([keep]);
|
||||||
|
expect(readOperations(ws.dbPath), 'CLI commands never write audit rows').toEqual([]);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deleted token stays deleted and keeps its original created_at and description', async ({ cli, ws }) => {
|
||||||
|
const t = await cli.tokenNew('history');
|
||||||
|
const created = readToken(ws.dbPath, t)!.created_at;
|
||||||
|
await cli.tokenDelete(t);
|
||||||
|
const first = readToken(ws.dbPath, t)!;
|
||||||
|
expectCliFailure(await cli.run(['token', 'delete', t]), { exitCode: 1, message: 'token already inactive' });
|
||||||
|
const second = readToken(ws.dbPath, t)!;
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
expect(second.created_at).toBe(created);
|
||||||
|
expect(second.description).toBe('history');
|
||||||
|
expect(countRows(ws.dbPath, 'tokens')).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 16: every startup/configuration failure and every CLI usage failure, with
|
||||||
|
* exact stderr, exit code (2 usage/config, 1 runtime) and proof that the database was not
|
||||||
|
* created or modified inappropriately.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
expectCliFailure,
|
||||||
|
expectCliSuccess,
|
||||||
|
fileDigest,
|
||||||
|
goQuote,
|
||||||
|
isPortFree,
|
||||||
|
occupyPort,
|
||||||
|
parseLogs,
|
||||||
|
readTokens,
|
||||||
|
USAGE_ERROR_MESSAGES,
|
||||||
|
USAGE_TEXT,
|
||||||
|
} from '../../harness';
|
||||||
|
import { cliOutcome, StateModel } from '../../harness/personas';
|
||||||
|
|
||||||
|
/** Server mode ([]) and a CLI command must both reject a bad configuration identically. */
|
||||||
|
const MODES: Array<[string, string[]]> = [
|
||||||
|
['server mode', []],
|
||||||
|
['token list', ['token', 'list']],
|
||||||
|
['token new', ['token', 'new', 'x']],
|
||||||
|
['audit', ['audit', '5m']],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Configuration is validated before dispatch: also `help` and `token list all` (which never open the database). */
|
||||||
|
const CONFIG_MODES: Array<[string, string[]]> = [...MODES, ['token list all', ['token', 'list', 'all']], ['audit all', ['audit', 'all']], ['help', ['help']], ['help with an argument', ['help', 'x']]];
|
||||||
|
|
||||||
|
const addrMsg = (v: string) => `configuration error: invalid HTTP_ADDR ${goQuote(v)}: must be an IPv4 or IPv6 address`;
|
||||||
|
const portMsg = (v: string) => `configuration error: invalid HTTP_PORT ${goQuote(v)}: must be an integer between 1 and 65535`;
|
||||||
|
const levelMsg = (v: string) => `configuration error: invalid LOG_LEVEL ${goQuote(v)}: must be one of ERROR, WARN, INFO, DEBUG, TRACE`;
|
||||||
|
|
||||||
|
const CONFIG_CASES: Array<[string, Record<string, string | undefined>, string]> = [
|
||||||
|
['DB_PATH unset', { DB_PATH: undefined }, 'configuration error: DB_PATH is required'],
|
||||||
|
['DB_PATH empty', { DB_PATH: '' }, 'configuration error: DB_PATH must not be empty'],
|
||||||
|
['HTTP_ADDR empty', { HTTP_ADDR: '' }, 'configuration error: HTTP_ADDR must not be empty'],
|
||||||
|
...['localhost', 'example.com', '127.0.0.1:8080', '[::1]', '256.0.0.1', '1.2.3', '010.0.0.1', ' 127.0.0.1', '127.0.0.1 ', 'fe80::1%lo', '0x7f.0.0.1', '127.0.0.1\n', '*'].map(
|
||||||
|
(v) => [`HTTP_ADDR=${JSON.stringify(v)}`, { HTTP_ADDR: v }, addrMsg(v)] as [string, Record<string, string>, string],
|
||||||
|
),
|
||||||
|
['HTTP_PORT empty', { HTTP_PORT: '' }, 'configuration error: HTTP_PORT must not be empty'],
|
||||||
|
...['0', '00000', '-1', '65536', '99999999999999999999', 'abc', ' 80', '80 ', '+80', '1e3', '0x50', '80', '80\n', '8O'].map(
|
||||||
|
(v) => [`HTTP_PORT=${JSON.stringify(v)}`, { HTTP_PORT: v }, portMsg(v)] as [string, Record<string, string>, string],
|
||||||
|
),
|
||||||
|
['LOG_LEVEL empty', { LOG_LEVEL: '' }, 'configuration error: LOG_LEVEL must not be empty'],
|
||||||
|
...['VERBOSE', 'INFO ', ' info', 'warning', 'ERR', 'trace\n', 'İNFO', 'INFO', 'debug2', 'FATAL', '0'].map(
|
||||||
|
(v) => [`LOG_LEVEL=${JSON.stringify(v)}`, { LOG_LEVEL: v }, levelMsg(v)] as [string, Record<string, string>, string],
|
||||||
|
),
|
||||||
|
// precedence: DB_PATH, HTTP_ADDR, HTTP_PORT, LOG_LEVEL — first problem wins
|
||||||
|
['DB_PATH missing beats everything', { DB_PATH: undefined, HTTP_ADDR: 'x', HTTP_PORT: 'y', LOG_LEVEL: 'z' }, 'configuration error: DB_PATH is required'],
|
||||||
|
['HTTP_ADDR beats HTTP_PORT and LOG_LEVEL', { HTTP_ADDR: 'x', HTTP_PORT: 'y', LOG_LEVEL: 'z' }, addrMsg('x')],
|
||||||
|
['HTTP_PORT beats LOG_LEVEL', { HTTP_PORT: 'y', LOG_LEVEL: 'z' }, portMsg('y')],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.describe('configuration errors → exit 2, exact message, no database', () => {
|
||||||
|
for (const [name, env, message] of CONFIG_CASES) {
|
||||||
|
test(name, async ({ cli, ws }) => {
|
||||||
|
for (const [mode, args] of CONFIG_MODES) {
|
||||||
|
await test.step(mode, async () => {
|
||||||
|
const r = await cli.run(args, { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1', ...env }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message, usage: false });
|
||||||
|
expect(fs.existsSync(ws.dbPath), 'database not created on configuration error').toBe(false);
|
||||||
|
expect(fs.readdirSync(ws.dir), 'nothing written to the workspace').toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('configuration errors take precedence over usage errors (no usage text)', async ({ cli, ws }) => {
|
||||||
|
const r = await cli.run(['bogus'], { env: { DB_PATH: undefined } });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: 'configuration error: DB_PATH is required', usage: false });
|
||||||
|
const r2 = await cli.run(['token', 'new', 'a', 'b'], { env: { LOG_LEVEL: 'nope' } });
|
||||||
|
expectCliFailure(r2, { exitCode: 2, message: levelMsg('nope'), usage: false });
|
||||||
|
const r3 = await cli.run(['audit', '0'], { env: { HTTP_PORT: '0' } });
|
||||||
|
expectCliFailure(r3, { exitCode: 2, message: portMsg('0'), usage: false });
|
||||||
|
expect(fs.existsSync(ws.dbPath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const USAGE_CASES: Array<[string[], string]> = [
|
||||||
|
[['bogus'], 'unknown command'],
|
||||||
|
[['-h'], 'unknown command'],
|
||||||
|
[['--help'], 'unknown command'],
|
||||||
|
[['Help'], 'unknown command'],
|
||||||
|
[['HELP'], 'unknown command'],
|
||||||
|
[['-help'], 'unknown command'],
|
||||||
|
[['help '], 'unknown command'],
|
||||||
|
[['help', 'x'], 'help takes no arguments'],
|
||||||
|
[['help', ''], 'help takes no arguments'],
|
||||||
|
[['help', 'help'], 'help takes no arguments'],
|
||||||
|
[['help', 'token', 'list'], 'help takes no arguments'],
|
||||||
|
[['token', 'help'], 'unknown token subcommand'],
|
||||||
|
[['audit', 'help'], 'invalid audit argument'],
|
||||||
|
[['Token', 'list'], 'unknown command'],
|
||||||
|
[['TOKEN'], 'unknown command'],
|
||||||
|
[[''], 'unknown command'],
|
||||||
|
[['tokens', 'list'], 'unknown command'],
|
||||||
|
[['token'], 'missing token subcommand'],
|
||||||
|
[['token', 'bogus'], 'unknown token subcommand'],
|
||||||
|
[['token', 'LIST'], 'unknown token subcommand'],
|
||||||
|
[['token', 'New'], 'unknown token subcommand'],
|
||||||
|
[['token', ''], 'unknown token subcommand'],
|
||||||
|
[['token', 'remove', 'x'], 'unknown token subcommand'],
|
||||||
|
[['token', 'list', 'extra'], 'unknown token list argument'],
|
||||||
|
[['token', 'list', ''], 'unknown token list argument'],
|
||||||
|
[['token', 'list', 'ALL'], 'unknown token list argument'],
|
||||||
|
[['token', 'list', 'All'], 'unknown token list argument'],
|
||||||
|
[['token', 'list', '--all'], 'unknown token list argument'],
|
||||||
|
[['token', 'list', 'all '], 'unknown token list argument'],
|
||||||
|
[['token', 'list', 'help'], 'unknown token list argument'],
|
||||||
|
[['token', 'list', 'all', 'all'], 'token list takes at most one argument'],
|
||||||
|
[['token', 'list', 'all', ''], 'token list takes at most one argument'],
|
||||||
|
[['token', 'list', 'x', 'y'], 'token list takes at most one argument'],
|
||||||
|
[['token', 'new', 'a', 'b'], 'token new takes at most one argument'],
|
||||||
|
[['token', 'new', '', ''], 'token new takes at most one argument'],
|
||||||
|
[['token', 'delete'], 'token delete requires exactly one argument'],
|
||||||
|
[['token', 'delete', 'a', 'b'], 'token delete requires exactly one argument'],
|
||||||
|
// audit is case-sensitive and singular; one representative per invalid-argument class
|
||||||
|
// (the exhaustive table is in cli-audit.spec.ts)
|
||||||
|
[['AUDIT'], 'unknown command'],
|
||||||
|
[['Audit', '10'], 'unknown command'],
|
||||||
|
[['audits'], 'unknown command'],
|
||||||
|
[['audit', '1', 'h'], 'audit takes at most one argument'],
|
||||||
|
[['audit', '0'], 'invalid audit argument'],
|
||||||
|
[['audit', '0h'], 'invalid audit argument'],
|
||||||
|
[['audit', '-5'], 'invalid audit argument'],
|
||||||
|
[['audit', ' 5'], 'invalid audit argument'],
|
||||||
|
[['audit', ''], 'invalid audit argument'],
|
||||||
|
[['audit', '5H'], 'invalid audit argument'],
|
||||||
|
[['audit', '5y'], 'invalid audit argument'],
|
||||||
|
[['audit', 'h'], 'invalid audit argument'],
|
||||||
|
[['audit', '1.5'], 'invalid audit argument'],
|
||||||
|
[['audit', '9223372036854775808'], 'invalid audit argument'],
|
||||||
|
[['audit', '15251w'], 'invalid audit argument'],
|
||||||
|
// `audit all`: `all` is exact and lowercase only, and takes no further argument
|
||||||
|
[['audit', 'ALL'], 'invalid audit argument'],
|
||||||
|
[['audit', 'All'], 'invalid audit argument'],
|
||||||
|
[['audit', 'all1'], 'invalid audit argument'],
|
||||||
|
[['audit', '--all'], 'invalid audit argument'],
|
||||||
|
[['audit', 'all', 'x'], 'audit takes at most one argument'],
|
||||||
|
[['audit', 'all', 'all'], 'audit takes at most one argument'],
|
||||||
|
];
|
||||||
|
|
||||||
|
test('the usage table covers every usage error message, and the CLI oracle agrees with it', () => {
|
||||||
|
const model = new StateModel();
|
||||||
|
for (const [args, msg] of USAGE_CASES) expect(cliOutcome(args, model), JSON.stringify(args)).toEqual({ kind: 'error', exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
expect([...new Set(USAGE_CASES.map(([, m]) => m))].sort()).toEqual([...USAGE_ERROR_MESSAGES].sort());
|
||||||
|
expect(cliOutcome(['help'], model)).toEqual({ kind: 'help', exitCode: 0 });
|
||||||
|
expect(cliOutcome(['token', 'list'], model)).toEqual({ kind: 'list', exitCode: 0, scope: 'active' });
|
||||||
|
expect(cliOutcome(['token', 'list', 'all'], model)).toEqual({ kind: 'list', exitCode: 0, scope: 'all' });
|
||||||
|
expect(cliOutcome(['audit', 'all'], model)).toEqual({ kind: 'audit', exitCode: 0, selection: { all: true } });
|
||||||
|
expect(cliOutcome(['audit'], model)).toEqual({ kind: 'audit', exitCode: 0, selection: { limit: 10n } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('help', () => {
|
||||||
|
test('help prints exactly the usage text on stdout, nothing on stderr, exit 0; no database created, an existing one byte-identical', async ({ cli, ws }) => {
|
||||||
|
const r = await cli.help();
|
||||||
|
expect(r.stdout).toBe(USAGE_TEXT);
|
||||||
|
expect(fs.existsSync(ws.dbPath), 'help never opens/creates the database').toBe(false);
|
||||||
|
expect(fs.readdirSync(ws.dir), 'nothing written to the workspace').toEqual([]);
|
||||||
|
// the same bytes as the usage text printed after a usage error
|
||||||
|
const bad = await cli.run(['bogus']);
|
||||||
|
expect(bad.stderr).toBe(`dpsapi: usage error: unknown command\n${r.stdout}`);
|
||||||
|
|
||||||
|
await cli.tokenNew('existing');
|
||||||
|
const digest = fileDigest(ws.dbPath);
|
||||||
|
const rows = JSON.stringify(readTokens(ws.dbPath));
|
||||||
|
await cli.help();
|
||||||
|
expect(fileDigest(ws.dbPath)).toBe(digest);
|
||||||
|
expect(JSON.stringify(readTokens(ws.dbPath))).toBe(rows);
|
||||||
|
// DB_PATH pointing somewhere unusable does not matter: the database is never opened
|
||||||
|
for (const db of [ws.path('missing-dir', 'x.db'), ws.mkdir('a-directory')]) await cli.help({ env: { DB_PATH: db } });
|
||||||
|
expect(fs.existsSync(ws.path('missing-dir'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a configuration error still wins over help: exit 2, the configuration error line only, no usage text, nothing on stdout', async ({ cli, ws }) => {
|
||||||
|
const cases: Array<[Record<string, string | undefined>, string]> = [
|
||||||
|
[{ DB_PATH: undefined }, 'configuration error: DB_PATH is required'],
|
||||||
|
[{ DB_PATH: '' }, 'configuration error: DB_PATH must not be empty'],
|
||||||
|
[{ HTTP_ADDR: 'localhost' }, addrMsg('localhost')],
|
||||||
|
[{ HTTP_PORT: '0' }, portMsg('0')],
|
||||||
|
[{ LOG_LEVEL: 'verbose' }, levelMsg('verbose')],
|
||||||
|
[{ LOG_LEVEL: '' }, 'configuration error: LOG_LEVEL must not be empty'],
|
||||||
|
];
|
||||||
|
for (const [env, message] of cases) {
|
||||||
|
for (const args of [['help'], ['help', 'x']]) {
|
||||||
|
const r = await cli.run(args, { env });
|
||||||
|
expectCliFailure(r, { exitCode: 2, message, usage: false });
|
||||||
|
expect(r.stderr, `${JSON.stringify(args)} ${JSON.stringify(env)}: exact stderr`).toBe(`dpsapi: ${message}\n`);
|
||||||
|
expect(r.stderr.includes('usage:'), 'no usage text').toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(fs.readdirSync(ws.dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('log levels: at DEBUG/TRACE stderr carries only log records (dispatch mode=cli, cli dispatch command=help) while stdout is still exactly the usage text; silent at INFO and above', async ({ cli, ws }) => {
|
||||||
|
for (const level of ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE', 'debug', 'Trace']) {
|
||||||
|
await test.step(level, async () => {
|
||||||
|
const r = await cli.run(['help'], { env: { LOG_LEVEL: level } });
|
||||||
|
expectCliSuccess(r, { stdout: USAGE_TEXT });
|
||||||
|
const verbose = /^(debug|trace)$/i.test(level);
|
||||||
|
if (!verbose) {
|
||||||
|
expect(r.stderr).toBe('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const logs = parseLogs(r.stderr);
|
||||||
|
expect(logs.unparsed.map((u) => u.text), 'every stderr line is a log record (no usage text, no dpsapi: line)').toEqual([]);
|
||||||
|
expect(logs.records.length).toBeGreaterThan(0);
|
||||||
|
expect(logs.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'cli' });
|
||||||
|
expect(logs.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([{ command: 'help' }]);
|
||||||
|
expect(logs.has({ msg: /^database / }), 'no database record').toBe(false);
|
||||||
|
expect(logs.count({ level: 'TRACE' }) > 0, 'TRACE records only at TRACE').toBe(/^trace$/i.test(level));
|
||||||
|
expect(r.stderr.includes('usage:'), 'the usage text is never on stderr').toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(fs.existsSync(ws.dbPath)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('usage errors → exit 2, error line + usage text, database untouched', () => {
|
||||||
|
for (const [args, msg] of USAGE_CASES) {
|
||||||
|
test(`${JSON.stringify(args)} → ${msg}`, async ({ cli, ws }) => {
|
||||||
|
// fresh workspace: the database must not be created
|
||||||
|
const r = await cli.run(args);
|
||||||
|
expectCliFailure(r, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
expect(r.stderr).toBe(`dpsapi: usage error: ${msg}\n${USAGE_TEXT}`);
|
||||||
|
expect(fs.existsSync(ws.dbPath), 'usage errors never open/create the database').toBe(false);
|
||||||
|
|
||||||
|
// existing database: byte-identical afterwards
|
||||||
|
await cli.tokenNew('existing');
|
||||||
|
const digest = fileDigest(ws.dbPath);
|
||||||
|
const rows = JSON.stringify(readTokens(ws.dbPath));
|
||||||
|
const r2 = await cli.run(args);
|
||||||
|
expectCliFailure(r2, { exitCode: 2, message: `usage error: ${msg}`, usage: true });
|
||||||
|
expect(fileDigest(ws.dbPath)).toBe(digest);
|
||||||
|
expect(JSON.stringify(readTokens(ws.dbPath))).toBe(rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('runtime failures → exit 1', () => {
|
||||||
|
test('DB_PATH in a missing directory: not created, exit 1 (server and CLI)', async ({ cli, ws }) => {
|
||||||
|
const db = ws.path('missing-dir', 'x.db');
|
||||||
|
for (const [mode, args] of MODES) {
|
||||||
|
await test.step(mode, async () => {
|
||||||
|
const r = await cli.run(args, { env: { DB_PATH: db, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
|
||||||
|
expect(fs.existsSync(ws.path('missing-dir'))).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DB_PATH is a directory → exit 1', async ({ cli, ws }) => {
|
||||||
|
const dir = ws.mkdir('a-directory');
|
||||||
|
for (const [mode, args] of MODES) {
|
||||||
|
await test.step(mode, async () => {
|
||||||
|
const r = await cli.run(args, { env: { DB_PATH: dir, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
|
||||||
|
expect(fs.readdirSync(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DB_PATH is not a SQLite database → exit 1, file left untouched', async ({ cli, ws, rng }) => {
|
||||||
|
const garbage = ws.writeFile('garbage.db', rng.bytes(8192));
|
||||||
|
const digest = fileDigest(garbage);
|
||||||
|
for (const [mode, args] of MODES) {
|
||||||
|
await test.step(mode, async () => {
|
||||||
|
const r = await cli.run(args, { env: { DB_PATH: garbage, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: file is not a database (26)' });
|
||||||
|
expect(fileDigest(garbage)).toBe(digest);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DB_PATH in a read-only directory → exit 1, nothing created', async ({ cli, ws }) => {
|
||||||
|
const dir = ws.mkdir('readonly');
|
||||||
|
fs.chmodSync(dir, 0o555);
|
||||||
|
try {
|
||||||
|
for (const [mode, args] of MODES) {
|
||||||
|
await test.step(mode, async () => {
|
||||||
|
const r = await cli.run(args, { env: { DB_PATH: `${dir}/x.db`, HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: 'initialize database schema: unable to open database file (14)' });
|
||||||
|
expect(fs.readdirSync(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.chmodSync(dir, 0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port already in use → exit 1 with the listen error; database still valid', async ({ cli, ws, e2e }) => {
|
||||||
|
const port = await e2e.allocatePort();
|
||||||
|
const holder = await occupyPort(port, '127.0.0.1');
|
||||||
|
try {
|
||||||
|
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: String(port) }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 127.0.0.1:${port}: bind: address already in use` });
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 0, operationCount: 0 });
|
||||||
|
} finally {
|
||||||
|
await holder.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('privileged port as an unprivileged user → exit 1 (permission denied)', async ({ cli }) => {
|
||||||
|
const r = await cli.run([], { env: { HTTP_ADDR: '127.0.0.1', HTTP_PORT: '1' }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: 'listen tcp4 127.0.0.1:1: bind: permission denied' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address not assigned to this host → exit 1 (cannot assign requested address)', async ({ cli, e2e }) => {
|
||||||
|
const port = await e2e.allocatePort();
|
||||||
|
const r = await cli.run([], { env: { HTTP_ADDR: '192.0.2.1', HTTP_PORT: String(port) }, timeoutMs: 10_000 });
|
||||||
|
expectCliFailure(r, { exitCode: 1, message: `listen tcp4 192.0.2.1:${port}: bind: cannot assign requested address` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('valid edge-case configuration', () => {
|
||||||
|
test('HTTP_PORT with leading zeros and the 65535 upper bound are accepted', async ({ startServer, e2e }) => {
|
||||||
|
const port = await e2e.allocatePort();
|
||||||
|
const s = await startServer({ port: `000${port}`, logLevel: 'INFO' });
|
||||||
|
expect(s.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`127.0.0.1:${port}`);
|
||||||
|
expect((await s.stop()).exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(await isPortFree(65535), 'port 65535 must be free on this host for this check').toBe(true);
|
||||||
|
const top = await startServer({ port: 65535 });
|
||||||
|
expect(top.logs().find({ msg: 'server listening' })!.attrs.addr).toBe('127.0.0.1:65535');
|
||||||
|
expect((await top.stop()).exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP_ADDR unset defaults to 0.0.0.0 (IPv4 only)', async ({ startServer, http, cli }) => {
|
||||||
|
const token = await cli.tokenNew();
|
||||||
|
const s = await startServer({ addr: null });
|
||||||
|
expect(s.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`0.0.0.0:${s.port}`);
|
||||||
|
expect((await http.get(s.url(), { token })).status).toBe(200);
|
||||||
|
// tcp4 listener: not reachable over IPv6 loopback
|
||||||
|
const v6 = await http.raw({ host: '::1', port: s.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
|
||||||
|
expect(v6.outcome).toBe('refused');
|
||||||
|
expect((await s.stop()).exitCode).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all defaults (0.0.0.0:80, INFO) in a private network namespace', async ({ startServer }) => {
|
||||||
|
const s = await startServer({ addr: null, port: null, logLevel: null, privateNetwork: true });
|
||||||
|
const rec = await s.waitForLog({ msg: 'server listening' });
|
||||||
|
expect(rec.level).toBe('INFO');
|
||||||
|
expect(rec.attrs.addr).toBe('0.0.0.0:80');
|
||||||
|
expect(s.env.HTTP_ADDR).toBeUndefined();
|
||||||
|
expect(s.env.HTTP_PORT).toBeUndefined();
|
||||||
|
expect(s.env.LOG_LEVEL).toBeUndefined();
|
||||||
|
const exit = await s.stop('SIGTERM');
|
||||||
|
expect(exit.exitCode).toBe(0);
|
||||||
|
expect(s.logs().count({ level: ['DEBUG', 'TRACE'] }), 'default level is INFO').toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an existing database is reused by every mode (repeated schema initialization)', async ({ cli, ws, startServer }) => {
|
||||||
|
const t = await cli.tokenNew('first');
|
||||||
|
for (let i = 0; i < 3; i++) expectCliSuccess(await cli.run(['token', 'list']));
|
||||||
|
const s = await startServer();
|
||||||
|
expect((await s.stop()).exitCode).toBe(0);
|
||||||
|
const s2 = await startServer();
|
||||||
|
expect((await s2.stop()).exitCode).toBe(0);
|
||||||
|
const rows = readTokens(ws.dbPath);
|
||||||
|
expect(rows.map((r) => r.token)).toEqual([t]);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 1, operationCount: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core items 2, 3, 14: start with explicit HTTP_ADDR/HTTP_PORT at every LOG_LEVEL and
|
||||||
|
* verify which records must and must not be emitted, for server and CLI mode.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
LOG_LEVELS,
|
||||||
|
LOG_TIME_RE,
|
||||||
|
USAGE_TEXT,
|
||||||
|
levelRank,
|
||||||
|
parseLogs,
|
||||||
|
tokenId,
|
||||||
|
visibleLevels,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
type LogLevel,
|
||||||
|
type LogSet,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
/** Go time.Duration.String() */
|
||||||
|
const GO_DURATION_RE = /^(\d+(\.\d+)?(h|m|s|ms|µs|ns))+$|^0s$/;
|
||||||
|
|
||||||
|
const SERVER_FUNCS = [
|
||||||
|
'app.execute',
|
||||||
|
'app.runServer',
|
||||||
|
'OpenDatabase',
|
||||||
|
'InitializeSchema',
|
||||||
|
'WebServer.Handler',
|
||||||
|
'WebServer.Serve',
|
||||||
|
'WebServer.logRequests',
|
||||||
|
'WebServer.handleContainers',
|
||||||
|
'authenticate',
|
||||||
|
'TokenStore.GetToken',
|
||||||
|
'AuditStore.RecordOperation',
|
||||||
|
'DockerClient.ListRunningContainers',
|
||||||
|
'WebServer.handleNotFound',
|
||||||
|
'WebServer.handleMethodNotAllowed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEBUG_SERVER_MESSAGES = [
|
||||||
|
'configuration resolved',
|
||||||
|
'dispatch',
|
||||||
|
'database schema initialized',
|
||||||
|
'database opened',
|
||||||
|
'docker client created',
|
||||||
|
'routes registered',
|
||||||
|
'authentication result',
|
||||||
|
'audit row inserted',
|
||||||
|
'docker request started',
|
||||||
|
'docker request finished',
|
||||||
|
'shutdown requested, draining http server',
|
||||||
|
'http server shut down',
|
||||||
|
'database closed',
|
||||||
|
];
|
||||||
|
|
||||||
|
function expectOnlyLevels(logs: LogSet, min: LogLevel): void {
|
||||||
|
const allowed = visibleLevels(min);
|
||||||
|
const bad = logs.records.filter((r) => !allowed.includes(r.level as LogLevel));
|
||||||
|
expect(bad.map((r) => r.raw), `records below ${min}`).toEqual([]);
|
||||||
|
for (const r of logs.records) {
|
||||||
|
expect(r.time, `time attribute of ${r.raw}`).toMatch(LOG_TIME_RE);
|
||||||
|
expect(LOG_LEVELS).toContain(r.level as LogLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const level of LOG_LEVELS) {
|
||||||
|
test(`server at LOG_LEVEL=${level}: emitted records match the level exactly`, async ({ cli, startServer, http }) => {
|
||||||
|
const active = await cli.tokenNew('logging probe');
|
||||||
|
const inactive = await cli.tokenNew('to be deleted');
|
||||||
|
await cli.tokenDelete(inactive);
|
||||||
|
const unknown = 'U'.repeat(43);
|
||||||
|
|
||||||
|
const server = await startServer({ logLevel: level, addr: '127.0.0.1' });
|
||||||
|
const fake = server.fakeDocker!.many(2);
|
||||||
|
|
||||||
|
// The same request sequence at every level
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: active }));
|
||||||
|
expectErrorResponse(await http.get(server.url()), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: unknown }), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: inactive }), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url('/nope')), 404);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST' }), 405);
|
||||||
|
fake.error500();
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: active }), 500);
|
||||||
|
fake.many(2);
|
||||||
|
|
||||||
|
const exit = await server.stop('SIGTERM');
|
||||||
|
expect(exit.exitCode).toBe(0);
|
||||||
|
expect(exit.stdout, 'server never writes to stdout').toBe('');
|
||||||
|
|
||||||
|
const logs = server.logs();
|
||||||
|
expect(logs.unparsed, 'every stderr line is a slog record').toEqual([]);
|
||||||
|
expectOnlyLevels(logs, level);
|
||||||
|
const shows = (l: LogLevel) => levelRank(l) >= levelRank(level);
|
||||||
|
|
||||||
|
// ERROR: request failures only
|
||||||
|
expect(logs.filter({ level: 'ERROR' }).map((r) => r.msg)).toEqual(['docker request failed']);
|
||||||
|
const err = logs.find({ level: 'ERROR', msg: 'docker request failed' })!;
|
||||||
|
expect(err.attrs.error, 'diagnostics stay in the logs').toContain('fake engine failure');
|
||||||
|
|
||||||
|
// WARN: nothing is expected from a healthy server
|
||||||
|
expect(logs.count({ level: 'WARN' })).toBe(0);
|
||||||
|
|
||||||
|
// INFO
|
||||||
|
const listening = logs.filter({ level: 'INFO', msg: 'server listening' });
|
||||||
|
const requests = logs.requests();
|
||||||
|
if (shows('INFO')) {
|
||||||
|
expect(listening).toHaveLength(1);
|
||||||
|
expect(listening[0].attrs.addr).toBe(`127.0.0.1:${server.port}`);
|
||||||
|
expect(listening[0].attrs.version).toBeTruthy();
|
||||||
|
expect(listening[0].attrs.commit).toBeTruthy();
|
||||||
|
expect(logs.filter({ level: 'INFO', msg: 'server stopped' })).toHaveLength(1);
|
||||||
|
expect(requests.map((r) => [r.level, r.attrs.method, r.attrs.path, r.attrs.status, r.attrs.origin])).toEqual([
|
||||||
|
['INFO', 'GET', '/api/v1/containers', '200', '127.0.0.1'],
|
||||||
|
['INFO', 'GET', '/api/v1/containers', '401', '127.0.0.1'],
|
||||||
|
['INFO', 'GET', '/api/v1/containers', '401', '127.0.0.1'],
|
||||||
|
['INFO', 'GET', '/api/v1/containers', '401', '127.0.0.1'],
|
||||||
|
['INFO', 'GET', '/nope', '404', '127.0.0.1'],
|
||||||
|
['INFO', 'POST', '/api/v1/containers', '405', '127.0.0.1'],
|
||||||
|
['INFO', 'GET', '/api/v1/containers', '500', '127.0.0.1'],
|
||||||
|
]);
|
||||||
|
for (const r of requests) {
|
||||||
|
expect(r.msg).toBe('this request has been made');
|
||||||
|
expect(r.attrs.duration).toMatch(GO_DURATION_RE);
|
||||||
|
expect(Object.keys(r.attrs).sort()).toEqual(['duration', 'method', 'origin', 'path', 'status']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(listening, 'server listening hidden below INFO').toHaveLength(0);
|
||||||
|
expect(requests, '"this request has been made" hidden below INFO').toHaveLength(0);
|
||||||
|
expect(logs.count({ msg: 'server stopped' })).toBe(0);
|
||||||
|
expect(logs.count({ level: 'INFO' })).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG
|
||||||
|
if (shows('DEBUG')) {
|
||||||
|
for (const m of DEBUG_SERVER_MESSAGES) expect(logs.count({ level: 'DEBUG', msg: m }), `DEBUG "${m}"`).toBeGreaterThan(0);
|
||||||
|
expect(logs.find({ msg: 'configuration resolved' })!.attrs).toEqual({
|
||||||
|
db_path: server.dbPath,
|
||||||
|
http_addr: '127.0.0.1',
|
||||||
|
http_port: String(server.port),
|
||||||
|
log_level: level,
|
||||||
|
});
|
||||||
|
expect(logs.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'server' });
|
||||||
|
expect(logs.find({ msg: 'docker client created' })!.attrs).toEqual({ host: 'unix:///var/run/docker.sock' });
|
||||||
|
expect(logs.find({ msg: 'routes registered' })!.attrs).toEqual({ route: 'GET /api/v1/containers' });
|
||||||
|
expect(logs.filter({ msg: 'authentication result' }).map((r) => r.attrs.result)).toEqual(['ok', 'missing_header', 'unknown_token', 'inactive_token', 'ok']);
|
||||||
|
for (const r of logs.filter({ msg: 'authentication result', attrs: { result: 'ok' } })) expect(r.attrs.token_id).toBe(tokenId(active));
|
||||||
|
expect(logs.filter({ msg: 'audit row inserted' }).map((r) => r.attrs)).toEqual([
|
||||||
|
{ token_id: tokenId(active), origin: '127.0.0.1', operation: 'GET containers' },
|
||||||
|
{ token_id: tokenId(active), origin: '127.0.0.1', operation: 'GET containers' },
|
||||||
|
]);
|
||||||
|
const finished = logs.filter({ msg: 'docker request finished' });
|
||||||
|
expect(finished.map((r) => [r.attrs.ok, r.attrs.containers])).toEqual([
|
||||||
|
['true', '2'],
|
||||||
|
['false', undefined],
|
||||||
|
]);
|
||||||
|
expect(logs.count({ msg: 'docker request started' })).toBe(2);
|
||||||
|
} else {
|
||||||
|
expect(logs.count({ level: 'DEBUG' })).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACE
|
||||||
|
if (shows('TRACE')) {
|
||||||
|
for (const fn of SERVER_FUNCS) {
|
||||||
|
const entries = logs.count({ level: 'TRACE', msg: 'function entry', attrs: { func: fn } });
|
||||||
|
const exits = logs.count({ level: 'TRACE', msg: 'function exit', attrs: { func: fn } });
|
||||||
|
expect(entries, `function entry ${fn}`).toBeGreaterThan(0);
|
||||||
|
expect(exits, `function exit ${fn} balanced with entries`).toBe(entries);
|
||||||
|
}
|
||||||
|
// row-level records: token rows identified by token_id only, audit rows, containers
|
||||||
|
const reads = logs.filter({ level: 'TRACE', msg: 'row read', attrs: { table: 'tokens' } });
|
||||||
|
expect(reads.map((r) => [r.attrs.token_id, r.attrs.active])).toEqual([
|
||||||
|
[tokenId(active), 'true'],
|
||||||
|
[tokenId(inactive), 'false'],
|
||||||
|
[tokenId(active), 'true'],
|
||||||
|
]);
|
||||||
|
expect(reads[0].attrs.description).toBe('logging probe');
|
||||||
|
expect(reads[0].attrs.deleted_at).toBe('<nil>');
|
||||||
|
expect(reads[1].attrs.deleted_at).toMatch(/^\d{4}-.*Z$/);
|
||||||
|
expect(logs.filter({ level: 'TRACE', msg: 'row not found' }).map((r) => r.attrs)).toEqual([{ table: 'tokens', token_id: tokenId(unknown) }]);
|
||||||
|
const inserted = logs.filter({ level: 'TRACE', msg: 'row inserted', attrs: { table: 'operations' } });
|
||||||
|
expect(inserted).toHaveLength(2);
|
||||||
|
for (const r of inserted) {
|
||||||
|
expect(r.attrs.token_id).toBe(tokenId(active));
|
||||||
|
expect(r.attrs.origin).toBe('127.0.0.1');
|
||||||
|
expect(r.attrs.operation).toBe('GET containers');
|
||||||
|
expect(r.attrs.time).toMatch(/^\d{4}-.*Z$/);
|
||||||
|
}
|
||||||
|
expect(logs.filter({ level: 'TRACE', msg: 'container listed' }).map((r) => [r.attrs.names, r.attrs.state])).toEqual([
|
||||||
|
['[/fake-0]', 'running'],
|
||||||
|
['[/fake-1]', 'running'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
expect(logs.count({ level: 'TRACE' })).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets / headers never logged at any level
|
||||||
|
for (const t of [active, inactive, unknown]) expect(server.stderr.includes(t), 'token value in logs').toBe(false);
|
||||||
|
expect(server.stderr).not.toContain('Bearer');
|
||||||
|
expect(server.stderr.toLowerCase()).not.toContain('authorization');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`CLI at LOG_LEVEL=${level}: token commands log exactly what the level allows`, async ({ cli }) => {
|
||||||
|
const env = { LOG_LEVEL: level };
|
||||||
|
const created = await cli.run(['token', 'new', 'cli level probe'], { env });
|
||||||
|
expect(created.exitCode).toBe(0);
|
||||||
|
const token = created.stdout.trim();
|
||||||
|
const listed = await cli.run(['token', 'list'], { env });
|
||||||
|
expect(listed.exitCode).toBe(0);
|
||||||
|
const deleted = await cli.run(['token', 'delete', token], { env });
|
||||||
|
expect(deleted.exitCode).toBe(0);
|
||||||
|
expect(deleted.stdout).toBe('');
|
||||||
|
const listedAll = await cli.run(['token', 'list', 'all'], { env });
|
||||||
|
expect(listedAll.exitCode).toBe(0);
|
||||||
|
const help = await cli.run(['help'], { env });
|
||||||
|
expect(help.exitCode).toBe(0);
|
||||||
|
expect(help.stdout, 'help: stdout is exactly the usage text at every level').toBe(USAGE_TEXT);
|
||||||
|
|
||||||
|
for (const r of [created, listed, deleted, listedAll, help]) {
|
||||||
|
expect(r.stderr.includes(token), 'token never in CLI logs').toBe(false);
|
||||||
|
const logs = parseLogs(r.stderr);
|
||||||
|
expect(logs.unparsed).toEqual([]);
|
||||||
|
expectOnlyLevels(logs, level);
|
||||||
|
if (levelRank(level) >= levelRank('INFO')) expect(r.stderr, 'successful CLI commands are silent at INFO and above').toBe('');
|
||||||
|
}
|
||||||
|
const c = parseLogs(created.stderr);
|
||||||
|
const d = parseLogs(deleted.stderr);
|
||||||
|
if (levelRank(level) <= levelRank('DEBUG')) {
|
||||||
|
expect(c.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'cli' });
|
||||||
|
expect(c.find({ msg: 'cli dispatch' })!.attrs).toEqual({ command: 'token new' });
|
||||||
|
expect(c.find({ msg: 'token created' })!.attrs).toEqual({ token_id: tokenId(token) });
|
||||||
|
expect(c.find({ msg: 'cli command finished' })!.attrs).toEqual({ command: 'token new', ok: 'true' });
|
||||||
|
expect(c.has({ msg: 'database closed' })).toBe(true);
|
||||||
|
expect(parseLogs(listed.stderr).find({ msg: 'cli dispatch' })!.attrs).toEqual({ command: 'token list' });
|
||||||
|
// as implemented: `token list all` dispatches under the same command name
|
||||||
|
expect(parseLogs(listedAll.stderr).find({ msg: 'cli dispatch' })!.attrs).toEqual({ command: 'token list' });
|
||||||
|
expect(d.find({ msg: 'token deactivated' })!.attrs).toEqual({ token_id: tokenId(token) });
|
||||||
|
const h = parseLogs(help.stderr);
|
||||||
|
expect(h.find({ msg: 'dispatch' })!.attrs).toEqual({ mode: 'cli' });
|
||||||
|
expect(h.filter({ msg: 'cli dispatch' }).map((x) => x.attrs)).toEqual([{ command: 'help' }]);
|
||||||
|
expect(h.has({ msg: /^database / }), 'help never opens the database').toBe(false);
|
||||||
|
} else {
|
||||||
|
expect(help.stderr, 'help is silent above DEBUG').toBe('');
|
||||||
|
}
|
||||||
|
if (level === 'TRACE') {
|
||||||
|
for (const fn of ['app.execute', 'app.runTokenCommand', 'OpenDatabase', 'InitializeSchema', 'app.tokenNew', 'TokenStore.CreateToken']) {
|
||||||
|
expect(c.count({ msg: 'function entry', attrs: { func: fn } }), fn).toBe(1);
|
||||||
|
expect(c.count({ msg: 'function exit', attrs: { func: fn } }), fn).toBe(1);
|
||||||
|
}
|
||||||
|
const ins = c.find({ msg: 'row inserted', attrs: { table: 'tokens' } })!;
|
||||||
|
expect(ins.attrs).toMatchObject({ token_id: tokenId(token), description: 'cli level probe', active: 'true', deleted_at: '<nil>' });
|
||||||
|
expect(parseLogs(listed.stderr).find({ msg: 'row read' })!.attrs.token_id).toBe(tokenId(token));
|
||||||
|
// after the deletion: plain `token list` reads no row (SQL filter), `token list all` reads it
|
||||||
|
expect(parseLogs(listedAll.stderr).filter({ msg: 'row read' }).map((x) => x.attrs.token_id)).toEqual([tokenId(token)]);
|
||||||
|
expect(parseLogs(listedAll.stderr).find({ msg: 'row read' })!.attrs).toMatchObject({ active: 'false' });
|
||||||
|
const upd = d.find({ msg: 'row updated', attrs: { table: 'tokens' } })!;
|
||||||
|
expect(upd.attrs).toMatchObject({ token_id: tokenId(token), active_from: 'true', active_to: 'false' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('LOG_LEVEL is ASCII case-insensitive and defaults to INFO', async ({ cli, startServer, http }) => {
|
||||||
|
for (const [value, canonical] of [
|
||||||
|
['trace', 'TRACE'],
|
||||||
|
['Debug', 'DEBUG'],
|
||||||
|
['iNfO', 'INFO'],
|
||||||
|
['warn', 'WARN'],
|
||||||
|
['eRRoR', 'ERROR'],
|
||||||
|
] as const) {
|
||||||
|
const r = await cli.run(['token', 'list'], { env: { LOG_LEVEL: value } });
|
||||||
|
expect(r.exitCode, value).toBe(0);
|
||||||
|
const logs = parseLogs(r.stderr);
|
||||||
|
expectOnlyLevels(logs, canonical);
|
||||||
|
if (levelRank(canonical) <= levelRank('DEBUG')) expect(logs.find({ msg: 'configuration resolved' })!.attrs.log_level).toBe(canonical);
|
||||||
|
else expect(r.stderr).toBe('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset LOG_LEVEL → INFO
|
||||||
|
const token = await cli.tokenNew();
|
||||||
|
const server = await startServer({ logLevel: null });
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token }));
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const logs = server.logs();
|
||||||
|
expectOnlyLevels(logs, 'INFO');
|
||||||
|
expect(logs.requests()).toHaveLength(1);
|
||||||
|
expect(logs.has({ msg: 'server listening' })).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 13: a large token population (300 tokens, created only through the binary)
|
||||||
|
* with listing, deletion, authentication and audit checks (SQLite and the `audit` command) against
|
||||||
|
* that population.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
expectCliFailure,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
formatAuditOutput,
|
||||||
|
mapLimit,
|
||||||
|
readOperations,
|
||||||
|
readTokens,
|
||||||
|
rfc3339ToNanos,
|
||||||
|
} from '../../harness';
|
||||||
|
import { expectedAuditStdout } from '../../harness/personas';
|
||||||
|
|
||||||
|
const N = 300;
|
||||||
|
|
||||||
|
test('300 tokens: create, list, delete, authenticate and audit', async ({ cli, startServer, http, ws, rng }) => {
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
const server = await startServer();
|
||||||
|
server.fakeDocker!.many(1);
|
||||||
|
|
||||||
|
// Created through the binary, 12 CLI processes at a time, while the server is running
|
||||||
|
const descriptions = Array.from({ length: N }, (_, i) => (i % 10 === 0 ? `pop ${i} ünïcødé ✓` : i % 7 === 0 ? '' : `pop ${i}`));
|
||||||
|
const tokens = await mapLimit(descriptions, 12, (d) => cli.tokenNew(d));
|
||||||
|
expect(new Set(tokens).size, 'all tokens distinct').toBe(N);
|
||||||
|
|
||||||
|
// list
|
||||||
|
const rows = await cli.tokenList();
|
||||||
|
expect(rows).toHaveLength(N);
|
||||||
|
const byToken = new Map(rows.map((r) => [r.token, r]));
|
||||||
|
tokens.forEach((t, i) => expect(byToken.get(t)?.description).toBe(descriptions[i]));
|
||||||
|
for (let i = 1; i < rows.length; i++) {
|
||||||
|
const a = rfc3339ToNanos(rows[i - 1].createdAt);
|
||||||
|
const b = rfc3339ToNanos(rows[i].createdAt);
|
||||||
|
expect(a < b || (a === b && rows[i - 1].token < rows[i].token)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(rows.every((r) => r.active && r.deletedAt === null)).toBe(true);
|
||||||
|
|
||||||
|
// delete a random third, 8 at a time
|
||||||
|
const shuffled = rng.shuffle(tokens);
|
||||||
|
const deleted = shuffled.slice(0, 100);
|
||||||
|
const alive = shuffled.slice(100);
|
||||||
|
await mapLimit(deleted, 8, (t) => cli.tokenDelete(t));
|
||||||
|
const after = await cli.tokenListAll();
|
||||||
|
expect(after.filter((r) => !r.active).map((r) => r.token).sort()).toEqual([...deleted].sort());
|
||||||
|
expect(after.filter((r) => r.active)).toHaveLength(200);
|
||||||
|
expect(after.filter((r) => !r.active).every((r) => r.deletedAt !== null)).toBe(true);
|
||||||
|
// plain `token list`: exactly the 200 alive tokens, in the same relative order as `token list all`
|
||||||
|
const activeOnly = await cli.tokenList();
|
||||||
|
expect(activeOnly.map((r) => r.token)).toEqual(after.filter((r) => r.active).map((r) => r.token));
|
||||||
|
expect(new Set(activeOnly.map((r) => r.token))).toEqual(new Set(alive));
|
||||||
|
|
||||||
|
// authenticate: 60 alive (200), 40 deleted (401), 20 unknown (401), concurrently
|
||||||
|
const probes = rng.shuffle([
|
||||||
|
...alive.slice(0, 60).map((t) => ({ t, ok: true })),
|
||||||
|
...deleted.slice(0, 40).map((t) => ({ t, ok: false })),
|
||||||
|
...Array.from({ length: 20 }, () => ({ t: rng.string(43, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'), ok: false })),
|
||||||
|
]);
|
||||||
|
await mapLimit(probes, 10, async (p) => {
|
||||||
|
const r = await http.get(server.url(), { token: p.t });
|
||||||
|
if (p.ok) expectContainersResponse(r);
|
||||||
|
else expectErrorResponse(r, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
// deleting already-deleted tokens fails and changes nothing
|
||||||
|
const snapshot = JSON.stringify(readTokens(ws.dbPath));
|
||||||
|
for (const t of deleted.slice(0, 10)) expectCliFailure(await cli.run(['token', 'delete', t]), { exitCode: 1, message: 'token already inactive' });
|
||||||
|
expect(JSON.stringify(readTokens(ws.dbPath))).toBe(snapshot);
|
||||||
|
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
|
||||||
|
// audit: exactly one row per successful probe, each for the right token
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
expect(ops.map((o) => o.token).sort()).toEqual(alive.slice(0, 60).sort());
|
||||||
|
expect(server.logs().requests().filter((r) => r.attrs.status === '200')).toHaveLength(60);
|
||||||
|
expect(server.logs().requests().filter((r) => r.attrs.status === '401')).toHaveLength(60);
|
||||||
|
// `audit` over the population: all 60 rows, the default 10, and a window holding every row
|
||||||
|
expect(formatAuditOutput(await cli.audit('60'))).toBe(expectedAuditStdout(ops, { limit: 60n }));
|
||||||
|
expect(formatAuditOutput(await cli.audit())).toBe(expectedAuditStdout(ops, { limit: 10n }));
|
||||||
|
expect((await cli.audit('1h')).map((r) => r.token).sort()).toEqual(alive.slice(0, 60).sort());
|
||||||
|
const report = assertDbInvariants(ws.dbPath, { tokenCount: N, operationCount: 60, auditBeforeDeletion: true });
|
||||||
|
expect(report).toEqual({ tokens: N, activeTokens: 200, inactiveTokens: 100, operations: 60 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 11 (server side): deleting tokens through the binary while the server runs —
|
||||||
|
* deleted_at populated, active=0, history retained, 401 afterwards, audit rows keep satisfying the
|
||||||
|
* foreign key; credential rotation; tokens created while the server runs are usable immediately.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
expectCliFailure,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
msToNanos,
|
||||||
|
readOperations,
|
||||||
|
readToken,
|
||||||
|
rfc3339ToNanos,
|
||||||
|
tokenId,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
test('delete while serving: 401 afterwards, history and audit rows retained', async ({ cli, startServer, http, ws }) => {
|
||||||
|
const a = await cli.tokenNew('to delete');
|
||||||
|
const b = await cli.tokenNew('survivor');
|
||||||
|
const server = await startServer({ logLevel: 'DEBUG' });
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: a }));
|
||||||
|
expectContainersResponse(await http.curl({ url: server.url(), token: a }));
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: b }));
|
||||||
|
const createdA = readToken(ws.dbPath, a)!.created_at;
|
||||||
|
|
||||||
|
const t0 = msToNanos(Date.now() - 1);
|
||||||
|
await cli.tokenDelete(a);
|
||||||
|
const t1 = msToNanos(Date.now() + 1);
|
||||||
|
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: a }), 401);
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(), token: a }), 401);
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: b }));
|
||||||
|
|
||||||
|
const rowA = readToken(ws.dbPath, a)!;
|
||||||
|
expect(rowA.active).toBe(0);
|
||||||
|
expect(rowA.created_at).toBe(createdA);
|
||||||
|
expect(rowA.description).toBe('to delete');
|
||||||
|
const del = rfc3339ToNanos(rowA.deleted_at!);
|
||||||
|
expect(del >= t0 && del <= t1).toBe(true);
|
||||||
|
expect(readToken(ws.dbPath, b)!.active).toBe(1);
|
||||||
|
|
||||||
|
const ops = readOperations(ws.dbPath);
|
||||||
|
expect(ops.map((o) => o.token)).toEqual([a, a, b, b]);
|
||||||
|
for (const o of ops.filter((x) => x.token === a)) expect(rfc3339ToNanos(o.time) < del).toBe(true);
|
||||||
|
|
||||||
|
const list = await cli.tokenListAll();
|
||||||
|
expect(list.map((r) => [r.token, r.active, r.deletedAt])).toEqual([
|
||||||
|
[a, false, rowA.deleted_at],
|
||||||
|
[b, true, null],
|
||||||
|
]);
|
||||||
|
expect((await cli.tokenList()).map((r) => [r.token, r.active, r.deletedAt]), 'token list: the active token only').toEqual([[b, true, null]]);
|
||||||
|
expectCliFailure(await cli.run(['token', 'delete', a]), { exitCode: 1, message: 'token already inactive' });
|
||||||
|
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const classes = server.logs().filter({ msg: 'authentication result' }).map((r) => [r.attrs.result, r.attrs.token_id]);
|
||||||
|
expect(classes).toEqual([
|
||||||
|
['ok', tokenId(a)],
|
||||||
|
['ok', tokenId(a)],
|
||||||
|
['ok', tokenId(b)],
|
||||||
|
['inactive_token', undefined],
|
||||||
|
['inactive_token', undefined],
|
||||||
|
['ok', tokenId(b)],
|
||||||
|
]);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 2, operationCount: 4, auditBeforeDeletion: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credential rotation: new token works immediately, old one stops working, nothing restarts', async ({ cli, startServer, http, ws }) => {
|
||||||
|
const server = await startServer();
|
||||||
|
const old = await cli.tokenNew('rotation v1');
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: old }));
|
||||||
|
for (let v = 2; v <= 4; v++) {
|
||||||
|
const next = await cli.tokenNew(`rotation v${v}`); // created while the server runs
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: next }));
|
||||||
|
await cli.tokenDelete(v === 2 ? old : (await cli.tokenList()).find((r) => r.description === `rotation v${v - 1}`)!.token);
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: next }));
|
||||||
|
}
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: old }), 401);
|
||||||
|
const rows = await cli.tokenListAll();
|
||||||
|
expect(rows.map((r) => [r.description, r.active])).toEqual([
|
||||||
|
['rotation v1', false],
|
||||||
|
['rotation v2', false],
|
||||||
|
['rotation v3', false],
|
||||||
|
['rotation v4', true],
|
||||||
|
]);
|
||||||
|
expect((await cli.tokenList()).map((r) => r.description), 'token list: only the current credential').toEqual(['rotation v4']);
|
||||||
|
expect(server.pid, 'same process throughout').toBe(server.pid);
|
||||||
|
expect(server.generation).toBe(1);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 4, operationCount: 7, auditBeforeDeletion: true });
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exit codes
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitFailure = 1
|
||||||
|
exitUsage = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// errUsage marks command-line usage errors (exit code 2, usage text printed)
|
||||||
|
var errUsage = errors.New("usage error")
|
||||||
|
|
||||||
|
// usageText is printed to stderr after a usage error and to stdout by `help`
|
||||||
|
const usageText = `usage:
|
||||||
|
dpsapi start the HTTP server
|
||||||
|
dpsapi help print this usage text
|
||||||
|
dpsapi token list [all] list the active tokens, or every token with "all"
|
||||||
|
dpsapi token new [description] create a token and print it
|
||||||
|
dpsapi token delete <token> deactivate a token
|
||||||
|
dpsapi audit print the latest 10 audit rows
|
||||||
|
dpsapi audit <n> print the latest n audit rows
|
||||||
|
dpsapi audit <n><unit> print the audit rows of the last n units (s, m, h, d, w)
|
||||||
|
dpsapi audit all print every audit row
|
||||||
|
`
|
||||||
|
|
||||||
|
// usageErrorf builds an error wrapping errUsage
|
||||||
|
func usageErrorf(format string, args ...any) error {
|
||||||
|
return fmt.Errorf("%w: "+format, append([]any{errUsage}, args...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dockerConn is a closable container lister
|
||||||
|
type dockerConn interface {
|
||||||
|
ContainerLister
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// app holds the process-level dependencies; every field is replaceable in tests
|
||||||
|
type app struct {
|
||||||
|
lookupEnv func(string) (string, bool)
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
now func() time.Time
|
||||||
|
generateToken func() (string, error)
|
||||||
|
newDocker func(log *slog.Logger) (dockerConn, error)
|
||||||
|
listen func(network, address string) (net.Listener, error)
|
||||||
|
shutdownTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// newApp returns an app wired with the production dependencies
|
||||||
|
func newApp(lookupEnv func(string) (string, bool), stdout, stderr io.Writer) *app {
|
||||||
|
return &app{
|
||||||
|
lookupEnv: lookupEnv,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
now: time.Now,
|
||||||
|
generateToken: GenerateToken,
|
||||||
|
newDocker: func(log *slog.Logger) (dockerConn, error) {
|
||||||
|
return NewDockerClient(dockerHost, log)
|
||||||
|
},
|
||||||
|
listen: net.Listen,
|
||||||
|
shutdownTimeout: defaultShutdownTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(os.Args[1:], os.LookupEnv, os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// run is the process entry point: it wires SIGINT/SIGTERM to cancellation and returns the exit code
|
||||||
|
func run(args []string, lookupEnv func(string) (string, bool), stdout, stderr io.Writer) int {
|
||||||
|
// Create a context cancelled by the OS signals
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
return newApp(lookupEnv, stdout, stderr).run(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// run executes the command and maps its outcome to the exit code
|
||||||
|
// The "dpsapi: ..." error line is therefore always the last line written to stderr
|
||||||
|
func (a *app) run(ctx context.Context, args []string) int {
|
||||||
|
return a.exitCode(a.execute(ctx, args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute loads the configuration, initializes the logger and dispatches on the arguments
|
||||||
|
func (a *app) execute(ctx context.Context, args []string) (err error) {
|
||||||
|
// Load and validate the configuration before anything else
|
||||||
|
cfg, err := LoadConfig(a.lookupEnv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the logger
|
||||||
|
log := NewLogger(a.stderr, cfg.LogLevel)
|
||||||
|
defer traceFunc(ctx, log, "app.execute")()
|
||||||
|
log.DebugContext(ctx, "configuration resolved",
|
||||||
|
"db_path", cfg.DBPath, "http_addr", cfg.HTTPAddr, "http_port", cfg.HTTPPort, "log_level", LevelName(cfg.LogLevel))
|
||||||
|
|
||||||
|
// Dispatch
|
||||||
|
switch {
|
||||||
|
case len(args) == 0:
|
||||||
|
log.DebugContext(ctx, "dispatch", "mode", "server")
|
||||||
|
err = a.runServer(ctx, cfg, log)
|
||||||
|
case args[0] == "help":
|
||||||
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
||||||
|
err = a.runHelpCommand(ctx, log, args[1:])
|
||||||
|
case args[0] == "token":
|
||||||
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
||||||
|
err = a.runTokenCommand(ctx, cfg, log, args[1:])
|
||||||
|
case args[0] == "audit":
|
||||||
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
||||||
|
err = a.runAuditCommand(ctx, cfg, log, args[1:])
|
||||||
|
default:
|
||||||
|
err = usageErrorf("unknown command")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHelpCommand prints the usage text to stdout; the database is never opened
|
||||||
|
func (a *app) runHelpCommand(ctx context.Context, log *slog.Logger, args []string) error {
|
||||||
|
defer traceFunc(ctx, log, "app.runHelpCommand")()
|
||||||
|
|
||||||
|
if len(args) != 0 {
|
||||||
|
return usageErrorf("help takes no arguments")
|
||||||
|
}
|
||||||
|
log.DebugContext(ctx, "cli dispatch", "command", "help")
|
||||||
|
if _, err := fmt.Fprint(a.stdout, usageText); err != nil {
|
||||||
|
return fmt.Errorf("write output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exitCode prints err (if any) to stderr as a single line and maps it to the process exit code
|
||||||
|
func (a *app) exitCode(err error) int {
|
||||||
|
if err == nil {
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
msg := strings.NewReplacer("\r", " ", "\n", " ").Replace(err.Error())
|
||||||
|
fmt.Fprintf(a.stderr, "dpsapi: %s\n", msg)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errUsage):
|
||||||
|
fmt.Fprint(a.stderr, usageText)
|
||||||
|
return exitUsage
|
||||||
|
case errors.Is(err, errConfig):
|
||||||
|
return exitUsage
|
||||||
|
default:
|
||||||
|
return exitFailure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeDatabase closes the pool, keeping the first error
|
||||||
|
func closeDatabase(ctx context.Context, db *sql.DB, log *slog.Logger, err *error) {
|
||||||
|
if cerr := db.Close(); cerr != nil && *err == nil {
|
||||||
|
*err = fmt.Errorf("close database: %w", cerr)
|
||||||
|
}
|
||||||
|
log.DebugContext(ctx, "database closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runServer runs the HTTP server until ctx is cancelled
|
||||||
|
func (a *app) runServer(ctx context.Context, cfg Config, log *slog.Logger) (err error) {
|
||||||
|
defer traceFunc(ctx, log, "app.runServer")()
|
||||||
|
|
||||||
|
// Open the database
|
||||||
|
db, err := OpenDatabase(ctx, cfg.DBPath, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer closeDatabase(ctx, db, log, &err)
|
||||||
|
tokens := NewTokenStore(db, log, a.now, a.generateToken)
|
||||||
|
audit := NewAuditStore(db, log, a.now)
|
||||||
|
|
||||||
|
// Create the Docker client (lazy: no connection is made yet)
|
||||||
|
docker, err := a.newDocker(log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer docker.Close()
|
||||||
|
log.DebugContext(ctx, "docker client created", "host", dockerHost)
|
||||||
|
|
||||||
|
// Create the web server and register the routes
|
||||||
|
ws := NewWebServer(log, tokens, audit, docker)
|
||||||
|
|
||||||
|
// Listen in the foreground
|
||||||
|
ln, err := a.listen(cfg.ListenNetwork(), cfg.ListenAddress())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ws.Serve(ctx, ln, a.shutdownTimeout); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.InfoContext(ctx, "server stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+399
@@ -0,0 +1,399 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverHarness runs app.run in server mode with an ephemeral listener and a fake Docker client
|
||||||
|
type serverHarness struct {
|
||||||
|
t *testing.T
|
||||||
|
path string
|
||||||
|
docker *fakeDocker
|
||||||
|
stderr *syncBuffer
|
||||||
|
stdout *syncBuffer
|
||||||
|
cancel context.CancelFunc
|
||||||
|
exited chan struct{}
|
||||||
|
code int
|
||||||
|
addr string
|
||||||
|
network string
|
||||||
|
reqAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func startServer(t *testing.T, env map[string]string, configure func(a *app)) *serverHarness {
|
||||||
|
t.Helper()
|
||||||
|
h := &serverHarness{
|
||||||
|
t: t,
|
||||||
|
path: testDBPath(t),
|
||||||
|
docker: &fakeDocker{},
|
||||||
|
stderr: &syncBuffer{},
|
||||||
|
stdout: &syncBuffer{},
|
||||||
|
exited: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if env == nil {
|
||||||
|
env = map[string]string{}
|
||||||
|
}
|
||||||
|
if _, ok := env["DB_PATH"]; !ok {
|
||||||
|
env["DB_PATH"] = h.path
|
||||||
|
}
|
||||||
|
h.path = env["DB_PATH"]
|
||||||
|
a := newApp(envMap(env), h.stdout, h.stderr)
|
||||||
|
ready := make(chan string, 1)
|
||||||
|
a.newDocker = func(*slog.Logger) (dockerConn, error) { return h.docker, nil }
|
||||||
|
a.listen = func(network, address string) (net.Listener, error) {
|
||||||
|
h.network, h.reqAddr = network, address
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err == nil {
|
||||||
|
ready <- ln.Addr().String()
|
||||||
|
}
|
||||||
|
return ln, err
|
||||||
|
}
|
||||||
|
if configure != nil {
|
||||||
|
configure(a)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
h.cancel = cancel
|
||||||
|
go func() {
|
||||||
|
h.code = a.run(ctx, nil)
|
||||||
|
close(h.exited)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case h.addr = <-ready:
|
||||||
|
case <-h.exited:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("server did not start")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-h.exited:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *serverHarness) wait() int {
|
||||||
|
h.t.Helper()
|
||||||
|
select {
|
||||||
|
case <-h.exited:
|
||||||
|
return h.code
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
h.t.Fatal("server did not exit")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *serverHarness) get(path string, header http.Header) (*http.Response, string) {
|
||||||
|
h.t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", "http://"+h.addr+path, nil)
|
||||||
|
if header != nil {
|
||||||
|
req.Header = header
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
h.t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerGracefulShutdown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := testDBPath(t)
|
||||||
|
tok := strings.TrimSpace(cli(t, path, nil, nil, "token", "new", "srv").stdout)
|
||||||
|
h := startServer(t, map[string]string{"DB_PATH": path, "HTTP_ADDR": "127.0.0.1", "HTTP_PORT": "8080", "LOG_LEVEL": "DEBUG"}, nil)
|
||||||
|
if h.network != "tcp4" || h.reqAddr != "127.0.0.1:8080" {
|
||||||
|
t.Fatalf("listen(%q, %q)", h.network, h.reqAddr)
|
||||||
|
}
|
||||||
|
h.docker.containers = makeContainers(1)
|
||||||
|
|
||||||
|
resp, body := h.get("/api/v1/containers", bearer(tok))
|
||||||
|
if resp.StatusCode != 200 || !strings.HasPrefix(body, `[{"Id":"`) {
|
||||||
|
t.Fatalf("got %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
resp, body = h.get("/api/v1/containers", nil)
|
||||||
|
if resp.StatusCode != 401 || body != `{"error":"unauthorized"}` {
|
||||||
|
t.Fatalf("got %d %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.cancel()
|
||||||
|
if code := h.wait(); code != 0 {
|
||||||
|
t.Fatalf("exit code %d, stderr:\n%s", code, h.stderr.String())
|
||||||
|
}
|
||||||
|
if h.stdout.String() != "" {
|
||||||
|
t.Fatalf("server wrote to stdout: %q", h.stdout.String())
|
||||||
|
}
|
||||||
|
out := h.stderr.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`level=DEBUG msg="configuration resolved"`,
|
||||||
|
`level=DEBUG msg=dispatch mode=server`,
|
||||||
|
`level=DEBUG msg="database opened"`,
|
||||||
|
`level=DEBUG msg="docker client created" host=unix:///var/run/docker.sock`,
|
||||||
|
`level=DEBUG msg="routes registered" route="GET /api/v1/containers"`,
|
||||||
|
`level=INFO msg="server listening" addr=` + h.addr + ` version=latest commit=undefined`,
|
||||||
|
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=200`,
|
||||||
|
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=401`,
|
||||||
|
`level=DEBUG msg="shutdown requested, draining http server" timeout=15s`,
|
||||||
|
`level=DEBUG msg="http server shut down"`,
|
||||||
|
`level=INFO msg="server stopped"`,
|
||||||
|
`level=DEBUG msg="database closed"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing %q in:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, tok) {
|
||||||
|
t.Fatal("token leaked into server logs")
|
||||||
|
}
|
||||||
|
if !h.docker.closed {
|
||||||
|
t.Fatal("docker client not closed")
|
||||||
|
}
|
||||||
|
// The audit row is in the database after shutdown
|
||||||
|
db := openTestDBAt(t, path)
|
||||||
|
if n := countRows(t, db, "operations"); n != 1 {
|
||||||
|
t.Fatalf("operations = %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDBAt(t *testing.T, path string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := OpenDatabase(context.Background(), path, traceDiscard())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerInfoLevelOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := startServer(t, nil, nil)
|
||||||
|
h.get("/nope", nil)
|
||||||
|
h.cancel()
|
||||||
|
if code := h.wait(); code != 0 {
|
||||||
|
t.Fatalf("exit %d", code)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(h.stderr.String()), "\n")
|
||||||
|
if len(lines) != 3 ||
|
||||||
|
!strings.Contains(lines[0], `level=INFO msg="server listening"`) ||
|
||||||
|
!strings.Contains(lines[1], `level=INFO msg="this request has been made" method=GET path=/nope status=404 duration=`) ||
|
||||||
|
!strings.Contains(lines[1], ` origin=127.0.0.1`) ||
|
||||||
|
!strings.Contains(lines[2], `level=INFO msg="server stopped"`) {
|
||||||
|
t.Fatalf("unexpected INFO output:\n%s", h.stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerErrorLevelIsSilent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := startServer(t, map[string]string{"LOG_LEVEL": "error"}, nil)
|
||||||
|
h.get("/nope", nil)
|
||||||
|
h.cancel()
|
||||||
|
if code := h.wait(); code != 0 || h.stderr.String() != "" {
|
||||||
|
t.Fatalf("exit %d stderr %q", code, h.stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingListener returns a permanent error from Accept
|
||||||
|
type failingListener struct {
|
||||||
|
net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func (failingListener) Accept() (net.Conn, error) { return nil, errors.New("accept exploded") }
|
||||||
|
func (failingListener) Close() error { return nil }
|
||||||
|
func (failingListener) Addr() net.Addr { return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1} }
|
||||||
|
|
||||||
|
func TestServerErrorPropagation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
stderr := &syncBuffer{}
|
||||||
|
a := newApp(envMap(map[string]string{"DB_PATH": testDBPath(t)}), io.Discard, stderr)
|
||||||
|
a.newDocker = func(*slog.Logger) (dockerConn, error) { return &fakeDocker{}, nil }
|
||||||
|
a.listen = func(string, string) (net.Listener, error) { return failingListener{}, nil }
|
||||||
|
code := a.run(context.Background(), nil)
|
||||||
|
if code != 1 || !strings.HasSuffix(stderr.String(), "dpsapi: http server: accept exploded\n") {
|
||||||
|
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerListenFailurePortInUse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
busy, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer busy.Close()
|
||||||
|
port := busy.Addr().(*net.TCPAddr).Port
|
||||||
|
stderr := &syncBuffer{}
|
||||||
|
a := newApp(envMap(map[string]string{
|
||||||
|
"DB_PATH": testDBPath(t), "HTTP_ADDR": "127.0.0.1", "HTTP_PORT": strconv.Itoa(port),
|
||||||
|
}), io.Discard, stderr)
|
||||||
|
a.newDocker = func(*slog.Logger) (dockerConn, error) { return &fakeDocker{}, nil }
|
||||||
|
code := a.run(context.Background(), nil)
|
||||||
|
want := fmt.Sprintf("dpsapi: listen tcp4 127.0.0.1:%d: bind: address already in use\n", port)
|
||||||
|
if code != 1 || stderr.String() != want {
|
||||||
|
t.Fatalf("code=%d stderr=%q want %q", code, stderr.String(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerShutdownFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
path := testDBPath(t)
|
||||||
|
tok := strings.TrimSpace(cli(t, path, nil, nil, "token", "new").stdout)
|
||||||
|
release := make(chan struct{})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
h := startServer(t, map[string]string{"DB_PATH": path}, func(a *app) { a.shutdownTimeout = 50 * time.Millisecond })
|
||||||
|
var once sync.Once
|
||||||
|
h.docker.onList = func(ctx context.Context) {
|
||||||
|
once.Do(func() { close(entered) })
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
defer close(release)
|
||||||
|
|
||||||
|
// Start a request that stays in flight
|
||||||
|
go func() {
|
||||||
|
req, _ := http.NewRequest("GET", "http://"+h.addr+"/api/v1/containers", nil)
|
||||||
|
req.Header = bearer(tok)
|
||||||
|
if resp, err := http.DefaultClient.Do(req); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("request did not reach Docker")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.cancel()
|
||||||
|
code := h.wait()
|
||||||
|
if code != 1 || !strings.HasSuffix(h.stderr.String(), "dpsapi: http server shutdown: context deadline exceeded\n") {
|
||||||
|
t.Fatalf("code=%d stderr=%q", code, h.stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerStartupDatabaseFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
stderr := &syncBuffer{}
|
||||||
|
a := newApp(envMap(map[string]string{"DB_PATH": t.TempDir()}), io.Discard, stderr)
|
||||||
|
listened := false
|
||||||
|
a.listen = func(string, string) (net.Listener, error) { listened = true; return nil, errors.New("unreachable") }
|
||||||
|
code := a.run(context.Background(), nil)
|
||||||
|
if code != 1 || listened || !strings.HasPrefix(stderr.String(), "dpsapi: initialize database schema: ") {
|
||||||
|
t.Fatalf("code=%d listened=%v stderr=%q", code, listened, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerDockerClientFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
stderr := &syncBuffer{}
|
||||||
|
a := newApp(envMap(map[string]string{"DB_PATH": testDBPath(t)}), io.Discard, stderr)
|
||||||
|
a.newDocker = func(*slog.Logger) (dockerConn, error) { return nil, errors.New("create docker client: bad host") }
|
||||||
|
code := a.run(context.Background(), nil)
|
||||||
|
if code != 1 || stderr.String() != "dpsapi: create docker client: bad host\n" {
|
||||||
|
t.Fatalf("code=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerProductionDockerFactory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// The default factory builds a lazy client for the production socket without connecting
|
||||||
|
a := newApp(envMap(nil), io.Discard, io.Discard)
|
||||||
|
d, err := a.newDocker(traceDiscard())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
if _, ok := d.(*DockerClient); !ok {
|
||||||
|
t.Fatalf("factory returned %T", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunSIGTERM drives the real entry point and delivers a real SIGTERM to the test process
|
||||||
|
// It is deliberately not parallel; the signal is sent only once the server is confirmed to be serving,
|
||||||
|
// so signal.NotifyContext is registered and the signal cannot terminate the test binary
|
||||||
|
func TestRunSIGTERM(t *testing.T) {
|
||||||
|
probe, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := probe.Addr().(*net.TCPAddr).Port
|
||||||
|
probe.Close()
|
||||||
|
|
||||||
|
stderr := &syncBuffer{}
|
||||||
|
env := envMap(map[string]string{"DB_PATH": testDBPath(t), "HTTP_ADDR": "127.0.0.1", "HTTP_PORT": strconv.Itoa(port)})
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() { done <- run(nil, env, io.Discard, stderr) }()
|
||||||
|
|
||||||
|
// Wait until our server answers (a 404 JSON body proves it is ours)
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/ready-probe", port))
|
||||||
|
if err == nil {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if string(body) == bodyNotFound {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
t.Fatalf("server exited early with %d: %s", code, stderr.String())
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("server not ready: %s", stderr.String())
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code %d: %s", code, stderr.String())
|
||||||
|
}
|
||||||
|
case <-time.After(20 * time.Second):
|
||||||
|
t.Fatal("server did not stop on SIGTERM")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `msg="server stopped"`) {
|
||||||
|
t.Fatalf("stderr:\n%s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExitCodeMapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, c := range []struct {
|
||||||
|
err error
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{nil, 0},
|
||||||
|
{usageErrorf("x"), 2},
|
||||||
|
{configErrorf("x"), 2},
|
||||||
|
{ErrTokenNotFound, 1},
|
||||||
|
{ErrTokenInactive, 1},
|
||||||
|
{errors.New("anything"), 1},
|
||||||
|
} {
|
||||||
|
a := newApp(envMap(nil), io.Discard, io.Discard)
|
||||||
|
if got := a.exitCode(c.err); got != c.code {
|
||||||
|
t.Errorf("exitCode(%v) = %d, want %d", c.err, got, c.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user