DO NOT SYNC THIS BEFORE MIGRATING. Removing fancy-dns-zones and fancy-dns-rpz from the kustomization lets a pruning GitOps tool delete them, and they hold the only copy of the zones and address redirects until the objects exist. Follow deploy/MIGRATION.md: install the definitions, run `fancy-dns-console migrate` against the live cluster, then roll this out. The StatefulSet starts the resolver with -kube and no longer mounts the two ConfigMaps; its service account loses the ConfigMap role and keeps only the read-only Role on the fancy-dns objects (crds/rbac.yaml). The kustomization now installs the definitions and that Role. The shipped config no longer lists the migrated sources. Config validation no longer refuses an enabled blocklist with no sources in the file, because BlockList, DomainRule and AddressOverride objects can supply every rule and the file cannot know. The existing "no source" warning says so. That removed the only validation message the settings tests used as a fixture; they now use an invalid acl.default_policy, and an empty settings document is refused by the handler instead of by that check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
491 lines
19 KiB
Go
491 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": "acl:\n default_policy: maybe\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) {
|
|
// An empty document would decode onto the defaults and be valid, so the
|
|
// editor could be used to wipe the config by accident. It is refused
|
|
// outright; 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": "acl:\n default_policy: maybe\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, "acl.default_policy must be") {
|
|
t.Errorf("errors = %q, want the default_policy 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)
|
|
}
|
|
})
|
|
}
|