Spaces:
Runtime error
Runtime error
AzureAD\AdityaDevarshi
Phase 5: consistent error envelope + validation + leak fix + /api 404
00c2742 | 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()) | |
| } | |
| }) | |
| } | |