Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/csm-portal/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ 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, "<fallback message>")` — 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

## OpenAPI spec

- 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
Expand All @@ -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"`
7 changes: 7 additions & 0 deletions apps/csm-portal/backend/internal/handler/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 30 additions & 14 deletions apps/csm-portal/backend/internal/handler/cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
45 changes: 38 additions & 7 deletions apps/csm-portal/backend/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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:
Expand All @@ -104,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:
Expand Down Expand Up @@ -160,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:
Expand Down