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

400 lines
12 KiB
Go

// 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)
}
}
}