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.
This commit is contained in:
1 parent
1e67a5d0f9
commit
48d37db15b
3 files changed
+1052
No files matched your search
@@ -0,0 +1,414 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func basicHeader(user, pass string) string {
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeAPIKey(c *config.Config) {
|
||||||
|
c.API.Auth = config.Auth{
|
||||||
|
Mode: "apikey",
|
||||||
|
APIKeys: []string{"rw-key"},
|
||||||
|
ReadOnlyAPIKeys: []string{"ro-key"},
|
||||||
|
SessionTTL: config.Duration(time.Hour),
|
||||||
|
TrustLoopback: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modeBasic(c *config.Config) {
|
||||||
|
c.API.Auth = config.Auth{
|
||||||
|
Mode: "basic",
|
||||||
|
Username: "admin",
|
||||||
|
Password: "s3cret",
|
||||||
|
SessionTTL: config.Duration(time.Hour),
|
||||||
|
TrustLoopback: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthModeAPIKey covers every way a request can present a key in apikey
|
||||||
|
// mode, and the invariant that matters: the right key always works and every
|
||||||
|
// other credential never does.
|
||||||
|
func TestAuthModeAPIKey(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want int
|
||||||
|
wantName string
|
||||||
|
wantWrite bool
|
||||||
|
wantAuthMd string
|
||||||
|
}{
|
||||||
|
{name: "no credentials", header: "", want: http.StatusUnauthorized},
|
||||||
|
{name: "correct key", header: "X-API-Key: rw-key", want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
|
||||||
|
{name: "read-only key", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
|
||||||
|
{name: "wrong key", header: "X-API-Key: nope", want: http.StatusUnauthorized},
|
||||||
|
{name: "key prefix", header: "X-API-Key: rw-ke", want: http.StatusUnauthorized},
|
||||||
|
{name: "key with suffix", header: "X-API-Key: rw-keyx", want: http.StatusUnauthorized},
|
||||||
|
{name: "key case folded", header: "X-API-Key: RW-KEY", want: http.StatusUnauthorized},
|
||||||
|
{name: "read-only key keeps its label", header: "X-API-Key: ro-key", want: http.StatusOK, wantName: "ro-1", wantWrite: false, wantAuthMd: "apikey"},
|
||||||
|
// curl -u any:KEY is a common client convention and is supported.
|
||||||
|
{name: "key in the basic password field", header: "Authorization: " + basicHeader("any", "rw-key"), want: http.StatusOK, wantName: "rw-1", wantWrite: true, wantAuthMd: "apikey"},
|
||||||
|
{name: "wrong basic password", header: "Authorization: " + basicHeader("any", "nope"), want: http.StatusUnauthorized},
|
||||||
|
{name: "unsupported scheme", header: "Authorization: Digest whatever", want: http.StatusUnauthorized},
|
||||||
|
{name: "malformed authorization", header: "Authorization: Bearer", want: http.StatusUnauthorized},
|
||||||
|
{name: "unknown bearer token", header: "Authorization: Bearer deadbeef", want: http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newHarness(t, modeAPIKey)
|
||||||
|
res := h.do(http.MethodGet, "/api/whoami", "", tc.header)
|
||||||
|
if res.status != tc.want {
|
||||||
|
t.Fatalf("GET /api/whoami with %q = %d, want %d: %s", tc.header, res.status, tc.want, res.body)
|
||||||
|
}
|
||||||
|
if tc.want != http.StatusOK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
who := res.json(t)
|
||||||
|
if who["name"] != tc.wantName {
|
||||||
|
t.Errorf("name = %v, want %v", who["name"], tc.wantName)
|
||||||
|
}
|
||||||
|
if who["can_write"] != tc.wantWrite {
|
||||||
|
t.Errorf("can_write = %v, want %v", who["can_write"], tc.wantWrite)
|
||||||
|
}
|
||||||
|
if who["auth_mode"] != tc.wantAuthMd {
|
||||||
|
t.Errorf("auth_mode = %v, want %v", who["auth_mode"], tc.wantAuthMd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthModeBasic covers the password login, the bearer session it issues and
|
||||||
|
// logout, plus the per-request basic form.
|
||||||
|
func TestAuthModeBasic(t *testing.T) {
|
||||||
|
h := newHarness(t, modeBasic)
|
||||||
|
|
||||||
|
// Wrong password is refused and does not issue a token.
|
||||||
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"wrong"}`)
|
||||||
|
if res.status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad password login = %d, want 401: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"nobody","password":"s3cret"}`)
|
||||||
|
if res.status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("bad username login = %d, want 401: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
res = h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
|
||||||
|
if res.status != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
body := res.json(t)
|
||||||
|
token, _ := body["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("login returned no token: %s", res.body)
|
||||||
|
}
|
||||||
|
if body["mode"] != "basic" || body["read_only"] != false {
|
||||||
|
t.Errorf("login payload mode/read_only = %v/%v", body["mode"], body["read_only"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is a bearer credential for the whole API.
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
||||||
|
t.Errorf("bearer whoami = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
// Basic auth also works per request, without a session.
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: "+basicHeader("admin", "s3cret")); res.status != http.StatusOK {
|
||||||
|
t.Errorf("basic whoami = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
// An API key is not a credential in basic mode.
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("api key in basic mode = %d, want 401", res.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes the session; the token must stop working.
|
||||||
|
if res := h.do(http.MethodPost, "/api/auth/logout", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
||||||
|
t.Fatalf("logout = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("revoked token = %d, want 401", res.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSessionExpiry checks a session stops being accepted once it is past its
|
||||||
|
// TTL, so a stolen token is not valid forever.
|
||||||
|
func TestSessionExpiry(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
modeBasic(c)
|
||||||
|
c.API.Auth.SessionTTL = config.Duration(10 * time.Millisecond)
|
||||||
|
})
|
||||||
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":"s3cret"}`)
|
||||||
|
if res.status != http.StatusOK {
|
||||||
|
t.Fatalf("login = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
token, _ := res.json(t)["token"].(string)
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token); res.status != http.StatusOK {
|
||||||
|
t.Fatalf("fresh session = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
waitFor(t, time.Second, "the session to expire", func() bool {
|
||||||
|
return h.do(http.MethodGet, "/api/whoami", "", "Authorization: Bearer "+token).status == http.StatusUnauthorized
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthModeNone pins mode "none": nothing is required, and the caller runs
|
||||||
|
// as a local principal that may write.
|
||||||
|
func TestAuthModeNone(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
|
||||||
|
})
|
||||||
|
res := h.do(http.MethodGet, "/api/whoami", "")
|
||||||
|
if res.status != http.StatusOK {
|
||||||
|
t.Fatalf("unauthenticated whoami in mode none = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
who := res.json(t)
|
||||||
|
if who["mode"] != "loopback" || who["can_write"] != true {
|
||||||
|
t.Errorf("whoami = %+v, want a writable loopback principal", who)
|
||||||
|
}
|
||||||
|
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: totally-wrong"); res.status != http.StatusOK {
|
||||||
|
t.Errorf("a rejected key in mode none = %d, want 200: the mode wins", res.status)
|
||||||
|
}
|
||||||
|
// Basic auth is not a configured scheme in this mode.
|
||||||
|
if res := h.do(http.MethodPost, "/api/auth/login", `{"username":"a","password":"b"}`); res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("login in mode none = %d, want 401", res.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginRequestValidation covers the login endpoint's own input checks.
|
||||||
|
func TestLoginRequestValidation(t *testing.T) {
|
||||||
|
h := newHarness(t, modeAPIKey)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"empty body", ``, http.StatusBadRequest},
|
||||||
|
{"no credentials", `{}`, http.StatusBadRequest},
|
||||||
|
{"malformed json", `{`, http.StatusBadRequest},
|
||||||
|
{"unknown field", `{"api_key":"rw-key","nope":1}`, http.StatusBadRequest},
|
||||||
|
{"wrong key", `{"api_key":"nope"}`, http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
res := h.do(http.MethodPost, "/api/auth/login", tc.body)
|
||||||
|
if res.status != tc.want {
|
||||||
|
t.Errorf("login %q = %d, want %d: %s", tc.body, res.status, tc.want, res.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadOnlyKeyCannotWrite walks every mutating route with a read-only key:
|
||||||
|
// each one must be refused and none of them may reach its hook.
|
||||||
|
func TestReadOnlyKeyCannotWrite(t *testing.T) {
|
||||||
|
writes := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
hook string
|
||||||
|
}{
|
||||||
|
{"cache flush", http.MethodPost, "/api/cache/flush", "", "FlushCache"},
|
||||||
|
{"logs clear", http.MethodPost, "/api/logs/clear", "", "ClearLog"},
|
||||||
|
{"domain add", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, "AddDomain"},
|
||||||
|
{"domain delete", http.MethodDelete, "/api/domains/a.example.com", "", "DeleteDomain"},
|
||||||
|
{"blocklist refresh", http.MethodPost, "/api/blocklists/refresh", "", "RefreshBlocklists"},
|
||||||
|
{"blocklist toggle", http.MethodPost, "/api/blocklists/toggle", `{"name":"a","enabled":true}`, "ToggleBlocklist"},
|
||||||
|
{"clients put", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "PutACL"},
|
||||||
|
{"upstreams put", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, "PutUpstream"},
|
||||||
|
{"settings put", http.MethodPut, "/api/settings", settingsBody(t, nil), ""},
|
||||||
|
{"settings reload", http.MethodPost, "/api/settings/reload", "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range writes {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
h := newHarness(t, modeAPIKey)
|
||||||
|
before := h.readConfig()
|
||||||
|
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: ro-key")
|
||||||
|
if res.status != http.StatusForbidden {
|
||||||
|
t.Fatalf("%s %s with a read-only key = %d, want 403: %s", tc.method, tc.path, res.status, res.body)
|
||||||
|
}
|
||||||
|
if got := res.json(t)["error"]; got != "this key is read-only" {
|
||||||
|
t.Errorf("error = %v, want \"this key is read-only\"", got)
|
||||||
|
}
|
||||||
|
if tc.hook != "" && h.fake.count(tc.hook) != 0 {
|
||||||
|
t.Errorf("refused request still reached hook %s", tc.hook)
|
||||||
|
}
|
||||||
|
if h.readConfig() != before {
|
||||||
|
t.Errorf("%s %s rewrote the config file", tc.method, tc.path)
|
||||||
|
}
|
||||||
|
// The same request with a read-write key is allowed, so the 403
|
||||||
|
// above is about the key and not about the payload.
|
||||||
|
if res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||||
|
t.Errorf("%s %s with a read-write key = %d: %s", tc.method, tc.path, res.status, res.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigWriteDisabled checks allow_config_write: false refuses the settings
|
||||||
|
// editor with a valid key, while the other mutations still work.
|
||||||
|
func TestConfigWriteDisabled(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
modeAPIKey(c)
|
||||||
|
c.API.AllowConfigWrite = false
|
||||||
|
})
|
||||||
|
before := h.readConfig()
|
||||||
|
res := h.do(http.MethodPut, "/api/settings", settingsBody(t, nil), "X-API-Key: rw-key")
|
||||||
|
if res.status != http.StatusForbidden {
|
||||||
|
t.Fatalf("settings put = %d, want 403: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
if got := res.json(t)["error"]; got != "config writes are disabled" {
|
||||||
|
t.Errorf("error = %v, want \"config writes are disabled\"", got)
|
||||||
|
}
|
||||||
|
if h.readConfig() != before {
|
||||||
|
t.Error("a refused settings write still rewrote the config file")
|
||||||
|
}
|
||||||
|
// The gate is specific to the config document.
|
||||||
|
if res := h.do(http.MethodPost, "/api/cache/flush", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
|
||||||
|
t.Errorf("cache flush with config writes disabled = %d, want 200", res.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrustLoopback checks trust_loopback lets a local client through with no
|
||||||
|
// credentials at all, and that a *wrong* credential is still refused: the
|
||||||
|
// loopback exemption is a fallback, not an override.
|
||||||
|
func TestTrustLoopback(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
modeAPIKey(c)
|
||||||
|
c.API.Auth.TrustLoopback = true
|
||||||
|
})
|
||||||
|
res := h.do(http.MethodGet, "/api/whoami", "")
|
||||||
|
if res.status != http.StatusOK {
|
||||||
|
t.Fatalf("loopback whoami without a key = %d, want 200: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
// A key that does not match is not accepted as a key: the loopback
|
||||||
|
// exemption is the fallback for a request with no usable credential, so a
|
||||||
|
// local client lands on the loopback principal rather than a named one.
|
||||||
|
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusOK {
|
||||||
|
t.Fatalf("loopback with a wrong key = %d, want 200: %s", res.status, res.body)
|
||||||
|
} else if name := res.json(t)["name"]; name != "loopback" {
|
||||||
|
t.Errorf("a wrong key on loopback ran as %v, want the loopback principal", name)
|
||||||
|
}
|
||||||
|
// Without trust_loopback the same request is refused.
|
||||||
|
hNoTrust := newHarness(t, modeAPIKey)
|
||||||
|
if res := hNoTrust.do(http.MethodGet, "/api/whoami", "", "X-API-Key: nope"); res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("wrong key without trust_loopback = %d, want 401", res.status)
|
||||||
|
}
|
||||||
|
// A trusted proxy in front of a loopback client does not extend the
|
||||||
|
// exemption to the forwarded address, which is not loopback.
|
||||||
|
h2 := newHarness(t, func(c *config.Config) {
|
||||||
|
modeAPIKey(c)
|
||||||
|
c.API.Auth.TrustLoopback = true
|
||||||
|
c.API.TrustedProxies = []string{"127.0.0.0/8"}
|
||||||
|
})
|
||||||
|
res = h2.do(http.MethodGet, "/api/whoami", "", "X-Forwarded-For: 198.51.100.7")
|
||||||
|
if res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("forwarded non-loopback client = %d, want 401", res.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPIKeysAreNotStoredInPlaintext pins the hashing: the authenticator keys
|
||||||
|
// its maps on the SHA-256 of the credential, so the configured key never sits
|
||||||
|
// in the long-lived structure as itself.
|
||||||
|
func TestAPIKeysAreNotStoredInPlaintext(t *testing.T) {
|
||||||
|
a := newAuthenticator(config.Auth{
|
||||||
|
Mode: "apikey",
|
||||||
|
APIKeys: []string{"rw-key"},
|
||||||
|
ReadOnlyAPIKeys: []string{"ro-key"},
|
||||||
|
Username: "admin",
|
||||||
|
Password: "s3cret",
|
||||||
|
SessionTTL: config.Duration(time.Hour),
|
||||||
|
})
|
||||||
|
if label, ok := a.readWrite[hashKey("rw-key")]; !ok || label != "rw-1" {
|
||||||
|
t.Errorf("readWrite is not keyed on the key hash: %v", a.readWrite)
|
||||||
|
}
|
||||||
|
if label, ok := a.readOnly[hashKey("ro-key")]; !ok || label != "ro-1" {
|
||||||
|
t.Errorf("readOnly is not keyed on the key hash: %v", a.readOnly)
|
||||||
|
}
|
||||||
|
if a.passwordHash == "s3cret" || a.passwordHash != hashKey("s3cret") {
|
||||||
|
t.Errorf("passwordHash = %q, want the sha256 of the password", a.passwordHash)
|
||||||
|
}
|
||||||
|
for _, stored := range append(mapKeys(a.readWrite), mapKeys(a.readOnly)...) {
|
||||||
|
if stored == "rw-key" || stored == "ro-key" || stored == "s3cret" {
|
||||||
|
t.Errorf("a credential is stored in plaintext: %q", stored)
|
||||||
|
}
|
||||||
|
if len(stored) != 64 {
|
||||||
|
t.Errorf("stored form %q is not a hex sha256", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// constantTimeEqual is the comparison helper: equal only when equal.
|
||||||
|
if !constantTimeEqual("abc", "abc") {
|
||||||
|
t.Error("constantTimeEqual(abc, abc) = false")
|
||||||
|
}
|
||||||
|
if constantTimeEqual("abc", "abd") || constantTimeEqual("abc", "ab") || constantTimeEqual("", "x") {
|
||||||
|
t.Error("constantTimeEqual accepted a mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapKeys(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPIModeReported checks AuthMode/whoami report the configured mode, which
|
||||||
|
// is what the console uses to decide whether to offer a login form.
|
||||||
|
func TestAPIModeReported(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
mode string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"apikey", "apikey"},
|
||||||
|
{"basic", "basic"},
|
||||||
|
{"none", "none"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.mode, func(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
modeAPIKey(c)
|
||||||
|
if tc.mode == "basic" {
|
||||||
|
modeBasic(c)
|
||||||
|
} else if tc.mode == "none" {
|
||||||
|
c.API.Auth = config.Auth{Mode: "none", SessionTTL: config.Duration(time.Hour)}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if got := h.srv.AuthMode(); got != tc.want {
|
||||||
|
t.Errorf("AuthMode() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
auth := "X-API-Key: rw-key"
|
||||||
|
if tc.mode == "basic" {
|
||||||
|
auth = "Authorization: " + basicHeader("admin", "s3cret")
|
||||||
|
} else if tc.mode == "none" {
|
||||||
|
auth = ""
|
||||||
|
}
|
||||||
|
res := h.do(http.MethodGet, "/api/whoami", "", auth)
|
||||||
|
if res.status != http.StatusOK {
|
||||||
|
t.Fatalf("whoami = %d: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
if got := res.json(t)["auth_mode"]; got != tc.want {
|
||||||
|
t.Errorf("whoami auth_mode = %v, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBasicAuthDisabledWithoutPassword checks basic mode with no configured
|
||||||
|
// password refuses rather than accepting an empty one.
|
||||||
|
func TestBasicAuthDisabledWithoutPassword(t *testing.T) {
|
||||||
|
h := newHarness(t, func(c *config.Config) {
|
||||||
|
c.API.Auth = config.Auth{Mode: "basic", Username: "admin", SessionTTL: config.Duration(time.Hour)}
|
||||||
|
})
|
||||||
|
if err := h.cfg.Validate(); err != nil && !strings.Contains(err.Error(), "username") {
|
||||||
|
t.Fatalf("unexpected validation error: %v", err)
|
||||||
|
}
|
||||||
|
res := h.do(http.MethodPost, "/api/auth/login", `{"username":"admin","password":""}`)
|
||||||
|
if res.status != http.StatusUnauthorized {
|
||||||
|
t.Errorf("login with no password configured = %d, want 401: %s", res.status, res.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
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.
|
||||||
|
dashRange []string
|
||||||
|
domainQuery []DomainQuery
|
||||||
|
domainAdd []DomainInput
|
||||||
|
domainDel []domainDelete
|
||||||
|
aclPut []aclPut
|
||||||
|
upstreamPut []config.Upstream
|
||||||
|
logQuery []LogQuery
|
||||||
|
refresh [][]string
|
||||||
|
toggle []toggleCall
|
||||||
|
cacheTop []int
|
||||||
|
clearLog int
|
||||||
|
flushes int
|
||||||
|
logStats 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
|
||||||
|
|
||||||
|
// stream plumbing for the SSE test.
|
||||||
|
stream chan LogEntry
|
||||||
|
unsub chan struct{}
|
||||||
|
unsubOnce sync.Once
|
||||||
|
subs int
|
||||||
|
}
|
||||||
|
|
||||||
|
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{},
|
||||||
|
stream: make(chan LogEntry, 16),
|
||||||
|
unsub: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Dashboard: func(_ context.Context, rangeKey string) (any, error) {
|
||||||
|
f.record("Dashboard", func() { f.dashRange = append(f.dashRange, rangeKey) })
|
||||||
|
return map[string]any{"range": rangeKey}, f.err("Dashboard")
|
||||||
|
},
|
||||||
|
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")
|
||||||
|
},
|
||||||
|
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")
|
||||||
|
},
|
||||||
|
SearchLog: func(_ context.Context, q LogQuery) (any, error) {
|
||||||
|
f.record("SearchLog", func() { f.logQuery = append(f.logQuery, q) })
|
||||||
|
return map[string]any{"entries": []any{}}, f.err("SearchLog")
|
||||||
|
},
|
||||||
|
LogStats: func() any {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.logStats++
|
||||||
|
f.mu.Unlock()
|
||||||
|
return map[string]any{"qps": 1}
|
||||||
|
},
|
||||||
|
SubscribeLog: func() (<-chan LogEntry, func()) {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.subs++
|
||||||
|
f.calls = append(f.calls, "SubscribeLog")
|
||||||
|
f.mu.Unlock()
|
||||||
|
return f.stream, func() {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.subs--
|
||||||
|
f.mu.Unlock()
|
||||||
|
f.unsubOnce.Do(func() { close(f.unsub) })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ClearLog: func() error {
|
||||||
|
f.record("ClearLog", func() { f.clearLog++ })
|
||||||
|
return f.err("ClearLog")
|
||||||
|
},
|
||||||
|
CacheStats: func() any {
|
||||||
|
f.record("CacheStats", func() {})
|
||||||
|
return map[string]any{"entries": 0}
|
||||||
|
},
|
||||||
|
CacheTop: func(limit int) any {
|
||||||
|
f.record("CacheTop", func() { f.cacheTop = append(f.cacheTop, limit) })
|
||||||
|
return []any{}
|
||||||
|
},
|
||||||
|
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() {}) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribers is the number of live SSE subscribers; it must be back to zero
|
||||||
|
// once a client has disconnected.
|
||||||
|
func (f *fakeHooks) subscribers() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.subs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeHooks) statsCalls() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.logStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user