1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
@@ -0,0 +1,129 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// errHook is what a component returns when it cannot do its job.
|
||||
var errHook = errors.New("component is unavailable")
|
||||
|
||||
// errMultiLine is what the config validator aggregates, one problem per line.
|
||||
var errMultiLine = errors.New("first problem\nsecond problem")
|
||||
|
||||
// setFail tells one hook to return an error, which is how the error branches of
|
||||
// a handler are reached without a real component failing underneath.
|
||||
func setFail(h *harness, hook string, err error) {
|
||||
h.fake.mu.Lock()
|
||||
h.fake.fail[hook] = err
|
||||
h.fake.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHookErrorsAreReported walks the routes whose hook can fail and pins what
|
||||
// each handler does with the error. The status codes are not uniform and that
|
||||
// is deliberate in two places: a component that could not answer is a 500 (the
|
||||
// operator has to know the console is lying), while a component that rejected
|
||||
// the *request* is a 400, and a domain lookup that failed is a 200 carrying the
|
||||
// reason, because "why is this name not blocked?" is the question the route
|
||||
// exists to answer.
|
||||
func TestHookErrorsAreReported(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fail string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
// Reads: the component could not produce an answer.
|
||||
{"dashboard", "Dashboard", http.MethodGet, "/api/dashboard", "", http.StatusInternalServerError},
|
||||
{"domain list", "ListDomains", http.MethodGet, "/api/domains", "", http.StatusInternalServerError},
|
||||
{"log search", "SearchLog", http.MethodGet, "/api/logs", "", http.StatusInternalServerError},
|
||||
// A domain check that fails still answers, with the reason: 200 here
|
||||
// is the point of the route.
|
||||
{"domain check answers 200 with the error", "DomainCheck", http.MethodGet, "/api/domains/check?name=a.example.com", "", http.StatusOK},
|
||||
// Writes: the component rejected what was asked for.
|
||||
{"blocklist refresh", "RefreshBlocklists", http.MethodPost, "/api/blocklists/refresh", "", http.StatusInternalServerError},
|
||||
{"blocklist toggle is a 404 for any failure", "ToggleBlocklist", http.MethodPost, "/api/blocklists/toggle", `{"name":"hosts","enabled":true}`, http.StatusNotFound},
|
||||
{"domain add", "AddDomain", http.MethodPost, "/api/domains", `{"name":"a.example.com","action":"block"}`, http.StatusBadRequest},
|
||||
{"domain delete", "DeleteDomain", http.MethodDelete, "/api/domains/a.example.com", "", http.StatusNotFound},
|
||||
{"clients put", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, http.StatusBadRequest},
|
||||
{"upstreams put", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`, http.StatusBadRequest},
|
||||
{"log clear", "ClearLog", http.MethodPost, "/api/logs/clear", "", http.StatusInternalServerError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
setFail(h, tc.fail, errHook)
|
||||
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
||||
if res.status != tc.want {
|
||||
t.Fatalf("%s %s with a failing %s hook = %d, want %d: %s",
|
||||
tc.method, tc.path, tc.fail, res.status, tc.want, res.body)
|
||||
}
|
||||
if got, _ := res.json(t)["error"].(string); got == "" {
|
||||
t.Errorf("response has no error message: %s", res.body)
|
||||
}
|
||||
// The server has to still be serving: the admin API is the only
|
||||
// way to fix a broken instance.
|
||||
if got := h.get("/api/whoami").status; got != http.StatusOK {
|
||||
t.Errorf("/whoami after a failing hook = %d, want 200", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookErrorsAreFieldedForConfigWrites checks the two config-writing routes
|
||||
// split a multi-line validator error into the fields the editor renders, so a
|
||||
// rejected write points at the offending lines instead of dumping prose.
|
||||
func TestHookErrorsAreFieldedForConfigWrites(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fail string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"acl", "PutACL", http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`},
|
||||
{"upstreams", "PutUpstream", http.MethodPut, "/api/upstreams", `{"policy":"parallel","servers":[]}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
setFail(h, tc.fail, errMultiLine)
|
||||
res := h.do(tc.method, tc.path, tc.body, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusBadRequest {
|
||||
t.Fatalf("%s %s = %d, want 400: %s", tc.method, tc.path, res.status, res.body)
|
||||
}
|
||||
fields, _ := res.json(t)["fields"].([]any)
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields = %v, want the two lines of the error", fields)
|
||||
}
|
||||
// Sorted, so the editor can diff two runs.
|
||||
if fields[0] != "first problem" || fields[1] != "second problem" {
|
||||
t.Errorf("fields = %v, want them split and sorted", fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanickingWriteHookIsRecovered checks the recoverer wraps the whole chain
|
||||
// and not just the read handlers the earlier test covers: a write that panics
|
||||
// must be a 500 and must not have half-applied anything.
|
||||
func TestPanickingWriteHookIsRecovered(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.fake.mu.Lock()
|
||||
h.fake.panicOn["PutACL"] = true
|
||||
h.fake.mu.Unlock()
|
||||
|
||||
res := h.do(http.MethodPut, "/api/clients", `{"groups":[],"default_policy":"allow"}`, "X-API-Key: rw-key")
|
||||
if res.status != http.StatusInternalServerError {
|
||||
t.Fatalf("a panicking write hook = %d, want 500: %s", res.status, res.body)
|
||||
}
|
||||
if len(h.fake.aclPut) != 0 {
|
||||
t.Error("a panicking write recorded a persisted ACL")
|
||||
}
|
||||
if got := h.get("/api/clients").status; got != http.StatusOK {
|
||||
t.Errorf("/clients after the panic = %d, want 200", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user