From d1330167c08a912f26488d0e5859551f499fba4f Mon Sep 17 00:00:00 2001 From: Rashmika998 Date: Sun, 7 Jun 2026 08:16:10 +0530 Subject: [PATCH 1/3] [CSM Portal] Align GET /cases/{id} OpenAPI spec with entity-service - Add format: uuid to path parameter - Add 200 response body referencing the Case schema - Add 400 (malformed UUID), 403, 404, 500 responses with ErrorPayload - Matches the GET /cases/{id} contract introduced in entity-service PR #818 Co-Authored-By: Claude Sonnet 4.6 --- apps/csm-portal/backend/openapi.yaml | 39 ++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 0766a547f5..c98ef4de8b 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -83,19 +83,48 @@ paths: parameters: - name: id in: path - description: ID of the case + description: UUID of the case. required: true schema: type: string + format: uuid responses: "200": - description: Ok + description: The case with the given ID. + content: + application/json: + schema: + $ref: '#/components/schemas/Case' + "400": + description: Bad request (e.g. malformed UUID). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' "401": - description: Unauthorized + description: Unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' "404": - description: NotFound + description: Case not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' "500": - description: InternalServerError + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' /cases/{id}/comments: post: From 0a62a0c99ec5ef54ca078097fe6861c8fe528971 Mon Sep 17 00:00:00 2001 From: Rashmika998 Date: Sun, 7 Jun 2026 08:27:10 +0530 Subject: [PATCH 2/3] [CSM Portal] Address CodeRabbit review on GET /cases/{id} - Add format: uuid to /cases/{id}/comments and /cases/{id}/comments/search path parameters for consistency - Add explicit UUID validation in GetCase handler to fail-fast with 400 before forwarding a malformed ID to the entity service - Update TestGetCase to use valid UUIDs; add malformed UUID subtest Co-Authored-By: Claude Sonnet 4.6 --- .../backend/internal/handler/cases.go | 7 +++ .../backend/internal/handler/cases_test.go | 44 +++++++++++++------ apps/csm-portal/backend/openapi.yaml | 6 ++- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/csm-portal/backend/internal/handler/cases.go b/apps/csm-portal/backend/internal/handler/cases.go index 804ff8eacb..8109d8bdc0 100644 --- a/apps/csm-portal/backend/internal/handler/cases.go +++ b/apps/csm-portal/backend/internal/handler/cases.go @@ -23,10 +23,13 @@ import ( "io" "log/slog" "net/http" + "regexp" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware" ) +var uuidRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + var errUserNotFound = errors.New("authenticated user not found in entity service") // resolveUserID looks up the entity-service UUID for the given email via users/search. @@ -338,6 +341,10 @@ func (h *CaseHandler) GetCase(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "Case ID cannot be empty!") return } + if !uuidRe.MatchString(caseID) { + writeError(w, http.StatusBadRequest, ErrMsgBadRequest) + return + } result, err := h.entity.GetCase(r.Context(), caseID) if err != nil { diff --git a/apps/csm-portal/backend/internal/handler/cases_test.go b/apps/csm-portal/backend/internal/handler/cases_test.go index 0ec85e970c..bfb940ef00 100644 --- a/apps/csm-portal/backend/internal/handler/cases_test.go +++ b/apps/csm-portal/backend/internal/handler/cases_test.go @@ -582,10 +582,15 @@ func TestSearchProjectCases(t *testing.T) { // ----- GetCase ----- func TestGetCase(t *testing.T) { + const ( + testCaseID = "11111111-1111-1111-1111-111111111111" + testCaseID42 = "42424242-4242-4242-4242-424242424242" + ) + t.Run("requires authenticated user", func(t *testing.T) { h := NewCaseHandler(&mockEntityCaseClient{}) - r := httptest.NewRequest(http.MethodGet, "/cases/case-1", nil) - r.SetPathValue("id", "case-1") + r := httptest.NewRequest(http.MethodGet, "/cases/"+testCaseID, nil) + r.SetPathValue("id", testCaseID) w := httptest.NewRecorder() h.GetCase(w, r) assertStatus(t, w, http.StatusUnauthorized) @@ -604,28 +609,39 @@ func TestGetCase(t *testing.T) { assertContentType(t, w, "application/json") }) + t.Run("rejects malformed UUID", func(t *testing.T) { + h := NewCaseHandler(&mockEntityCaseClient{}) + r := withUser(httptest.NewRequest(http.MethodGet, "/cases/not-a-uuid", nil)) + r.SetPathValue("id", "not-a-uuid") + w := httptest.NewRecorder() + h.GetCase(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgBadRequest) + assertContentType(t, w, "application/json") + }) + t.Run("passes case ID to upstream and returns 200 with next_states injected", func(t *testing.T) { var capturedID string client := &mockEntityCaseClient{ getCaseFn: func(_ context.Context, caseID string) ([]byte, error) { capturedID = caseID - return []byte(`{"id":"case-42","state":"open"}`), nil + return []byte(`{"id":"` + testCaseID42 + `","state":"open"}`), nil }, } h := NewCaseHandler(client) - r := withUser(httptest.NewRequest(http.MethodGet, "/cases/case-42", nil)) - r.SetPathValue("id", "case-42") + r := withUser(httptest.NewRequest(http.MethodGet, "/cases/"+testCaseID42, nil)) + r.SetPathValue("id", testCaseID42) w := httptest.NewRecorder() h.GetCase(w, r) assertStatus(t, w, http.StatusOK) assertContentType(t, w, "application/json") - if capturedID != "case-42" { - t.Errorf("upstream received caseID %q, want %q", capturedID, "case-42") + if capturedID != testCaseID42 { + t.Errorf("upstream received caseID %q, want %q", capturedID, testCaseID42) } resp := decodeJSON[map[string]any](t, w) - if resp["id"] != "case-42" { - t.Errorf("response id = %v, want case-42", resp["id"]) + if resp["id"] != testCaseID42 { + t.Errorf("response id = %v, want %s", resp["id"], testCaseID42) } ns, ok := resp["next_states"].([]any) if !ok || len(ns) != 1 || ns[0] != caseStateWorkInProgress { @@ -649,13 +665,13 @@ func TestGetCase(t *testing.T) { for _, tc := range cases { t.Run(tc.state, func(t *testing.T) { t.Parallel() - body, _ := json.Marshal(map[string]string{"id": "case-1", "state": tc.state}) + body, _ := json.Marshal(map[string]string{"id": testCaseID, "state": tc.state}) client := &mockEntityCaseClient{ getCaseFn: func(_ context.Context, _ string) ([]byte, error) { return body, nil }, } h := NewCaseHandler(client) - r := withUser(httptest.NewRequest(http.MethodGet, "/cases/case-1", nil)) - r.SetPathValue("id", "case-1") + r := withUser(httptest.NewRequest(http.MethodGet, "/cases/"+testCaseID, nil)) + r.SetPathValue("id", testCaseID) w := httptest.NewRecorder() h.GetCase(w, r) @@ -684,8 +700,8 @@ func TestGetCase(t *testing.T) { }, } h := NewCaseHandler(client) - r := withUser(httptest.NewRequest(http.MethodGet, "/cases/case-1", nil)) - r.SetPathValue("id", "case-1") + r := withUser(httptest.NewRequest(http.MethodGet, "/cases/"+testCaseID, nil)) + r.SetPathValue("id", testCaseID) w := httptest.NewRecorder() h.GetCase(w, r) assertStatus(t, w, tc.wantCode) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index c98ef4de8b..6d847b83b1 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -133,10 +133,11 @@ paths: parameters: - name: id in: path - description: ID of the case + description: UUID of the case. required: true schema: type: string + format: uuid requestBody: description: Case comment creation payload content: @@ -189,10 +190,11 @@ paths: parameters: - name: id in: path - description: ID of the case + description: UUID of the case. required: true schema: type: string + format: uuid requestBody: description: Case comment search payload content: From b9aac29c1ee6c0b8efaf3a939058246616ff2e2f Mon Sep 17 00:00:00 2001 From: Rashmika998 Date: Sun, 7 Jun 2026 08:35:01 +0530 Subject: [PATCH 3/3] [CSM Portal] Update CLAUDE.md with UUID validation and OpenAPI conventions Co-Authored-By: Claude Sonnet 4.6 --- apps/csm-portal/backend/CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/csm-portal/backend/CLAUDE.md b/apps/csm-portal/backend/CLAUDE.md index 9a90ae3b77..1b575de008 100644 --- a/apps/csm-portal/backend/CLAUDE.md +++ b/apps/csm-portal/backend/CLAUDE.md @@ -39,7 +39,7 @@ Follow these steps in order: - **Auth**: always check `middleware.UserInfoFromContext(r.Context()) == nil` first → 401 - **Body size**: cap with `http.MaxBytesReader(w, r.Body, maxRequestBodyBytes)` (1 MiB) before reading -- **Path params**: guard against empty string after `r.PathValue("id")` +- **Path params**: guard against empty string after `r.PathValue("id")`; if the param is a UUID, also validate format using the package-level `uuidRe` compiled regex and return 400 on mismatch — fail fast before calling the upstream - **Upstream errors**: always use `mapUpstreamError(w, err, "")` — never write custom status mappings inline - **Response**: return raw `[]byte` with `writeJSON` for simple passthroughs; unmarshal into typed structs only when the response shape needs to change @@ -47,6 +47,7 @@ Follow these steps in order: - Error responses use `$ref: '#/components/schemas/ErrorPayload'` - Every endpoint must declare a `403` response +- Path parameters that expect UUIDs must declare `format: uuid` on the schema - The `Case` schema includes a computed `next_states` read-only field populated server-side from `state` ## Response shape @@ -69,3 +70,4 @@ Follow these steps in order: - `upstreamErrors(fallback)` returns the standard upstream error table used across all handler tests - `withUser()` injects a test user into the request context - `decodeJSON[T]()` decodes response bodies in assertions +- Use real UUIDs (e.g. `"11111111-1111-1111-1111-111111111111"`) for UUID path param test values — not fake slugs like `"case-1"`