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