package apierror import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "go.bigb.es/auxilia/culpa" ) // TestRender_CodeToStatusMapping verifies each documented culpa code maps to // the expected HTTP status, and the response body has the RFC 7807 shape with // the code echoed under the "code" extension field. func TestRender_CodeToStatusMapping(t *testing.T) { cases := []struct { name string code string wantSt int wantTit string }{ {"not-found", "NOT_FOUND", http.StatusNotFound, http.StatusText(http.StatusNotFound)}, {"invalid", "INVALID", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}, {"validation", "VALIDATION", http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}, {"unauthorized", "UNAUTHORIZED", http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized)}, {"forbidden", "FORBIDDEN", http.StatusForbidden, http.StatusText(http.StatusForbidden)}, {"conflict", "CONFLICT", http.StatusConflict, http.StatusText(http.StatusConflict)}, {"too-large", "TOO_LARGE", http.StatusRequestEntityTooLarge, http.StatusText(http.StatusRequestEntityTooLarge)}, {"unknown-defaults-500", "WHO_KNOWS", http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := culpa.WithCode(culpa.WithPublic(culpa.New("boom"), "user-facing"), tc.code) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/things/42", nil) Render(rec, req, err) if got := rec.Code; got != tc.wantSt { t.Fatalf("status = %d; want %d", got, tc.wantSt) } if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" { t.Errorf("Content-Type = %q; want application/problem+json", ct) } var p Problem if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("unmarshal body: %v", err) } if p.Status != tc.wantSt { t.Errorf("body.status = %d; want %d", p.Status, tc.wantSt) } if p.Title != tc.wantTit { t.Errorf("body.title = %q; want %q", p.Title, tc.wantTit) } if p.Instance != "/api/v1/things/42" { t.Errorf("body.instance = %q; want /api/v1/things/42", p.Instance) } // 5xx codes have their detail sanitized to the constant message. if tc.wantSt >= 500 { if p.Detail != "internal server error" { t.Errorf("body.detail = %q; want %q", p.Detail, "internal server error") } // Unknown code is not echoed as the public error code. if p.Code == "WHO_KNOWS" { t.Errorf("body.code = WHO_KNOWS leaked for 5xx response") } } else { if p.Code != tc.code { t.Errorf("body.code = %q; want %q", p.Code, tc.code) } if p.Detail != "user-facing" { t.Errorf("body.detail = %q; want %q", p.Detail, "user-facing") } } }) } } // TestRender_5xxSanitizesDetail proves an internal error never leaks the // underlying message (which may contain credentials or stack-like text) // through the response body. func TestRender_5xxSanitizesDetail(t *testing.T) { sensitive := "DB password=hunter2 connection refused" err := culpa.Wrap(culpa.New(sensitive), "open db") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/whatever", nil) Render(rec, req, err) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d; want 500", rec.Code) } body := rec.Body.String() if strings.Contains(body, "hunter2") || strings.Contains(body, "password") { t.Fatalf("response body leaked sensitive substring; body=%q", body) } var p Problem if err := json.Unmarshal([]byte(body), &p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.Detail != "internal server error" { t.Errorf("detail = %q; want sanitized", p.Detail) } } // TestRender_NoCodeDefaultsTo500 ensures errors without a CodeDetail still // produce a valid problem document at 500. func TestRender_NoCodeDefaultsTo500(t *testing.T) { err := culpa.New("plain") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) Render(rec, req, err) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d; want 500", rec.Code) } var p Problem if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.Status != 500 { t.Errorf("body.status = %d; want 500", p.Status) } if p.Detail != "internal server error" { t.Errorf("detail = %q; want sanitized", p.Detail) } } // TestRender_PublicDetailUsedForClientErrors confirms PublicDetail is what // shows up in the client-facing detail for non-5xx responses. func TestRender_PublicDetailUsedForClientErrors(t *testing.T) { err := culpa.WithCode(culpa.WithPublic(culpa.New("internal"), "session not found"), "NOT_FOUND") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions/abc", nil) Render(rec, req, err) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d; want 404", rec.Code) } var p Problem if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil { t.Fatalf("unmarshal: %v", err) } if p.Detail != "session not found" { t.Errorf("detail = %q; want public message", p.Detail) } if p.Code != "NOT_FOUND" { t.Errorf("code = %q; want NOT_FOUND", p.Code) } }