Skip to content

Commit 5126428

Browse files
Merge pull request #441 from trakrf/fix/tra-883-oauth-415-detail
fix(api): TRA-883 — oauth/token 415 detail names both accepted media types
2 parents 723e3ea + b37a892 commit 5126428

4 files changed

Lines changed: 57 additions & 1 deletion

File tree

backend/internal/middleware/middleware.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ const bulkCSVUploadPath = "/api/v1/assets/bulk"
113113
// remains JSON-only.
114114
const oauthTokenPath = "/api/v1/oauth/token"
115115

116+
// oauthTokenUnsupportedMediaDetail is the 415 detail for oauthTokenPath. Unlike
117+
// the JSON-only write paths, this endpoint accepts two media types, so the
118+
// detail names both rather than under-reporting application/json (TRA-883).
119+
const oauthTokenUnsupportedMediaDetail = "Content-Type must be application/json or application/x-www-form-urlencoded"
120+
116121
// ContentType enforces declared Content-Type per method (BB32 D4 / TRA-703).
117122
// The public docs commit to a strict per-method matrix on every write
118123
// endpoint, and missing or otherwise-unlisted Content-Type returns 415 with
@@ -160,7 +165,7 @@ func ContentType(next http.Handler) http.Handler {
160165
next.ServeHTTP(w, r)
161166
return
162167
}
163-
httputil.Respond415(w, r, GetRequestID(r.Context()))
168+
httputil.Respond415Detail(w, r, oauthTokenUnsupportedMediaDetail, GetRequestID(r.Context()))
164169
return
165170
}
166171

backend/internal/middleware/middleware_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,36 @@ func TestContentType_OAuthTokenAcceptsFormUrlencoded(t *testing.T) {
357357
}
358358
}
359359

360+
// TestContentType_OAuthTokenUnsupportedMediaNamesBothTypes verifies the 415
361+
// detail on /api/v1/oauth/token names BOTH accepted media types, not just
362+
// application/json — the endpoint genuinely accepts form-urlencoded as well
363+
// (TRA-883 / Round 2.4 F2). text/plain is the unsupported Content-Type; a
364+
// bare `curl -d` would auto-set form-urlencoded and pass, so the probe sets
365+
// text/plain explicitly.
366+
func TestContentType_OAuthTokenUnsupportedMediaNamesBothTypes(t *testing.T) {
367+
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
368+
t.Fatal("handler must not be reached on unsupported Content-Type")
369+
})
370+
req := httptest.NewRequest(http.MethodPost, "/api/v1/oauth/token",
371+
strings.NewReader("grant_type=client_credentials"))
372+
req.Header.Set("Content-Type", "text/plain")
373+
rr := httptest.NewRecorder()
374+
375+
ContentType(next).ServeHTTP(rr, req)
376+
377+
if rr.Code != http.StatusUnsupportedMediaType {
378+
t.Fatalf("status = %d, want 415", rr.Code)
379+
}
380+
var resp apierrors.ErrorResponse
381+
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
382+
t.Fatalf("unmarshal: %v", err)
383+
}
384+
if !strings.Contains(resp.Error.Detail, "application/json") ||
385+
!strings.Contains(resp.Error.Detail, "application/x-www-form-urlencoded") {
386+
t.Errorf("detail = %q, must name both application/json and application/x-www-form-urlencoded", resp.Error.Detail)
387+
}
388+
}
389+
360390
func TestAuth_MissingHeader_Respond401(t *testing.T) {
361391
h := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not reach handler") }))
362392
w := httptest.NewRecorder()

backend/internal/util/httputil/method_error.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ func Respond415(w http.ResponseWriter, r *http.Request, requestID string) {
4242
if r.Method == http.MethodPatch {
4343
detail = "Content-Type must be application/merge-patch+json on PATCH operations"
4444
}
45+
Respond415Detail(w, r, detail, requestID)
46+
}
47+
48+
// Respond415Detail writes the same normalized 415 envelope as Respond415 but
49+
// with a caller-supplied detail. Routes that accept media types beyond the
50+
// default application/json — the oauth/token endpoint also takes
51+
// application/x-www-form-urlencoded (RFC 6749) — use this so the detail names
52+
// every accepted type instead of under-reporting JSON only (TRA-883). type and
53+
// title stay canonical; only the non-contractual detail varies.
54+
func Respond415Detail(w http.ResponseWriter, r *http.Request, detail, requestID string) {
4555
WriteJSONError(w, r, http.StatusUnsupportedMediaType, apierrors.ErrUnsupportedMedia,
4656
detail, requestID)
4757
}

backend/internal/util/httputil/method_error_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ func TestRespond415_PatchNamesMergePatch(t *testing.T) {
8282
assertRespond415Envelope(t, w, "Content-Type must be application/merge-patch+json on PATCH operations", "req-4p")
8383
}
8484

85+
// Respond415Detail writes the canonical 415 envelope with a caller-supplied
86+
// detail, used by routes that accept more than application/json (oauth/token
87+
// also takes application/x-www-form-urlencoded; TRA-883).
88+
func TestRespond415Detail_UsesCallerDetail(t *testing.T) {
89+
w := httptest.NewRecorder()
90+
r := httptest.NewRequest("POST", "/api/v1/oauth/token", nil)
91+
detail := "Content-Type must be application/json or application/x-www-form-urlencoded"
92+
httputil.Respond415Detail(w, r, detail, "req-4o")
93+
assertRespond415Envelope(t, w, detail, "req-4o")
94+
}
95+
8596
func assertRespond415Envelope(t *testing.T, w *httptest.ResponseRecorder, wantDetail, wantReqID string) {
8697
t.Helper()
8798

0 commit comments

Comments
 (0)