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>
127 lines
5.2 KiB
Go
127 lines
5.2 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// errHook is what a component returns when it cannot do its job.
|
|
var errHook = errors.New("component is unavailable")
|
|
|
|
// errMultiLine is what the config validator aggregates, one problem per line.
|
|
var errMultiLine = errors.New("first problem\nsecond problem")
|
|
|
|
// setFail tells one hook to return an error, which is how the error branches of
|
|
// a handler are reached without a real component failing underneath.
|
|
func setFail(h *harness, hook string, err error) {
|
|
h.fake.mu.Lock()
|
|
h.fake.fail[hook] = err
|
|
h.fake.mu.Unlock()
|
|
}
|
|
|
|
// TestHookErrorsAreReported walks the routes whose hook can fail and pins what
|
|
// each handler does with the error. The status codes are not uniform and that
|
|
// is deliberate in two places: a component that could not answer is a 500 (the
|
|
// operator has to know the console is lying), while a component that rejected
|
|
// the *request* is a 400, and a domain lookup that failed is a 200 carrying the
|
|
// reason, because "why is this name not blocked?" is the question the route
|
|
// exists to answer.
|
|
func TestHookErrorsAreReported(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fail string
|
|
method string
|
|
path string
|
|
body string
|
|
want int
|
|
}{
|
|
// Reads: the component could not produce an answer.
|
|
{"domain list", "ListDomains", http.MethodGet, "/api/domains", "", http.StatusInternalServerError},
|
|
// A domain check that fails still answers, with the reason: 200 here
|
|
// is the point of the route.
|
|
{"domain check answers 200 with the error", "DomainCheck", http.MethodGet, "/api/domains/check?name=a.example.com", "", http.StatusOK},
|
|
// Writes: the component rejected what was asked for.
|
|
{"blocklist refresh", "RefreshBlocklists", http.MethodPost, "/api/blocklists/refresh", "", http.StatusInternalServerError},
|
|
{"blocklist toggle is a 404 for any failure", "ToggleBlocklist", http.MethodPost, "/api/blocklists/toggle", `{"name":"hosts","enabled":true}`, http.StatusNotFound},
|
|
{"domain add", "AddDomain", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, http.StatusBadRequest},
|
|
{"domain delete", "DeleteDomain", http.MethodDelete, "/api/domains/a.example.com", "", http.StatusNotFound},
|
|
{"clients put", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, http.StatusBadRequest},
|
|
{"upstreams put", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, http.StatusBadRequest},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
setFail(h, tc.fail, errHook)
|
|
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
|
if res.status != tc.want {
|
|
t.Fatalf("%s %s with a failing %s hook = %d, want %d: %s",
|
|
tc.method, tc.path, tc.fail, res.status, tc.want, res.body)
|
|
}
|
|
if got, _ := res.json(t)["error"].(string); got == "" {
|
|
t.Errorf("response has no error message: %s", res.body)
|
|
}
|
|
// The server has to still be serving: the admin API is the only
|
|
// way to fix a broken instance.
|
|
if got := h.get("/api/whoami").status; got != http.StatusOK {
|
|
t.Errorf("/whoami after a failing hook = %d, want 200", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHookErrorsAreFieldedForConfigWrites checks the two config-writing routes
|
|
// split a multi-line validator error into the fields the editor renders, so a
|
|
// rejected write points at the offending lines instead of dumping prose.
|
|
func TestHookErrorsAreFieldedForConfigWrites(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
fail string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"acl", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`},
|
|
{"upstreams", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
setFail(h, tc.fail, errMultiLine)
|
|
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
|
if res.status != http.StatusBadRequest {
|
|
t.Fatalf("%s %s = %d, want 400: %s", tc.method, tc.path, res.status, res.body)
|
|
}
|
|
fields, _ := res.json(t)["fields"].([]any)
|
|
if len(fields) != 2 {
|
|
t.Fatalf("fields = %v, want the two lines of the error", fields)
|
|
}
|
|
// Sorted, so the editor can diff two runs.
|
|
if fields[0] != "first problem" || fields[1] != "second problem" {
|
|
t.Errorf("fields = %v, want them split and sorted", fields)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPanickingWriteHookIsRecovered checks the recoverer wraps the whole chain
|
|
// and not just the read handlers the earlier test covers: a write that panics
|
|
// must be a 500 and must not have half-applied anything.
|
|
func TestPanickingWriteHookIsRecovered(t *testing.T) {
|
|
h := newHarness(t, nil)
|
|
h.fake.mu.Lock()
|
|
h.fake.panicOn["PutACL"] = true
|
|
h.fake.mu.Unlock()
|
|
|
|
res := h.do(http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "X-API-Key: rw-key")
|
|
if res.status != http.StatusInternalServerError {
|
|
t.Fatalf("a panicking write hook = %d, want 500: %s", res.status, res.body)
|
|
}
|
|
if len(h.fake.aclPut) != 0 {
|
|
t.Error("a panicking write recorded a persisted ACL")
|
|
}
|
|
if got := h.get("/api/clients").status; got != http.StatusOK {
|
|
t.Errorf("/clients after the panic = %d, want 200", got)
|
|
}
|
|
}
|