The resolver kept its own query-log store (a bbolt ring, a live ring with SSE fanout, rolling per-domain counters and dashboard time-series) for the console. In a multi-node deployment each pod held a different slice of that, so every view disagreed with itself, and the cost sat on the query path. Charts belong in Grafana and log search in the log collector, so the store is gone. internal/accesslog writes one JSON line per notable query to stdout. The default is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped, refused); log_cached and sample_rate add ordinary answers for a debugging session. Lines go through a bounded buffer to a single writer goroutine and are dropped, and counted in fancydns_access_log_dropped_total, when the stream cannot keep up, so a stalled log pipe can never stall a query. The config key is access_log (the old qlog block is ignored, like any unknown key). The metrics gain what the dashboard used to compute: fancydns_group_queries_total by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear, /api/cache/top, the series rings, and the cache's per-name hit table, which took a mutex and built a string on every cache hit. Tests that read the old log now read the access-log lines; the contract test drops the removed endpoints. The web console still calls them and is changed in the next commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
431 lines
11 KiB
Go
431 lines
11 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.
|
|
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)
|
|
}
|
|
}
|