diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000..4798fbf --- /dev/null +++ b/internal/api/auth_test.go @@ -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) + } +} diff --git a/internal/api/harness_test.go b/internal/api/harness_test.go new file mode 100644 index 0000000..6399a11 --- /dev/null +++ b/internal/api/harness_test.go @@ -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) + } +} diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go new file mode 100644 index 0000000..008cff2 --- /dev/null +++ b/internal/api/routes_test.go @@ -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) + } +}