From 289f864572ef57046258dbe3c57c29a6a9e02779 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Sat, 24 Jan 2026 09:51:26 -0300 Subject: [PATCH] feat: local-definitions routes for POAM Signed-off-by: Gustavo Carvalho --- docs/docs.go | 221 ++++++++ docs/swagger.json | 221 ++++++++ docs/swagger.yaml | 149 +++++ .../oscal/plan_of_action_and_milestones.go | 307 ++++++++++- ..._action_and_milestones_integration_test.go | 510 ++++++++++++++++++ .../plan_of_action_and_milestones.go | 109 ++-- .../plan_of_action_and_milestones_test.go | 62 --- ..._action_and_milestones_integration_test.go | 9 +- 8 files changed, 1474 insertions(+), 114 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 1da491bf..928c1214 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -10634,6 +10634,227 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Creates local definitions for a given POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Create local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "localDefinitions", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" + } + } + ], + "responses": { + "201": { + "description": "Created", + "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}/local-definitions/{definitionId}": { + "get": { + "description": "Retrieves local definitions for a given POA\u0026M (same as GET /local-definitions but with consistent routing).", + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Get local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + } + ], + "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" + } + } + } + }, + "put": { + "description": "Updates local definitions for a given POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Update local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "localDefinitions", + "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" + } + } + } + }, + "delete": { + "description": "Deletes local definitions for a given POA\u0026M.", + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Delete local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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..6515cb11 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -10628,6 +10628,227 @@ } } } + }, + "post": { + "description": "Creates local definitions for a given POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Create local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "localDefinitions", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions" + } + } + ], + "responses": { + "201": { + "description": "Created", + "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}/local-definitions/{definitionId}": { + "get": { + "description": "Retrieves local definitions for a given POA\u0026M (same as GET /local-definitions but with consistent routing).", + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Get local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + } + ], + "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" + } + } + } + }, + "put": { + "description": "Updates local definitions for a given POA\u0026M.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Update local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + }, + { + "description": "Local definitions data", + "name": "localDefinitions", + "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" + } + } + } + }, + "delete": { + "description": "Deletes local definitions for a given POA\u0026M.", + "tags": [ + "Plan Of Action and Milestones" + ], + "summary": "Delete local definitions for a POA\u0026M", + "parameters": [ + { + "type": "string", + "description": "POA\u0026M ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Definition ID (placeholder for consistency, not used in implementation)", + "name": "definitionId", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "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..5313aede 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -12562,6 +12562,155 @@ paths: summary: Get POA&M local definitions tags: - Plan Of Action and Milestones + post: + consumes: + - application/json + description: Creates local definitions for a given POA&M. + parameters: + - description: POA&M ID + in: path + name: id + required: true + type: string + - description: Local definitions data + in: body + name: localDefinitions + required: true + schema: + $ref: '#/definitions/oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions' + produces: + - application/json + responses: + "201": + description: Created + 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: Create local definitions for a POA&M + tags: + - Plan Of Action and Milestones + /oscal/plan-of-action-and-milestones/{id}/local-definitions/{definitionId}: + delete: + description: Deletes local definitions for a given POA&M. + parameters: + - description: POA&M ID + in: path + name: id + required: true + type: string + - description: Definition ID (placeholder for consistency, not used in implementation) + in: path + name: definitionId + required: true + type: string + responses: + "204": + description: No Content + "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: Delete local definitions for a POA&M + tags: + - Plan Of Action and Milestones + get: + description: Retrieves local definitions for a given POA&M (same as GET /local-definitions + but with consistent routing). + parameters: + - description: POA&M ID + in: path + name: id + required: true + type: string + - description: Definition ID (placeholder for consistency, not used in implementation) + in: path + name: definitionId + required: true + type: string + 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: Get local definitions for a POA&M + tags: + - Plan Of Action and Milestones + put: + consumes: + - application/json + description: Updates local definitions for a given POA&M. + parameters: + - description: POA&M ID + in: path + name: id + required: true + type: string + - description: Definition ID (placeholder for consistency, not used in implementation) + in: path + name: definitionId + required: true + type: string + - description: Local definitions data + in: body + name: localDefinitions + 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 local definitions for a POA&M + 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..823f8a75 100644 --- a/internal/api/handler/oscal/plan_of_action_and_milestones.go +++ b/internal/api/handler/oscal/plan_of_action_and_milestones.go @@ -67,6 +67,10 @@ 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.POST("/:id/local-definitions", h.CreateLocalDefinitions) + api.PUT("/:id/local-definitions/:definitionId", h.UpdateLocalDefinitions) + api.GET("/:id/local-definitions/:definitionId", h.GetLocalDefinition) + api.DELETE("/:id/local-definitions/:definitionId", h.DeleteLocalDefinitions) api.GET("/:id/back-matter", h.GetBackMatter) api.POST("/:id/back-matter", h.CreateBackMatter) api.PUT("/:id/back-matter", h.UpdateBackMatter) @@ -190,6 +194,49 @@ func (h *PlanOfActionAndMilestonesHandler) validatePoamItemInput(item *oscalType return nil } +// validateLocalDefinitionsInput validates local definitions input +func (h *PlanOfActionAndMilestonesHandler) validateLocalDefinitionsInput(localDefs *oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions) error { + // Local definitions are optional, but if provided, validate structure + if localDefs == nil { + return nil + } + + // Validate components if present + if localDefs.Components != nil { + for i, component := range *localDefs.Components { + if component.UUID == "" { + return fmt.Errorf("component UUID is required at index %d", i) + } + if _, err := uuid.Parse(component.UUID); err != nil { + return fmt.Errorf("invalid component UUID format at index %d: %v", i, err) + } + if component.Title == "" { + return fmt.Errorf("component title is required at index %d", i) + } + if component.Type == "" { + return fmt.Errorf("component type is required at index %d", i) + } + } + } + + // Validate inventory items if present + if localDefs.InventoryItems != nil { + for i, item := range *localDefs.InventoryItems { + if item.UUID == "" { + return fmt.Errorf("inventory item UUID is required at index %d", i) + } + if _, err := uuid.Parse(item.UUID); err != nil { + return fmt.Errorf("invalid inventory item UUID format at index %d: %v", i, err) + } + if item.Description == "" { + return fmt.Errorf("inventory item description is required at index %d", i) + } + } + } + + return nil +} + // List godoc // // @Summary List POA&Ms @@ -830,11 +877,45 @@ func (h *PlanOfActionAndMilestonesHandler) GetLocalDefinitions(ctx echo.Context) h.sugar.Errorw("failed to get poam", "error", err) return ctx.JSON(http.StatusNotFound, api.NewError(err)) } - localDefs := poam.LocalDefinitions.Data() - if localDefs.Remarks == "" && len(localDefs.Components) == 0 && len(localDefs.InventoryItems) == 0 { + + // Preload local definitions + if err := h.db.Preload("LocalDefinitions").First(&poam, "id = ?", id).Error; err != nil { + h.sugar.Errorw("failed to get poam with local definitions", "error", err) + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + + if len(poam.LocalDefinitions) == 0 { 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()}) + + // Combine all local definitions into one OSCAL structure + var allComponents []oscalTypes_1_1_3.SystemComponent + var allInventoryItems []oscalTypes_1_1_3.InventoryItem + var remarks string + + for _, ld := range poam.LocalDefinitions { + if len(ld.Components) > 0 { + allComponents = append(allComponents, ld.Components...) + } + if len(ld.InventoryItems) > 0 { + allInventoryItems = append(allInventoryItems, ld.InventoryItems...) + } + if ld.Remarks != "" { + remarks = ld.Remarks + } + } + + localDefs := oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: remarks, + } + if len(allComponents) > 0 { + localDefs.Components = &allComponents + } + if len(allInventoryItems) > 0 { + localDefs.InventoryItems = &allInventoryItems + } + + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: localDefs}) } // GetBackMatter godoc @@ -2132,3 +2213,223 @@ func (h *PlanOfActionAndMilestonesHandler) DeleteBackMatterResource(ctx echo.Con return ctx.NoContent(http.StatusNoContent) } + +// CreateLocalDefinitions godoc +// +// @Summary Create local definitions for a POA&M +// @Description Creates local definitions for a given POA&M. +// @Tags Plan Of Action and Milestones +// @Accept json +// @Produce json +// @Param id path string true "POA&M ID" +// @Param localDefinitions body oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions true "Local definitions data" +// @Success 201 {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 [post] +func (h *PlanOfActionAndMilestonesHandler) CreateLocalDefinitions(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 + } + + var oscalLocalDefs oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions + if err := ctx.Bind(&oscalLocalDefs); err != nil { + h.sugar.Warnw("Invalid create local-definitions request", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Validate input + if err := h.validateLocalDefinitionsInput(&oscalLocalDefs); err != nil { + h.sugar.Warnw("Invalid local-definitions input", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var poam relational.PlanOfActionAndMilestones + if err := h.db.First(&poam, "id = ?", id).Error; err != nil { + h.sugar.Errorw("failed to get poam", "error", err) + return ctx.JSON(http.StatusNotFound, api.NewError(err)) + } + + // Create a single local definition entity that contains all components and inventory items + var newLocalDef relational.PlanOfActionAndMilestonesLocalDefinitions + newLocalDef.UnmarshalOscal(oscalLocalDefs, id) + + // Create the local definition + if err := h.db.Create(&newLocalDef).Error; err != nil { + h.sugar.Errorf("Failed to create local-definitions: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Return the created definitions in OSCAL format + return ctx.JSON(http.StatusCreated, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: oscalLocalDefs}) +} + +// UpdateLocalDefinitions godoc +// +// @Summary Update local definitions for a POA&M +// @Description Updates local definitions for a given POA&M. +// @Tags Plan Of Action and Milestones +// @Accept json +// @Produce json +// @Param id path string true "POA&M ID" +// @Param definitionId path string true "Definition ID (placeholder for consistency, not used in implementation)" +// @Param localDefinitions 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/{definitionId} [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)) + } + + definitionIdParam := ctx.Param("definitionId") + definitionID, err := uuid.Parse(definitionIdParam) + if err != nil { + h.sugar.Errorw("invalid definition id", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Verify POAM exists + if err := h.verifyPoamExists(ctx, id); err != nil { + return err + } + + 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)) + } + + // Validate input + if err := h.validateLocalDefinitionsInput(&oscalLocalDefs); err != nil { + h.sugar.Warnw("Invalid local-definitions input", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Find the existing local definition + var existingLocalDef relational.PlanOfActionAndMilestonesLocalDefinitions + if err := h.db.Where("id = ? AND plan_of_action_and_milestones_id = ?", definitionID, id).First(&existingLocalDef).Error; err != nil { + h.sugar.Errorw("failed to get local definition", "error", err) + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("local definition not found"))) + } + + // Update the local definition based on the OSCAL input + var updatedLocalDef relational.PlanOfActionAndMilestonesLocalDefinitions + updatedLocalDef.UnmarshalOscal(oscalLocalDefs, id) + updatedLocalDef.ID = existingLocalDef.ID // Keep the existing ID + + // Save the updated local definition + if err := h.db.Save(&updatedLocalDef).Error; err != nil { + h.sugar.Errorf("Failed to update local definition: %v", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + // Return the updated definition in OSCAL format + localDefs := updatedLocalDef.MarshalOscal() + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: *localDefs}) +} + +// GetLocalDefinition godoc +// +// @Summary Get local definitions for a POA&M +// @Description Retrieves local definitions for a given POA&M (same as GET /local-definitions but with consistent routing). +// @Tags Plan Of Action and Milestones +// @Produce json +// @Param id path string true "POA&M ID" +// @Param definitionId path string true "Definition ID (placeholder for consistency, not used in implementation)" +// @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/{definitionId} [get] +func (h *PlanOfActionAndMilestonesHandler) GetLocalDefinition(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)) + } + + definitionIdParam := ctx.Param("definitionId") + definitionID, err := uuid.Parse(definitionIdParam) + if err != nil { + h.sugar.Errorw("invalid definition id", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Verify POAM exists + if err := h.verifyPoamExists(ctx, id); err != nil { + return err + } + + // Find the specific local definition + var localDef relational.PlanOfActionAndMilestonesLocalDefinitions + if err := h.db.Where("id = ? AND plan_of_action_and_milestones_id = ?", definitionID, id).First(&localDef).Error; err != nil { + h.sugar.Errorw("failed to get local definition", "error", err) + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("local definition not found"))) + } + + // Convert back to OSCAL format and return as a single-item collection + localDefs := localDef.MarshalOscal() + return ctx.JSON(http.StatusOK, handler.GenericDataResponse[oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions]{Data: *localDefs}) +} + +// DeleteLocalDefinitions godoc +// +// @Summary Delete local definitions for a POA&M +// @Description Deletes local definitions for a given POA&M. +// @Tags Plan Of Action and Milestones +// @Param id path string true "POA&M ID" +// @Param definitionId path string true "Definition ID (placeholder for consistency, not used in implementation)" +// @Success 204 "No Content" +// @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/{definitionId} [delete] +func (h *PlanOfActionAndMilestonesHandler) DeleteLocalDefinitions(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)) + } + + definitionIdParam := ctx.Param("definitionId") + definitionID, err := uuid.Parse(definitionIdParam) + if err != nil { + h.sugar.Errorw("invalid definition id", "error", err) + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + // Verify POAM exists + if err := h.verifyPoamExists(ctx, id); err != nil { + return err + } + + // Delete the specific local definition + result := h.db.Where("id = ? AND plan_of_action_and_milestones_id = ?", definitionID, id).Delete(&relational.PlanOfActionAndMilestonesLocalDefinitions{}) + if result.Error != nil { + h.sugar.Errorf("Failed to delete local definition: %v", result.Error) + return ctx.JSON(http.StatusInternalServerError, api.NewError(result.Error)) + } + + if result.RowsAffected == 0 { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("local definition not found"))) + } + + return ctx.NoContent(http.StatusNoContent) +} 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..e450e9ba 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 @@ -14,6 +14,7 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" + "github.com/compliance-framework/api/internal/service/relational" "github.com/compliance-framework/api/internal/tests" oscaltypes "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" "github.com/google/uuid" @@ -3450,3 +3451,512 @@ func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestUpdateMetadataFul suite.Equal("2.0.0", finalResponse.Data.Version) suite.Equal("Final lifecycle test", finalResponse.Data.Remarks) } + +// LOCAL DEFINITIONS CRUD TESTS + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsCreateMultipleItems() { + poamUUID := suite.createBasicPOAM() + + // Create local definitions with multiple components and inventory items + createLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test local definitions with multiple items", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Test Component 1", + Type: "Software", + Description: "First test component description", + }, + { + UUID: uuid.New().String(), + Title: "Test Component 2", + Type: "Hardware", + Description: "Second test component description", + }, + }, + InventoryItems: &[]oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "Test inventory item 1", + Props: &[]oscaltypes.Property{ + { + Name: "asset-type", + Value: "server", + }, + }, + }, + { + UUID: uuid.New().String(), + Description: "Test inventory item 2", + Props: &[]oscaltypes.Property{ + { + Name: "asset-type", + Value: "workstation", + }, + }, + }, + { + UUID: uuid.New().String(), + Description: "Test inventory item 3", + Props: &[]oscaltypes.Property{ + { + Name: "asset-type", + Value: "network-device", + }, + }, + }, + }, + } + + createRec, createReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusCreated, createRec.Code) + + // Verify the created local definitions contain all items + 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) + + // Verify we have 2 components + suite.Require().NotNil(getResponse.Data.Components) + suite.Equal(2, len(*getResponse.Data.Components)) + suite.Equal("Test Component 1", (*getResponse.Data.Components)[0].Title) + suite.Equal("Test Component 2", (*getResponse.Data.Components)[1].Title) + + // Verify we have 3 inventory items + suite.Require().NotNil(getResponse.Data.InventoryItems) + suite.Equal(3, len(*getResponse.Data.InventoryItems)) + suite.Equal("Test inventory item 1", (*getResponse.Data.InventoryItems)[0].Description) + suite.Equal("Test inventory item 2", (*getResponse.Data.InventoryItems)[1].Description) + suite.Equal("Test inventory item 3", (*getResponse.Data.InventoryItems)[2].Description) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsCreateEndpoint() { + poamUUID := suite.createBasicPOAM() + + // Create local definitions + createLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Test Component", + Type: "Software", + Description: "Test component description", + }, + }, + InventoryItems: &[]oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "Test inventory item", + Props: &[]oscaltypes.Property{ + { + Name: "asset-type", + Value: "server", + }, + }, + }, + }, + } + + createRec, createReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusCreated, createRec.Code) + + var createResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(createRec.Body.Bytes(), &createResponse) + suite.Require().NoError(err) + suite.Equal("Test local definitions", createResponse.Data.Remarks) + suite.Require().NotNil(createResponse.Data.Components) + suite.Equal(1, len(*createResponse.Data.Components)) + suite.Require().NotNil(createResponse.Data.InventoryItems) + suite.Equal(1, len(*createResponse.Data.InventoryItems)) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsUpdateEndpoint() { + poamUUID := suite.createBasicPOAM() + + // First create local definitions + createLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Initial local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Initial Component", + Type: "Software", + Description: "Initial component description", + }, + }, + } + + createRec, createReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusCreated, createRec.Code) + + // Get the created component to get its ID + var createResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(createRec.Body.Bytes(), &createResponse) + suite.Require().NoError(err) + + // Extract the component ID from the created response + var componentID string + if createResponse.Data.Components != nil && len(*createResponse.Data.Components) > 0 { + // We need to get the actual database ID of the created local definition + // For now, let's create a new component for the update test + updateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Updated Component", + Type: "Hardware", + Description: "Updated component description", + }, + }, + } + + // Create a new component and then update it + createUpdateRec, createUpdateReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), updateLocalDefs) + suite.server.E().ServeHTTP(createUpdateRec, createUpdateReq) + suite.Equal(http.StatusCreated, createUpdateRec.Code) + + var createUpdateResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err = json.Unmarshal(createUpdateRec.Body.Bytes(), &createUpdateResponse) + suite.Require().NoError(err) + + // Get the database ID of the newly created component + // We need to query the database to get the actual ID + var allDefs []relational.PlanOfActionAndMilestonesLocalDefinitions + suite.DB.Where("plan_of_action_and_milestones_id = ?", poamUUID).Find(&allDefs) + suite.Require().GreaterOrEqual(len(allDefs), 2) + + // Find the component we just created + for _, def := range allDefs { + if len(def.Components) > 0 { + // Check if this local definition contains the component we're looking for + for _, comp := range def.Components { + if comp.Title == "Updated Component" { + componentID = def.ID.String() + break + } + } + if componentID != "" { + break + } + } + } + suite.Require().NotEmpty(componentID) + + // Now update the component + finalUpdateLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Components: &[]oscaltypes.SystemComponent{ + { + UUID: (*createUpdateResponse.Data.Components)[0].UUID, + Title: "Final Updated Component", + Type: "Hardware", + Description: "Final updated component description", + }, + }, + } + + updateRec, updateReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/%s", poamUUID, componentID), finalUpdateLocalDefs) + suite.server.E().ServeHTTP(updateRec, updateReq) + suite.Equal(http.StatusOK, updateRec.Code) + + var updateResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err = json.Unmarshal(updateRec.Body.Bytes(), &updateResponse) + suite.Require().NoError(err) + suite.Equal("Final Updated Component", (*updateResponse.Data.Components)[0].Title) + } +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsGetSingleEndpoint() { + poamUUID := suite.createBasicPOAM() + + // Create local definitions first + createLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test get single local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Test Component for Get", + Type: "Software", + Description: "Test component for get endpoint", + }, + }, + } + + createRec, createReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusCreated, createRec.Code) + + // Get the database ID of the created component to get it + var allDefs []relational.PlanOfActionAndMilestonesLocalDefinitions + suite.DB.Where("plan_of_action_and_milestones_id = ?", poamUUID).Find(&allDefs) + suite.Require().GreaterOrEqual(len(allDefs), 1) + + // Find the component we created + var componentID string + targetUUID := (*createLocalDefs.Components)[0].UUID + for _, def := range allDefs { + if len(def.Components) > 0 { + // Check if this local definition contains the component we're looking for + for _, comp := range def.Components { + if comp.UUID == targetUUID { + componentID = def.ID.String() + break + } + } + if componentID != "" { + break + } + } + } + suite.Require().NotEmpty(componentID) + + // Get single local definition + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/%s", poamUUID, componentID), 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)) + suite.Equal("Test Component for Get", (*getResponse.Data.Components)[0].Title) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsDeleteEndpoint() { + poamUUID := suite.createBasicPOAM() + + // Create local definitions first + createLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test delete local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Test Component for Delete", + Type: "Software", + Description: "Test component for delete endpoint", + }, + }, + } + + createRec, createReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs) + suite.server.E().ServeHTTP(createRec, createReq) + suite.Equal(http.StatusCreated, createRec.Code) + + // Verify local definitions exist before deletion + verifyRec, verifyReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(verifyRec, verifyReq) + suite.Equal(http.StatusOK, verifyRec.Code) + + // Get the database ID of the created component to delete it + var allDefs []relational.PlanOfActionAndMilestonesLocalDefinitions + suite.DB.Where("plan_of_action_and_milestones_id = ?", poamUUID).Find(&allDefs) + suite.Require().GreaterOrEqual(len(allDefs), 1) + + // Find the component we created + var componentID string + for _, def := range allDefs { + if len(def.Components) > 0 { + // Check if this local definition contains the component we're looking for + for _, comp := range def.Components { + if comp.Title == "Test Component for Delete" { + componentID = def.ID.String() + break + } + } + if componentID != "" { + break + } + } + } + suite.Require().NotEmpty(componentID) + + // Delete the specific local definition + deleteRec, deleteReq := suite.createRequest(http.MethodDelete, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/%s", poamUUID, componentID), nil) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + suite.Equal(http.StatusNoContent, deleteRec.Code) + + // Verify that specific local definition no longer exists + getSingleRec, getSingleReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/%s", poamUUID, componentID), nil) + suite.server.E().ServeHTTP(getSingleRec, getSingleReq) + suite.Equal(http.StatusNotFound, getSingleRec.Code) + + // Verify other local definitions still exist (if any) + var remainingDefs []relational.PlanOfActionAndMilestonesLocalDefinitions + suite.DB.Where("plan_of_action_and_milestones_id = ?", poamUUID).Find(&remainingDefs) + + if len(remainingDefs) == 0 { + // If no remaining definitions, the get all should return 404 + verifyAfterRec, verifyAfterReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(verifyAfterRec, verifyAfterReq) + suite.Equal(http.StatusNotFound, verifyAfterRec.Code) + } else { + // If remaining definitions exist, the get all should return 200 + verifyAfterRec, verifyAfterReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(verifyAfterRec, verifyAfterReq) + suite.Equal(http.StatusOK, verifyAfterRec.Code) + } +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsCreateMerge() { + poamUUID := suite.createBasicPOAM() + + // Create initial local definitions + createLocalDefs1 := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Initial local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Initial Component", + Type: "Software", + Description: "Initial component description", + }, + }, + } + + createRec1, createReq1 := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs1) + suite.server.E().ServeHTTP(createRec1, createReq1) + suite.Equal(http.StatusCreated, createRec1.Code) + + // Add more local definitions (should merge, not conflict) + createLocalDefs2 := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Additional local definitions", + Components: &[]oscaltypes.SystemComponent{ + { + UUID: uuid.New().String(), + Title: "Additional Component", + Type: "Hardware", + Description: "Additional component description", + }, + }, + InventoryItems: &[]oscaltypes.InventoryItem{ + { + UUID: uuid.New().String(), + Description: "New inventory item", + Props: &[]oscaltypes.Property{ + { + Name: "asset-type", + Value: "workstation", + }, + }, + }, + }, + } + + createRec2, createReq2 := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), createLocalDefs2) + suite.server.E().ServeHTTP(createRec2, createReq2) + suite.Equal(http.StatusCreated, createRec2.Code) + + // Verify the create worked correctly + var mergeResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err := json.Unmarshal(createRec2.Body.Bytes(), &mergeResponse) + suite.Require().NoError(err) + + // Should have the new remarks (from second request) + suite.Equal("Additional local definitions", mergeResponse.Data.Remarks) + + // Should have the new components (from second request only, since they're separate entities) + suite.Require().NotNil(mergeResponse.Data.Components) + suite.Equal(1, len(*mergeResponse.Data.Components)) + + // Should have the new inventory items (from second request only) + suite.Require().NotNil(mergeResponse.Data.InventoryItems) + suite.Equal(1, len(*mergeResponse.Data.InventoryItems)) + + // Verify total count by getting all local definitions + getAllRec, getAllReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", poamUUID), nil) + suite.server.E().ServeHTTP(getAllRec, getAllReq) + suite.Equal(http.StatusOK, getAllRec.Code) + + var getAllResponse handler.GenericDataResponse[oscaltypes.PlanOfActionAndMilestonesLocalDefinitions] + err = json.Unmarshal(getAllRec.Body.Bytes(), &getAllResponse) + suite.Require().NoError(err) + + // Should have total of 2 components (1 from first request + 1 from second request) + suite.Require().NotNil(getAllResponse.Data.Components) + suite.Equal(2, len(*getAllResponse.Data.Components)) + + // Should have total of 1 inventory item (from second request) + suite.Require().NotNil(getAllResponse.Data.InventoryItems) + suite.Equal(1, len(*getAllResponse.Data.InventoryItems)) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsNotFound() { + poamUUID := suite.createBasicPOAM() + + // Try to get local definitions that don't exist + 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) + + // Try to get single local definitions that don't exist + getSingleRec, getSingleReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", poamUUID), nil) + suite.server.E().ServeHTTP(getSingleRec, getSingleReq) + suite.Equal(http.StatusNotFound, getSingleRec.Code) + + // Try to delete local definitions that don't exist + deleteRec, deleteReq := suite.createRequest(http.MethodDelete, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", poamUUID), nil) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + suite.Equal(http.StatusNotFound, deleteRec.Code) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsWithInvalidUUID() { + invalidUUID := "invalid-uuid-format" + testLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test with invalid UUID", + } + + // Try POST with invalid UUID + postRec, postReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", invalidUUID), testLocalDefs) + suite.server.E().ServeHTTP(postRec, postReq) + suite.Equal(http.StatusBadRequest, postRec.Code) + + // Try PUT with invalid UUID + putRec, putReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", invalidUUID), testLocalDefs) + suite.server.E().ServeHTTP(putRec, putReq) + suite.Equal(http.StatusBadRequest, putRec.Code) + + // Try GET with invalid UUID + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", invalidUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusBadRequest, getRec.Code) + + // Try DELETE with invalid UUID + deleteRec, deleteReq := suite.createRequest(http.MethodDelete, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", invalidUUID), nil) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + suite.Equal(http.StatusBadRequest, deleteRec.Code) +} + +func (suite *PlanOfActionAndMilestonesApiIntegrationSuite) TestLocalDefinitionsWithInvalidPOAM() { + nonExistentUUID := uuid.New().String() + testLocalDefs := oscaltypes.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: "Test with non-existent POAM", + } + + // Try POST with non-existent POAM + postRec, postReq := suite.createRequest(http.MethodPost, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", nonExistentUUID), testLocalDefs) + suite.server.E().ServeHTTP(postRec, postReq) + suite.Equal(http.StatusNotFound, postRec.Code) + + // Try PUT with non-existent POAM + putRec, putReq := suite.createRequest(http.MethodPut, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", nonExistentUUID), testLocalDefs) + suite.server.E().ServeHTTP(putRec, putReq) + suite.Equal(http.StatusNotFound, putRec.Code) + + // Try GET with non-existent POAM + getRec, getReq := suite.createRequest(http.MethodGet, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions", nonExistentUUID), nil) + suite.server.E().ServeHTTP(getRec, getReq) + suite.Equal(http.StatusNotFound, getRec.Code) + + // Try DELETE with non-existent POAM + deleteRec, deleteReq := suite.createRequest(http.MethodDelete, fmt.Sprintf("/api/oscal/plan-of-action-and-milestones/%s/local-definitions/test-id", nonExistentUUID), nil) + suite.server.E().ServeHTTP(deleteRec, deleteReq) + suite.Equal(http.StatusNotFound, deleteRec.Code) +} diff --git a/internal/service/relational/plan_of_action_and_milestones.go b/internal/service/relational/plan_of_action_and_milestones.go index 90f164cf..8dba3124 100644 --- a/internal/service/relational/plan_of_action_and_milestones.go +++ b/internal/service/relational/plan_of_action_and_milestones.go @@ -16,12 +16,12 @@ type PlanOfActionAndMilestones struct { BackMatter BackMatter `json:"back-matter" gorm:"polymorphic:Parent;"` // Simple fields stored as JSON - ImportSsp datatypes.JSONType[ImportSsp] `json:"import-ssp"` - SystemId datatypes.JSONType[SystemId] `json:"system-id"` - LocalDefinitions datatypes.JSONType[PlanOfActionAndMilestonesLocalDefinitions] `json:"local-definitions"` + ImportSsp datatypes.JSONType[ImportSsp] `json:"import-ssp"` + SystemId datatypes.JSONType[SystemId] `json:"system-id"` // Complex entities as proper tables with polymorphic relationships - PoamItems []PoamItem `gorm:"foreignKey:PlanOfActionAndMilestonesID"` + PoamItems []PoamItem `gorm:"foreignKey:PlanOfActionAndMilestonesID"` + LocalDefinitions []PlanOfActionAndMilestonesLocalDefinitions `gorm:"foreignKey:PlanOfActionAndMilestonesID"` Observations []Observation `gorm:"many2many:poam_observations;"` Risks []Risk `gorm:"many2many:poam_risks;"` @@ -49,11 +49,12 @@ func (p *PlanOfActionAndMilestones) UnmarshalOscal(opam oscalTypes_1_1_3.PlanOfA systemId = datatypes.NewJSONType(sid) } - var localDefinitions datatypes.JSONType[PlanOfActionAndMilestonesLocalDefinitions] + var localDefinitions []PlanOfActionAndMilestonesLocalDefinitions if opam.LocalDefinitions != nil { - ld := PlanOfActionAndMilestonesLocalDefinitions{} - ld.UnmarshalOscal(*opam.LocalDefinitions) - localDefinitions = datatypes.NewJSONType(ld) + ld := *opam.LocalDefinitions + localDef := PlanOfActionAndMilestonesLocalDefinitions{} + localDef.UnmarshalOscal(ld, id) + localDefinitions = append(localDefinitions, localDef) } var observations []Observation @@ -127,8 +128,35 @@ func (p *PlanOfActionAndMilestones) MarshalOscal() *oscalTypes_1_1_3.PlanOfActio opam.SystemId = sid.MarshalOscal() } - if ld := p.LocalDefinitions.Data(); ld.Remarks != "" || len(ld.Components) > 0 || len(ld.InventoryItems) > 0 { - opam.LocalDefinitions = ld.MarshalOscal() + if len(p.LocalDefinitions) > 0 { + // Combine all local definitions into one OSCAL structure + var allComponents []oscalTypes_1_1_3.SystemComponent + var allInventoryItems []oscalTypes_1_1_3.InventoryItem + var remarks string + + for _, ld := range p.LocalDefinitions { + if len(ld.Components) > 0 { + allComponents = append(allComponents, ld.Components...) + } + if len(ld.InventoryItems) > 0 { + allInventoryItems = append(allInventoryItems, ld.InventoryItems...) + } + if ld.Remarks != "" { + remarks = ld.Remarks + } + } + + localDefs := oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions{ + Remarks: remarks, + } + if len(allComponents) > 0 { + localDefs.Components = &allComponents + } + if len(allInventoryItems) > 0 { + localDefs.InventoryItems = &allInventoryItems + } + + opam.LocalDefinitions = &localDefs } if len(p.Observations) > 0 { @@ -549,56 +577,45 @@ func (p *PoamItem) MarshalOscal() *oscalTypes_1_1_3.PoamItem { return &ret } -// PlanOfActionAndMilestonesLocalDefinitions represents local definitions in POAM. +// PlanOfActionAndMilestonesLocalDefinitions represents a local definition collection in POAM. type PlanOfActionAndMilestonesLocalDefinitions struct { - AssessmentAssets datatypes.JSONType[oscalTypes_1_1_3.AssessmentAssets] `json:"assessment-assets"` - Components datatypes.JSONSlice[oscalTypes_1_1_3.SystemComponent] `json:"components" gorm:"type:json"` - InventoryItems datatypes.JSONSlice[oscalTypes_1_1_3.InventoryItem] `json:"inventory-items" gorm:"type:json"` - Remarks string `json:"remarks"` + UUIDModel + PlanOfActionAndMilestonesID uuid.UUID `gorm:"index"` + Remarks string `json:"remarks"` + Components datatypes.JSONSlice[oscalTypes_1_1_3.SystemComponent] `json:"components" gorm:"type:json"` + InventoryItems datatypes.JSONSlice[oscalTypes_1_1_3.InventoryItem] `json:"inventory-items" gorm:"type:json"` } -func (p *PlanOfActionAndMilestonesLocalDefinitions) UnmarshalOscal(op oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions) *PlanOfActionAndMilestonesLocalDefinitions { - var assessmentAssets datatypes.JSONType[oscalTypes_1_1_3.AssessmentAssets] - if op.AssessmentAssets != nil { - assessmentAssets = datatypes.NewJSONType(*op.AssessmentAssets) - } - - components := ConvertList(op.Components, func(oc oscalTypes_1_1_3.SystemComponent) oscalTypes_1_1_3.SystemComponent { - return oc - }) +func (ld *PlanOfActionAndMilestonesLocalDefinitions) UnmarshalOscal(op oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions, planID uuid.UUID) *PlanOfActionAndMilestonesLocalDefinitions { + ld.PlanOfActionAndMilestonesID = planID + ld.Remarks = op.Remarks - inventoryItems := ConvertList(op.InventoryItems, func(oi oscalTypes_1_1_3.InventoryItem) oscalTypes_1_1_3.InventoryItem { - return oi - }) + if op.Components != nil { + ld.Components = datatypes.NewJSONSlice(*op.Components) + } - *p = PlanOfActionAndMilestonesLocalDefinitions{ - AssessmentAssets: assessmentAssets, - Components: datatypes.NewJSONSlice(components), - InventoryItems: datatypes.NewJSONSlice(inventoryItems), - Remarks: op.Remarks, + if op.InventoryItems != nil { + ld.InventoryItems = datatypes.NewJSONSlice(*op.InventoryItems) } - return p + + return ld } -func (p *PlanOfActionAndMilestonesLocalDefinitions) MarshalOscal() *oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions { +func (ld *PlanOfActionAndMilestonesLocalDefinitions) MarshalOscal() *oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions { ret := oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions{ - Remarks: p.Remarks, - } - - if assessmentAssets := p.AssessmentAssets.Data(); len(assessmentAssets.AssessmentPlatforms) > 0 || (assessmentAssets.Components != nil && len(*assessmentAssets.Components) > 0) { - ret.AssessmentAssets = &assessmentAssets + Remarks: ld.Remarks, } - if len(p.Components) > 0 { - components := make([]oscalTypes_1_1_3.SystemComponent, len(p.Components)) - copy(components, p.Components) + if len(ld.Components) > 0 { + components := make([]oscalTypes_1_1_3.SystemComponent, len(ld.Components)) + copy(components, ld.Components) 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 + if len(ld.InventoryItems) > 0 { + inventoryItems := make([]oscalTypes_1_1_3.InventoryItem, len(ld.InventoryItems)) + copy(inventoryItems, ld.InventoryItems) + ret.InventoryItems = &inventoryItems } return &ret diff --git a/internal/service/relational/plan_of_action_and_milestones_test.go b/internal/service/relational/plan_of_action_and_milestones_test.go index 8a885f27..f51c0b74 100644 --- a/internal/service/relational/plan_of_action_and_milestones_test.go +++ b/internal/service/relational/plan_of_action_and_milestones_test.go @@ -868,68 +868,6 @@ func TestFinding_MarshalUnmarshalOscal(t *testing.T) { } } -// TestPlanOfActionAndMilestonesLocalDefinitions_MarshalUnmarshalOscal tests the marshaling and unmarshaling of PlanOfActionAndMilestonesLocalDefinitions -// to and from OSCAL format, verifying correct handling of all nested components. -func TestPlanOfActionAndMilestonesLocalDefinitions_MarshalUnmarshalOscal(t *testing.T) { - tests := []struct { - name string - data oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions - }{ - { - name: "minimal fields", - data: oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions{ - Remarks: "Minimal local definitions", - }, - }, - { - name: "all fields set", - data: oscalTypes_1_1_3.PlanOfActionAndMilestonesLocalDefinitions{ - AssessmentAssets: &oscalTypes_1_1_3.AssessmentAssets{ - AssessmentPlatforms: []oscalTypes_1_1_3.AssessmentPlatform{ - { - UUID: uuid.New().String(), - Title: "Assessment Platform 1", - }, - }, - }, - Components: &[]oscalTypes_1_1_3.SystemComponent{ - { - UUID: uuid.New().String(), - Type: "software", - Title: "Component 1", - Description: "System component", - Status: oscalTypes_1_1_3.SystemComponentStatus{ - State: "operational", - }, - }, - }, - InventoryItems: &[]oscalTypes_1_1_3.InventoryItem{ - { - UUID: uuid.New().String(), - Description: "Inventory item", - }, - }, - Remarks: "Full local definitions", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inputJson, err := json.Marshal(tt.data) - assert.NoError(t, err) - - ld := &PlanOfActionAndMilestonesLocalDefinitions{} - ld.UnmarshalOscal(tt.data) - output := ld.MarshalOscal() - outputJson, err := json.Marshal(output) - assert.NoError(t, err) - - assert.JSONEq(t, string(inputJson), string(outputJson)) - }) - } -} - // TestPoamItem_MarshalUnmarshalOscal tests the marshaling and unmarshaling of PoamItem // to and from OSCAL format, ensuring all fields are correctly handled. func TestPoamItem_MarshalUnmarshalOscal(t *testing.T) { diff --git a/internal/service/relational/tests/plan_of_action_and_milestones_integration_test.go b/internal/service/relational/tests/plan_of_action_and_milestones_integration_test.go index 89a0f4f4..b197012d 100644 --- a/internal/service/relational/tests/plan_of_action_and_milestones_integration_test.go +++ b/internal/service/relational/tests/plan_of_action_and_milestones_integration_test.go @@ -335,9 +335,12 @@ func (suite *PlanOfActionAndMilestonesIntegrationSuite) TestPOAMCompleteStructur ID: "SYS-001", IdentifierType: "https://organization.gov", }), - LocalDefinitions: datatypes.NewJSONType(relational.PlanOfActionAndMilestonesLocalDefinitions{ - Remarks: "Local definitions for POAM-specific components and assets", - }), + LocalDefinitions: []relational.PlanOfActionAndMilestonesLocalDefinitions{ + { + Remarks: "Local definitions for POAM-specific components and assets", + }, + }, + PoamItems: []relational.PoamItem{ *poamItem, },