Artifact f546b871500ecad53f526c50c35fd47514431300de87539e3c3075a74a16d5fc:
- File
internal/api/router_test.go
— part of check-in
[f073a79918]
at
2026-08-15 16:10:40
on branch trunk
— error taxonomy: 404s, themed error pages, message hygiene (tickets 020/040/070/090)
ops.ErrNotFound sentinel; router maps to 404, hides internals on 500; JSON 404 for unmatched API paths; themed web 404/500; zitcli.ErrNoEntry; clean codec parse messages; shunned-diff note. (user: dmestas, size: 7390) [annotate] [blame] [check-ins using]
package api import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "craftdesign.group/quarry/internal/ops" ) type routerIn struct { Repo string `json:"repo" validate:"required"` Limit int `json:"limit" validate:"min=0,max=100"` } type routerOut struct { Repo string `json:"repo"` Limit int `json:"limit"` } type postIn struct { Repo string `json:"repo" validate:"required"` Body string `json:"body" validate:"required"` } type postOut struct { Echo string `json:"echo"` } func routerRegistry(t *testing.T) *ops.Registry { t.Helper() r := ops.NewRegistry() if err := ops.Register(r, ops.Op[routerIn, routerOut]{ Name: "echo.get", Doc: "Echo query input.", Kind: ops.Query, Route: "GET /api/v1/r/{repo}/echo", Run: func(_ context.Context, in routerIn) (routerOut, error) { return routerOut(in), nil }, }); err != nil { t.Fatal(err) } if err := ops.Register(r, ops.Op[postIn, postOut]{ Name: "echo.post", Doc: "Echo body input.", Kind: ops.Command, Route: "POST /api/v1/r/{repo}/echo", Run: func(_ context.Context, in postIn) (postOut, error) { return postOut{Echo: in.Repo + ":" + in.Body}, nil }, }); err != nil { t.Fatal(err) } return r } func serve(t *testing.T, req *http.Request) *httptest.ResponseRecorder { t.Helper() h, err := Router(routerRegistry(t), "0.0.0-test") if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestGetOpPathAndQuery(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=5", nil)) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var out routerOut if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatal(err) } if out.Repo != "sqlite" || out.Limit != 5 { t.Fatalf("got %+v", out) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Fatalf("content-type %q", ct) } } func TestGetOpValidateFails400(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=101", nil)) if rec.Code != 400 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var e map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { t.Fatalf("error body not JSON: %s", rec.Body) } if e["error"] == nil { t.Fatalf("no error field: %v", e) } } func TestGetOpBadTypeFails400(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil)) if rec.Code != 400 { t.Fatalf("status %d", rec.Code) } } func TestPostOpFormBody(t *testing.T) { form := url.Values{"body": {"hello"}} req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := serve(t, req) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var out postOut json.Unmarshal(rec.Body.Bytes(), &out) if out.Echo != "sqlite:hello" { t.Fatalf("got %+v", out) } } func TestPostOpJSONBody(t *testing.T) { req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"body":"hi"}`)) req.Header.Set("Content-Type", "application/json") rec := serve(t, req) if rec.Code != 200 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var out postOut json.Unmarshal(rec.Body.Bytes(), &out) if out.Echo != "sqlite:hi" { t.Fatalf("got %+v", out) } } func TestPostOpJSONCannotOverridePathParam(t *testing.T) { // The path is authoritative for wildcard fields; a body value for the // same field must not win. req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{"repo":"evil","body":"x"}`)) req.Header.Set("Content-Type", "application/json") rec := serve(t, req) var out postOut json.Unmarshal(rec.Body.Bytes(), &out) if !strings.HasPrefix(out.Echo, "sqlite:") { t.Fatalf("path param overridden: %+v", out) } } func TestPostOpMissingRequired400(t *testing.T) { req := httptest.NewRequest("POST", "/api/v1/r/sqlite/echo", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") rec := serve(t, req) if rec.Code != 400 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } } func TestOpenAPIDocumentServed(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/openapi.json", nil)) if rec.Code != 200 { t.Fatalf("status %d", rec.Code) } var doc map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatal(err) } if doc["openapi"] != "3.1.0" { t.Fatalf("doc = %v", doc["openapi"]) } } // --- error taxonomy (tickets 020/040/070) --- func TestNotFoundIs404WithCleanJSON(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/nomatch/echo", nil)) _ = rec // route exists; entity checks below use the full registry } func TestUnmatchedAPIPathIsJSON404(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/nope", nil)) if rec.Code != 404 { t.Fatalf("status %d", rec.Code) } var e map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil { t.Fatalf("not JSON: %s", rec.Body) } if !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") { t.Fatalf("content-type %q", rec.Header().Get("Content-Type")) } } func TestOpErrorIs500WithoutInternals(t *testing.T) { r := ops.NewRegistry() if err := ops.Register(r, ops.Op[routerIn, routerOut]{ Name: "boom.get", Doc: "Always fails.", Kind: ops.Query, Route: "GET /api/v1/r/{repo}/boom", Run: func(_ context.Context, _ routerIn) (routerOut, error) { return routerOut{}, fmt.Errorf("zitcli: cat secret/path: exit status 1") }, }); err != nil { t.Fatal(err) } h, err := Router(r, "v") if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/boom", nil)) if rec.Code != 500 { t.Fatalf("status %d", rec.Code) } if strings.Contains(rec.Body.String(), "zitcli") || strings.Contains(rec.Body.String(), "secret") { t.Fatalf("internals leaked: %s", rec.Body) } } func TestNotFoundSentinelIs404(t *testing.T) { r := ops.NewRegistry() if err := ops.Register(r, ops.Op[routerIn, routerOut]{ Name: "gone.get", Doc: "Always missing.", Kind: ops.Query, Route: "GET /api/v1/r/{repo}/gone", Run: func(_ context.Context, _ routerIn) (routerOut, error) { return routerOut{}, fmt.Errorf("no such thing: %w", ops.ErrNotFound) }, }); err != nil { t.Fatal(err) } h, err := Router(r, "v") if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/r/x/gone", nil)) if rec.Code != 404 { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var e map[string]string json.Unmarshal(rec.Body.Bytes(), &e) if !strings.Contains(e["error"], "no such thing") { t.Fatalf("error body: %v", e) } } func TestValidationErrorMessageHygiene(t *testing.T) { rec := serve(t, httptest.NewRequest("GET", "/api/v1/r/sqlite/echo?limit=abc", nil)) if rec.Code != 400 { t.Fatalf("status %d", rec.Code) } body := rec.Body.String() for _, leak := range []string{"strconv", "ParseInt", "ops:"} { if strings.Contains(body, leak) { t.Fatalf("leaked %q: %s", leak, body) } } if !strings.Contains(body, "must be an integer") { t.Fatalf("unhelpful message: %s", body) } }