1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
@@ -0,0 +1,492 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user