internal/api is the admin REST + SSE surface and had no test file at all, so nothing pinned the auth modes, the X-Forwarded-For trust boundary, the rate limiter or the fact that a read route must not mutate anything. This adds a local harness (a Server over fake hooks on an httptest listener, with a real config file on disk) and three test files. What it pins: * Routing. A table over every route in Server.routes: each one answers 200 and reaches the hook it is supposed to, so a new route without a case here is a route nothing tests. Plus method-not-allowed / 404 / outside the base path, a base path other than /api, and a read-route sweep that asserts no mutating hook was reached, the config generation did not move and the file on disk is byte-identical afterwards. A panicking component becomes a 500 and the server keeps serving, because the admin API is the only way to fix a broken instance. * Auth. All three modes. apikey: the right key always works, and a prefix, a suffix, a case-folded key, a wrong basic password, an unknown bearer token, an unsupported scheme and a malformed Authorization header never do; curl -u any:KEY is accepted because that is what clients send. basic: bad username and bad password refused, a successful login issues a bearer token, the token works for the whole API, basic auth also works per request, an API key is not a credential in this mode, logout revokes the token, and a token past its session_ttl stops working. none: nothing is required and the caller runs as a writable loopback principal. trust _loopback: a local client with no credentials is let through, and a forwarded non-loopback client is not. Every mutating route refuses a read-only key with 403 without reaching its hook or touching the file (and the same request with a read-write key succeeds, so the 403 is about the key and not the payload), and allow_config_write: false refuses the settings editor with a valid key while the other mutations still work. The hashing is pinned on the state, not on timing: the authenticator's maps are keyed on the SHA-256 of the credential and hold no plaintext key or password, so a copied server struct cannot leak one. * Product code is untouched; the suite is `package api` with its own setup.
This commit is contained in:
1 parent
1e67a5d0f9
commit
48d37db15b
3 files changed
+1052
No files matched your search
@@ -0,0 +1,463 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
)
|
||||
|
||||
// testConfig is a minimal configuration that passes Validate and keeps the
|
||||
// product quiet: no blocklist sources, no CORS, no rate limit and metrics off,
|
||||
// so a test only enables what it is actually about.
|
||||
func testConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
cfg.Block.Enabled = false
|
||||
cfg.API.BasePath = "/api"
|
||||
cfg.API.Auth = config.Auth{
|
||||
Mode: "apikey",
|
||||
APIKeys: []string{"rw-key"},
|
||||
ReadOnlyAPIKeys: []string{"ro-key"},
|
||||
SessionTTL: config.Duration(time.Hour),
|
||||
TrustLoopback: false,
|
||||
}
|
||||
cfg.API.RateLimit = config.AuthRate{}
|
||||
cfg.API.CORSOrigins = nil
|
||||
cfg.API.TrustedProxies = nil
|
||||
cfg.Metrics = config.Metrics{Enabled: false}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// fakeHooks is the live component set the API drives, with every call
|
||||
// recorded so a test can assert which hook a route reached (and, more
|
||||
// importantly, which it did not).
|
||||
type fakeHooks struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
|
||||
// arguments the API passed down, for the routes where the argument is
|
||||
// the interesting part of the contract.
|
||||
dashRange []string
|
||||
domainQuery []DomainQuery
|
||||
domainAdd []DomainInput
|
||||
domainDel []domainDelete
|
||||
aclPut []aclPut
|
||||
upstreamPut []config.Upstream
|
||||
logQuery []LogQuery
|
||||
refresh [][]string
|
||||
toggle []toggleCall
|
||||
cacheTop []int
|
||||
clearLog int
|
||||
flushes int
|
||||
logStats int
|
||||
|
||||
// fail maps a hook name to the error it should return, so the error
|
||||
// branches of a handler can be reached without a real component.
|
||||
fail map[string]error
|
||||
// panicOn names hooks that panic instead of returning, which is how the
|
||||
// recoverer gets exercised.
|
||||
panicOn map[string]bool
|
||||
|
||||
// stream plumbing for the SSE test.
|
||||
stream chan LogEntry
|
||||
unsub chan struct{}
|
||||
unsubOnce sync.Once
|
||||
subs int
|
||||
}
|
||||
|
||||
type domainDelete struct {
|
||||
name string
|
||||
action string
|
||||
list string
|
||||
}
|
||||
|
||||
type aclPut struct {
|
||||
groups []config.Group
|
||||
def string
|
||||
}
|
||||
|
||||
type toggleCall struct {
|
||||
name string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func newFakeHooks() *fakeHooks {
|
||||
return &fakeHooks{
|
||||
fail: map[string]error{},
|
||||
panicOn: map[string]bool{},
|
||||
stream: make(chan LogEntry, 16),
|
||||
unsub: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeHooks) note(name string) {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, name)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeHooks) record(name string, save func()) {
|
||||
if f.panics(name) {
|
||||
panic("fake hook panic: " + name)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, name)
|
||||
save()
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// panics reports whether a hook was told to panic instead of returning.
|
||||
func (f *fakeHooks) panics(name string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.panicOn[name]
|
||||
}
|
||||
|
||||
func (f *fakeHooks) err(name string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.fail[name]
|
||||
}
|
||||
|
||||
func (f *fakeHooks) seen() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.calls...)
|
||||
}
|
||||
|
||||
func (f *fakeHooks) saw(names ...string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, n := range names {
|
||||
found := false
|
||||
for _, c := range f.calls {
|
||||
if c == n {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (f *fakeHooks) count(name string) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range f.calls {
|
||||
if c == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (f *fakeHooks) reset() {
|
||||
f.mu.Lock()
|
||||
f.calls = nil
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeHooks) hooks() Hooks {
|
||||
return Hooks{
|
||||
Dashboard: func(_ context.Context, rangeKey string) (any, error) {
|
||||
f.record("Dashboard", func() { f.dashRange = append(f.dashRange, rangeKey) })
|
||||
return map[string]any{"range": rangeKey}, f.err("Dashboard")
|
||||
},
|
||||
BlocklistState: func() any {
|
||||
f.record("BlocklistState", func() {})
|
||||
return map[string]any{"lists": []any{}}
|
||||
},
|
||||
BlocklistStats: func() any {
|
||||
f.record("BlocklistStats", func() {})
|
||||
return map[string]any{"rules": 1}
|
||||
},
|
||||
RefreshBlocklists: func(_ context.Context, names []string) error {
|
||||
f.record("RefreshBlocklists", func() { f.refresh = append(f.refresh, names) })
|
||||
return f.err("RefreshBlocklists")
|
||||
},
|
||||
ToggleBlocklist: func(name string, enabled bool) error {
|
||||
f.record("ToggleBlocklist", func() { f.toggle = append(f.toggle, toggleCall{name, enabled}) })
|
||||
return f.err("ToggleBlocklist")
|
||||
},
|
||||
DomainCheck: func(name string) (any, error) {
|
||||
f.record("DomainCheck", func() {})
|
||||
return map[string]any{"name": name, "blocked": false}, f.err("DomainCheck")
|
||||
},
|
||||
ListDomains: func(q DomainQuery) (any, error) {
|
||||
f.record("ListDomains", func() { f.domainQuery = append(f.domainQuery, q) })
|
||||
return map[string]any{"items": []any{}}, f.err("ListDomains")
|
||||
},
|
||||
AddDomain: func(_ context.Context, in DomainInput) (any, error) {
|
||||
f.record("AddDomain", func() { f.domainAdd = append(f.domainAdd, in) })
|
||||
return map[string]any{"ok": true}, f.err("AddDomain")
|
||||
},
|
||||
DeleteDomain: func(_ context.Context, name, action, list string) error {
|
||||
f.record("DeleteDomain", func() { f.domainDel = append(f.domainDel, domainDelete{name, action, list}) })
|
||||
return f.err("DeleteDomain")
|
||||
},
|
||||
GetACL: func() any {
|
||||
f.record("GetACL", func() {})
|
||||
return map[string]any{"groups": []any{}}
|
||||
},
|
||||
PutACL: func(_ context.Context, groups []config.Group, def string) (int64, error) {
|
||||
f.record("PutACL", func() { f.aclPut = append(f.aclPut, aclPut{groups, def}) })
|
||||
return 42, f.err("PutACL")
|
||||
},
|
||||
GetUpstream: func() any {
|
||||
f.record("GetUpstream", func() {})
|
||||
return map[string]any{"policy": "parallel"}
|
||||
},
|
||||
PutUpstream: func(_ context.Context, up config.Upstream) (int64, error) {
|
||||
f.record("PutUpstream", func() { f.upstreamPut = append(f.upstreamPut, up) })
|
||||
return 43, f.err("PutUpstream")
|
||||
},
|
||||
SearchLog: func(_ context.Context, q LogQuery) (any, error) {
|
||||
f.record("SearchLog", func() { f.logQuery = append(f.logQuery, q) })
|
||||
return map[string]any{"entries": []any{}}, f.err("SearchLog")
|
||||
},
|
||||
LogStats: func() any {
|
||||
f.mu.Lock()
|
||||
f.logStats++
|
||||
f.mu.Unlock()
|
||||
return map[string]any{"qps": 1}
|
||||
},
|
||||
SubscribeLog: func() (<-chan LogEntry, func()) {
|
||||
f.mu.Lock()
|
||||
f.subs++
|
||||
f.calls = append(f.calls, "SubscribeLog")
|
||||
f.mu.Unlock()
|
||||
return f.stream, func() {
|
||||
f.mu.Lock()
|
||||
f.subs--
|
||||
f.mu.Unlock()
|
||||
f.unsubOnce.Do(func() { close(f.unsub) })
|
||||
}
|
||||
},
|
||||
ClearLog: func() error {
|
||||
f.record("ClearLog", func() { f.clearLog++ })
|
||||
return f.err("ClearLog")
|
||||
},
|
||||
CacheStats: func() any {
|
||||
f.record("CacheStats", func() {})
|
||||
return map[string]any{"entries": 0}
|
||||
},
|
||||
CacheTop: func(limit int) any {
|
||||
f.record("CacheTop", func() { f.cacheTop = append(f.cacheTop, limit) })
|
||||
return []any{}
|
||||
},
|
||||
FlushCache: func() int64 {
|
||||
f.record("FlushCache", func() { f.flushes++ })
|
||||
return 0
|
||||
},
|
||||
Health: func() any {
|
||||
f.record("Health", func() {})
|
||||
return map[string]any{"status": "ok"}
|
||||
},
|
||||
Status: func() any {
|
||||
f.record("Status", func() {})
|
||||
return map[string]any{"engine": "ok"}
|
||||
},
|
||||
Flush: func() { f.record("Flush", func() {}) },
|
||||
}
|
||||
}
|
||||
|
||||
// subscribers is the number of live SSE subscribers; it must be back to zero
|
||||
// once a client has disconnected.
|
||||
func (f *fakeHooks) subscribers() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.subs
|
||||
}
|
||||
|
||||
func (f *fakeHooks) statsCalls() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.logStats
|
||||
}
|
||||
|
||||
// harness is a wired Server behind an httptest listener, with a config file on
|
||||
// disk so the settings and reload routes have something real to work on.
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
cfg *config.Config
|
||||
store *config.Store
|
||||
path string
|
||||
base string
|
||||
fake *fakeHooks
|
||||
srv *Server
|
||||
ts *httptest.Server
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, mutate func(*config.Config)) *harness {
|
||||
t.Helper()
|
||||
cfg := testConfig(t)
|
||||
if mutate != nil {
|
||||
mutate(cfg)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("test config does not validate: %v", err)
|
||||
}
|
||||
doc, err := config.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, doc, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newHarnessWithFile(t, cfg, path)
|
||||
}
|
||||
|
||||
// newHarnessWithFile builds a server over an existing document, for the reload
|
||||
// and settings tests that need to edit the file first.
|
||||
func newHarnessWithFile(t *testing.T, cfg *config.Config, path string) *harness {
|
||||
t.Helper()
|
||||
store := config.NewStore(cfg, path)
|
||||
fake := newFakeHooks()
|
||||
h := fake.hooks()
|
||||
h.Store = store
|
||||
h.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
h.Version = "test"
|
||||
h.StartedAt = time.Now().Add(-time.Minute)
|
||||
srv, err := New(h)
|
||||
if err != nil {
|
||||
t.Fatalf("api.New: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &harness{
|
||||
t: t,
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
path: path,
|
||||
base: cfg.API.BasePath,
|
||||
fake: fake,
|
||||
srv: srv,
|
||||
ts: ts,
|
||||
}
|
||||
}
|
||||
|
||||
type response struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (r response) json(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(r.body, &out); err != nil {
|
||||
t.Fatalf("response is not a JSON object (%d): %v: %s", r.status, err, r.body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// do issues a request against the harness. header pairs are "Name: value".
|
||||
func (h *harness) do(method, path, body string, header ...string) response {
|
||||
h.t.Helper()
|
||||
return h.doCtx(context.Background(), method, path, body, header...)
|
||||
}
|
||||
|
||||
func (h *harness) doCtx(ctx context.Context, method, path, body string, header ...string) response {
|
||||
h.t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, h.ts.URL+path, rdr)
|
||||
if err != nil {
|
||||
h.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
if body != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for _, kv := range header {
|
||||
if kv == "" {
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(kv, ": ")
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := h.ts.Client().Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
return response{status: res.StatusCode, header: res.Header, body: b}
|
||||
}
|
||||
|
||||
// get is the common authenticated read.
|
||||
func (h *harness) get(path string) response {
|
||||
h.t.Helper()
|
||||
return h.do(http.MethodGet, path, "", "X-API-Key: rw-key")
|
||||
}
|
||||
|
||||
// writeConfig reads the harness's own document back off disk.
|
||||
func (h *harness) writeConfig(t *testing.T, text string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(h.path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *harness) readConfig() string {
|
||||
h.t.Helper()
|
||||
b, err := os.ReadFile(h.path)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// settingsBody renders the request body the settings editor sends: a full,
|
||||
// valid document (a partial one lands on top of config.Default, which leaves
|
||||
// the blocklist enabled and therefore invalid).
|
||||
func settingsBody(t *testing.T, mutate func(*config.Config)) string {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
cfg.Block.Enabled = false
|
||||
if mutate != nil {
|
||||
mutate(cfg)
|
||||
}
|
||||
doc, err := config.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(map[string]string{"config_yaml": string(doc)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// waitFor polls cond until it holds or the deadline passes. Every wait in this
|
||||
// suite is bounded: a hang looks exactly like a suite failure.
|
||||
func waitFor(t *testing.T, timeout time.Duration, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out after %s waiting for %s", timeout, what)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user