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

+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"testing"
)
// fakeLookup is an in-memory TokenLookup
type fakeLookup struct {
mu sync.Mutex
tokens map[string]Token
err error
calls []string
}
func (f *fakeLookup) GetToken(ctx context.Context, value string) (Token, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, value)
if f.err != nil {
return Token{}, f.err
}
tok, ok := f.tokens[value]
if !ok {
return Token{}, ErrTokenNotFound
}
return tok, nil
}
func newFakeLookup() *fakeLookup {
return &fakeLookup{tokens: map[string]Token{
"good": {Token: "good", Active: true},
"Good": {Token: "Good", Active: true},
"inactive": {Token: "inactive", Active: false},
}}
}
func headerWith(values ...string) http.Header {
h := http.Header{}
for _, v := range values {
h.Add("Authorization", v)
}
return h
}
func TestParseAuthorization(t *testing.T) {
t.Parallel()
cases := []struct {
name string
header http.Header
token string
class string
}{
{"missing", http.Header{}, "", authMissingHeader},
{"other headers only", http.Header{"X-Authorization": {"Bearer good"}}, "", authMissingHeader},
{"empty", headerWith(""), "", authEmptyHeader},
{"multiple", headerWith("Bearer good", "Bearer good"), "", authMultipleHeaders},
{"multiple with empty", headerWith("", "Bearer good"), "", authMultipleHeaders},
{"basic", headerWith("Basic Z29vZDo="), "", authBadScheme},
{"token scheme", headerWith("Token good"), "", authBadScheme},
{"no space", headerWith("Bearergood"), "", authBadScheme},
{"tab separator", headerWith("Bearer\tgood"), "", authBadScheme},
{"leading space", headerWith(" Bearer good"), "", authBadScheme},
{"bare token", headerWith("good"), "", authBadScheme},
{"unicode scheme", headerWith("Beаrer good"), "", authBadScheme}, // Cyrillic a
{"scheme only", headerWith("Bearer"), "", authEmptyToken},
{"scheme and space", headerWith("Bearer "), "", authEmptyToken},
{"ok", headerWith("Bearer good"), "good", ""},
{"lowercase scheme", headerWith("bearer good"), "good", ""},
{"uppercase scheme", headerWith("BEARER good"), "good", ""},
{"double space kept", headerWith("Bearer good"), " good", ""},
{"trailing kept", headerWith("Bearer good "), "good ", ""},
{"extra words kept", headerWith("Bearer good extra"), "good extra", ""},
{"comma kept", headerWith("Bearer good, Bearer other"), "good, Bearer other", ""},
}
for _, c := range cases {
token, class := parseAuthorization(c.header)
if token != c.token || class != c.class {
t.Errorf("%s: got (%q, %q), want (%q, %q)", c.name, token, class, c.token, c.class)
}
}
}
func TestAuthenticate(t *testing.T) {
t.Parallel()
cases := []struct {
name string
header http.Header
class string
token string
}{
{"missing", http.Header{}, authMissingHeader, ""},
{"empty", headerWith(""), authEmptyHeader, ""},
{"wrong scheme", headerWith("Basic good"), authBadScheme, ""},
{"empty bearer", headerWith("Bearer "), authEmptyToken, ""},
{"malformed bearer", headerWith("Bearer good"), authUnknownToken, ""},
{"multiple", headerWith("Bearer good", "Bearer good"), authMultipleHeaders, ""},
{"unknown", headerWith("Bearer nope"), authUnknownToken, ""},
{"case changed", headerWith("Bearer GOOD"), authUnknownToken, ""},
{"near match", headerWith("Bearer goo"), authUnknownToken, ""},
{"inactive", headerWith("Bearer inactive"), authInactiveToken, ""},
{"valid", headerWith("Bearer good"), authOK, "good"},
{"valid case-distinct token", headerWith("bearer Good"), authOK, "Good"},
}
lookup := newFakeLookup()
for _, c := range cases {
tok, class, err := authenticate(context.Background(), traceDiscard(), lookup, c.header)
if err != nil || class != c.class || tok.Token != c.token {
t.Errorf("%s: got (%q, %q, %v), want (%q, %q)", c.name, tok.Token, class, err, c.token, c.class)
}
}
}
func TestAuthenticateLookupError(t *testing.T) {
t.Parallel()
lookup := &fakeLookup{err: errors.New("database is locked")}
_, class, err := authenticate(context.Background(), traceDiscard(), lookup, headerWith("Bearer good"))
if err == nil || class != authLookupError {
t.Fatalf("got (%q, %v)", class, err)
}
}
func TestAuthenticateDoesNotLookUpMalformedHeaders(t *testing.T) {
t.Parallel()
lookup := newFakeLookup()
for _, h := range []http.Header{{}, headerWith(""), headerWith("Basic x"), headerWith("Bearer "), headerWith("Bearer a", "Bearer b")} {
authenticate(context.Background(), traceDiscard(), lookup, h)
}
if len(lookup.calls) != 0 {
t.Fatalf("lookups performed: %q", lookup.calls)
}
}
func TestAuthenticationClassesAreFixedStrings(t *testing.T) {
t.Parallel()
for _, c := range []string{authMissingHeader, authEmptyHeader, authMultipleHeaders, authBadScheme,
authEmptyToken, authUnknownToken, authInactiveToken, authLookupError, authOK} {
if c == "" || strings.ContainsAny(c, " =\"") {
t.Errorf("class %q is not a plain identifier", c)
}
}
}