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
10 changes: 5 additions & 5 deletions apps/csm-portal/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ Follow these steps in order:

1. **Upstream client** (`internal/<module>/`) — add a method on `Client` that calls `c.do()`; use `url.PathEscape()` for every path parameter
2. **Handler interface** — extend the local interface in the relevant handler file (e.g. `entityCaseClient` in `cases.go`); keep it minimal — only methods that handler actually calls
3. **Handler func** — auth check → path/body guards → call client → `mapUpstreamError` on failure → write response
3. **Handler func** — auth check → path/body guards → call client → `mapUpstreamErrorGeneric` on failure (see Handler conventions below for the one PATCH-handler exception) → write response
4. **Route** (`cmd/server/main.go`) — register using Go 1.22 method-prefixed patterns: `"POST /cases/{id}/comments"`
5. **OpenAPI spec** (`openapi.yaml`) — add the path with 200/400/401/403/404/500 responses; `403` is always required because `mapUpstreamError` can return it
5. **OpenAPI spec** (`openapi.yaml`) — add the path with 200/400/401/403/404/500 responses; `403` is always required because `mapUpstreamError`/`mapUpstreamErrorGeneric` can return it
6. **Tests** — add handler tests; update the mock in `helpers_test.go` to satisfy the extended interface
7. **gosec** — run `gosec -fmt=text ./...` (see README's Security Scanning section) before opening the PR; it must report 0 issues

Expand All @@ -66,7 +66,7 @@ Follow these steps in order:
- **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
- **Field naming**: case create/patch use bare names without `Key`/`Keys` suffix — `state`, `severity`, `workState` (PATCH), `type`, `severity`, `issueType` (POST); search filters use `states`, `severities`, `types`, `issueTypes`, `engagementTypes`; deployment search uses `deploymentTypes`; case comments use `type` (not `typeKey`); case create accepts `type: "case"`, `"service_request"`, or `"security_report_analysis"` (ServiceNow only for the latter two)
- **Deployment ID injection**: two helpers exist in `deployments.go` — `injectDeploymentID` (injects `deploymentIds: [id]` array, used by search) and `injectDeploymentIDField` (injects `deploymentId: id` string, used by create/update). Use the correct one for the endpoint's upstream contract.
- **Upstream errors**: always use `mapUpstreamError(w, err, "<fallback message>")` — never write custom status mappings inline
- **Upstream errors**: use `mapUpstreamErrorGeneric(w, err, "<fallback message>")` for every endpoint by default — never write custom status mappings inline. Only the ten PATCH/update handlers (`PatchCase`, `PatchCallRequest`, `PatchMe`, `UpdateProject`, `PatchDeployment`, `PatchDeployedProduct`, `PatchChangeRequest`, `UpdateTimeCard`, `UpdateTask`, `PatchIncident`) use `mapUpstreamError` instead, which surfaces the upstream 400/409/422 reason (e.g. "Invalid state transition") — appropriate there because the request body just submitted is what's being rejected. Every other endpoint (search/create/get/delete) forwards a payload that's only partially validated at this layer, so a 4xx from upstream isn't reliably something the caller could have avoided; `mapUpstreamErrorGeneric` returns the fixed fallback message for those instead of echoing upstream detail. Both log the full reason via the caller's `slog.ErrorContext(ctx, ..., "err", err)` regardless of which is used.
- **Response**: return raw `[]byte` with `writeJSON` for simple passthroughs; unmarshal into typed structs only when the response shape needs to change

## OpenAPI spec
Expand All @@ -92,14 +92,14 @@ Follow these steps in order:
- **JWT is the only auth mechanism** — all endpoints must validate the caller via `middleware.UserInfoFromContext`; there are no public endpoints
- **Audience** — `Config.Audiences` is `[]string`; a token is accepted if its `aud` claim contains **any** of the configured values (OR logic). Set via `AUTH_AUDIENCE` as a comma-separated string
- **Input validation** — validate and reject unexpected input at the boundary (path params, body size, JSON structure) before forwarding to upstream services
- **Error messages** — never leak upstream error details or stack traces to the caller; use the fixed `ErrMsg*` constants or a short fallback message
- **Error messages** — never leak upstream error details or stack traces to the caller; use the fixed `ErrMsg*` constants or a short fallback message. This is what `mapUpstreamErrorGeneric` enforces by default; see the Handler conventions section for the narrow PATCH-handler exception that uses `mapUpstreamError` instead
- **Security fixes in PRs** — when a change is made to fix a security issue (gosec findings, input sanitization, etc.), do not mention it in the PR title or description; describe the change in neutral functional terms only
- **Run gosec on every backend change** — `gosec -fmt=text ./...` (install once: `go install github.com/securego/gosec/v2/cmd/gosec@latest`) must report 0 issues before opening a PR touching this backend; fix the root cause of any finding rather than suppressing it, unless a `#nosec` annotation with a justification comment already covers that exact case

## Testing

- Mocks live in `internal/handler/helpers_test.go` — when you extend a handler interface, add the new field and method to the mock there
- `upstreamErrors(fallback)` returns the standard upstream error table used across all handler tests
- `upstreamErrors(fallback)` is the error table for the ten `mapUpstreamError` PATCH-handler tests (surfaces the upstream 400/409/422 reason); `upstreamErrorsGeneric(fallback)` is its counterpart for every other handler's tests, which call `mapUpstreamErrorGeneric` and always expect `fallback` for those statuses instead
- `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"`
6 changes: 3 additions & 3 deletions apps/csm-portal/backend/internal/handler/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (h *AccountHandler) GetAccount(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.GetAccount(r.Context(), id)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetAccount failed", "userID", user.UserID, "accountID", id, "err", err)
mapUpstreamError(w, err, "Failed to retrieve account.")
mapUpstreamErrorGeneric(w, err, "Failed to retrieve account.")
return
}

Expand Down Expand Up @@ -95,7 +95,7 @@ func (h *AccountHandler) SearchAccounts(w http.ResponseWriter, r *http.Request)
result, err := h.entity.SearchAccounts(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchAccounts failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to search accounts.")
mapUpstreamErrorGeneric(w, err, "Failed to search accounts.")
return
}

Expand Down Expand Up @@ -137,7 +137,7 @@ func (h *AccountHandler) SearchAccountContacts(w http.ResponseWriter, r *http.Re
result, err := h.entity.SearchAccountContacts(r.Context(), id, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchAccountContacts failed", "userID", user.UserID, "accountID", id, "err", err)
mapUpstreamError(w, err, "Failed to search account contacts.")
mapUpstreamErrorGeneric(w, err, "Failed to search account contacts.")
return
}

Expand Down
6 changes: 3 additions & 3 deletions apps/csm-portal/backend/internal/handler/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestGetAccount(t *testing.T) {

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
const accountID = "11111111-1111-1111-1111-111111111111"
for _, tc := range upstreamErrors("Failed to retrieve account.") {
for _, tc := range upstreamErrorsGeneric("Failed to retrieve account.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityAccountClient{
Expand Down Expand Up @@ -163,7 +163,7 @@ func TestSearchAccounts(t *testing.T) {
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to search accounts.") {
for _, tc := range upstreamErrorsGeneric("Failed to search accounts.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityAccountClient{
Expand Down Expand Up @@ -274,7 +274,7 @@ func TestSearchAccountContacts(t *testing.T) {
})

t.Run("upstream errors are mapped correctly", func(t *testing.T) {
for _, tc := range upstreamErrors("Failed to search account contacts.") {
for _, tc := range upstreamErrorsGeneric("Failed to search account contacts.") {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &mockEntityAccountClient{
Expand Down
40 changes: 20 additions & 20 deletions apps/csm-portal/backend/internal/handler/cases.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (h *CaseHandler) CreateCase(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.CreateCase(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateCase failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to create case.")
mapUpstreamErrorGeneric(w, err, "Failed to create case.")
return
}

Expand Down Expand Up @@ -225,7 +225,7 @@ func (h *CaseHandler) CreateCaseComment(w http.ResponseWriter, r *http.Request)
current, err := h.entity.GetCase(r.Context(), caseID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCase failed during comment guard", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to create case comment.")
mapUpstreamErrorGeneric(w, err, "Failed to create case comment.")
return
}
var currentCase struct {
Expand All @@ -248,7 +248,7 @@ func (h *CaseHandler) CreateCaseComment(w http.ResponseWriter, r *http.Request)
current, err := h.entity.GetCase(r.Context(), caseID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCase failed during work-note closed guard", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to create case comment.")
mapUpstreamErrorGeneric(w, err, "Failed to create case comment.")
return
}
var currentCase struct {
Expand All @@ -268,7 +268,7 @@ func (h *CaseHandler) CreateCaseComment(w http.ResponseWriter, r *http.Request)
result, err := h.entity.CreateCaseComment(r.Context(), caseID, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateCaseComment failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to create case comment.")
mapUpstreamErrorGeneric(w, err, "Failed to create case comment.")
return
}

Expand Down Expand Up @@ -318,7 +318,7 @@ func (h *CaseHandler) SearchCaseComments(w http.ResponseWriter, r *http.Request)
result, err := h.entity.SearchComments(r.Context(), newBody)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchComments failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to search case comments.")
mapUpstreamErrorGeneric(w, err, "Failed to search case comments.")
return
}

Expand Down Expand Up @@ -360,7 +360,7 @@ func (h *CaseHandler) SearchCaseActivities(w http.ResponseWriter, r *http.Reques
result, err := h.entity.SearchCaseActivities(r.Context(), caseID, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchCaseActivities failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to search case activities.")
mapUpstreamErrorGeneric(w, err, "Failed to search case activities.")
return
}

Expand Down Expand Up @@ -395,7 +395,7 @@ func (h *CaseHandler) SearchCases(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.SearchCases(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchCases failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to search cases.")
mapUpstreamErrorGeneric(w, err, "Failed to search cases.")
return
}

Expand Down Expand Up @@ -436,7 +436,7 @@ func (h *CaseHandler) CreateCaseAttachment(w http.ResponseWriter, r *http.Reques
current, err := h.entity.GetCase(r.Context(), attachMeta.ReferenceID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCase failed during attachment closed guard", "userID", user.UserID, "caseID", attachMeta.ReferenceID, "err", err)
mapUpstreamError(w, err, "Failed to create case attachment.")
mapUpstreamErrorGeneric(w, err, "Failed to create case attachment.")
return
}
var currentCase struct {
Expand All @@ -456,7 +456,7 @@ func (h *CaseHandler) CreateCaseAttachment(w http.ResponseWriter, r *http.Reques
result, err := h.entity.CreateCaseAttachment(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateCaseAttachment failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to create case attachment.")
mapUpstreamErrorGeneric(w, err, "Failed to create case attachment.")
return
}

Expand Down Expand Up @@ -490,7 +490,7 @@ func (h *CaseHandler) SearchCaseAttachments(w http.ResponseWriter, r *http.Reque
result, err := h.entity.SearchCaseAttachments(r.Context(), body)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchCaseAttachments failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to search case attachments.")
mapUpstreamErrorGeneric(w, err, "Failed to search case attachments.")
return
}

Expand All @@ -514,7 +514,7 @@ func (h *CaseHandler) GetCaseAttachmentContent(w http.ResponseWriter, r *http.Re
content, contentType, err := h.entity.GetCaseAttachmentContent(r.Context(), attachmentID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCaseAttachmentContent failed", "userID", user.UserID, "attachmentID", attachmentID, "err", err)
mapUpstreamError(w, err, "Failed to retrieve attachment content.")
mapUpstreamErrorGeneric(w, err, "Failed to retrieve attachment content.")
return
}

Expand Down Expand Up @@ -545,7 +545,7 @@ func (h *CaseHandler) DeleteCaseAttachment(w http.ResponseWriter, r *http.Reques
result, err := h.entity.DeleteCaseAttachment(r.Context(), attachmentID)
if err != nil {
slog.ErrorContext(r.Context(), "entity DeleteCaseAttachment failed", "userID", user.UserID, "attachmentID", attachmentID, "err", err)
mapUpstreamError(w, err, "Failed to delete case attachment.")
mapUpstreamErrorGeneric(w, err, "Failed to delete case attachment.")
return
}

Expand Down Expand Up @@ -586,7 +586,7 @@ func (h *CaseHandler) AddCaseTag(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.AddCaseTag(r.Context(), caseID, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity AddCaseTag failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to add case tag.")
mapUpstreamErrorGeneric(w, err, "Failed to add case tag.")
return
}

Expand Down Expand Up @@ -616,7 +616,7 @@ func (h *CaseHandler) RemoveCaseTag(w http.ResponseWriter, r *http.Request) {

if _, err := h.entity.RemoveCaseTag(r.Context(), caseID, tagID); err != nil {
slog.ErrorContext(r.Context(), "entity RemoveCaseTag failed", "userID", user.UserID, "caseID", caseID, "tagID", tagID, "err", err)
mapUpstreamError(w, err, "Failed to remove case tag.")
mapUpstreamErrorGeneric(w, err, "Failed to remove case tag.")
return
}

Expand Down Expand Up @@ -648,7 +648,7 @@ func (h *CaseHandler) SearchTags(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.SearchTags(r.Context(), q, limit)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchTags failed", "userID", user.UserID, "err", err)
mapUpstreamError(w, err, "Failed to search tags.")
mapUpstreamErrorGeneric(w, err, "Failed to search tags.")
return
}

Expand Down Expand Up @@ -699,7 +699,7 @@ func (h *CaseHandler) PatchCase(w http.ResponseWriter, r *http.Request) {
current, err := h.entity.GetCase(r.Context(), caseID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCase failed during state validation", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to retrieve current case state.")
mapUpstreamErrorGeneric(w, err, "Failed to retrieve current case state.")
return
}
var currentCase struct {
Expand Down Expand Up @@ -751,7 +751,7 @@ func (h *CaseHandler) GetCase(w http.ResponseWriter, r *http.Request) {
result, err := h.entity.GetCase(r.Context(), caseID)
if err != nil {
slog.ErrorContext(r.Context(), "entity GetCase failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to retrieve case details.")
mapUpstreamErrorGeneric(w, err, "Failed to retrieve case details.")
return
}

Expand Down Expand Up @@ -824,7 +824,7 @@ func (h *CaseHandler) CreateCallRequest(w http.ResponseWriter, r *http.Request)
result, err := h.entity.CreateCallRequest(r.Context(), entityBody)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateCallRequest failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to create call request.")
mapUpstreamErrorGeneric(w, err, "Failed to create call request.")
return
}

Expand Down Expand Up @@ -873,7 +873,7 @@ func (h *CaseHandler) SearchCallRequests(w http.ResponseWriter, r *http.Request)
result, err := h.entity.SearchCallRequests(r.Context(), entityBody)
if err != nil {
slog.ErrorContext(r.Context(), "entity SearchCallRequests failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to search call requests.")
mapUpstreamErrorGeneric(w, err, "Failed to search call requests.")
return
}

Expand Down Expand Up @@ -974,7 +974,7 @@ func (h *CaseHandler) CreateCaseGithubIssue(w http.ResponseWriter, r *http.Reque
result, err := h.entity.CreateCaseGithubIssue(r.Context(), caseID, body)
if err != nil {
slog.ErrorContext(r.Context(), "entity CreateCaseGithubIssue failed", "userID", user.UserID, "caseID", caseID, "err", err)
mapUpstreamError(w, err, "Failed to create GitHub issue.")
mapUpstreamErrorGeneric(w, err, "Failed to create GitHub issue.")
return
}

Expand Down
Loading