diff --git a/docs/docs.go b/docs/docs.go index 1da491bf..dacc842b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -10634,6 +10634,63 @@ const docTemplate = `{ } } } + }, + "put": { + "description": "Updates local-definitions for a given POA\u0026M with special handling of array and object fields.\n- Components and inventory-items arrays are treated as full replacements: the existing values on the POA\u0026M are overwritten by the arrays provided in the request body (no per-element merge is performed).\n- Sending an empty array [] for components or inventory-items clears that specific field (resulting in an empty array on the POA\u0026M).\n- Omitting a field in the request body leaves the existing value for that field unchanged.\n- Sending an empty JSON object {} as the payload deletes the entire local-definitions object for the POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Update POA\u0026M local-definitions", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "local-definitions", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestonesLocalDefinitions" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } } }, "/oscal/plan-of-action-and-milestones/{id}/metadata": { diff --git a/docs/swagger.json b/docs/swagger.json index 941ed07a..b9fe7c8f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -10628,6 +10628,63 @@ } } } + }, + "put": { + "description": "Updates local-definitions for a given POA\u0026M with special handling of array and object fields.\n- Components and inventory-items arrays are treated as full replacements: the existing values on the POA\u0026M are overwritten by the arrays provided in the request body (no per-element merge is performed).\n- Sending an empty array [] for components or inventory-items clears that specific field (resulting in an empty array on the POA\u0026M).\n- Omitting a field in the request body leaves the existing value for that field unchanged.\n- Sending an empty JSON object {} as the payload deletes the entire local-definitions object for the POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Update POA\u0026M local-definitions", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "local-definitions", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestonesLocalDefinitions" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + } } }, "/oscal/plan-of-action-and-milestones/{id}/metadata": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7e3ebe99..8c57dbf3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12562,6 +12562,49 @@ paths: summary: Get POA&M local definitions tags: - Plan Of Action and Milestones + put: + consumes: + - application/json + description: |- + Updates local-definitions for a given POA&M with special handling of array and object fields. + - Components and inventory-items arrays are treated as full replacements: the existing values on the POA&M are overwritten by the arrays provided in the request body (no per-element merge is performed). + - Sending an empty array [] for components or inventory-items clears that specific field (resulting in an empty array on the POA&M). + - Omitting a field in the request body leaves the existing value for that field unchanged. + - Sending an empty JSON object {} as the payload deletes the entire local-definitions object for the POA&M. + parameters: + - description: POA&M ID + in: path + name: id + required: true + type: string + - description: Local definitions data + in: body + name: local-definitions + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-oscalTypes_1_1_3_PlanOfActionAndMilestonesLocalDefinitions' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "404": + description: Not Found + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + summary: Update POA&M local-definitions + tags: + - Plan Of Action and Milestones /oscal/plan-of-action-and-milestones/{id}/metadata: get: description: Retrieves metadata for a given POA&M. diff --git a/internal/api/handler/oscal/plan_of_action_and_milestones.go b/internal/api/handler/oscal/plan_of_action_and_milestones.go index f17eb3e4..9de063a7 100644 --- a/internal/api/handler/oscal/plan_of_action_and_milestones.go +++ b/internal/api/handler/oscal/plan_of_action_and_milestones.go @@ -1,8 +1,11 @@ package oscal import ( + "bytes" + "encoding/json" "errors" "fmt" + "io" "net/http" "time" @@ -67,6 +70,7 @@ func (h *PlanOfActionAndMilestonesHandler) Register(api *echo.Group) { api.POST("/:id/system-id", h.CreateSystemId) api.PUT("/:id/system-id", h.UpdateSystemId) api.GET("/:id/local-definitions", h.GetLocalDefinitions) + api.PUT("/:id/local-definitions", h.UpdateLocalDefinitions) api.GET("/:id/back-matter", h.GetBackMatter) api.POST("/:id/back-matter", h.CreateBackMatter) api.PUT("/:id/back-matter", h.UpdateBackMatter) @@ -831,12 +835,205 @@ func (h *PlanOfActionAndMilestonesHandler) GetLocalDefinitions(ctx echo.Context) return ctx.JSON(http.StatusNotFound, api.NewError(err)) } localDefs := poam.LocalDefinitions.Data() - if localDefs.Remarks == "" && len(localDefs.Components) == 0 && len(localDefs.InventoryItems) == 0 { + if isLocalDefinitionsEmpty(localDefs) { return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("no local-definitions for POA&M %s", idParam))) } return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: *localDefs.MarshalOscal()}) } +// UpdateLocalDefinitions godoc +// +// @Summary Update POA&M local-definitions +// @Description Updates local-definitions for a given POA&M with special handling of array and object fields. +// @Description - Components and inventory-items arrays are treated as full replacements: the existing values on the POA&M are overwritten by the arrays provided in the request body (no per-element merge is performed). +// @Description - Sending an empty array [] for components or inventory-items clears that specific field (resulting in an empty array on the POA&M). +// @Description - Omitting a field in the request body leaves the existing value for that field unchanged. +// @Description - Sending an empty JSON object {} as the payload deletes the entire local-definitions object for the POA&M. +// @Tags Plan Of Action and Milestones +// @Accept json +// @Produce json +// @Param id path string true "POA&M ID" +// @Param local-definitions body oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions true "Local definitions data" +// @Success 200 {object} handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Router /oscal/plan-of-action-and-milestones/{id}/local-definitions [put] +func (h *PlanOfActionAndMilestonesHandler) UpdateLocalDefinitions(ctx echo.Context) error { + idParam := ctx.Param("id") + id, err := uuid.Parse(idParam) + if err != nil { + h.sugar.Errorw("invalid id", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Verify POAM exists + if err := h.verifyPoamExists(ctx, id); err != nil { + return err + } + + bodyBytes, err := io.ReadAll(ctx.Request().Body) + if err != nil { + h.sugar.Warnw("Failed to read update local-definitions payload", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + ctx.Request().Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + trimmedBody := bytes.TrimSpace(bodyBytes) + if len(trimmedBody) == 0 { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("request body must be a JSON object"))) + } + + var payloadFields map[string]json.RawMessage + if err := json.Unmarshal(trimmedBody, &payloadFields); err != nil { + h.sugar.Warnw("Invalid update local-definitions request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if payloadFields == nil { + h.sugar.Warnw("Non-object update local-definitions payload", "payload", string(trimmedBody)) + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("request body must be a JSON object"))) + } + + var oscalLocalDefs oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions + if err := ctx.Bind(&oscalLocalDefs); err != nil { + h.sugar.Warnw("Invalid update local-definitions request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Get existing POAM + var existingPoam relational.PlanOfActionAndMilestones + if err := h.db.First(&existingPoam, "id = ?", id).Error; err != nil { + h.sugar.Errorw("failed to get poam", "error", err) + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + + // Handle special logic for empty payload (DELETE entire local-definitions) + if isEmptyLocalDefinitionsPayload(payloadFields) { + return h.deleteLocalDefinitionsForPOAM(ctx, existingPoam) + } + + existingLocalDefs := existingPoam.LocalDefinitions.Data() + + // Create new local-definitions from request + newLocalDefs := relational.PlanOfActionAndMilestonesLocalDefinitions{} + newLocalDefs.UnmarshalOscal(oscalLocalDefs) + + componentsProvided := fieldProvided(payloadFields, "components") + inventoryProvided := fieldProvided(payloadFields, "inventory-items") + assessmentAssetsProvided := fieldProvided(payloadFields, "assessment-assets") + remarksProvided := fieldProvided(payloadFields, "remarks") + + // Handle components array logic + if componentsProvided { + if oscalLocalDefs.Components == nil { + newLocalDefs.Components = nil + h.sugar.Infow("Removing components field in local-definitions") + } else if len(*oscalLocalDefs.Components) == 0 { + newLocalDefs.Components = datatypes.NewJSONSlice([]oscalTypes_1_1_3.SystemComponent{}) + h.sugar.Infow("Clearing all components in local-definitions") + } else { + h.sugar.Infow("Replacing components array", "count", len(*oscalLocalDefs.Components)) + newLocalDefs.Components = datatypes.NewJSONSlice(*oscalLocalDefs.Components) + } + } else { + newLocalDefs.Components = existingLocalDefs.Components + } + + // Handle inventory items array logic (same pattern as components) + if inventoryProvided { + if oscalLocalDefs.InventoryItems == nil { + newLocalDefs.InventoryItems = nil + h.sugar.Infow("Removing inventory items field in local-definitions") + } else if len(*oscalLocalDefs.InventoryItems) == 0 { + newLocalDefs.InventoryItems = datatypes.NewJSONSlice([]oscalTypes_1_1_3.InventoryItem{}) + h.sugar.Infow("Clearing all inventory items in local-definitions") + } else { + h.sugar.Infow("Replacing inventory items array", "count", len(*oscalLocalDefs.InventoryItems)) + newLocalDefs.InventoryItems = datatypes.NewJSONSlice(*oscalLocalDefs.InventoryItems) + } + } else { + newLocalDefs.InventoryItems = existingLocalDefs.InventoryItems + } + + // Handle assessment-assets (preserve existing if not sent) + if assessmentAssetsProvided { + if oscalLocalDefs.AssessmentAssets == nil { + newLocalDefs.AssessmentAssets = datatypes.JSONType[oscalTypes_1_1_3.AssessmentAssets]{} + } else { + newLocalDefs.AssessmentAssets = datatypes.NewJSONType(*oscalLocalDefs.AssessmentAssets) + } + } else { + newLocalDefs.AssessmentAssets = existingLocalDefs.AssessmentAssets + } + + // Handle remarks (preserve existing if not sent) + if remarksProvided { + newLocalDefs.Remarks = oscalLocalDefs.Remarks + } else { + newLocalDefs.Remarks = existingLocalDefs.Remarks + } + + // Update the POAM with the new local-definitions + existingPoam.LocalDefinitions = datatypes.NewJSONType(newLocalDefs) + + if err := h.db.Save(&existingPoam).Error; err != nil { + h.sugar.Errorf("Failed to update local-definitions: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: *newLocalDefs.MarshalOscal()}) +} + +// isEmptyLocalDefinitionsPayload checks if the local-definitions payload is effectively empty ({}) +// This is used to determine if we should delete the entire local-definitions +func isEmptyLocalDefinitionsPayload(payloadFields map[string]json.RawMessage) bool { + if payloadFields == nil { + return false + } + return len(payloadFields) == 0 +} + +func fieldProvided(payloadFields map[string]json.RawMessage, key string) bool { + if payloadFields == nil { + return false + } + _, ok := payloadFields[key] + return ok +} + +func isLocalDefinitionsEmpty(localDefs relational.PlanOfActionAndMilestonesLocalDefinitions) bool { + if localDefs.Components != nil { + return false + } + if localDefs.InventoryItems != nil { + return false + } + if localDefs.Remarks != "" { + return false + } + return !hasAssessmentAssets(localDefs.AssessmentAssets) +} + +func hasAssessmentAssets(assets datatypes.JSONType[oscalTypes_1_1_3.AssessmentAssets]) bool { + assessmentAssets := assets.Data() + if len(assessmentAssets.AssessmentPlatforms) > 0 { + return true + } + return assessmentAssets.Components != nil && len(*assessmentAssets.Components) > 0 +} + +// deleteLocalDefinitionsForPOAM handles the deletion of entire local-definitions when empty payload is sent +func (h *PlanOfActionAndMilestonesHandler) deleteLocalDefinitionsForPOAM(ctx echo.Context, existingPoam relational.PlanOfActionAndMilestones) error { + // Clear the local-definitions field + emptyLocalDefs := relational.PlanOfActionAndMilestonesLocalDefinitions{} + if err := h.db.Model(&existingPoam).Update("local_definitions", datatypes.NewJSONType(emptyLocalDefs)).Error; err != nil { + h.sugar.Errorf("Failed to delete local-definitions: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: *emptyLocalDefs.MarshalOscal()}) +} + // GetBackMatter godoc // // @Summary Get POA&M back-matter diff --git a/internal/api/handler/oscal/plan_of_action_and_milestones_integration_test.go b/internal/api/handler/oscal/plan_of_action_and_milestones_integration_test.go index 7ea7b8da..554cdf43 100644 --- a/internal/api/handler/oscal/plan_of_action_and_milestones_integration_test.go +++ b/internal/api/handler/oscal/plan_of_action_and_milestones_integration_test.go @@ -63,11 +63,15 @@ func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) createRequest(method, suite.Require().NoError(err, "Failed to marshal request body") } + return suite.createRawRequest(method, path, reqBody) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) createRawRequest(method, path string, body []byte) (*httptest.ResponseRecorder, *http.Request) { token, err := suite.GetAuthToken() suite.Require().NoError(err) rec := httptest.NewRecorder() - req := httptest.NewRequest(method, path, bytes.NewReader(reqBody)) + req := httptest.NewRequest(method, path, bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.Header.Set(echo.HeaderAuthorization, fmt.Sprintf("Bearer %s", *token)) @@ -3450,3 +3454,397 @@ func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateMetadataFul suite.Equal("2.0.0", finalResponse.Data.Version) suite.Equal("Final lifecycle test", finalResponse.Data.Remarks) } + +// Test GET route for POAM local-definitions + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestGetLocalDefinitions() { + poamUUID := suite.createBasicPOAM() + + // Test GET when no local-definitions exist (should return 404) + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusNotFound, getRec.Code) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestGetLocalDefinitionsWithData() { + poamUUID := suite.createBasicPOAM() + + // Create local-definitions with data + components := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Test Component", + Description: "Test component description", + }, + } + + inventoryItems := []oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "Test inventory item", + }, + } + + localDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &components, + InventoryItems: &inventoryItems, + Remarks: "Test local definitions", + } + + // Create local-definitions via PUT + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), localDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Test GET when local-definitions exist (should return 200) + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + + // Verify the returned data + var getResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(getRec.Body.Bytes(), &getResponse) + suite.Require().NoError(err) + suite.Require().NotNil(getResponse.Data.Components) + suite.Require().NotNil(getResponse.Data.InventoryItems) + suite.Equal(1, len(*getResponse.Data.Components)) + suite.Equal(1, len(*getResponse.Data.InventoryItems)) + suite.Equal("Test Component", (*getResponse.Data.Components)[0].Title) + suite.Equal("Test inventory item", (*getResponse.Data.InventoryItems)[0].Description) + suite.Equal("Test local definitions", getResponse.Data.Remarks) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestGetLocalDefinitionsAfterDeletion() { + poamUUID := suite.createBasicPOAM() + + // Create local-definitions first + components := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Test Component", + Description: "Test component description", + }, + } + + localDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &components, + Remarks: "Test local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), localDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Verify local-definitions exist + getRec1, getReq1 := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec1, getReq1) + suite.Equal(http.StatusOK, getRec1.Code) + + // Delete local-definitions by sending empty payload + emptyPayload := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{} + deleteRec, deleteReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), emptyPayload) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + suite.Equal(http.StatusOK, deleteRec.Code) + + // Verify GET now returns 404 + getRec2, getReq2 := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec2, getReq2) + suite.Equal(http.StatusNotFound, getRec2.Code) +} + +// Test PUT route for POAM local-definitions with special logic + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithEmptyPayload() { + poamUUID := suite.createBasicPOAM() + + // Send empty payload {} to delete entire local-definitions + emptyPayload := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{} + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), emptyPayload) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify local-definitions was cleared + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusNotFound, getRec.Code) // Should be 404 since local-definitions is empty +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithComponentsArray() { + poamUUID := suite.createBasicPOAM() + + // Create POAM with initial local-definitions + initialComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Initial Component", + Description: "Initial component description", + }, + } + + initialLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &initialComponents, + Remarks: "Initial local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), initialLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Update with new components array (full replacement) + newComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "hardware", + Title: "New Component 1", + Description: "New hardware component", + }, + { + UUID: uuid.New().String(), + Type: "software", + Title: "New Component 2", + Description: "New software component", + }, + } + + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &newComponents, + Remarks: "Updated local definitions", + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify components were replaced + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + + var getResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(getRec.Body.Bytes(), &getResponse) + suite.Require().NoError(err) + suite.Require().NotNil(getResponse.Data.Components) + suite.Equal(2, len(*getResponse.Data.Components)) + suite.Equal("New Component 1", (*getResponse.Data.Components)[0].Title) + suite.Equal("New Component 2", (*getResponse.Data.Components)[1].Title) + suite.Equal("Updated local definitions", getResponse.Data.Remarks) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithEmptyComponentsArray() { + poamUUID := suite.createBasicPOAM() + + // Create POAM with initial local-definitions + initialComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Initial Component", + Description: "Initial component description", + }, + } + + initialLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &initialComponents, + Remarks: "Initial local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), initialLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Update with empty components array [] to clear all components + emptyComponents := []oscaltypes.SystemComponent{} + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &emptyComponents, + Remarks: "Updated with empty components", + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify components were cleared + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithInventoryItemsArray() { + poamUUID := suite.createBasicPOAM() + + // ... (rest of the code remains the same) + initialInventoryItems := []oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "Initial inventory item", + }, + } + + initialLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + InventoryItems: &initialInventoryItems, + Remarks: "Initial local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), initialLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Update with new inventory items array (full replacement) + newInventoryItems := []oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "New hardware inventory item", + }, + { + UUID: uuid.New().String(), + Description: "New software inventory item", + }, + } + + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + InventoryItems: &newInventoryItems, + Remarks: "Updated local definitions", + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify inventory items were replaced + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + + var getResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(getRec.Body.Bytes(), &getResponse) + suite.Require().NoError(err) + suite.Require().NotNil(getResponse.Data.InventoryItems) + suite.Equal(2, len(*getResponse.Data.InventoryItems)) + suite.Equal("New hardware inventory item", (*getResponse.Data.InventoryItems)[0].Description) + suite.Equal("New software inventory item", (*getResponse.Data.InventoryItems)[1].Description) + suite.Equal("Updated local definitions", getResponse.Data.Remarks) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithAbsentComponents() { + poamUUID := suite.createBasicPOAM() + + // Create POAM with initial local-definitions + initialComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Initial Component", + Description: "Initial component description", + }, + } + + initialLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &initialComponents, + Remarks: "Initial local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), initialLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Update without components field (should preserve existing) + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Updated without components field", + // Components is nil/absent - should preserve existing + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify components were preserved + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + + var getResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(getRec.Body.Bytes(), &getResponse) + suite.Require().NoError(err) + suite.Require().NotNil(getResponse.Data.Components) + suite.Equal(1, len(*getResponse.Data.Components)) // Should still have the original component + suite.Equal("Initial Component", (*getResponse.Data.Components)[0].Title) + suite.Equal("Updated without components field", getResponse.Data.Remarks) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateLocalDefinitionsWithMixedFields() { + poamUUID := suite.createBasicPOAM() + + // Create POAM with both components and inventory items + initialComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Initial Component", + Description: "Initial component description", + }, + } + + initialInventoryItems := []oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "Initial inventory item", + }, + } + + initialLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &initialComponents, + InventoryItems: &initialInventoryItems, + Remarks: "Initial mixed local definitions", + } + + createRec, createReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), initialLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusOK, createRec.Code) + + // Update with only new components (inventory items should be preserved) + newComponents := []oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Type: "software", + Title: "Updated Component", + Description: "Updated component description", + }, + } + + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &newComponents, + // InventoryItems is absent - should preserve existing + Remarks: "Updated mixed local definitions", + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + // Verify the mixed update behavior + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusOK, getRec.Code) + + var getResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(getRec.Body.Bytes(), &getResponse) + suite.Require().NoError(err) + + // Components should be updated + suite.Require().NotNil(getResponse.Data.Components) + suite.Equal(1, len(*getResponse.Data.Components)) + suite.Equal("Updated Component", (*getResponse.Data.Components)[0].Title) + + // Inventory items should be preserved + suite.Require().NotNil(getResponse.Data.InventoryItems) + suite.Equal(1, len(*getResponse.Data.InventoryItems)) + suite.Equal("Initial inventory item", (*getResponse.Data.InventoryItems)[0].Description) + + // Remarks should be updated + suite.Equal("Updated mixed local definitions", getResponse.Data.Remarks) +} diff --git a/internal/service/relational/plan_of_action_and_milestones.go b/internal/service/relational/plan_of_action_and_milestones.go index 90f164cf..415c7bd2 100644 --- a/internal/service/relational/plan_of_action_and_milestones.go +++ b/internal/service/relational/plan_of_action_and_milestones.go @@ -593,12 +593,20 @@ func (p *PlanOfActionAndMilestonesLocalDefinitions) MarshalOscal() *oscalTypes_1 components := make([]oscalTypes_1_1_3.SystemComponent, len(p.Components)) copy(components, p.Components) ret.Components = &components + } else if p.Components != nil { + // Include empty slice if it was explicitly set to empty + components := []oscalTypes_1_1_3.SystemComponent{} + ret.Components = &components } if len(p.InventoryItems) > 0 { items := make([]oscalTypes_1_1_3.InventoryItem, len(p.InventoryItems)) copy(items, p.InventoryItems) ret.InventoryItems = &items + } else if p.InventoryItems != nil { + // Include empty slice if it was explicitly set to empty + items := []oscalTypes_1_1_3.InventoryItem{} + ret.InventoryItems = &items } return &ret