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.
400 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|