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
+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