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)
}
}