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) } }