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. domainQuery []DomainQuery domainAdd []DomainInput domainDel []domainDelete aclPut []aclPut upstreamPut []config.Upstream refresh [][]string toggle []toggleCall flushes 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 } 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{}, } } 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{ 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") }, ListZones: func() (any, error) { f.record("ListZones", func() {}) return map[string]any{"zones": []any{}}, f.err("ListZones") }, AddZoneRecord: func(_ context.Context, in ZoneRecordInput) (any, error) { f.record("AddZoneRecord", func() {}) return map[string]any{"ok": true, "serial": 7}, f.err("AddZoneRecord") }, PutZoneRecord: func(_ context.Context, in ZoneRecordInput) (any, error) { f.record("PutZoneRecord", func() {}) return map[string]any{"ok": true, "serial": 8}, f.err("PutZoneRecord") }, DeleteZoneRecord: func(_ context.Context, zone, name, rrtype, value string) error { f.record("DeleteZoneRecord", func() {}) return f.err("DeleteZoneRecord") }, DeleteZone: func(_ context.Context, zone string) error { f.record("DeleteZone", func() {}) return f.err("DeleteZone") }, AddZoneDelegation: func(_ context.Context, in ZoneDelegationInput) (any, error) { f.record("AddZoneDelegation", func() {}) return map[string]any{"ok": true}, f.err("AddZoneDelegation") }, DeleteZoneDelegation: func(_ context.Context, zone, name string) error { f.record("DeleteZoneDelegation", func() {}) return f.err("DeleteZoneDelegation") }, 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") }, CacheStats: func() any { f.record("CacheStats", func() {}) return map[string]any{"entries": 0} }, 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() {}) }, } } // 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) } }