feat(api): add authenticated containers http api

GET /api/v1/containers behind bearer token auth, JSON errors for 401/404/405/500, one audit row per authenticated request, graceful shutdown; unit and e2e tests for auth, routing, auditing, request logging and server lifecycle.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 9a85e9acc8
commit b792ad256e
11 files changed
+2028

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"log/slog"
"math"
"regexp"
"strings"
"testing"
"time"
)
func TestLevelOrdering(t *testing.T) {
t.Parallel()
ordered := []slog.Level{LevelTrace, slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError}
for i := 1; i < len(ordered); i++ {
if !(ordered[i-1] < ordered[i]) {
t.Fatalf("level %v must be below %v", ordered[i-1], ordered[i])
}
}
if LevelTrace != slog.Level(-8) {
t.Fatalf("LevelTrace = %d", LevelTrace)
}
}
func TestParseLogLevel(t *testing.T) {
t.Parallel()
for name, want := range map[string]slog.Level{
"ERROR": slog.LevelError, "WARN": slog.LevelWarn, "INFO": slog.LevelInfo,
"DEBUG": slog.LevelDebug, "TRACE": LevelTrace, "trace": LevelTrace, "Debug": slog.LevelDebug,
} {
got, err := ParseLogLevel(name)
if err != nil || got != want {
t.Errorf("ParseLogLevel(%q) = %v, %v", name, got, err)
}
}
for _, name := range []string{"", "WARNING", "TRACEE", "TRAC", "ınfo"} {
if _, err := ParseLogLevel(name); err == nil {
t.Errorf("ParseLogLevel(%q) should fail", name)
}
}
}
func TestLevelName(t *testing.T) {
t.Parallel()
for level, want := range map[slog.Level]string{
slog.LevelError: "ERROR", slog.LevelWarn: "WARN", slog.LevelInfo: "INFO",
slog.LevelDebug: "DEBUG", LevelTrace: "TRACE", slog.Level(2): "INFO+2", slog.Level(-12): "DEBUG-8",
} {
if got := LevelName(level); got != want {
t.Errorf("LevelName(%d) = %q, want %q", level, got, want)
}
}
}
// emitAll writes one record per supported level
func emitAll(log *slog.Logger) {
ctx := context.Background()
trace(ctx, log, "trace-record")
log.Debug("debug-record")
log.Info("info-record")
log.Warn("warn-record")
log.Error("error-record")
}
func TestLoggerFiltering(t *testing.T) {
t.Parallel()
all := []string{"trace", "debug", "info", "warn", "error"}
levels := map[slog.Level]int{ // index of the lowest emitted record
LevelTrace: 0, slog.LevelDebug: 1, slog.LevelInfo: 2, slog.LevelWarn: 3, slog.LevelError: 4,
}
for level, lowest := range levels {
log, buf := newTestLogger(level)
emitAll(log)
out := buf.String()
for i, name := range all {
present := strings.Contains(out, "msg="+name+"-record")
if present != (i >= lowest) {
t.Errorf("level %s: record %s present=%v", LevelName(level), name, present)
}
if present && !strings.Contains(out, "level="+strings.ToUpper(name)+" msg="+name+"-record") {
t.Errorf("level %s: record %s has wrong level label:\n%s", LevelName(level), name, out)
}
}
}
}
func TestLoggerFormat(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
trace(context.Background(), log, "hello", "k", "v")
line := strings.TrimSuffix(buf.String(), "\n")
re := regexp.MustCompile(`^time=\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z level=TRACE msg=hello k=v$`)
if !re.MatchString(line) {
t.Fatalf("unexpected line %q", line)
}
}
func TestLoggerEmitsRequestMessageAtInfo(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(slog.LevelInfo)
s := NewWebServer(log, &fakeLookup{}, &fakeAudit{}, &fakeDocker{})
rec := doRequest(s, "GET", "/nope", nil)
if rec.Code != 404 {
t.Fatalf("status %d", rec.Code)
}
if !strings.Contains(buf.String(), `level=INFO msg="this request has been made"`) {
t.Fatalf("missing request message:\n%s", buf.String())
}
if strings.Contains(buf.String(), "level=DEBUG") || strings.Contains(buf.String(), "level=TRACE") {
t.Fatalf("lower-priority records leaked:\n%s", buf.String())
}
}
func TestTraceFuncEmitsEntryAndExit(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
func() {
defer traceFunc(context.Background(), log, "Example")()
log.Debug("inside")
}()
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 3 ||
!strings.Contains(lines[0], `level=TRACE msg="function entry" func=Example`) ||
!strings.Contains(lines[1], "msg=inside") ||
!strings.Contains(lines[2], `level=TRACE msg="function exit" func=Example`) {
t.Fatalf("unexpected output:\n%s", buf.String())
}
// Nothing is emitted above TRACE
quiet, qbuf := newTestLogger(slog.LevelDebug)
traceFunc(context.Background(), quiet, "Example")()
if qbuf.String() != "" {
t.Fatalf("trace emitted at DEBUG: %q", qbuf.String())
}
}
func TestLoggerUnusualFieldsDoNotPanic(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(LevelTrace)
var nilPtr *int
log.Info("")
// Malformed argument lists are passed as slices on purpose (vet rejects them as literals)
log.Info("x", []any{""}...)
log.Info("x", "", "")
log.Info("x", []any{"odd"}...)
log.Info("x", []any{42, "non-string key"}...)
log.Info("x", "nil", nil, "nilptr", nilPtr)
log.Info("x", "level", "spoofed", "time", "not-a-time", "msg", "dup")
log.Info("x", "level", slog.LevelWarn)
log.Info("x", "nan", math.NaN(), "inf", math.Inf(1))
log.Info("x", "ctl", "a\nb\x00c\x1b[31m", "bad", string([]byte{0xff, 0xfe}))
log.Info("x", "unicode", "日本語 🚀", "long", strings.Repeat("y", 100000))
log.Info("x", slog.Group("g", "level", "inner"), slog.Any("map", map[string]any{"a": []int{1}}))
log.WithGroup("grp").Info("x", "level", "grouped", "time", time.Unix(0, 0))
log.Log(context.Background(), slog.Level(-100), "very low")
log.Log(context.Background(), slog.Level(100), "very high")
out := buf.String()
// 15 calls, of which the level -100 record is below TRACE and filtered
if c := strings.Count(out, "\n"); c != 14 {
t.Fatalf("expected 14 records, got %d:\n%s", c, out)
}
// Control characters are escaped, so one record is one line
if strings.Contains(out, "a\nb") {
t.Fatalf("raw newline leaked into log output")
}
if strings.Contains(out, "very low") || !strings.Contains(out, "level=ERROR+92") {
t.Fatalf("out-of-range levels not rendered:\n%s", out)
}
if !strings.Contains(out, "level=WARN") {
t.Fatalf("user level attr of type slog.Level not rendered by name")
}
}
func TestTokenID(t *testing.T) {
t.Parallel()
// sha256("abc") = ba7816bf...
if got := tokenID("abc"); got != "ba7816bf" {
t.Fatalf("tokenID(abc) = %q", got)
}
if tokenID("abc") != tokenID("abc") || tokenID("abc") == tokenID("abd") {
t.Fatal("tokenID must be stable and distinguish inputs")
}
if got := tokenID(""); len(got) != 8 {
t.Fatalf("tokenID('') = %q", got)
}
}
func TestASCIIEqualFold(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want bool
}{
{"Bearer", "bearer", true},
{"BEARER", "bearer", true},
{"bearer", "bearer", true},
{"bearer", "bearer ", false},
{"bearerx", "bearer", false},
{"bearer", "bearex", false},
{"", "", true},
{"ı", "i", false},
{"[", "{", false},
{"@", "`", false},
}
for _, c := range cases {
if got := asciiEqualFold(c.a, c.b); got != c.want {
t.Errorf("asciiEqualFold(%q, %q) = %v", c.a, c.b, got)
}
}
}