milkclient / backend /internal /api /error_test.go
AzureAD\AdityaDevarshi
Phase 5: consistent error envelope + validation + leak fix + /api 404
00c2742
Raw
History Blame Contribute Delete
5.51 kB
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"milkway/internal/config"
"milkway/internal/models"
)
// decodeEnvelope decodes the standard error envelope {"error":{...}} from a
// recorded response body, failing the test if it does not match the contract.
func decodeEnvelope(t *testing.T, body []byte) apiError {
t.Helper()
var env struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode error envelope: %v (body=%s)", err, body)
}
return env.Error
}
// TestWriteErrorCodeFromStatus verifies writeError emits the {"error":{...}}
// envelope and derives the code purely from the HTTP status.
func TestWriteErrorCodeFromStatus(t *testing.T) {
tests := []struct {
status int
wantCode string
}{
{http.StatusBadRequest, CodeValidation},
{http.StatusUnauthorized, CodeUnauthenticated},
{http.StatusForbidden, CodeForbidden},
{http.StatusNotFound, CodeNotFound},
{http.StatusConflict, CodeConflict},
{http.StatusInternalServerError, CodeInternal},
{http.StatusTeapot, CodeInternal}, // any unmapped status falls back to INTERNAL
}
for _, tc := range tests {
rr := httptest.NewRecorder()
writeError(rr, tc.status, "boom")
if rr.Code != tc.status {
t.Errorf("status %d: recorder code = %d", tc.status, rr.Code)
}
got := decodeEnvelope(t, rr.Body.Bytes())
if got.Code != tc.wantCode {
t.Errorf("status %d: code = %q, want %q", tc.status, got.Code, tc.wantCode)
}
if got.Message != "boom" {
t.Errorf("status %d: message = %q, want %q", tc.status, got.Message, "boom")
}
if got.Ref != "" {
t.Errorf("status %d: ref = %q, want empty (only INTERNAL via internalError sets ref)", tc.status, got.Ref)
}
}
}
// testServer builds a Server with a nil store; the routes exercised here
// (static handler / unmatched API path) never touch the database.
func testServer() *Server {
return NewServer(&config.Config{Profile: "test"}, nil)
}
// TestUnmatchedAPIPathReturns404Envelope asserts that an unknown /api/* path is
// served as a JSON 404 NOT_FOUND envelope (never the SPA HTML), while a non-API
// path falls through to the HTML placeholder.
func TestUnmatchedAPIPathReturns404Envelope(t *testing.T) {
// A real (empty) static dir with no index.html: the path-traversal guard
// passes and the handler falls through to the built-in placeholder HTML.
h := testServer().Routes(t.TempDir())
t.Run("unmatched /api path is a NOT_FOUND envelope", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/does-not-exist", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
ct := rr.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Errorf("content-type = %q, want application/json", ct)
}
got := decodeEnvelope(t, rr.Body.Bytes())
if got.Code != CodeNotFound {
t.Errorf("code = %q, want %q", got.Code, CodeNotFound)
}
if got.Message == "" {
t.Error("message is empty")
}
})
t.Run("non-api path serves HTML", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/some/spa/route", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
ct := rr.Header().Get("Content-Type")
if !strings.HasPrefix(ct, "text/html") {
t.Errorf("content-type = %q, want text/html", ct)
}
if !strings.Contains(rr.Body.String(), "<html") {
t.Errorf("body is not HTML: %s", rr.Body.String())
}
})
}
// TestValidateStructFieldErrors covers the validator -> envelope mapping: a
// missing required field and a bad oneof both yield a 400 VALIDATION naming the
// offending json field.
func TestValidateStructFieldErrors(t *testing.T) {
t.Run("missing required name -> 400 VALIDATION naming name", func(t *testing.T) {
rr := httptest.NewRecorder()
// Customer with no Name (required) — Unit has no constraint on Customer.
ok := validateStruct(rr, models.Customer{})
if ok {
t.Fatal("validateStruct returned true for an invalid struct")
}
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
got := decodeEnvelope(t, rr.Body.Bytes())
if got.Code != CodeValidation {
t.Errorf("code = %q, want %q", got.Code, CodeValidation)
}
if got.Field != "name" {
t.Errorf("field = %q, want %q", got.Field, "name")
}
if !strings.Contains(got.Message, "name") {
t.Errorf("message = %q, want it to mention the field", got.Message)
}
})
t.Run("bad oneof unit -> 400 VALIDATION naming unit", func(t *testing.T) {
rr := httptest.NewRecorder()
// Product with a valid name but an invalid unit (oneof=litre kg piece).
ok := validateStruct(rr, models.Product{Name: "Milk", Unit: "gallon"})
if ok {
t.Fatal("validateStruct returned true for an invalid unit")
}
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
got := decodeEnvelope(t, rr.Body.Bytes())
if got.Code != CodeValidation {
t.Errorf("code = %q, want %q", got.Code, CodeValidation)
}
if got.Field != "unit" {
t.Errorf("field = %q, want %q", got.Field, "unit")
}
})
t.Run("valid struct passes", func(t *testing.T) {
rr := httptest.NewRecorder()
if !validateStruct(rr, models.Customer{Name: "Ravi"}) {
t.Fatalf("validateStruct returned false for a valid customer (body=%s)", rr.Body.String())
}
})
}