Files
fancy-dns/internal/api/harness_test.go
T
developer 48d37db15b
build / build-push (push) Failing after 19s
Add the first internal/api test suite
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.
2026-09-27 15:54:50 +00:00

464 lines
12 KiB
Go

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)
}
}