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. {"domain list", "ListDomains", http.MethodGet, "/api/domains", "", 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}, } 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) } }