Files
matsandClaude Sonnet 5.5 c6c5ca4ff7 Make the resolver pure DNS: JSON access log on stdout instead of a log store
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>
2026-10-16 09:53:04 +02:00

178 lines
8.8 KiB
Go

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: "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: "zones", method: http.MethodGet, path: "/api/zones", hook: "ListZones", want: `"zones"`},
{name: "zone record add", method: http.MethodPost, path: "/api/zones/records", body: `{"zone":"example.com.","name":"www","type":"A","value":"10.0.0.1"}`, hook: "AddZoneRecord", want: `"ok":true`},
{name: "zone record put", method: http.MethodPut, path: "/api/zones/records", body: `{"zone":"example.com.","name":"www","type":"A","value":"10.0.0.1"}`, hook: "PutZoneRecord", want: `"ok":true`},
{name: "zone record delete", method: http.MethodDelete, path: "/api/zones/records?zone=example.com.&name=www.example.com.&type=A", hook: "DeleteZoneRecord", want: `"ok":true`},
{name: "zone delegation add", method: http.MethodPost, path: "/api/zones/delegations", body: `{"zone":"example.com.","name":"sub","targets":["ns1.sub.example.com."]}`, hook: "AddZoneDelegation", want: `"ok":true`},
{name: "zone delegation delete", method: http.MethodDelete, path: "/api/zones/delegations?zone=example.com.&name=sub.example.com.", hook: "DeleteZoneDelegation", want: `"ok":true`},
{name: "zone delete", method: http.MethodDelete, path: "/api/zones?zone=example.com.", hook: "DeleteZone", 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: "cache stats", method: http.MethodGet, path: "/api/cache", hook: "CacheStats", want: `"entries":0`},
{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/status"},
{http.MethodGet, "/api/blocklists"},
{http.MethodGet, "/api/domains"},
{http.MethodGet, "/api/domains/check?name=a.example.com"},
{http.MethodGet, "/api/zones"},
{http.MethodGet, "/api/clients"},
{http.MethodGet, "/api/upstreams"},
{http.MethodGet, "/api/cache"},
{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",
"AddZoneRecord", "PutZoneRecord", "DeleteZoneRecord",
"AddZoneDelegation", "DeleteZoneDelegation", "DeleteZone",
}
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["CacheStats"] = true
h.fake.mu.Unlock()
res := h.get("/api/cache")
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)
}
}