half broken
build / build-push (push) Failing after 23s

This commit is contained in:
developer committed 2026-09-27 17:17:53 +00:00
1 parent cc7466823b
commit e8c5b414d9
44 files changed
+7649 -344

No files matched your search

+129
View File
@@ -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)
}
}