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:
1 parent
9a85e9acc8
commit
b792ad256e
11 files changed
+2028
No files matched your search
+149
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user