From 8bb27cfc2b79f31067d9aa2ab13a8ac8b6607228 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Wed, 22 Jul 2026 16:19:21 +0530 Subject: [PATCH] [CSM Portal] Add PATCH /projects/{id} pass-through to BFF Adds an opaque-passthrough handler for the ACP closure-state fields (and existing hasAgent/hasKbReferences toggles) on the Go BFF, mirroring the entity-service's PATCH /projects/{id} contract. Auth-checks and UUID-validates the path id, caps and forwards the body verbatim, and returns the upstream response as-is -- no BFF-level RBAC beyond "authenticated user", matching this codebase's existing convention for PATCH endpoints (e.g. PatchCallRequest) of letting the entity service reject unsupported writes. --- apps/csm-portal/backend/cmd/server/main.go | 1 + .../backend/internal/entity/entity.go | 6 + .../backend/internal/handler/helpers_test.go | 8 ++ .../backend/internal/handler/projects.go | 47 +++++++ .../backend/internal/handler/projects_test.go | 112 +++++++++++++++++ apps/csm-portal/backend/openapi.yaml | 117 ++++++++++++++++++ 6 files changed, 291 insertions(+) diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 24d4d45070..5af5fc77cb 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -126,6 +126,7 @@ func main() { mux.HandleFunc("GET /projects/{id}", projectHandler.GetProject) mux.HandleFunc("POST /projects/search", projectHandler.SearchProjects) mux.HandleFunc("POST /projects/{id}/contacts/search", projectHandler.SearchProjectContacts) + mux.HandleFunc("PATCH /projects/{id}", projectHandler.UpdateProject) mux.HandleFunc("POST /products/search", productHandler.SearchProducts) mux.HandleFunc("POST /products/{id}/versions/search", productHandler.SearchProductVersions) mux.HandleFunc("POST /deployments", deploymentHandler.PostDeployment) diff --git a/apps/csm-portal/backend/internal/entity/entity.go b/apps/csm-portal/backend/internal/entity/entity.go index a41d1cbd44..47d13ba89a 100644 --- a/apps/csm-portal/backend/internal/entity/entity.go +++ b/apps/csm-portal/backend/internal/entity/entity.go @@ -120,6 +120,12 @@ func (c *Client) SearchProjectContacts(ctx context.Context, projectID string, bo return c.do(ctx, http.MethodPost, fmt.Sprintf("/projects/%s/contacts/search", url.PathEscape(projectID)), body) } +// UpdateProject calls PATCH /projects/{id} on the entity service. +// Response is returned as raw JSON; typed response structs are deferred. +func (c *Client) UpdateProject(ctx context.Context, id string, body []byte) ([]byte, error) { + return c.do(ctx, http.MethodPatch, fmt.Sprintf("/projects/%s", url.PathEscape(id)), body) +} + // SearchProducts calls POST /products/search on the entity service. // Response is returned as raw JSON; field filtering to the portal shape is deferred. func (c *Client) SearchProducts(ctx context.Context, body []byte) ([]byte, error) { diff --git a/apps/csm-portal/backend/internal/handler/helpers_test.go b/apps/csm-portal/backend/internal/handler/helpers_test.go index 15f3a1595b..a4ea4a46ec 100644 --- a/apps/csm-portal/backend/internal/handler/helpers_test.go +++ b/apps/csm-portal/backend/internal/handler/helpers_test.go @@ -313,6 +313,7 @@ type mockEntityProjectClient struct { getProjectFn func(ctx context.Context, id string) ([]byte, error) searchProjectsFn func(ctx context.Context, body []byte) ([]byte, error) searchProjectContactsFn func(ctx context.Context, projectID string, body []byte) ([]byte, error) + updateProjectFn func(ctx context.Context, id string, body []byte) ([]byte, error) } func (m *mockEntityProjectClient) GetProject(ctx context.Context, id string) ([]byte, error) { @@ -336,6 +337,13 @@ func (m *mockEntityProjectClient) SearchProjectContacts(ctx context.Context, pro return []byte(`{}`), nil } +func (m *mockEntityProjectClient) UpdateProject(ctx context.Context, id string, body []byte) ([]byte, error) { + if m.updateProjectFn != nil { + return m.updateProjectFn(ctx, id, body) + } + return []byte(`{}`), nil +} + // ----- mock entity product client ----- type mockEntityProductClient struct { diff --git a/apps/csm-portal/backend/internal/handler/projects.go b/apps/csm-portal/backend/internal/handler/projects.go index f5a84b6a11..ffc003dbb8 100644 --- a/apps/csm-portal/backend/internal/handler/projects.go +++ b/apps/csm-portal/backend/internal/handler/projects.go @@ -31,6 +31,7 @@ type entityProjectClient interface { GetProject(ctx context.Context, id string) ([]byte, error) SearchProjects(ctx context.Context, body []byte) ([]byte, error) SearchProjectContacts(ctx context.Context, projectID string, body []byte) ([]byte, error) + UpdateProject(ctx context.Context, id string, body []byte) ([]byte, error) } // ProjectHandler handles HTTP requests for project operations, delegating to the @@ -146,3 +147,49 @@ func (h *ProjectHandler) SearchProjectContacts(w http.ResponseWriter, r *http.Re writeJSON(w, http.StatusOK, result) } + +// UpdateProject handles PATCH /projects/{id}. +// The endpoint is path-scoped, so the request body is capped and forwarded to the +// entity service as-is (no fields are injected) and the response is returned verbatim. +// The entity service is the source of truth for field-level validation (e.g. at +// least one field must be provided); the backend has no role-based access control +// layer yet, so any authenticated user may invoke this today, matching the +// existing convention on other PATCH endpoints in this codebase. +func (h *ProjectHandler) UpdateProject(w http.ResponseWriter, r *http.Request) { + user := middleware.UserInfoFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + return + } + + id := r.PathValue("id") + if id == "" || !uuidRe.MatchString(id) { + writeError(w, http.StatusBadRequest, ErrMsgInvalidUUID) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := err.(*http.MaxBytesError); ok { + writeError(w, http.StatusRequestEntityTooLarge, ErrMsgTooLarge) + return + } + writeError(w, http.StatusBadRequest, errMsgReadBody) + return + } + + if !json.Valid(body) { + writeError(w, http.StatusBadRequest, ErrMsgBadRequest) + return + } + + result, err := h.entity.UpdateProject(r.Context(), id, body) + if err != nil { + slog.ErrorContext(r.Context(), "entity UpdateProject failed", "userID", user.UserID, "projectID", id, "err", err) + mapUpstreamError(w, err, "Failed to update project.") + return + } + + writeJSON(w, http.StatusOK, result) +} diff --git a/apps/csm-portal/backend/internal/handler/projects_test.go b/apps/csm-portal/backend/internal/handler/projects_test.go index 903c1c4247..ec2d945a3e 100644 --- a/apps/csm-portal/backend/internal/handler/projects_test.go +++ b/apps/csm-portal/backend/internal/handler/projects_test.go @@ -281,3 +281,115 @@ func TestSearchProjectContacts(t *testing.T) { } }) } + +func TestUpdateProject(t *testing.T) { + const projectID = "11111111-1111-1111-1111-111111111111" + + t.Run("requires authenticated user", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := httptest.NewRequest(http.MethodPatch, "/projects/"+projectID, strings.NewReader(`{"hasAgent":true}`)) + r.SetPathValue("id", projectID) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, http.StatusUnauthorized) + assertErrorMessage(t, w, ErrMsgUnauthorized) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects empty project ID", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/", strings.NewReader(`{"hasAgent":true}`))) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgInvalidUUID) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects non-UUID project ID", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/proj-42", strings.NewReader(`{"hasAgent":true}`))) + r.SetPathValue("id", "proj-42") + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgInvalidUUID) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects body exceeding 1 MiB", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/"+projectID, strings.NewReader(strings.Repeat("x", maxRequestBodyBytes+1)))) + r.SetPathValue("id", projectID) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, http.StatusRequestEntityTooLarge) + assertErrorMessage(t, w, ErrMsgTooLarge) + assertContentType(t, w, "application/json") + }) + + t.Run("rejects invalid JSON body", func(t *testing.T) { + h := NewProjectHandler(&mockEntityProjectClient{}) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/"+projectID, strings.NewReader(`not-json`))) + r.SetPathValue("id", projectID) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, http.StatusBadRequest) + assertErrorMessage(t, w, ErrMsgBadRequest) + assertContentType(t, w, "application/json") + }) + + t.Run("forwards body verbatim and returns upstream response", func(t *testing.T) { + var capturedID string + var capturedBody []byte + reqBody := `{"endDateClosureState":"in_progress","complianceViolationClosureState":"completed"}` + client := &mockEntityProjectClient{ + updateProjectFn: func(_ context.Context, id string, body []byte) ([]byte, error) { + capturedID = id + capturedBody = body + return []byte(`{"message":"Project updated.","project":{"id":"` + projectID + `","updatedBy":"user@example.com","updatedOn":"2026-01-01T00:00:00Z","closureState":"in_progress","endDateClosureState":"in_progress","invoiceDueDateClosureState":null,"complianceViolationClosureState":"completed"}}`), nil + }, + } + h := NewProjectHandler(client) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/"+projectID, strings.NewReader(reqBody))) + r.SetPathValue("id", projectID) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + + if capturedID != projectID { + t.Errorf("projectID = %q, want %q", capturedID, projectID) + } + if string(capturedBody) != reqBody { + t.Errorf("upstream body = %q, want verbatim %q", string(capturedBody), reqBody) + } + + resp := decodeJSON[map[string]any](t, w) + if resp["message"] != "Project updated." { + t.Errorf("message = %v, want %q", resp["message"], "Project updated.") + } + }) + + t.Run("upstream errors are mapped correctly", func(t *testing.T) { + for _, tc := range upstreamErrors("Failed to update project.") { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + client := &mockEntityProjectClient{ + updateProjectFn: func(_ context.Context, _ string, _ []byte) ([]byte, error) { + return nil, tc.err + }, + } + h := NewProjectHandler(client) + r := withUser(httptest.NewRequest(http.MethodPatch, "/projects/"+projectID, strings.NewReader(`{"hasAgent":true}`))) + r.SetPathValue("id", projectID) + w := httptest.NewRecorder() + h.UpdateProject(w, r) + assertStatus(t, w, tc.wantCode) + assertErrorMessage(t, w, tc.wantMsg) + assertContentType(t, w, "application/json") + }) + } + }) +} diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 465846e6aa..68dcd098ce 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -948,6 +948,72 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' + patch: + summary: Update a project's closure sub-state fields or KB/agent toggles. + description: > + Request body is forwarded to the integration service as-is and the + response is returned verbatim. At least one field must be provided; + the integration service validates this and rejects an empty update. + operationId: patchProjectsId + parameters: + - name: id + in: path + description: UUID of the project to update. + required: true + schema: + type: string + format: uuid + requestBody: + description: Project update payload. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectUpdatePayload' + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectUpdateResponse' + "400": + description: BadRequest + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "404": + description: NotFound + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "413": + description: RequestEntityTooLarge + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + /projects/search: post: summary: Search projects. @@ -4930,6 +4996,57 @@ components: type: string format: date-time + ProjectUpdatePayload: + type: object + minProperties: 1 + description: > + At least one field must be provided. hasAgent and hasKbReferences are + simple toggles; the closure-state fields track sub-states of an + Account Closure Process and are only applicable to projects sourced + from the backing data source. + properties: + hasAgent: + type: boolean + hasKbReferences: + type: boolean + endDateClosureState: + type: string + invoiceDueDateClosureState: + type: string + complianceViolationClosureState: + type: string + + ProjectUpdateResponse: + type: object + required: [message, project] + properties: + message: + type: string + project: + type: object + required: [id, updatedBy, updatedOn] + properties: + id: + type: string + format: uuid + updatedBy: + type: string + updatedOn: + type: string + format: date-time + closureState: + type: string + nullable: true + endDateClosureState: + type: string + nullable: true + invoiceDueDateClosureState: + type: string + nullable: true + complianceViolationClosureState: + type: string + nullable: true + DeploymentCreatePayload: type: object required: [projectId, name, type, description]