Add the first internal/api test suite
build / build-push (push) Failing after 19s

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:
developer committed 2026-09-27 15:54:50 +00:00
1 parent 1e67a5d0f9
commit 48d37db15b
3 files changed
+1052

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
package api
import (
"encoding/base64"
"net/http"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
func basicHeader(user, pass string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
}
func modeAPIKey(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
func modeBasic(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "basic",
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
}
}
// TestAuthModeAPIKey covers every way a request can present a key in apikey
// mode, and the invariant that matters: the right key always works and every
// other credential never does.
func TestAuthModeAPIKey(t *testing.T) {
cases := []struct {
name string
header string
want int
wantName string
wantWrite bool
wantAuthMd string
}{
{name: "no credentials", header: "", want: http.StatusUnauthorized},
{name: "correct key", header: "X-API-Key: rw-key", want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "read-only key", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
{name: "wrong key", header: "X-API-Key: nope", want: http.StatusUnauthorized},
{name: "key prefix", header: "X-API-Key: rw-ke", want: http.StatusUnauthorized},
{name: "key with suffix", header: "X-API-Key: rw-keyx", want: http.StatusUnauthorized},
{name: "key case folded", header: "X-API-Key: RW-KEY", want: http.StatusUnauthorized},
{name: "read-only key keeps its label", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
// curl -u any:KEY is a common client convention and is supported.
{name: "key in the basic password field", header: "Authorization: " + basicHeader("any", "rw-key"), want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
{name: "wrong basic password", header: "Authorization: " + basicHeader("any", "nope"), want: http.StatusUnauthorized},
{name: "unsupported scheme", header: "Authorization: Digest whatever", want: http.StatusUnauthorized},
{name: "malformed authorization", header: "Authorization: Bearer", want: http.StatusUnauthorized},
{name: "unknown bearer token", header: "Authorization: Bearer deadbeef", want: http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
res := h.do(http.MethodGet, "/api/whoami", "", tc.header)
if res.status != tc.want {
t.Fatalf("GET /api/whoami with %q = %d, want %d: %s", tc.header, res.status, tc.want, res.body)
}
if tc.want != http.StatusOK {
return
}
who := res.json(t)
if who["name"] != tc.wantName {
t.Errorf("name = %v, want %v", who["name"], tc.wantName)
}
if who["can_write"] != tc.wantWrite {
t.Errorf("can_write = %v, want %v", who["can_write"], tc.wantWrite)
}
if who["auth_mode"] != tc.wantAuthMd {
t.Errorf("auth_mode = %v, want %v", who["auth_mode"], tc.wantAuthMd)
}
})
}
}
// TestAuthModeBasic covers the password login, the bearer session it issues and
// logout, plus the per-request basic form.
func TestAuthModeBasic(t *testing.T) {
h := newHarness(t, modeBasic)
// Wrong password is refused and does not issue a token.
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"wrong"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad password login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"nobody","password":"s3cret"}`)
if res.status != http.StatusUnauthorized {
t.Fatalf("bad username login = %d, want 401: %s", res.status, res.body)
}
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
body := res.json(t)
token, _ := body["token"].(string)
if token == "" {
t.Fatalf("login returned no token: %s", res.body)
}
if body["mode"] != "basic" || body["read_only"] != false {
t.Errorf("login payload mode/read_only = %v/%v", body["mode"], body["read_only"])
}
// The token is a bearer credential for the whole API.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Errorf("bearer whoami = %d: %s", res.status, res.body)
}
// Basic auth also works per request, without a session.
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: "+basicHeader("admin", "s3cret")); res.status != http.StatusOK {
t.Errorf("basic whoami = %d: %s", res.status, res.body)
}
// An API key is not a credential in basic mode.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusUnauthorized {
t.Errorf("api key in basic mode = %d, want 401", res.status)
}
// Logout revokes the session; the token must stop working.
if res := h.do(http.MethodPost, "/api/auth/logout", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("logout = %d: %s", res.status, res.body)
}
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusUnauthorized {
t.Errorf("revoked token = %d, want 401", res.status)
}
}
// TestSessionExpiry checks a session stops being accepted once it is past its
// TTL, so a stolen token is not valid forever.
func TestSessionExpiry(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeBasic(c)
c.API.Auth.SessionTTL = config.Duration(10 * time.Millisecond)
})
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
if res.status != http.StatusOK {
t.Fatalf("login = %d: %s", res.status, res.body)
}
token, _ := res.json(t)["token"].(string)
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
t.Fatalf("fresh session = %d: %s", res.status, res.body)
}
waitFor(t, time.Second, "the session to expire", func() bool {
return h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token).status == http.StatusUnauthorized
})
}
// TestAuthModeNone pins mode "none": nothing is required, and the caller runs
// as a local principal that may write.
func TestAuthModeNone(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("unauthenticated whoami in mode none = %d: %s", res.status, res.body)
}
who := res.json(t)
if who["mode"] != "loopback" || who["can_write"] != true {
t.Errorf("whoami = %+v, want a writable loopback principal", who)
}
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: totally-wrong"); res.status != http.StatusOK {
t.Errorf("a rejected key in mode none = %d, want 200: the mode wins", res.status)
}
// Basic auth is not a configured scheme in this mode.
if res := h.do(http.MethodPost, "/api/auth/login", `{"username":"a","password":"b"}`); res.status != http.StatusUnauthorized {
t.Errorf("login in mode none = %d, want 401", res.status)
}
}
// TestLoginRequestValidation covers the login endpoint's own input checks.
func TestLoginRequestValidation(t *testing.T) {
h := newHarness(t, modeAPIKey)
cases := []struct {
name string
body string
want int
}{
{"empty body", ``, http.StatusBadRequest},
{"no credentials", `{}`, http.StatusBadRequest},
{"malformed json", `{`, http.StatusBadRequest},
{"unknown field", `{"api_key":"rw-key","nope":1}`, http.StatusBadRequest},
{"wrong key", `{"api_key":"nope"}`, http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := h.do(http.MethodPost, "/api/auth/login", tc.body)
if res.status != tc.want {
t.Errorf("login %q = %d, want %d: %s", tc.body, res.status, tc.want, res.body)
}
})
}
}
// TestReadOnlyKeyCannotWrite walks every mutating route with a read-only key:
// each one must be refused and none of them may reach its hook.
func TestReadOnlyKeyCannotWrite(t *testing.T) {
writes := []struct {
name string
method string
path string
body string
hook string
}{
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
{"logs clear", http.MethodPost, "/api/logs/clear", "", "ClearLog"},
{"domain add", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, "AddDomain"},
{"domain delete", http.MethodDelete, "/api/domains/a.example.com", "", "DeleteDomain"},
{"blocklist refresh", http.MethodPost, "/api/blocklists/refresh", "", "RefreshBlocklists"},
{"blocklist toggle", http.MethodPost, "/api/blocklists/toggle", `{"name":"a","enabled":true}`, "ToggleBlocklist"},
{"clients put", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "PutACL"},
{"upstreams put", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, "PutUpstream"},
{"settings put", http.MethodPut, "/api/settings", settingsBody(t, nil), ""},
{"settings reload", http.MethodPost, "/api/settings/reload", "", ""},
}
for _, tc := range writes {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, modeAPIKey)
before := h.readConfig()
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: ro-key")
if res.status != http.StatusForbidden {
t.Fatalf("%s %s with a read-only key = %d, want 403: %s", tc.method, tc.path, res.status, res.body)
}
if got := res.json(t)["error"]; got != "this key is read-only" {
t.Errorf("error = %v, want \"this key is read-only\"", got)
}
if tc.hook != "" && h.fake.count(tc.hook) != 0 {
t.Errorf("refused request still reached hook %s", tc.hook)
}
if h.readConfig() != before {
t.Errorf("%s %s rewrote the config file", tc.method, tc.path)
}
// The same request with a read-write key is allowed, so the 403
// above is about the key and not about the payload.
if res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("%s %s with a read-write key = %d: %s", tc.method, tc.path, res.status, res.body)
}
})
}
}
// TestConfigWriteDisabled checks allow_config_write: false refuses the settings
// editor with a valid key, while the other mutations still work.
func TestConfigWriteDisabled(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.AllowConfigWrite = false
})
before := h.readConfig()
res := h.do(http.MethodPut, "/api/settings", settingsBody(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusForbidden {
t.Fatalf("settings put = %d, want 403: %s", res.status, res.body)
}
if got := res.json(t)["error"]; got != "config writes are disabled" {
t.Errorf("error = %v, want \"config writes are disabled\"", got)
}
if h.readConfig() != before {
t.Error("a refused settings write still rewrote the config file")
}
// The gate is specific to the config document.
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("cache flush with config writes disabled = %d, want 200", res.status)
}
}
// TestTrustLoopback checks trust_loopback lets a local client through with no
// credentials at all, and that a *wrong* credential is still refused: the
// loopback exemption is a fallback, not an override.
func TestTrustLoopback(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
})
res := h.do(http.MethodGet, "/api/whoami", "")
if res.status != http.StatusOK {
t.Fatalf("loopback whoami without a key = %d, want 200: %s", res.status, res.body)
}
// A key that does not match is not accepted as a key: the loopback
// exemption is the fallback for a request with no usable credential, so a
// local client lands on the loopback principal rather than a named one.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusOK {
t.Fatalf("loopback with a wrong key = %d, want 200: %s", res.status, res.body)
} else if name := res.json(t)["name"]; name != "loopback" {
t.Errorf("a wrong key on loopback ran as %v, want the loopback principal", name)
}
// Without trust_loopback the same request is refused.
hNoTrust := newHarness(t, modeAPIKey)
if res := hNoTrust.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusUnauthorized {
t.Errorf("wrong key without trust_loopback = %d, want 401", res.status)
}
// A trusted proxy in front of a loopback client does not extend the
// exemption to the forwarded address, which is not loopback.
h2 := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
c.API.Auth.TrustLoopback = true
c.API.TrustedProxies = []string{"127.0.0.0/8"}
})
res = h2.do(http.MethodGet, "/api/whoami", "", "X-Forwarded-For: 198.51.100.7")
if res.status != http.StatusUnauthorized {
t.Errorf("forwarded non-loopback client = %d, want 401", res.status)
}
}
// TestAPIKeysAreNotStoredInPlaintext pins the hashing: the authenticator keys
// its maps on the SHA-256 of the credential, so the configured key never sits
// in the long-lived structure as itself.
func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
a := newAuthenticator(config.Auth{
Mode: "apikey",
APIKeys: []string{"rw-key"},
ReadOnlyAPIKeys: []string{"ro-key"},
Username: "admin",
Password: "s3cret",
SessionTTL: config.Duration(time.Hour),
})
if label, ok := a.readWrite[hashKey("rw-key")]; !ok || label != "rw-1" {
t.Errorf("readWrite is not keyed on the key hash: %v", a.readWrite)
}
if label, ok := a.readOnly[hashKey("ro-key")]; !ok || label != "ro-1" {
t.Errorf("readOnly is not keyed on the key hash: %v", a.readOnly)
}
if a.passwordHash == "s3cret" || a.passwordHash != hashKey("s3cret") {
t.Errorf("passwordHash = %q, want the sha256 of the password", a.passwordHash)
}
for _, stored := range append(mapKeys(a.readWrite), mapKeys(a.readOnly)...) {
if stored == "rw-key" || stored == "ro-key" || stored == "s3cret" {
t.Errorf("a credential is stored in plaintext: %q", stored)
}
if len(stored) != 64 {
t.Errorf("stored form %q is not a hex sha256", stored)
}
}
// constantTimeEqual is the comparison helper: equal only when equal.
if !constantTimeEqual("abc", "abc") {
t.Error("constantTimeEqual(abc, abc) = false")
}
if constantTimeEqual("abc", "abd") || constantTimeEqual("abc", "ab") || constantTimeEqual("", "x") {
t.Error("constantTimeEqual accepted a mismatch")
}
}
func mapKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestAPIModeReported checks AuthMode/whoami report the configured mode, which
// is what the console uses to decide whether to offer a login form.
func TestAPIModeReported(t *testing.T) {
cases := []struct {
mode string
want string
}{
{"apikey", "apikey"},
{"basic", "basic"},
{"none", "none"},
}
for _, tc := range cases {
t.Run(tc.mode, func(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
modeAPIKey(c)
if tc.mode == "basic" {
modeBasic(c)
} else if tc.mode == "none" {
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
}
})
if got := h.srv.AuthMode(); got != tc.want {
t.Errorf("AuthMode() = %q, want %q", got, tc.want)
}
auth := "X-API-Key: rw-key"
if tc.mode == "basic" {
auth = "Authorization: " + basicHeader("admin", "s3cret")
} else if tc.mode == "none" {
auth = ""
}
res := h.do(http.MethodGet, "/api/whoami", "", auth)
if res.status != http.StatusOK {
t.Fatalf("whoami = %d: %s", res.status, res.body)
}
if got := res.json(t)["auth_mode"]; got != tc.want {
t.Errorf("whoami auth_mode = %v, want %q", got, tc.want)
}
})
}
}
// TestBasicAuthDisabledWithoutPassword checks basic mode with no configured
// password refuses rather than accepting an empty one.
func TestBasicAuthDisabledWithoutPassword(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
c.API.Auth = config.Auth{Mode: "basic", Username: "admin", SessionTTL: config.Duration(time.Hour)}
})
if err := h.cfg.Validate(); err != nil && !strings.Contains(err.Error(), "username") {
t.Fatalf("unexpected validation error: %v", err)
}
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":""}`)
if res.status != http.StatusUnauthorized {
t.Errorf("login with no password configured = %d, want 401: %s", res.status, res.body)
}
}
+463
View File
@@ -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)
}
}
+175
View File
@@ -0,0 +1,175 @@
package api
import (
"net/http"
"strings"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// TestRouteTableHappyPath walks every route the server registers and asserts
// it is reachable, answers 200 and reaches the hook it is supposed to. A route
// added to the table in api.go without a case here is a route nothing tests.
func TestRouteTableHappyPath(t *testing.T) {
cases := []struct {
name string
method string
path string
body string
hook string
want string // substring the response must contain
}{
{name: "healthz", method: http.MethodGet, path: "/api/healthz", hook: "Health", want: `"status":"ok"`},
{name: "dashboard", method: http.MethodGet, path: "/api/dashboard?range=1h", hook: "Dashboard", want: `"range":"1h"`},
{name: "status", method: http.MethodGet, path: "/api/status", hook: "Status", want: `"version":"test"`},
{name: "blocklists", method: http.MethodGet, path: "/api/blocklists", hook: "BlocklistState", want: `"lists"`},
{name: "blocklist refresh all", method: http.MethodPost, path: "/api/blocklists/refresh", hook: "RefreshBlocklists", want: `"started":true`},
{name: "blocklist toggle", method: http.MethodPost, path: "/api/blocklists/toggle", body: `{"name":"hosts","enabled":false}`, hook: "ToggleBlocklist", want: `"enabled":false`},
{name: "domains list", method: http.MethodGet, path: "/api/domains?page=1&per_page=10", hook: "ListDomains", want: `"items"`},
{name: "domain check", method: http.MethodGet, path: "/api/domains/check?name=a.example.com", hook: "DomainCheck", want: `"a.example.com"`},
{name: "domain add", method: http.MethodPost, path: "/api/domains", body: `{"name":"a.example.com","action":"block"}`, hook: "AddDomain", want: `"ok":true`},
{name: "domain delete", method: http.MethodDelete, path: "/api/domains/a.example.com", hook: "DeleteDomain", want: `"ok":true`},
{name: "clients get", method: http.MethodGet, path: "/api/clients", hook: "GetACL", want: `"groups"`},
{name: "clients put", method: http.MethodPut, path: "/api/clients", body: `{"groups":[],"default_policy":"allow"}`, hook: "PutACL", want: `"version":42`},
{name: "upstreams get", method: http.MethodGet, path: "/api/upstreams", hook: "GetUpstream", want: `"policy":"parallel"`},
{name: "upstreams put", method: http.MethodPut, path: "/api/upstreams", body: `{"policy":"parallel","servers":[{"addr":"127.0.0.1:53","proto":"udp"}]}`, hook: "PutUpstream", want: `"version":43`},
{name: "logs search", method: http.MethodGet, path: "/api/logs?limit=5", hook: "SearchLog", want: `"entries"`},
{name: "logs clear", method: http.MethodPost, path: "/api/logs/clear", hook: "ClearLog", want: `"ok":true`},
{name: "cache stats", method: http.MethodGet, path: "/api/cache", hook: "CacheStats", want: `"entries":0`},
{name: "cache top", method: http.MethodGet, path: "/api/cache/top?limit=7", hook: "CacheTop", want: `"items"`},
{name: "cache flush", method: http.MethodPost, path: "/api/cache/flush", hook: "FlushCache", want: `"ok":true`},
{name: "settings get", method: http.MethodGet, path: "/api/settings", hook: "", want: `"config_yaml"`},
{name: "settings reload", method: http.MethodPost, path: "/api/settings/reload", hook: "", want: `"ok":true`},
{name: "whoami", method: http.MethodGet, path: "/api/whoami", hook: "", want: `"name":"rw-1"`},
{name: "auth login", method: http.MethodPost, path: "/api/auth/login", body: `{"api_key":"rw-key"}`, hook: "", want: `"token"`},
{name: "auth logout", method: http.MethodPost, path: "/api/auth/logout", hook: "", want: `"ok":true`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, nil)
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("%s %s = %d: %s", tc.method, tc.path, res.status, res.body)
}
if tc.want != "" && !strings.Contains(string(res.body), tc.want) {
t.Errorf("%s %s body %q does not contain %q", tc.method, tc.path, res.body, tc.want)
}
if tc.hook != "" && !h.fake.saw(tc.hook) {
t.Errorf("%s %s did not reach hook %s (calls: %v)", tc.method, tc.path, tc.hook, h.fake.seen())
}
})
}
}
// TestRouteMethodAndPathRejection pins the two ways a request can miss: the
// path exists but not for this method, and the path does not exist at all.
func TestRouteMethodAndPathRejection(t *testing.T) {
cases := []struct {
name string
method string
path string
want int
}{
{"put whoami", http.MethodPut, "/api/whoami", http.StatusMethodNotAllowed},
{"get cache flush", http.MethodGet, "/api/cache/flush", http.StatusMethodNotAllowed},
{"get auth login", http.MethodGet, "/api/auth/login", http.StatusMethodNotAllowed},
{"post whoami", http.MethodPost, "/api/whoami", http.StatusMethodNotAllowed},
{"unknown path", http.MethodGet, "/api/nope", http.StatusNotFound},
{"outside the base path", http.MethodGet, "/healthz", http.StatusNotFound},
{"base path itself", http.MethodGet, "/api", http.StatusNotFound},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, nil)
res := h.do(tc.method, tc.path, "", "X-API-Key: rw-key")
if res.status != tc.want {
t.Errorf("%s %s = %d, want %d", tc.method, tc.path, res.status, tc.want)
}
})
}
}
// TestReadRoutesDoNotMutate exercises every read route and asserts none of them
// reached a mutating hook, swapped the config or touched the file on disk.
func TestReadRoutesDoNotMutate(t *testing.T) {
h := newHarness(t, nil)
before := h.readConfig()
gen := h.store.Generation()
reads := []struct {
method string
path string
}{
{http.MethodGet, "/api/healthz"},
{http.MethodGet, "/api/dashboard?range=24h"},
{http.MethodGet, "/api/status"},
{http.MethodGet, "/api/blocklists"},
{http.MethodGet, "/api/domains"},
{http.MethodGet, "/api/domains/check?name=a.example.com"},
{http.MethodGet, "/api/clients"},
{http.MethodGet, "/api/upstreams"},
{http.MethodGet, "/api/logs"},
{http.MethodGet, "/api/cache"},
{http.MethodGet, "/api/cache/top"},
{http.MethodGet, "/api/settings"},
{http.MethodGet, "/api/whoami"},
}
for _, r := range reads {
res := h.do(r.method, r.path, "", "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Errorf("%s %s = %d: %s", r.method, r.path, res.status, res.body)
}
}
mutations := []string{
"RefreshBlocklists", "ToggleBlocklist", "AddDomain", "DeleteDomain",
"PutACL", "PutUpstream", "ClearLog", "FlushCache",
}
for _, m := range mutations {
if h.fake.count(m) != 0 {
t.Errorf("read routes called the mutating hook %s", m)
}
}
if gen != h.store.Generation() {
t.Errorf("read routes bumped the config generation %d -> %d", gen, h.store.Generation())
}
if h.readConfig() != before {
t.Error("read routes rewrote the config file")
}
}
// TestHandlerRecoversFromPanic checks a panicking component becomes a 500 and
// the server keeps serving: the admin API is the only way to fix a broken
// instance, so it must not be the thing that dies.
func TestHandlerRecoversFromPanic(t *testing.T) {
h := newHarness(t, nil)
h.fake.mu.Lock()
h.fake.panicOn["Dashboard"] = true
h.fake.mu.Unlock()
res := h.get("/api/dashboard")
if res.status != http.StatusInternalServerError {
t.Fatalf("panicking hook = %d, want 500: %s", res.status, res.body)
}
if got := res.json(t)["error"]; got != "internal error" {
t.Errorf("error field = %v, want \"internal error\"", got)
}
if res := h.get("/api/whoami"); res.status != http.StatusOK {
t.Errorf("server did not survive the panic: /whoami = %d", res.status)
}
}
// TestBasePathIsHonoured checks the route table is built from api.base_path:
// a different prefix serves the same routes and the default one stops working.
func TestBasePathIsHonoured(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.API.BasePath = "/admin" })
if res := h.get("/admin/whoami"); res.status != http.StatusOK {
t.Errorf("GET /admin/whoami = %d: %s", res.status, res.body)
}
if res := h.get("/api/whoami"); res.status != http.StatusNotFound {
t.Errorf("GET /api/whoami = %d, want 404 once the base path moved", res.status)
}
if base := h.get("/admin/whoami").json(t)["base_path"]; base != "/admin" {
t.Errorf("whoami base_path = %v, want /admin", base)
}
}