build / build-push (push) Failing after 19s
internal/api is the admin REST + SSE surface and had no test file at all, so nothing pinned the auth modes, the X-Forwarded-For trust boundary, the rate limiter or the fact that a read route must not mutate anything. This adds a local harness (a Server over fake hooks on an httptest listener, with a real config file on disk) and three test files. What it pins: * Routing. A table over every route in Server.routes: each one answers 200 and reaches the hook it is supposed to, so a new route without a case here is a route nothing tests. Plus method-not-allowed / 404 / outside the base path, a base path other than /api, and a read-route sweep that asserts no mutating hook was reached, the config generation did not move and the file on disk is byte-identical afterwards. A panicking component becomes a 500 and the server keeps serving, because the admin API is the only way to fix a broken instance. * Auth. All three modes. apikey: the right key always works, and a prefix, a suffix, a case-folded key, a wrong basic password, an unknown bearer token, an unsupported scheme and a malformed Authorization header never do; curl -u any:KEY is accepted because that is what clients send. basic: bad username and bad password refused, a successful login issues a bearer token, the token works for the whole API, basic auth also works per request, an API key is not a credential in this mode, logout revokes the token, and a token past its session_ttl stops working. none: nothing is required and the caller runs as a writable loopback principal. trust _loopback: a local client with no credentials is let through, and a forwarded non-loopback client is not. Every mutating route refuses a read-only key with 403 without reaching its hook or touching the file (and the same request with a read-write key succeeds, so the 403 is about the key and not the payload), and allow_config_write: false refuses the settings editor with a valid key while the other mutations still work. The hashing is pinned on the state, not on timing: the authenticator's maps are keyed on the SHA-256 of the credential and hold no plaintext key or password, so a copied server struct cannot leak one. * Product code is untouched; the suite is `package api` with its own setup.
176 lines
8.1 KiB
Go
176 lines
8.1 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: "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)
|
|
}
|
|
}
|