Files
fancy-dns/internal/api/settings_test.go
T
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

493 lines
19 KiB
Go

package api
import (
"net/http"
"strings"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// post is the authenticated write the settings routes need.
func (h *harness) post(path string) response {
h.t.Helper()
return h.do(http.MethodPost, path, "", "X-API-Key: rw-key")
}
// newYAML renders a complete, valid document with one field changed, as it
// would sit in the config file. The settings editor sends a whole document, and
// so do these tests: a partial one lands on top of config.Default, which leaves
// the blocklist enabled and therefore invalid.
func newYAML(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)
}
return string(doc)
}
// newDoc is newYAML wrapped in the JSON envelope the settings routes expect.
func newDoc(t *testing.T, mutate func(*config.Config)) string {
t.Helper()
return settingsBody(t, mutate)
}
// TestSettingsGet pins the payload the settings editor renders from: the file
// on disk as the editable text, the effective document the running server
// actually has, and the defaults as the reset target.
func TestSettingsGet(t *testing.T) {
h := newHarness(t, nil)
res := h.get("/api/settings")
if res.status != http.StatusOK {
t.Fatalf("GET /api/settings = %d: %s", res.status, res.body)
}
body := res.json(t)
if body["path"] != h.path {
t.Errorf("path = %v, want %q", body["path"], h.path)
}
if body["config_yaml"] != h.readConfig() {
t.Error("config_yaml is not the document on disk")
}
if !strings.Contains(body["effective_yaml"].(string), "api_keys") {
t.Error("effective_yaml does not carry the running auth config")
}
if !strings.Contains(body["defaults_yaml"].(string), "session_ttl") {
t.Error("defaults_yaml does not look like a default document")
}
if body["reload_available"] != true {
t.Errorf("reload_available = %v, want true for a config with a file", body["reload_available"])
}
if body["writable"] != true {
t.Errorf("writable = %v, want true for a 0600 file in a temp dir", body["writable"])
}
if body["last_error"] != "" {
t.Errorf("last_error = %v, want empty on a fresh server", body["last_error"])
}
}
// TestSettingsGetWithoutAConfigFile pins the fallback for an instance running
// with no file: reload is unavailable and the editor shows the effective
// document as the editable text, so the UI still works.
func TestSettingsGetWithoutAConfigFile(t *testing.T) {
h := newHarnessWithFile(t, testConfig(t), "")
body := h.get("/api/settings").json(t)
if body["path"] != "" {
t.Errorf("path = %v, want empty", body["path"])
}
if body["reload_available"] != false {
t.Errorf("reload_available = %v, want false with no file", body["reload_available"])
}
if body["config_yaml"] != body["effective_yaml"] {
t.Error("config_yaml should fall back to the effective document when there is no file")
}
}
// TestSettingsPutRoundTrips is the happy path: a written document must reach
// the disk and the running config, and the version must move so the cluster
// notices. The round-trip is checked through config.Load, not by reading the
// store back, because a document that does not re-parse is the failure mode
// that costs an operator a restart.
func TestSettingsPutRoundTrips(t *testing.T) {
h := newHarness(t, nil)
before := h.store.Generation()
doc := newDoc(t, func(c *config.Config) {
c.Server.MaxConcurrent = 4242
c.Log.Level = "debug"
})
res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
}
if body := res.json(t); body["ok"] != true {
t.Fatalf("response = %v, want ok", body)
}
if h.store.Generation() != before+1 {
t.Errorf("generation = %d, want %d", h.store.Generation(), before+1)
}
// The file on disk parses back to what was asked for.
back, err := config.Load(h.path)
if err != nil {
t.Fatalf("the written document does not re-parse: %v", err)
}
if back.Server.MaxConcurrent != 4242 || back.Log.Level != "debug" {
t.Errorf("reloaded config = max_concurrent %d, log.level %q", back.Server.MaxConcurrent, back.Log.Level)
}
// And the running server agrees with the file.
if got := h.store.Get().Server.MaxConcurrent; got != 4242 {
t.Errorf("store max_concurrent = %d, want 4242", got)
}
// A follow-up GET re-renders the new document, so the editor does not
// snap back to the old text.
if got := h.get("/api/settings").json(t)["config_yaml"]; got != h.readConfig() {
t.Error("GET /api/settings after a write does not match the file")
}
}
// TestSettingsPutRejects walks the ways a settings write is refused. Each of
// them is a case where the operator would otherwise find out from a restart
// that the change was lost.
func TestSettingsPutRejects(t *testing.T) {
cases := []struct {
name string
body string
wantField string // substring the error must contain
}{
{"no body", "", "malformed request body"},
{"not json", "{", "malformed request body"},
{"unknown field", `{"config_yaml": "server:\n max_concurrent: 1\n", "comment": "x"}`, "malformed request body"},
{"empty document", `{"config_yaml": " \n"}`, "config_yaml is empty"},
{"broken yaml", `{"config_yaml": "api: [\n"}`, "cannot parse config"},
{
"invalid config",
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`,
"invalid config",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newHarness(t, nil)
before := h.readConfig()
res := h.do(http.MethodPut, "/api/settings", tc.body, "X-API-Key: rw-key")
if res.status != http.StatusBadRequest {
t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body)
}
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, tc.wantField) {
t.Errorf("error = %q, want it to mention %q", msg, tc.wantField)
}
if h.readConfig() != before {
t.Error("a refused write still touched the config file")
}
if h.store.Generation() != 0 {
t.Error("a refused write still swapped the config")
}
})
}
}
// TestSettingsPutValidationErrorsAreFielded checks the aggregated validator
// output reaches the editor as per-field messages, which is the whole point of
// writing a config through a form.
func TestSettingsPutValidationErrorsAreFielded(t *testing.T) {
h := newHarness(t, nil)
res := h.do(http.MethodPut, "/api/settings",
`{"config_yaml": "upstream:\n policy: sideways\n servers: []\n"}`, "X-API-Key: rw-key")
if res.status != http.StatusBadRequest {
t.Fatalf("PUT /api/settings = %d, want 400: %s", res.status, res.body)
}
fields, _ := res.json(t)["fields"].([]any)
if len(fields) < 2 {
t.Fatalf("fields = %v, want one message per validation error", fields)
}
joined := ""
for _, f := range fields {
s, _ := f.(string)
joined += s + "\n"
}
for _, want := range []string{"upstream.policy", "upstream.servers must not be empty"} {
if !strings.Contains(joined, want) {
t.Errorf("fields %q do not mention %q", joined, want)
}
}
}
// TestSettingsPutRefusedWhenWritesAreDisabled pins allow_config_write: false.
// A read-only key and a disabled write are different refusals, and an operator
// needs to be able to tell them apart from the 403 text.
func TestSettingsPutRefusedWhenWritesAreDisabled(t *testing.T) {
h := newHarness(t, func(c *config.Config) { c.API.AllowConfigWrite = false })
before := h.readConfig()
res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusForbidden {
t.Fatalf("PUT /api/settings = %d, want 403: %s", res.status, res.body)
}
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "disabled") {
t.Errorf("error = %q, want it to say writes are disabled", msg)
}
if h.readConfig() != before {
t.Error("a refused write still touched the config file")
}
}
// TestSettingsPutNeedsAWritableFile pins the 500 path: a valid document that
// cannot be persisted must say so rather than answering ok and losing the
// change. The harness can produce this without a permissions race by giving
// the store no path at all.
func TestSettingsPutNeedsAWritableFile(t *testing.T) {
h := newHarnessWithFile(t, testConfig(t), "")
res := h.do(http.MethodPut, "/api/settings", newDoc(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusInternalServerError {
t.Fatalf("PUT /api/settings with no file = %d, want 500: %s", res.status, res.body)
}
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "cannot persist") {
t.Errorf("error = %q, want it to say it cannot persist", msg)
}
if h.store.Generation() != 0 {
t.Error("a config that could not be written was swapped in anyway")
}
}
// TestSettingsValidate checks the dry run. It must never touch the running
// config or the file, and it reports a valid document as valid — including an
// empty one, which decodes onto the defaults, so a "clear the editor and
// save" lands on a complete default config rather than an error.
func TestSettingsValidate(t *testing.T) {
h := newHarness(t, nil)
before := h.readConfig()
t.Run("valid document", func(t *testing.T) {
res := h.do(http.MethodPost, "/api/settings/validate", newDoc(t, nil), "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
}
body := res.json(t)
if body["ok"] != true {
t.Errorf("ok = %v, want true: %v", body["ok"], body["errors"])
}
if errs, _ := body["errors"].([]any); len(errs) != 0 {
t.Errorf("errors = %v, want none", errs)
}
// Warnings are the point of a validator: a valid config can still be
// a bad idea, and this document leaves the blocklist off.
if warns, _ := body["warnings"].([]any); len(warns) == 0 {
t.Error("no warnings for a document with the blocklist disabled")
}
})
t.Run("empty document is not valid", func(t *testing.T) {
// Not the obvious answer, and worth pinning: an empty document
// decodes onto config.Default, and the shipped defaults enable the
// blocklist without a single source, so Validate rejects them. The
// UI therefore cannot be used to "reset to nothing"; a reset to
// defaults has to send a real default document.
res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": ""}`, "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
}
body := res.json(t)
if body["ok"] != false {
t.Errorf("ok = %v for an empty document, want false: %v", body["ok"], body["errors"])
}
})
t.Run("broken yaml", func(t *testing.T) {
res := h.do(http.MethodPost, "/api/settings/validate", `{"config_yaml": "api: [\n"}`, "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: the answer is a report, not a refusal: %s", res.status, res.body)
}
body := res.json(t)
if body["ok"] != false {
t.Errorf("ok = %v, want false", body["ok"])
}
errs, _ := body["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("errors = %v, want exactly one", errs)
}
first, _ := errs[0].(map[string]any)
if first["field"] != "config" {
t.Errorf("field = %v, want \"config\" for a parse error", first["field"])
}
})
t.Run("invalid config", func(t *testing.T) {
res := h.do(http.MethodPost, "/api/settings/validate",
`{"config_yaml": "blocklist:\n enabled: true\n lists: []\n"}`, "X-API-Key: rw-key")
if res.status != http.StatusOK {
t.Fatalf("validate = %d, want 200: %s", res.status, res.body)
}
body := res.json(t)
if body["ok"] != false {
t.Fatalf("ok = %v, want false: %s", body["ok"], res.body)
}
errs, _ := body["errors"].([]any)
if len(errs) == 0 {
t.Fatal("no errors reported for an invalid config")
}
first, _ := errs[0].(map[string]any)
joined := ""
for _, f := range errs {
m, _ := f.(map[string]any)
msg, _ := m["message"].(string)
joined += msg + "\n"
}
if !strings.Contains(joined, "blocklist is enabled but no lists") {
t.Errorf("errors = %q, want the blocklist complaint", joined)
}
// fieldOf falls back to "config" for a message with no dotted path,
// which is what this one is. Pinned so the editor's field highlighting
// does not quietly start pointing at a different thing.
if first["field"] != "config" {
t.Errorf("field = %v, want \"config\" for a message with no path", first["field"])
}
})
t.Run("malformed request body", func(t *testing.T) {
if res := h.post("/api/settings/validate"); res.status != http.StatusBadRequest {
t.Fatalf("validate with no body = %d, want 400: %s", res.status, res.body)
}
})
if h.readConfig() != before {
t.Error("validate touched the config file")
}
if h.store.Generation() != 0 {
t.Error("validate swapped the running config")
}
}
// TestSettingsReload checks the reload path end to end against a real file: a
// changed document on disk reaches the running config, a broken one leaves the
// running config alone and records why, and there is no file to reload from at
// all when the instance was started without one.
func TestSettingsReload(t *testing.T) {
t.Run("applies a changed document", func(t *testing.T) {
h := newHarness(t, nil)
h.writeConfig(t, newYAML(t, func(c *config.Config) { c.Log.Level = "debug" }))
gen := h.store.Generation()
res := h.post("/api/settings/reload")
if res.status != http.StatusOK {
t.Fatalf("reload = %d: %s", res.status, res.body)
}
if body := res.json(t); body["ok"] != true {
t.Fatalf("response = %v, want ok", body)
}
if got := h.store.Get().Log.Level; got != "debug" {
t.Errorf("running log level = %q, want debug", got)
}
if h.store.Generation() != gen+1 {
t.Errorf("generation = %d, want %d", h.store.Generation(), gen+1)
}
})
t.Run("a broken document keeps the running config", func(t *testing.T) {
h := newHarness(t, nil)
level := h.store.Get().Log.Level
gen := h.store.Generation()
h.writeConfig(t, "this: is: not: yaml:\n - [\n")
res := h.post("/api/settings/reload")
if res.status != http.StatusInternalServerError {
t.Fatalf("reload of a broken document = %d, want 500: %s", res.status, res.body)
}
if msg, _ := res.json(t)["error"].(string); !strings.Contains(msg, "reload failed") {
t.Errorf("error = %q, want it to say the reload failed", msg)
}
// A failed reload must not take the resolver down with it: the old
// config keeps serving and the reason is reported by /status.
if got := h.store.Get().Log.Level; got != level {
t.Errorf("running log level = %q, want the previous %q", got, level)
}
if h.store.Generation() != gen {
t.Errorf("generation = %d, want it unchanged at %d", h.store.Generation(), gen)
}
if h.store.LastError() == "" {
t.Error("a failed reload recorded no last_error")
}
if last := h.get("/api/status").json(t)["config"].(map[string]any)["last_error"]; last == "" {
t.Error("/status does not report the failed reload")
}
})
t.Run("no config file", func(t *testing.T) {
h := newHarnessWithFile(t, testConfig(t), "")
res := h.post("/api/settings/reload")
if res.status != http.StatusBadRequest {
t.Fatalf("reload with no file = %d, want 400: %s", res.status, res.body)
}
})
}
// TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart pins a known limitation
// (STATUS.md, "Auth configuration is frozen at New"): the authenticator, the
// base path and the trusted-proxy list are captured when New builds the server,
// so a settings write answers ok and the running server keeps the old values.
// The write endpoint cannot apply its own auth change; only a restart can.
//
// This test exists to make the limitation visible, not to endorse it. If the
// freezing is ever fixed, this test is the one that should fail, and the fix
// has to make each case below actually take effect.
func TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart(t *testing.T) {
t.Run("api key", func(t *testing.T) {
h := newHarness(t, nil)
doc := newDoc(t, func(c *config.Config) {
c.API.Auth = config.Auth{
Mode: "apikey",
APIKeys: []string{"new-key"},
SessionTTL: config.Duration(time.Hour),
TrustLoopback: false,
ReadOnlyAPIKeys: []string{"ro-key"},
}
})
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
}
// The store has the new key...
if got := h.store.Get().API.Auth.APIKeys; len(got) != 1 || got[0] != "new-key" {
t.Fatalf("store api_keys = %v, want the written key", got)
}
// ...and the running server still does not: a pinned limitation.
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: new-key"); res.status != http.StatusUnauthorized {
t.Errorf("the written key = %d, want 401 until a restart (pinned limitation): %s", res.status, res.body)
}
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Errorf("the original key = %d, want 200: %s", res.status, res.body)
}
// The login endpoint, which is what brute force goes through, is
// frozen the same way.
res := h.do(http.MethodPost, "/api/auth/login", `{"api_key":"new-key"}`)
if res.status != http.StatusUnauthorized {
t.Errorf("login with the written key = %d, want 401 until a restart: %s", res.status, res.body)
}
})
t.Run("base path", func(t *testing.T) {
h := newHarness(t, nil)
doc := newDoc(t, func(c *config.Config) { c.API.BasePath = "/admin" })
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
}
if got := h.get("/api/whoami").status; got != http.StatusOK {
t.Errorf("the old base path = %d, want 200: the mux is built once (pinned limitation)", got)
}
if got := h.get("/admin/whoami").status; got != http.StatusNotFound {
t.Errorf("the written base path = %d, want 404 until a restart (pinned limitation)", got)
}
})
t.Run("trusted proxies", func(t *testing.T) {
// Two requests a minute: the PUT below is the first, so the two
// spoofed reads that follow land in the same per-IP bucket as the
// loopback client if — and only if — the written trusted_proxies is
// not in effect.
h := newHarness(t, func(c *config.Config) {
c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)}
})
doc := newDoc(t, func(c *config.Config) {
c.API.RateLimit = config.AuthRate{PerMinute: 2, Lockout: config.Duration(time.Minute)}
// Trust every proxy, which would make any X-Forwarded-For
// authoritative if the list were re-read.
c.API.TrustedProxies = []string{"0.0.0.0/0"}
})
if res := h.do(http.MethodPut, "/api/settings", doc, "X-API-Key: rw-key"); res.status != http.StatusOK {
t.Fatalf("PUT /api/settings = %d: %s", res.status, res.body)
}
if got := h.store.Get().API.TrustedProxies; len(got) != 1 {
t.Fatalf("store trusted_proxies = %v", got)
}
spoof := "X-Forwarded-For: 198.51.100.7"
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof); res.status != http.StatusOK {
t.Fatalf("first spoofed request = %d, want 200: %s", res.status, res.body)
}
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", spoof)
if res.status != http.StatusTooManyRequests {
t.Errorf("the second spoofed request = %d, want 429: the forwarded address is still ignored, so it shares the loopback bucket (pinned limitation): %s",
res.status, res.body)
}
})
}