diff --git a/apps/csm-portal/backend/CLAUDE.md b/apps/csm-portal/backend/CLAUDE.md index 68c0afecf3..15080a16c5 100644 --- a/apps/csm-portal/backend/CLAUDE.md +++ b/apps/csm-portal/backend/CLAUDE.md @@ -53,9 +53,9 @@ Follow these steps in order: 1. **Upstream client** (`internal//`) — 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 @@ -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, "")` — never write custom status mappings inline +- **Upstream errors**: use `mapUpstreamErrorGeneric(w, err, "")` 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 @@ -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"` diff --git a/apps/csm-portal/backend/internal/handler/accounts.go b/apps/csm-portal/backend/internal/handler/accounts.go index 75da487d54..e4719e0dbc 100644 --- a/apps/csm-portal/backend/internal/handler/accounts.go +++ b/apps/csm-portal/backend/internal/handler/accounts.go @@ -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 } @@ -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 } @@ -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 } diff --git a/apps/csm-portal/backend/internal/handler/accounts_test.go b/apps/csm-portal/backend/internal/handler/accounts_test.go index 5357531743..b073055d1d 100644 --- a/apps/csm-portal/backend/internal/handler/accounts_test.go +++ b/apps/csm-portal/backend/internal/handler/accounts_test.go @@ -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{ @@ -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{ @@ -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{ diff --git a/apps/csm-portal/backend/internal/handler/cases.go b/apps/csm-portal/backend/internal/handler/cases.go index b53c10035e..56843296cb 100644 --- a/apps/csm-portal/backend/internal/handler/cases.go +++ b/apps/csm-portal/backend/internal/handler/cases.go @@ -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 } @@ -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 { @@ -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 { @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 { @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 { @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/apps/csm-portal/backend/internal/handler/cases_test.go b/apps/csm-portal/backend/internal/handler/cases_test.go index ec815a5466..128cc894a8 100644 --- a/apps/csm-portal/backend/internal/handler/cases_test.go +++ b/apps/csm-portal/backend/internal/handler/cases_test.go @@ -29,8 +29,10 @@ import ( "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/apierror" ) -// upstreamErrorCases is the table used by every handler that calls mapUpstreamError. -// It covers all four explicit apierror mappings plus an unmapped code and a plain error. +// upstreamErrorCases is the table used by every PATCH/update handler — the +// ones that call mapUpstreamError (see upstreamErrorsGeneric for every other +// handler, which calls mapUpstreamErrorGeneric instead). It covers all four +// explicit apierror mappings plus an unmapped code and a plain error. type upstreamErrorCase struct { name string err error @@ -72,6 +74,30 @@ func upstreamErrors(fallback string) []upstreamErrorCase { } } +// upstreamErrorsGeneric is upstreamErrors' counterpart for every non-PATCH +// handler (the vast majority), which calls mapUpstreamErrorGeneric: every +// 4xx case falls back to fallback instead of surfacing the upstream body, +// since those endpoints forward a request that's only partially validated at +// this layer, so a 4xx from upstream isn't necessarily something the caller +// could have avoided. +func upstreamErrorsGeneric(fallback string) []upstreamErrorCase { + return []upstreamErrorCase{ + {"apierror 401", &apierror.Error{StatusCode: http.StatusUnauthorized}, http.StatusUnauthorized, ErrMsgUnauthorized}, + {"apierror 403", &apierror.Error{StatusCode: http.StatusForbidden}, http.StatusForbidden, ErrMsgForbidden}, + {"apierror 404", &apierror.Error{StatusCode: http.StatusNotFound}, http.StatusNotFound, ErrMsgNotFound}, + {"apierror 400 JSON envelope body is not echoed", &apierror.Error{StatusCode: http.StatusBadRequest, Body: `{"code":400,"message":"invalid type \"bogus\""}`}, http.StatusBadRequest, fallback}, + {"apierror 400 empty body falls back", &apierror.Error{StatusCode: http.StatusBadRequest, Body: ""}, http.StatusBadRequest, fallback}, + {"apierror 409 plain text body is not echoed", &apierror.Error{StatusCode: http.StatusConflict, Body: "conflict upstream message"}, http.StatusConflict, fallback}, + {"apierror 422 plain text body is not echoed", &apierror.Error{StatusCode: http.StatusUnprocessableEntity, Body: "invalid state transition"}, http.StatusUnprocessableEntity, fallback}, + {"apierror 502", &apierror.Error{StatusCode: http.StatusBadGateway}, http.StatusServiceUnavailable, fallback}, + {"apierror 503", &apierror.Error{StatusCode: http.StatusServiceUnavailable}, http.StatusServiceUnavailable, fallback}, + {"apierror 504", &apierror.Error{StatusCode: http.StatusGatewayTimeout}, http.StatusServiceUnavailable, fallback}, + {"apierror unmapped (418)", &apierror.Error{StatusCode: http.StatusTeapot}, http.StatusInternalServerError, fallback}, + {"apierror 500 JSON envelope body is not echoed", &apierror.Error{StatusCode: http.StatusInternalServerError, Body: `{"code":500,"message":"internal detail"}`}, http.StatusInternalServerError, fallback}, + {"non-apierror error", errors.New("upstream connection refused"), http.StatusInternalServerError, fallback}, + } +} + // ----- CreateCase ----- func TestCreateCase(t *testing.T) { @@ -163,7 +189,7 @@ func TestCreateCase(t *testing.T) { }) t.Run("upstream errors on create are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create case.") { + for _, tc := range upstreamErrorsGeneric("Failed to create case.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -359,7 +385,7 @@ func TestCreateCaseComment(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create case comment.") { + for _, tc := range upstreamErrorsGeneric("Failed to create case comment.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -465,7 +491,7 @@ func TestSearchCaseComments(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search case comments.") { + for _, tc := range upstreamErrorsGeneric("Failed to search case comments.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -566,7 +592,7 @@ func TestSearchCaseActivities(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search case activities.") { + for _, tc := range upstreamErrorsGeneric("Failed to search case activities.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -773,7 +799,7 @@ func TestSearchCases(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search cases.") { + for _, tc := range upstreamErrorsGeneric("Failed to search cases.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1123,7 +1149,7 @@ func TestPatchCase(t *testing.T) { }) t.Run("GetCase failure during state validation is mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve current case state.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve current case state.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1448,7 +1474,7 @@ func TestGetCase(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve case details.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve case details.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1521,7 +1547,7 @@ func TestCreateCaseAttachment(t *testing.T) { }) t.Run("maps upstream errors", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create case attachment.") { + for _, tc := range upstreamErrorsGeneric("Failed to create case attachment.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1604,7 +1630,7 @@ func TestSearchCaseAttachments(t *testing.T) { }) t.Run("maps upstream errors", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search case attachments.") { + for _, tc := range upstreamErrorsGeneric("Failed to search case attachments.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1694,7 +1720,7 @@ func TestGetCaseAttachmentContent(t *testing.T) { }) t.Run("maps upstream errors", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve attachment content.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve attachment content.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1800,7 +1826,7 @@ func TestCreateCaseGithubIssue(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create GitHub issue.") { + for _, tc := range upstreamErrorsGeneric("Failed to create GitHub issue.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1896,7 +1922,7 @@ func TestAddCaseTag(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to add case tag.") { + for _, tc := range upstreamErrorsGeneric("Failed to add case tag.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -1986,7 +2012,7 @@ func TestRemoveCaseTag(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to remove case tag.") { + for _, tc := range upstreamErrorsGeneric("Failed to remove case tag.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ @@ -2388,7 +2414,7 @@ func TestSearchTags(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search tags.") { + for _, tc := range upstreamErrorsGeneric("Failed to search tags.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityCaseClient{ diff --git a/apps/csm-portal/backend/internal/handler/catalogs.go b/apps/csm-portal/backend/internal/handler/catalogs.go index a68a7af849..fc9cf5ad6e 100644 --- a/apps/csm-portal/backend/internal/handler/catalogs.go +++ b/apps/csm-portal/backend/internal/handler/catalogs.go @@ -81,7 +81,7 @@ func (h *CatalogHandler) SearchCatalogs(w http.ResponseWriter, r *http.Request) result, err := h.entity.SearchCatalogs(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchCatalogs failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search catalogs.") + mapUpstreamErrorGeneric(w, err, "Failed to search catalogs.") return } @@ -111,7 +111,7 @@ func (h *CatalogHandler) GetCatalogItemVariables(w http.ResponseWriter, r *http. result, err := h.entity.GetCatalogItemVariables(r.Context(), catalogID, catalogItemID) if err != nil { slog.ErrorContext(r.Context(), "entity GetCatalogItemVariables failed", "userID", user.UserID, "catalogID", catalogID, "catalogItemID", catalogItemID, "err", err) - mapUpstreamError(w, err, "Failed to retrieve catalog item variables.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve catalog item variables.") return } diff --git a/apps/csm-portal/backend/internal/handler/change_requests.go b/apps/csm-portal/backend/internal/handler/change_requests.go index 0bbd269897..0e94a91df3 100644 --- a/apps/csm-portal/backend/internal/handler/change_requests.go +++ b/apps/csm-portal/backend/internal/handler/change_requests.go @@ -85,7 +85,7 @@ func (h *ChangeRequestHandler) CreateChangeRequest(w http.ResponseWriter, r *htt result, err := h.entity.CreateChangeRequest(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity CreateChangeRequest failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to create change request.") + mapUpstreamErrorGeneric(w, err, "Failed to create change request.") return } @@ -150,7 +150,7 @@ func (h *ChangeRequestHandler) GetChangeRequest(w http.ResponseWriter, r *http.R result, err := h.entity.GetChangeRequest(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetChangeRequest failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve change request.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve change request.") return } @@ -174,7 +174,7 @@ func (h *ChangeRequestHandler) GetChangeRequestApprovals(w http.ResponseWriter, result, err := h.entity.GetChangeRequestApprovals(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetChangeRequestApprovals failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve change request approvals.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve change request approvals.") return } @@ -216,7 +216,7 @@ func (h *ChangeRequestHandler) CreateChangeRequestComment(w http.ResponseWriter, if _, err := h.entity.GetChangeRequest(r.Context(), id); err != nil { slog.ErrorContext(r.Context(), "entity GetChangeRequest failed during comment guard", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to create change request comment.") + mapUpstreamErrorGeneric(w, err, "Failed to create change request comment.") return } @@ -229,7 +229,7 @@ func (h *ChangeRequestHandler) CreateChangeRequestComment(w http.ResponseWriter, result, err := h.entity.CreateComment(r.Context(), newBody) if err != nil { slog.ErrorContext(r.Context(), "entity CreateComment failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to create change request comment.") + mapUpstreamErrorGeneric(w, err, "Failed to create change request comment.") return } @@ -278,7 +278,7 @@ func (h *ChangeRequestHandler) SearchChangeRequestComments(w http.ResponseWriter result, err := h.entity.SearchComments(r.Context(), newBody) if err != nil { slog.ErrorContext(r.Context(), "entity SearchComments failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to search change request comments.") + mapUpstreamErrorGeneric(w, err, "Failed to search change request comments.") return } @@ -328,7 +328,7 @@ func (h *ChangeRequestHandler) DecideChangeRequestApproval(w http.ResponseWriter result, err := h.entity.DecideChangeRequestApproval(r.Context(), id, body) if err != nil { slog.ErrorContext(r.Context(), "entity DecideChangeRequestApproval failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to submit change request approval decision.") + mapUpstreamErrorGeneric(w, err, "Failed to submit change request approval decision.") return } @@ -363,7 +363,7 @@ func (h *ChangeRequestHandler) SearchChangeRequests(w http.ResponseWriter, r *ht result, err := h.entity.SearchChangeRequests(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchChangeRequests failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search change requests.") + mapUpstreamErrorGeneric(w, err, "Failed to search change requests.") return } diff --git a/apps/csm-portal/backend/internal/handler/change_requests_test.go b/apps/csm-portal/backend/internal/handler/change_requests_test.go index 116e2f7439..752b94d5d9 100644 --- a/apps/csm-portal/backend/internal/handler/change_requests_test.go +++ b/apps/csm-portal/backend/internal/handler/change_requests_test.go @@ -79,7 +79,7 @@ func TestCreateChangeRequest(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create change request.") { + for _, tc := range upstreamErrorsGeneric("Failed to create change request.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -163,7 +163,7 @@ func TestGetChangeRequest(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve change request.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve change request.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -356,7 +356,7 @@ func TestGetChangeRequestApprovals(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve change request approvals.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve change request approvals.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -509,7 +509,7 @@ func TestDecideChangeRequestApproval(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to submit change request approval decision.") { + for _, tc := range upstreamErrorsGeneric("Failed to submit change request approval decision.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -588,7 +588,7 @@ func TestSearchChangeRequests(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search change requests.") { + for _, tc := range upstreamErrorsGeneric("Failed to search change requests.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -666,7 +666,7 @@ func TestCreateChangeRequestComment(t *testing.T) { }) t.Run("upstream GetChangeRequest error is mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create change request comment.") { + for _, tc := range upstreamErrorsGeneric("Failed to create change request comment.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityChangeRequestClient{ @@ -684,6 +684,29 @@ func TestCreateChangeRequestComment(t *testing.T) { }) } }) + + t.Run("upstream CreateComment error is mapped correctly", func(t *testing.T) { + for _, tc := range upstreamErrorsGeneric("Failed to create change request comment.") { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := &mockEntityChangeRequestClient{ + getChangeRequestFn: func(_ context.Context, _ string) ([]byte, error) { + return []byte(`{"id":"` + testCRID + `"}`), nil + }, + createCommentFn: func(_ context.Context, _ []byte) ([]byte, error) { + return nil, tc.err + }, + } + h := NewChangeRequestHandler(client) + r := withUser(httptest.NewRequest(http.MethodPost, "/change-requests/"+testCRID+"/comments", strings.NewReader(`{"type":"comment","content":"hi"}`))) + r.SetPathValue("id", testCRID) + w := httptest.NewRecorder() + h.CreateChangeRequestComment(w, r) + assertStatus(t, w, tc.wantCode) + assertErrorMessage(t, w, tc.wantMsg) + }) + } + }) } func TestSearchChangeRequestComments(t *testing.T) { diff --git a/apps/csm-portal/backend/internal/handler/configuration_items.go b/apps/csm-portal/backend/internal/handler/configuration_items.go index e9de71e8b8..a791a68b9a 100644 --- a/apps/csm-portal/backend/internal/handler/configuration_items.go +++ b/apps/csm-portal/backend/internal/handler/configuration_items.go @@ -70,7 +70,7 @@ func (h *ConfigurationItemHandler) SearchConfigurationItems(w http.ResponseWrite result, err := h.entity.SearchConfigurationItems(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchConfigurationItems failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search configuration items.") + mapUpstreamErrorGeneric(w, err, "Failed to search configuration items.") return } diff --git a/apps/csm-portal/backend/internal/handler/configuration_items_test.go b/apps/csm-portal/backend/internal/handler/configuration_items_test.go index 53fc0d658c..a2f774d910 100644 --- a/apps/csm-portal/backend/internal/handler/configuration_items_test.go +++ b/apps/csm-portal/backend/internal/handler/configuration_items_test.go @@ -77,7 +77,7 @@ func TestSearchConfigurationItems(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search configuration items.") { + for _, tc := range upstreamErrorsGeneric("Failed to search configuration items.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityConfigurationItemClient{ diff --git a/apps/csm-portal/backend/internal/handler/conversations.go b/apps/csm-portal/backend/internal/handler/conversations.go index 0368d73a9d..5c6c165488 100644 --- a/apps/csm-portal/backend/internal/handler/conversations.go +++ b/apps/csm-portal/backend/internal/handler/conversations.go @@ -148,7 +148,7 @@ func (h *ConversationHandler) GetConversationMessages(w http.ResponseWriter, r * result, err := h.entity.SearchComments(r.Context(), payload) if err != nil { slog.ErrorContext(r.Context(), "entity SearchComments failed", "userID", user.UserID, "conversationID", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve conversation messages.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve conversation messages.") return } @@ -188,7 +188,7 @@ func (h *ConversationHandler) SearchConversations(w http.ResponseWriter, r *http result, err := h.entity.SearchConversations(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchConversations failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search conversations.") + mapUpstreamErrorGeneric(w, err, "Failed to search conversations.") return } diff --git a/apps/csm-portal/backend/internal/handler/conversations_test.go b/apps/csm-portal/backend/internal/handler/conversations_test.go index 9c2d591bbf..9013d12b04 100644 --- a/apps/csm-portal/backend/internal/handler/conversations_test.go +++ b/apps/csm-portal/backend/internal/handler/conversations_test.go @@ -127,7 +127,7 @@ func TestGetConversationMessages(t *testing.T) { }) t.Run("maps upstream errors", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve conversation messages.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve conversation messages.") { t.Run(tc.name, func(t *testing.T) { client := &mockEntityConversationClient{ searchCommentsFn: func(_ context.Context, _ []byte) ([]byte, error) { @@ -243,7 +243,7 @@ func TestSearchConversations(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search conversations.") { + for _, tc := range upstreamErrorsGeneric("Failed to search conversations.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityConversationClient{ diff --git a/apps/csm-portal/backend/internal/handler/deployments.go b/apps/csm-portal/backend/internal/handler/deployments.go index 6d5095fdbd..e0e37fba18 100644 --- a/apps/csm-portal/backend/internal/handler/deployments.go +++ b/apps/csm-portal/backend/internal/handler/deployments.go @@ -111,7 +111,7 @@ func (h *DeploymentHandler) PostDeployment(w http.ResponseWriter, r *http.Reques result, err := h.entity.PostDeployment(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity PostDeployment failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to create deployment.") + mapUpstreamErrorGeneric(w, err, "Failed to create deployment.") return } @@ -189,7 +189,7 @@ func (h *DeploymentHandler) SearchDeployments(w http.ResponseWriter, r *http.Req result, err := h.entity.SearchDeployments(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchDeployments failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search deployments.") + mapUpstreamErrorGeneric(w, err, "Failed to search deployments.") return } @@ -236,7 +236,7 @@ func (h *DeploymentHandler) SearchDeployedProducts(w http.ResponseWriter, r *htt result, err := h.entity.SearchDeployedProducts(r.Context(), entityBody) if err != nil { slog.ErrorContext(r.Context(), "entity SearchDeployedProducts failed", "userID", user.UserID, "deploymentID", deploymentID, "err", err) - mapUpstreamError(w, err, "Failed to search deployed products.") + mapUpstreamErrorGeneric(w, err, "Failed to search deployed products.") return } @@ -284,7 +284,7 @@ func (h *DeploymentHandler) PostDeployedProduct(w http.ResponseWriter, r *http.R result, err := h.entity.PostDeployedProduct(r.Context(), entityBody) if err != nil { slog.ErrorContext(r.Context(), "entity PostDeployedProduct failed", "userID", user.UserID, "deploymentID", deploymentID, "err", err) - mapUpstreamError(w, err, "Failed to create deployed product.") + mapUpstreamErrorGeneric(w, err, "Failed to create deployed product.") return } diff --git a/apps/csm-portal/backend/internal/handler/deployments_test.go b/apps/csm-portal/backend/internal/handler/deployments_test.go index 08db63dec7..c47c0e81ee 100644 --- a/apps/csm-portal/backend/internal/handler/deployments_test.go +++ b/apps/csm-portal/backend/internal/handler/deployments_test.go @@ -83,7 +83,7 @@ func TestPostDeployment(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create deployment.") { + for _, tc := range upstreamErrorsGeneric("Failed to create deployment.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityDeploymentClient{ @@ -160,7 +160,7 @@ func TestSearchDeployments(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search deployments.") { + for _, tc := range upstreamErrorsGeneric("Failed to search deployments.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityDeploymentClient{ @@ -368,7 +368,7 @@ func TestSearchDeployedProducts(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search deployed products.") { + for _, tc := range upstreamErrorsGeneric("Failed to search deployed products.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityDeploymentClient{ @@ -470,7 +470,7 @@ func TestPostDeployedProduct(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create deployed product.") { + for _, tc := range upstreamErrorsGeneric("Failed to create deployed product.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityDeploymentClient{ diff --git a/apps/csm-portal/backend/internal/handler/groups.go b/apps/csm-portal/backend/internal/handler/groups.go index 779738bc4a..5165ce0abf 100644 --- a/apps/csm-portal/backend/internal/handler/groups.go +++ b/apps/csm-portal/backend/internal/handler/groups.go @@ -70,7 +70,7 @@ func (h *GroupHandler) SearchGroups(w http.ResponseWriter, r *http.Request) { result, err := h.entity.SearchGroups(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchGroups failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search groups.") + mapUpstreamErrorGeneric(w, err, "Failed to search groups.") return } diff --git a/apps/csm-portal/backend/internal/handler/groups_test.go b/apps/csm-portal/backend/internal/handler/groups_test.go index 82333b86c0..faedbe0458 100644 --- a/apps/csm-portal/backend/internal/handler/groups_test.go +++ b/apps/csm-portal/backend/internal/handler/groups_test.go @@ -77,7 +77,7 @@ func TestSearchGroups(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search groups.") { + for _, tc := range upstreamErrorsGeneric("Failed to search groups.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityGroupClient{ diff --git a/apps/csm-portal/backend/internal/handler/incidents.go b/apps/csm-portal/backend/internal/handler/incidents.go index 55e30a9a7b..ceb1bc219b 100644 --- a/apps/csm-portal/backend/internal/handler/incidents.go +++ b/apps/csm-portal/backend/internal/handler/incidents.go @@ -357,7 +357,7 @@ func (h *IncidentHandler) SearchIncidents(w http.ResponseWriter, r *http.Request result, err := h.entity.SearchIncidents(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchIncidents failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search incidents.") + mapUpstreamErrorGeneric(w, err, "Failed to search incidents.") return } @@ -398,7 +398,7 @@ func (h *IncidentHandler) CreateIncident(w http.ResponseWriter, r *http.Request) result, err := h.entity.CreateIncident(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity CreateIncident failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to create incident.") + mapUpstreamErrorGeneric(w, err, "Failed to create incident.") return } @@ -422,7 +422,7 @@ func (h *IncidentHandler) GetIncident(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetIncident(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetIncident failed", "userID", user.UserID, "incidentID", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve incident.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve incident.") return } @@ -510,7 +510,7 @@ func (h *IncidentHandler) CreateIncidentComment(w http.ResponseWriter, r *http.R if _, err := h.entity.GetIncident(r.Context(), id); err != nil { slog.ErrorContext(r.Context(), "entity GetIncident failed during comment guard", "userID", user.UserID, "incidentID", id, "err", err) - mapUpstreamError(w, err, "Failed to create incident comment.") + mapUpstreamErrorGeneric(w, err, "Failed to create incident comment.") return } @@ -523,7 +523,7 @@ func (h *IncidentHandler) CreateIncidentComment(w http.ResponseWriter, r *http.R result, err := h.entity.CreateComment(r.Context(), newBody) if err != nil { slog.ErrorContext(r.Context(), "entity CreateComment failed", "userID", user.UserID, "incidentID", id, "err", err) - mapUpstreamError(w, err, "Failed to create incident comment.") + mapUpstreamErrorGeneric(w, err, "Failed to create incident comment.") return } @@ -572,7 +572,7 @@ func (h *IncidentHandler) SearchIncidentComments(w http.ResponseWriter, r *http. result, err := h.entity.SearchComments(r.Context(), newBody) if err != nil { slog.ErrorContext(r.Context(), "entity SearchComments failed", "userID", user.UserID, "incidentID", id, "err", err) - mapUpstreamError(w, err, "Failed to search incident comments.") + mapUpstreamErrorGeneric(w, err, "Failed to search incident comments.") return } diff --git a/apps/csm-portal/backend/internal/handler/incidents_test.go b/apps/csm-portal/backend/internal/handler/incidents_test.go index c704627be6..95d44dad2e 100644 --- a/apps/csm-portal/backend/internal/handler/incidents_test.go +++ b/apps/csm-portal/backend/internal/handler/incidents_test.go @@ -121,7 +121,7 @@ func TestSearchIncidents(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search incidents.") { + for _, tc := range upstreamErrorsGeneric("Failed to search incidents.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityIncidentClient{ @@ -239,7 +239,7 @@ func TestCreateIncident(t *testing.T) { t.Run("upstream errors are mapped correctly", func(t *testing.T) { const validPayload = `{"callerId":"11111111-1111-1111-1111-111111111111","category":"SECURITY","serviceId":"22222222-2222-2222-2222-222222222222","impact":"HIGH","urgency":"HIGH","subject":"Something broke"}` - for _, tc := range upstreamErrors("Failed to create incident.") { + for _, tc := range upstreamErrorsGeneric("Failed to create incident.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityIncidentClient{ @@ -320,7 +320,7 @@ func TestGetIncident(t *testing.T) { t.Run("upstream errors are mapped correctly", func(t *testing.T) { const incidentID = "11111111-1111-1111-1111-111111111111" - for _, tc := range upstreamErrors("Failed to retrieve incident.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve incident.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityIncidentClient{ @@ -558,7 +558,7 @@ func TestCreateIncidentComment(t *testing.T) { }) t.Run("upstream GetIncident error is mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create incident comment.") { + for _, tc := range upstreamErrorsGeneric("Failed to create incident comment.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityIncidentClient{ diff --git a/apps/csm-portal/backend/internal/handler/notifications.go b/apps/csm-portal/backend/internal/handler/notifications.go index 4706f023f7..b49c12902f 100644 --- a/apps/csm-portal/backend/internal/handler/notifications.go +++ b/apps/csm-portal/backend/internal/handler/notifications.go @@ -105,7 +105,7 @@ func (h *NotificationHandler) PostGoogleChatAlert(w http.ResponseWriter, r *http // err's text is safe to log: SendIncidentAlert never wraps a URL // (which would carry the webhook's key/token) into its error text. slog.ErrorContext(r.Context(), "google chat SendIncidentAlert failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to send Google Chat alert.") + mapUpstreamErrorGeneric(w, err, "Failed to send Google Chat alert.") return } diff --git a/apps/csm-portal/backend/internal/handler/problems.go b/apps/csm-portal/backend/internal/handler/problems.go index 7cd9fec53d..11506400b3 100644 --- a/apps/csm-portal/backend/internal/handler/problems.go +++ b/apps/csm-portal/backend/internal/handler/problems.go @@ -112,7 +112,7 @@ func (h *ProblemHandler) SearchProblems(w http.ResponseWriter, r *http.Request) result, err := h.entity.SearchProblems(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProblems failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search problems.") + mapUpstreamErrorGeneric(w, err, "Failed to search problems.") return } @@ -152,7 +152,7 @@ func (h *ProblemHandler) CreateProblem(w http.ResponseWriter, r *http.Request) { result, err := h.entity.CreateProblem(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity CreateProblem failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to create problem.") + mapUpstreamErrorGeneric(w, err, "Failed to create problem.") return } @@ -176,7 +176,7 @@ func (h *ProblemHandler) GetProblem(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetProblem(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetProblem failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve problem.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve problem.") return } diff --git a/apps/csm-portal/backend/internal/handler/problems_test.go b/apps/csm-portal/backend/internal/handler/problems_test.go index 4de07a2605..db3e391c5e 100644 --- a/apps/csm-portal/backend/internal/handler/problems_test.go +++ b/apps/csm-portal/backend/internal/handler/problems_test.go @@ -83,7 +83,7 @@ func TestCreateProblem(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create problem.") { + for _, tc := range upstreamErrorsGeneric("Failed to create problem.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProblemClient{ @@ -163,7 +163,7 @@ func TestGetProblem(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve problem.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve problem.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProblemClient{ @@ -241,7 +241,7 @@ func TestSearchProblems(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search problems.") { + for _, tc := range upstreamErrorsGeneric("Failed to search problems.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProblemClient{ diff --git a/apps/csm-portal/backend/internal/handler/product_vulnerabilities.go b/apps/csm-portal/backend/internal/handler/product_vulnerabilities.go index 816775df03..14509f02b8 100644 --- a/apps/csm-portal/backend/internal/handler/product_vulnerabilities.go +++ b/apps/csm-portal/backend/internal/handler/product_vulnerabilities.go @@ -71,7 +71,7 @@ func (h *ProductVulnerabilityHandler) SearchProductVulnerabilities(w http.Respon result, err := h.entity.SearchProductVulnerabilities(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProductVulnerabilities failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search product vulnerabilities.") + mapUpstreamErrorGeneric(w, err, "Failed to search product vulnerabilities.") return } @@ -95,7 +95,7 @@ func (h *ProductVulnerabilityHandler) GetProductVulnerability(w http.ResponseWri result, err := h.entity.GetProductVulnerability(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetProductVulnerability failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve product vulnerability.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve product vulnerability.") return } diff --git a/apps/csm-portal/backend/internal/handler/products.go b/apps/csm-portal/backend/internal/handler/products.go index cc9c3ba0ec..78b7379a51 100644 --- a/apps/csm-portal/backend/internal/handler/products.go +++ b/apps/csm-portal/backend/internal/handler/products.go @@ -74,7 +74,7 @@ func (h *ProductHandler) SearchProducts(w http.ResponseWriter, r *http.Request) result, err := h.entity.SearchProducts(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProducts failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search products.") + mapUpstreamErrorGeneric(w, err, "Failed to search products.") return } @@ -118,7 +118,7 @@ func (h *ProductHandler) SearchProductVersions(w http.ResponseWriter, r *http.Re result, err := h.entity.SearchProductVersions(r.Context(), productID, body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProductVersions failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search product versions.") + mapUpstreamErrorGeneric(w, err, "Failed to search product versions.") return } diff --git a/apps/csm-portal/backend/internal/handler/products_test.go b/apps/csm-portal/backend/internal/handler/products_test.go index 32550dd2ad..c949001cb1 100644 --- a/apps/csm-portal/backend/internal/handler/products_test.go +++ b/apps/csm-portal/backend/internal/handler/products_test.go @@ -81,7 +81,7 @@ func TestSearchProducts(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search products.") { + for _, tc := range upstreamErrorsGeneric("Failed to search products.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProductClient{ @@ -177,7 +177,7 @@ func TestSearchProductVersions(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search product versions.") { + for _, tc := range upstreamErrorsGeneric("Failed to search product versions.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProductClient{ diff --git a/apps/csm-portal/backend/internal/handler/projects.go b/apps/csm-portal/backend/internal/handler/projects.go index 3a9f0f8d83..046669100f 100644 --- a/apps/csm-portal/backend/internal/handler/projects.go +++ b/apps/csm-portal/backend/internal/handler/projects.go @@ -63,7 +63,7 @@ func (h *ProjectHandler) GetProject(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetProject(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetProject failed", "userID", user.UserID, "projectID", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve project.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve project.") return } @@ -99,7 +99,7 @@ func (h *ProjectHandler) SearchProjects(w http.ResponseWriter, r *http.Request) result, err := h.entity.SearchProjects(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProjects failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search projects.") + mapUpstreamErrorGeneric(w, err, "Failed to search projects.") return } @@ -142,7 +142,7 @@ func (h *ProjectHandler) SearchProjectContacts(w http.ResponseWriter, r *http.Re result, err := h.entity.SearchProjectContacts(r.Context(), id, body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchProjectContacts failed", "userID", user.UserID, "projectID", id, "err", err) - mapUpstreamError(w, err, "Failed to search project contacts.") + mapUpstreamErrorGeneric(w, err, "Failed to search project contacts.") return } @@ -176,7 +176,7 @@ func (h *ProjectHandler) GetProjectContact(w http.ResponseWriter, r *http.Reques if err != nil { slog.ErrorContext(r.Context(), "entity GetProjectContact failed", "userID", user.UserID, "projectID", id, "contactID", contactID, "err", err) - mapUpstreamError(w, err, "Failed to fetch the project contact.") + mapUpstreamErrorGeneric(w, err, "Failed to fetch the project contact.") return } diff --git a/apps/csm-portal/backend/internal/handler/projects_test.go b/apps/csm-portal/backend/internal/handler/projects_test.go index ec2d945a3e..bcfa5f9d43 100644 --- a/apps/csm-portal/backend/internal/handler/projects_test.go +++ b/apps/csm-portal/backend/internal/handler/projects_test.go @@ -72,7 +72,7 @@ func TestGetProject(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve project.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve project.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProjectClient{ @@ -150,7 +150,7 @@ func TestSearchProjects(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search projects.") { + for _, tc := range upstreamErrorsGeneric("Failed to search projects.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProjectClient{ @@ -261,7 +261,7 @@ func TestSearchProjectContacts(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search project contacts.") { + for _, tc := range upstreamErrorsGeneric("Failed to search project contacts.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityProjectClient{ @@ -282,6 +282,99 @@ func TestSearchProjectContacts(t *testing.T) { }) } +func TestGetProjectContact(t *testing.T) { + const projectID = "11111111-1111-1111-1111-111111111111" + const contactID = "22222222-2222-2222-2222-222222222222" + + t.Run("requires authenticated user", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := httptest.NewRequest(http.MethodGet, "/projects/"+projectID+"/contacts/"+contactID, nil) + r.SetPathValue("id", projectID) + r.SetPathValue("contactId", contactID) + w := httptest.NewRecorder() + h.GetProjectContact(w, r) + assertStatus(t, w, http.StatusUnauthorized) + assertErrorMessage(t, w, ErrMsgUnauthorized) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects non-UUID project ID", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodGet, "/projects/not-a-uuid/contacts/"+contactID, nil)) + r.SetPathValue("id", "not-a-uuid") + r.SetPathValue("contactId", contactID) + w := httptest.NewRecorder() + h.GetProjectContact(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgInvalidUUID) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects non-UUID contact ID", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodGet, "/projects/"+projectID+"/contacts/not-a-uuid", nil)) + r.SetPathValue("id", projectID) + r.SetPathValue("contactId", "not-a-uuid") + w := httptest.NewRecorder() + h.GetProjectContact(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgInvalidUUID) + assertContentType(t, w, "application/json") + }) + + t.Run("passes both IDs to upstream and returns 200 with response", func(t *testing.T) { + var capturedProjectID, capturedContactID string + client := &mockEntityProjectClient{ + getProjectContactFn: func(_ context.Context, pID, cID string) ([]byte, error) { + capturedProjectID = pID + capturedContactID = cID + return []byte(`{"id":"` + contactID + `","name":"Jane Doe"}`), nil + }, + } + h := NewProjectHandler(client) + r := withUser(httptest.NewRequest(http.MethodGet, "/projects/"+projectID+"/contacts/"+contactID, nil)) + r.SetPathValue("id", projectID) + r.SetPathValue("contactId", contactID) + w := httptest.NewRecorder() + h.GetProjectContact(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + if capturedProjectID != projectID { + t.Errorf("upstream received projectID %q, want %q", capturedProjectID, projectID) + } + if capturedContactID != contactID { + t.Errorf("upstream received contactID %q, want %q", capturedContactID, contactID) + } + resp := decodeJSON[map[string]any](t, w) + if resp["id"] != contactID { + t.Errorf("response id = %v, want %v", resp["id"], contactID) + } + }) + + t.Run("upstream errors are mapped correctly", func(t *testing.T) { + for _, tc := range upstreamErrorsGeneric("Failed to fetch the project contact.") { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := &mockEntityProjectClient{ + getProjectContactFn: func(_ context.Context, _, _ string) ([]byte, error) { + return nil, tc.err + }, + } + h := NewProjectHandler(client) + r := withUser(httptest.NewRequest(http.MethodGet, "/projects/"+projectID+"/contacts/"+contactID, nil)) + r.SetPathValue("id", projectID) + r.SetPathValue("contactId", contactID) + w := httptest.NewRecorder() + h.GetProjectContact(w, r) + assertStatus(t, w, tc.wantCode) + assertErrorMessage(t, w, tc.wantMsg) + assertContentType(t, w, "application/json") + }) + } + }) +} + func TestUpdateProject(t *testing.T) { const projectID = "11111111-1111-1111-1111-111111111111" diff --git a/apps/csm-portal/backend/internal/handler/reference.go b/apps/csm-portal/backend/internal/handler/reference.go index 5242b497a7..f6f2e689d0 100644 --- a/apps/csm-portal/backend/internal/handler/reference.go +++ b/apps/csm-portal/backend/internal/handler/reference.go @@ -92,7 +92,7 @@ func (h *ReferenceHandler) forward( result, err := call(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity "+op+" failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, failureMsg) + mapUpstreamErrorGeneric(w, err, failureMsg) return } diff --git a/apps/csm-portal/backend/internal/handler/response.go b/apps/csm-portal/backend/internal/handler/response.go index fae7502fbc..59b36b8198 100644 --- a/apps/csm-portal/backend/internal/handler/response.go +++ b/apps/csm-portal/backend/internal/handler/response.go @@ -72,7 +72,15 @@ func writeJSONValue(w http.ResponseWriter, statusCode int, v any) { } // mapUpstreamError translates an upstream service error to an HTTP response, -// mirroring the Ballerina getStatusCode pattern in the customer-portal. +// mirroring the Ballerina getStatusCode pattern in the customer-portal. Use +// this only for PATCH/update endpoints: their 4xx failures are reliably a +// rejection of something in the payload the caller just sent (e.g. "Invalid +// state transition"), so the upstream reason is worth showing. Every other +// endpoint should use mapUpstreamErrorGeneric instead — most of them forward +// a request that's only partially validated at this layer, so a 4xx from +// upstream isn't necessarily something the caller could have avoided, and +// echoing it would just leak upstream/internal implementation detail (e.g. a +// JSON decoder's field names). func mapUpstreamError(w http.ResponseWriter, err error, fallbackMsg string) { var apiErr *apierror.Error if errors.As(err, &apiErr) { @@ -107,6 +115,38 @@ func mapUpstreamError(w http.ResponseWriter, err error, fallbackMsg string) { writeError(w, http.StatusInternalServerError, fallbackMsg) } +// mapUpstreamErrorGeneric is mapUpstreamError's counterpart for every +// non-PATCH endpoint: 401/403/404 still translate to the fixed messages, but +// every other case — 400, 409, 422, 5xx, and unmapped statuses alike — falls +// back to fallbackMsg instead of echoing the upstream body to the caller. +// The full upstream reason (status + body) is still expected in the caller's +// own slog.ErrorContext(ctx, ..., "err", err) call — server-side logs are +// operator-facing, not caller-facing, so the detail this function withholds +// from the HTTP response is deliberately preserved there for debugging. +func mapUpstreamErrorGeneric(w http.ResponseWriter, err error, fallbackMsg string) { + var apiErr *apierror.Error + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusUnauthorized: + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + case http.StatusForbidden: + writeError(w, http.StatusForbidden, ErrMsgForbidden) + case http.StatusNotFound: + writeError(w, http.StatusNotFound, ErrMsgNotFound) + case http.StatusBadRequest: + writeError(w, http.StatusBadRequest, fallbackMsg) + case http.StatusConflict, http.StatusUnprocessableEntity: + writeError(w, apiErr.StatusCode, fallbackMsg) + case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + writeError(w, http.StatusServiceUnavailable, fallbackMsg) + default: + writeError(w, http.StatusInternalServerError, fallbackMsg) + } + return + } + writeError(w, http.StatusInternalServerError, fallbackMsg) +} + // upstreamErrorMessage extracts the human-readable message from an upstream // JSON error body shaped like {"message": "..."} (the entity service's error // envelope). Passing the raw JSON body straight through as the outer diff --git a/apps/csm-portal/backend/internal/handler/service_offerings.go b/apps/csm-portal/backend/internal/handler/service_offerings.go index e56c80feaf..bf27e4b454 100644 --- a/apps/csm-portal/backend/internal/handler/service_offerings.go +++ b/apps/csm-portal/backend/internal/handler/service_offerings.go @@ -70,7 +70,7 @@ func (h *ServiceOfferingHandler) SearchServiceOfferings(w http.ResponseWriter, r result, err := h.entity.SearchServiceOfferings(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchServiceOfferings failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search service offerings.") + mapUpstreamErrorGeneric(w, err, "Failed to search service offerings.") return } diff --git a/apps/csm-portal/backend/internal/handler/service_offerings_test.go b/apps/csm-portal/backend/internal/handler/service_offerings_test.go index fdfe5f3fc0..f7480a9c4a 100644 --- a/apps/csm-portal/backend/internal/handler/service_offerings_test.go +++ b/apps/csm-portal/backend/internal/handler/service_offerings_test.go @@ -77,7 +77,7 @@ func TestSearchServiceOfferings(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search service offerings.") { + for _, tc := range upstreamErrorsGeneric("Failed to search service offerings.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityServiceOfferingClient{ diff --git a/apps/csm-portal/backend/internal/handler/services.go b/apps/csm-portal/backend/internal/handler/services.go index 8672b965e7..1b5da9ecb7 100644 --- a/apps/csm-portal/backend/internal/handler/services.go +++ b/apps/csm-portal/backend/internal/handler/services.go @@ -70,7 +70,7 @@ func (h *ITServiceHandler) SearchITServices(w http.ResponseWriter, r *http.Reque result, err := h.entity.SearchITServices(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchITServices failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search IT services.") + mapUpstreamErrorGeneric(w, err, "Failed to search IT services.") return } diff --git a/apps/csm-portal/backend/internal/handler/services_test.go b/apps/csm-portal/backend/internal/handler/services_test.go index 79287720df..49c2572f9c 100644 --- a/apps/csm-portal/backend/internal/handler/services_test.go +++ b/apps/csm-portal/backend/internal/handler/services_test.go @@ -77,7 +77,7 @@ func TestSearchITServices(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search IT services.") { + for _, tc := range upstreamErrorsGeneric("Failed to search IT services.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityITServiceClient{ diff --git a/apps/csm-portal/backend/internal/handler/task_slas.go b/apps/csm-portal/backend/internal/handler/task_slas.go index 16878f11e7..13560b02b3 100644 --- a/apps/csm-portal/backend/internal/handler/task_slas.go +++ b/apps/csm-portal/backend/internal/handler/task_slas.go @@ -71,7 +71,7 @@ func (h *TaskSlaHandler) SearchTaskSlas(w http.ResponseWriter, r *http.Request) result, err := h.entity.SearchTaskSlas(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchTaskSlas failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search task SLAs.") + mapUpstreamErrorGeneric(w, err, "Failed to search task SLAs.") return } @@ -95,7 +95,7 @@ func (h *TaskSlaHandler) GetTaskSla(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetTaskSla(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetTaskSla failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve task SLA.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve task SLA.") return } diff --git a/apps/csm-portal/backend/internal/handler/task_slas_test.go b/apps/csm-portal/backend/internal/handler/task_slas_test.go index c3b9ef5679..24e3261b4c 100644 --- a/apps/csm-portal/backend/internal/handler/task_slas_test.go +++ b/apps/csm-portal/backend/internal/handler/task_slas_test.go @@ -77,7 +77,7 @@ func TestSearchTaskSlas(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search task SLAs.") { + for _, tc := range upstreamErrorsGeneric("Failed to search task SLAs.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTaskSlaClient{ @@ -154,7 +154,7 @@ func TestGetTaskSla(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve task SLA.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve task SLA.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTaskSlaClient{ diff --git a/apps/csm-portal/backend/internal/handler/tasks.go b/apps/csm-portal/backend/internal/handler/tasks.go index 690b3e4a7d..846915c774 100644 --- a/apps/csm-portal/backend/internal/handler/tasks.go +++ b/apps/csm-portal/backend/internal/handler/tasks.go @@ -79,7 +79,7 @@ func (h *TaskHandler) SearchCaseTasks(w http.ResponseWriter, r *http.Request) { result, err := h.entity.SearchCaseTasks(r.Context(), caseID, body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchCaseTasks failed", "userID", user.UserID, "caseID", caseID, "err", err) - mapUpstreamError(w, err, "Failed to retrieve case tasks.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve case tasks.") return } @@ -103,7 +103,7 @@ func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetTask(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetTask failed", "userID", user.UserID, "id", id, "err", err) - mapUpstreamError(w, err, "Failed to retrieve task.") + mapUpstreamErrorGeneric(w, err, "Failed to retrieve task.") return } @@ -145,7 +145,7 @@ func (h *TaskHandler) CreateCaseTask(w http.ResponseWriter, r *http.Request) { result, err := h.entity.CreateCaseTask(r.Context(), caseID, body) if err != nil { slog.ErrorContext(r.Context(), "entity CreateCaseTask failed", "userID", user.UserID, "caseID", caseID, "err", err) - mapUpstreamError(w, err, "Failed to create case task.") + mapUpstreamErrorGeneric(w, err, "Failed to create case task.") return } diff --git a/apps/csm-portal/backend/internal/handler/tasks_test.go b/apps/csm-portal/backend/internal/handler/tasks_test.go index 17a7acb304..0bb668df1d 100644 --- a/apps/csm-portal/backend/internal/handler/tasks_test.go +++ b/apps/csm-portal/backend/internal/handler/tasks_test.go @@ -115,7 +115,7 @@ func TestSearchCaseTasks(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve case tasks.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve case tasks.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTaskClient{ @@ -196,7 +196,7 @@ func TestGetTask(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to retrieve task.") { + for _, tc := range upstreamErrorsGeneric("Failed to retrieve task.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTaskClient{ @@ -305,7 +305,7 @@ func TestCreateCaseTask(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create case task.") { + for _, tc := range upstreamErrorsGeneric("Failed to create case task.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTaskClient{ diff --git a/apps/csm-portal/backend/internal/handler/time_cards.go b/apps/csm-portal/backend/internal/handler/time_cards.go index 3c5da7a281..526d5167b1 100644 --- a/apps/csm-portal/backend/internal/handler/time_cards.go +++ b/apps/csm-portal/backend/internal/handler/time_cards.go @@ -72,7 +72,7 @@ func (h *TimeCardHandler) SearchTimeCards(w http.ResponseWriter, r *http.Request result, err := h.entity.SearchTimeCards(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchTimeCards failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search time cards.") + mapUpstreamErrorGeneric(w, err, "Failed to search time cards.") return } @@ -116,7 +116,7 @@ func (h *TimeCardHandler) CreateTimeCard(w http.ResponseWriter, r *http.Request) result, err := h.entity.CreateTimeCard(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity CreateTimeCard failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to create time card.") + mapUpstreamErrorGeneric(w, err, "Failed to create time card.") return } diff --git a/apps/csm-portal/backend/internal/handler/time_cards_test.go b/apps/csm-portal/backend/internal/handler/time_cards_test.go index b053f51908..5efe724f65 100644 --- a/apps/csm-portal/backend/internal/handler/time_cards_test.go +++ b/apps/csm-portal/backend/internal/handler/time_cards_test.go @@ -64,7 +64,7 @@ func TestCreateTimeCard(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to create time card.") { + for _, tc := range upstreamErrorsGeneric("Failed to create time card.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockEntityTimeCardClient{ diff --git a/apps/csm-portal/backend/internal/handler/updates.go b/apps/csm-portal/backend/internal/handler/updates.go index 9417d58637..2b04e27092 100644 --- a/apps/csm-portal/backend/internal/handler/updates.go +++ b/apps/csm-portal/backend/internal/handler/updates.go @@ -56,7 +56,7 @@ func (h *UpdatesHandler) GetProductUpdateLevels(w http.ResponseWriter, r *http.R result, err := h.updates.GetProductUpdateLevels(r.Context()) if err != nil { slog.ErrorContext(r.Context(), "updates GetProductUpdateLevels failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to get product update levels.") + mapUpstreamErrorGeneric(w, err, "Failed to get product update levels.") return } @@ -91,7 +91,7 @@ func (h *UpdatesHandler) SearchUpdatesBetweenUpdateLevels(w http.ResponseWriter, result, err := h.updates.SearchUpdatesBetweenUpdateLevels(r.Context(), payload, user.Email) if err != nil { slog.ErrorContext(r.Context(), "updates SearchUpdatesBetweenUpdateLevels failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search updates.") + mapUpstreamErrorGeneric(w, err, "Failed to search updates.") return } diff --git a/apps/csm-portal/backend/internal/handler/updates_test.go b/apps/csm-portal/backend/internal/handler/updates_test.go index d4d54d1301..cf5895873a 100644 --- a/apps/csm-portal/backend/internal/handler/updates_test.go +++ b/apps/csm-portal/backend/internal/handler/updates_test.go @@ -69,7 +69,7 @@ func TestGetProductUpdateLevels(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to get product update levels.") { + for _, tc := range upstreamErrorsGeneric("Failed to get product update levels.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockUpdatesClient{ @@ -156,7 +156,7 @@ func TestSearchUpdatesBetweenUpdateLevels(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search updates.") { + for _, tc := range upstreamErrorsGeneric("Failed to search updates.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() client := &mockUpdatesClient{ diff --git a/apps/csm-portal/backend/internal/handler/users.go b/apps/csm-portal/backend/internal/handler/users.go index a1f228cd78..832305be8c 100644 --- a/apps/csm-portal/backend/internal/handler/users.go +++ b/apps/csm-portal/backend/internal/handler/users.go @@ -237,7 +237,7 @@ func (h *UsersHandler) SearchUsers(w http.ResponseWriter, r *http.Request) { result, err := h.entity.SearchUsers(r.Context(), body) if err != nil { slog.ErrorContext(r.Context(), "entity SearchUsers failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to search users.") + mapUpstreamErrorGeneric(w, err, "Failed to search users.") return } @@ -265,7 +265,7 @@ func (h *UsersHandler) GetUser(w http.ResponseWriter, r *http.Request) { result, err := h.entity.GetUser(r.Context(), id) if err != nil { slog.ErrorContext(r.Context(), "entity GetUser failed", "userID", user.UserID, "err", err) - mapUpstreamError(w, err, "Failed to fetch the user.") + mapUpstreamErrorGeneric(w, err, "Failed to fetch the user.") return } diff --git a/apps/csm-portal/backend/internal/handler/users_test.go b/apps/csm-portal/backend/internal/handler/users_test.go index 4a3e018429..19f8e765ab 100644 --- a/apps/csm-portal/backend/internal/handler/users_test.go +++ b/apps/csm-portal/backend/internal/handler/users_test.go @@ -349,7 +349,7 @@ func TestSearchUsers(t *testing.T) { }) t.Run("upstream errors are mapped correctly", func(t *testing.T) { - for _, tc := range upstreamErrors("Failed to search users.") { + for _, tc := range upstreamErrorsGeneric("Failed to search users.") { t.Run(tc.name, func(t *testing.T) { t.Parallel() entityClient := &mockEntityUserClient{ diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx index c52f81927a..ba84bed5cc 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -14,7 +14,7 @@ // specific language governing permissions and limitations // under the License. -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom/vitest"; @@ -144,4 +144,35 @@ describe("AgentsLandingPagePilot", () => { expect(screen.getByText("12")).toBeInTheDocument(); expect(screen.queryByText("3")).not.toBeInTheDocument(); }); + + it("shows skeleton tiles again and re-fetches every widget's own data when refresh is clicked", async () => { + getMock.mockResolvedValueOnce(DASHBOARD_DETAIL); + postMock.mockResolvedValue(searchResponseFor(3)); + + const { container } = renderWithClient(); + await waitFor(() => expect(screen.getByText("My Patches")).toBeInTheDocument()); + expect(postMock).toHaveBeenCalledTimes(3); + + let resolveRefetch: (value: typeof DASHBOARD_DETAIL) => void = () => {}; + getMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveRefetch = resolve; + }), + ); + + fireEvent.click(screen.getByRole("button", { name: "Refresh widget pilot" })); + + // Skeletons reappear immediately, before the held-open refetch resolves — + // not just stale tiles sitting there while new data loads underneath. + await waitFor(() => + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(3), + ); + + resolveRefetch(DASHBOARD_DETAIL); + + // Every widget's own /cases/search re-runs too, not just the dashboard + // metadata — 3 more calls on top of the initial 3. + await waitFor(() => expect(postMock).toHaveBeenCalledTimes(6)); + expect(screen.getByText("My Patches")).toBeInTheDocument(); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx index 84dc413dd0..8901b590ef 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -15,7 +15,9 @@ // under the License. import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; -import type { JSX } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useState, type JSX } from "react"; +import { ApiQueryKeys } from "@constants/apiConstants"; import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; import SectionCard from "@features/csm-dashboard/components/SectionCard"; @@ -42,8 +44,29 @@ interface AgentsLandingPagePilotProps { export default function AgentsLandingPagePilot({ dashboardId, }: AgentsLandingPagePilotProps): JSX.Element { + const queryClient = useQueryClient(); const { data, isLoading, isError, isFetching, refetch } = useDashboard(dashboardId); + // Separate from `isFetching` (which only covers the dashboard's own + // metadata refetch): each tile resolves its own count/list data via its + // own `useWidgetData` query, so a "refresh" click has to also invalidate + // those — tracked here so the skeleton grid stays up across the whole + // round trip, not just the metadata half of it. + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleRefresh = async (): Promise => { + setIsRefreshing(true); + try { + await Promise.all([ + refetch(), + queryClient.invalidateQueries({ + queryKey: [ApiQueryKeys.CSM_DASHBOARD_WIDGET_DATA], + }), + ]); + } finally { + setIsRefreshing(false); + } + }; return ( void refetch()} - isFetching={isFetching} + onRefresh={() => void handleRefresh()} + isFetching={isFetching || isRefreshing} label="Refresh widget pilot" /> } @@ -75,7 +98,7 @@ export default function AgentsLandingPagePilot({ }, }} > - {isLoading + {isLoading || isRefreshing ? Array.from({ length: PILOT_TILE_COUNT }, (_, i) => ( diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index e878dd6c8b..36b5f7e226 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -14,7 +14,8 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; +import { Box, Card, Skeleton, Tooltip, Typography, alpha, useTheme } from "@wso2/oxygen-ui"; +import { Info } from "@wso2/oxygen-ui-icons-react"; import type { JSX } from "react"; import { Link as RouterLink } from "react-router"; import type { BeWidgetResourceType, BeWidgetShape } from "@api/backend/types"; @@ -47,6 +48,7 @@ export default function DashboardWidgetTile({ filters, listLimit, }: DashboardWidgetTileProps): JSX.Element { + const theme = useTheme(); const { data, isLoading, isError } = useWidgetData( widgetId, resourceType, @@ -73,6 +75,7 @@ export default function DashboardWidgetTile({ } const href = config.buildHref(filters); + const Icon = config.icon; return ( + {/* Tooltip copy is intentionally empty until the per-widget messages + are finalized — the icon renders now so the layout/interaction is + in place ahead of that content. */} + + + + + + {isLoading ? ( ) : isError ? ( @@ -98,29 +124,46 @@ export default function DashboardWidgetTile({ Could not load this widget. ) : ( - <> - - {displayName} - - {shape === "list" ? ( - - ) : shape === "count" ? ( - - {data?.total ?? 0} - - ) : ( - // pie/bar: no aggregate endpoint exists anywhere in the stack - // today, so there is nothing to resolve or render yet — see - // `BeWidgetShape`. - - Not yet supported. + + + + + + + {displayName} - )} - + {shape === "list" ? ( + + ) : shape === "count" ? ( + + {data?.total ?? 0} + + ) : ( + // pie/bar: no aggregate endpoint exists anywhere in the stack + // today, so there is nothing to resolve or render yet — see + // `BeWidgetShape`. + + Not yet supported. + + )} + + )} ); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts index aa3df6a9a6..bc4036efaa 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts @@ -14,6 +14,18 @@ // specific language governing permissions and limitations // under the License. +import { + AlertOctagon, + AlertTriangle, + Briefcase, + Building2, + Clock, + FolderKanban, + GitPullRequest, + ShieldAlert, + Users, + type LucideIcon, +} from "@wso2/oxygen-ui-icons-react"; import type { BeWidgetResourceType } from "@api/backend/types"; import { humanizeState } from "@features/csm-dashboard/utils/abtDashboard"; import { casesHref } from "@features/csm-cases/utils/casesFiltersUrl"; @@ -51,6 +63,12 @@ export interface WidgetResourceConfig { /** Where a click on this widget's tile navigates, given its (opaque, * already current-user-resolved) filters. */ buildHref: (filters: Record) => string; + /** Icon shown on the tile, one per resource type (not per individual + * widget — the backend registry doesn't carry per-widget icon metadata). */ + icon: LucideIcon; + /** Theme palette key the icon (and nothing else — see DashboardWidgetTile's + * hover treatment) is colored with. */ + iconColor: "primary" | "secondary" | "success" | "error" | "info" | "warning"; } function asString(v: unknown): string | undefined { @@ -181,6 +199,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< primaryLabel: numberSubjectLabel, secondaryLabel: stateSecondaryLabel, buildHref: (filters) => casesHref(translateCaseDashboardFilters(filters)), + icon: Briefcase, + iconColor: "primary", }, incident: { searchEndpoint: "/incidents/search", @@ -195,6 +215,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< ...translateIncidentDashboardFilters(filters), }), ), + icon: AlertTriangle, + iconColor: "warning", }, change_request: { searchEndpoint: "/change-requests/search", @@ -209,6 +231,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< ...translateChangeRequestDashboardFilters(filters), }), ), + icon: GitPullRequest, + iconColor: "info", }, problem: { searchEndpoint: "/problems/search", @@ -218,6 +242,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< // No dashboard widget filters problems today; the tab has no URL filter // scheme of its own yet either, so this is unfiltered. buildHref: () => operationsHref("problems"), + icon: AlertOctagon, + iconColor: "error", }, account: { searchEndpoint: "/accounts/search", @@ -225,6 +251,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< primaryLabel: (item) => asString(item.name) ?? "—", secondaryLabel: (item) => asString(item.tier), buildHref: () => "/customers/accounts", + icon: Building2, + iconColor: "secondary", }, project: { searchEndpoint: "/projects/search", @@ -232,6 +260,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< primaryLabel: (item) => asString(item.name) ?? asString(item.projectKey) ?? "—", secondaryLabel: (item) => asString(item.subscriptionType), buildHref: () => "/customers/projects", + icon: FolderKanban, + iconColor: "secondary", }, user: { searchEndpoint: "/users/search", @@ -244,6 +274,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< }, secondaryLabel: (item) => asString(item.email), buildHref: () => "/admin/users", + icon: Users, + iconColor: "info", }, time_card: { searchEndpoint: "/time-cards/search", @@ -258,6 +290,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< return state ? humanizeState(state) : undefined; }, buildHref: () => "/time-cards", + icon: Clock, + iconColor: "warning", }, product_vulnerability: { searchEndpoint: "/products/vulnerabilities/search", @@ -267,6 +301,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< secondaryLabel: (item) => asString(item.priority) ?? asString(item.productName), buildHref: () => "/security-center", + icon: ShieldAlert, + iconColor: "error", }, }; diff --git a/apps/csm-portal/webapp/src/features/csm-operations/components/ChangeRequestsTab.tsx b/apps/csm-portal/webapp/src/features/csm-operations/components/ChangeRequestsTab.tsx index d8b0efa880..e2cf5f35e5 100644 --- a/apps/csm-portal/webapp/src/features/csm-operations/components/ChangeRequestsTab.tsx +++ b/apps/csm-portal/webapp/src/features/csm-operations/components/ChangeRequestsTab.tsx @@ -94,7 +94,7 @@ export default function ChangeRequestsTab(): JSX.Element { () => readChangeRequestFiltersFromUrl(searchParams), [searchParams], ); - const [isFiltersOpen, setIsFiltersOpen] = useState(false); + const [isFiltersOpen, setIsFiltersOpen] = useState(true); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE); const debouncedSearch = useDebouncedValue(filters.search.trim(), 300); diff --git a/apps/csm-portal/webapp/src/features/csm-operations/components/IncidentsFilterBar.tsx b/apps/csm-portal/webapp/src/features/csm-operations/components/IncidentsFilterBar.tsx index 5acbba3f16..0cc54ce3a5 100644 --- a/apps/csm-portal/webapp/src/features/csm-operations/components/IncidentsFilterBar.tsx +++ b/apps/csm-portal/webapp/src/features/csm-operations/components/IncidentsFilterBar.tsx @@ -72,6 +72,14 @@ function formatDateOnly(date: Date): string { return `${y}-${m}-${d}`; } +/** Today's UTC calendar date, as a local-midnight Date (see `parseDateOnly`) + * — the upper bound for both created-date fields, since incidents can't be + * created in the future. */ +function todayUTCDateOnly(): Date { + const now = new Date(); + return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); +} + interface IncidentsFilterBarProps { filters: IncidentFilters; onChange: (next: IncidentFilters) => void; @@ -81,8 +89,8 @@ interface IncidentsFilterBarProps { } /** - * Search + filters bar for the Incidents tab: priority, SLA-violated, created - * date range, and product (see `IncidentSearchPayload.filters` in + * Search + filters bar for the Incidents tab: priority, product, + * SLA-violated, and created date range (see `IncidentSearchPayload.filters` in * openapi.yaml — there's still no server-side state/category filter to build * a control for). The created-date bounds are inclusive and interpreted in * UTC by the backend, so both date fields are labelled "(UTC)" rather than @@ -100,6 +108,14 @@ export default function IncidentsFilterBar({ const activeCount = countActiveIncidentFilters(filters); const hasActive = activeCount > 0; + // Recomputed every render (not memoized) — a `useMemo(..., [])` would + // freeze this at the component's mount date and stop matching "today" for + // any session left open across a UTC midnight. + const today = todayUTCDateOnly(); + const createdEndDate = parseDateOnly(filters.createdEndDate); + const createdStartDate = parseDateOnly(filters.createdStartDate); + const fromMaxDate = createdEndDate && createdEndDate < today ? createdEndDate : today; + const priorityOptions = useMemo( () => INCIDENT_PRIORITIES.map((p) => ({ @@ -117,6 +133,31 @@ export default function IncidentsFilterBar({ }); }; + /** + * `minDate`/`maxDate` only constrain the calendar popup — MUI's DatePicker + * still fires `onChange` for an out-of-range value typed directly into the + * field, so each handler re-checks the same bound here before accepting it, + * rather than trusting the picker's UI-only validation. + */ + const handleCreatedStartChange = (date: unknown): void => { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + onChange({ ...filters, createdStartDate: "" }); + return; + } + if (date > fromMaxDate) return; + onChange({ ...filters, createdStartDate: formatDateOnly(date) }); + }; + + const handleCreatedEndChange = (date: unknown): void => { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) { + onChange({ ...filters, createdEndDate: "" }); + return; + } + if (date > today) return; + if (createdStartDate && date < createdStartDate) return; + onChange({ ...filters, createdEndDate: formatDateOnly(date) }); + }; + return ( @@ -178,7 +219,7 @@ export default function IncidentsFilterBar({ <> - + Priority