diff --git a/Makefile b/Makefile index 3bba0285..f6203def 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ IMG ?= controller:latest ENVTEST_K8S_VERSION = 1.26.1 # Default Test Path for a single integration test. Defaults to root TEST_PATH ?= ./... +# Number of times to run integration tests (set >1 to hunt flakes) +INTEGRATION_RUNS ?= 1 BLUE := $(shell printf "\033[34m") YELLOW := $(shell printf "\033[33m") @@ -63,11 +65,18 @@ test: swag ## Run tests $(OK) Tests passed .PHONY: test-integration -test-integration: swag ## Run tests - @if ! go test ./... -coverprofile cover.out -v --tags integration; then \ - $(WARN) "Tests failed"; \ - exit 1; \ - fi ; \ +test-integration: swag ## Run integration tests (set INTEGRATION_RUNS>1 for flakiness detection) + @for run in $$(seq 1 $(INTEGRATION_RUNS)); do \ + $(INFO) "Integration run $$run/$(INTEGRATION_RUNS)"; \ + coverprofile_flag=""; \ + if [ "$$run" -eq "$(INTEGRATION_RUNS)" ]; then \ + coverprofile_flag="-coverprofile cover.out"; \ + fi; \ + if ! go test ./... -count=1 $$coverprofile_flag -v --tags integration; then \ + $(WARN) "Tests failed on run $$run"; \ + exit 1; \ + fi ; \ + done ; \ $(OK) Tests passed diff --git a/docs/docs.go b/docs/docs.go index 25a5fa5f..c85333ec 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17648,6 +17648,70 @@ const docTemplate = `{ ] } }, + "/risks/{id}/accept": { + "post": { + "description": "Accepts a risk with required justification and a future review deadline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/components": { "get": { "description": "Lists components linked to a risk.", @@ -18079,6 +18143,70 @@ const docTemplate = `{ ] } }, + "/risks/{id}/review": { + "post": { + "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/subjects": { "get": { "description": "Lists subjects linked to a risk.", @@ -18570,6 +18698,148 @@ const docTemplate = `{ ] } }, + "/ssp/{sspId}/risks/{id}/accept": { + "post": { + "description": "Accepts a risk by ID scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/ssp/{sspId}/risks/{id}/review": { + "post": { + "description": "Records a risk review by ID scoped to an SSP. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", @@ -24067,6 +24337,17 @@ const docTemplate = `{ "handler.UserHandler": { "type": "object" }, + "handler.acceptRiskRequest": { + "type": "object", + "properties": { + "justification": { + "type": "string" + }, + "reviewDeadline": { + "type": "string" + } + } + }, "handler.addComponentLinkRequest": { "type": "object", "properties": { @@ -24173,6 +24454,23 @@ const docTemplate = `{ } } }, + "handler.reviewRiskRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string" + }, + "nextReviewDeadline": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "reviewedAt": { + "type": "string" + } + } + }, "handler.riskControlLinkResponse": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 889a23a6..4165d632 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -17642,6 +17642,70 @@ ] } }, + "/risks/{id}/accept": { + "post": { + "description": "Accepts a risk with required justification and a future review deadline.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/components": { "get": { "description": "Lists components linked to a risk.", @@ -18073,6 +18137,70 @@ ] } }, + "/risks/{id}/review": { + "post": { + "description": "Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk", + "parameters": [ + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/risks/{id}/subjects": { "get": { "description": "Lists subjects linked to a risk.", @@ -18564,6 +18692,148 @@ ] } }, + "/ssp/{sspId}/risks/{id}/accept": { + "post": { + "description": "Accepts a risk by ID scoped to an SSP.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Accept risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Accept payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.acceptRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/ssp/{sspId}/risks/{id}/review": { + "post": { + "description": "Records a risk review by ID scoped to an SSP. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Risks" + ], + "summary": "Review risk for SSP", + "parameters": [ + { + "type": "string", + "description": "SSP ID", + "name": "sspId", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Risk ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Review payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.reviewRiskRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_riskResponse" + } + }, + "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" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/subject-templates": { "get": { "description": "List subject templates with optional filters and pagination.", @@ -24061,6 +24331,17 @@ "handler.UserHandler": { "type": "object" }, + "handler.acceptRiskRequest": { + "type": "object", + "properties": { + "justification": { + "type": "string" + }, + "reviewDeadline": { + "type": "string" + } + } + }, "handler.addComponentLinkRequest": { "type": "object", "properties": { @@ -24167,6 +24448,23 @@ } } }, + "handler.reviewRiskRequest": { + "type": "object", + "properties": { + "decision": { + "type": "string" + }, + "nextReviewDeadline": { + "type": "string" + }, + "notes": { + "type": "string" + }, + "reviewedAt": { + "type": "string" + } + } + }, "handler.riskControlLinkResponse": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 013b946e..c195c04b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1362,6 +1362,13 @@ definitions: type: object handler.UserHandler: type: object + handler.acceptRiskRequest: + properties: + justification: + type: string + reviewDeadline: + type: string + type: object handler.addComponentLinkRequest: properties: componentId: @@ -1431,6 +1438,17 @@ definitions: title: type: string type: object + handler.reviewRiskRequest: + properties: + decision: + type: string + nextReviewDeadline: + type: string + notes: + type: string + reviewedAt: + type: string + type: object handler.riskControlLinkResponse: properties: catalogId: @@ -19616,6 +19634,48 @@ paths: summary: Update risk tags: - Risks + /risks/{id}/accept: + post: + consumes: + - application/json + description: Accepts a risk with required justification and a future review + deadline. + parameters: + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Accept payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.acceptRiskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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' + security: + - OAuth2Password: [] + summary: Accept risk + tags: + - Risks /risks/{id}/components: get: description: Lists components linked to a risk. @@ -19893,6 +19953,48 @@ paths: summary: Delete risk evidence link tags: - Risks + /risks/{id}/review: + post: + consumes: + - application/json + description: Records a structured review for an accepted risk. nextReviewDeadline + is required for decision=extend and must be omitted for decision=reopen. + parameters: + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Review payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.reviewRiskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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' + security: + - OAuth2Password: [] + summary: Review risk + tags: + - Risks /risks/{id}/subjects: get: description: Lists subjects linked to a risk. @@ -20211,6 +20313,99 @@ paths: summary: Update risk for SSP tags: - Risks + /ssp/{sspId}/risks/{id}/accept: + post: + consumes: + - application/json + description: Accepts a risk by ID scoped to an SSP. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Accept payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.acceptRiskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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' + security: + - OAuth2Password: [] + summary: Accept risk for SSP + tags: + - Risks + /ssp/{sspId}/risks/{id}/review: + post: + consumes: + - application/json + description: Records a risk review by ID scoped to an SSP. nextReviewDeadline + is required for decision=extend and must be omitted for decision=reopen. + parameters: + - description: SSP ID + in: path + name: sspId + required: true + type: string + - description: Risk ID + in: path + name: id + required: true + type: string + - description: Review payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/handler.reviewRiskRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_riskResponse' + "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' + security: + - OAuth2Password: [] + summary: Review risk for SSP + tags: + - Risks /subject-templates: get: description: List subject templates with optional filters and pagination. diff --git a/internal/api/handler/risks.go b/internal/api/handler/risks.go index 95114157..65b04be8 100644 --- a/internal/api/handler/risks.go +++ b/internal/api/handler/risks.go @@ -43,6 +43,8 @@ func (h *RiskHandler) Register(api *echo.Group) { api.POST("", h.Create) api.GET("/:id", h.Get) api.PUT("/:id", h.Update) + api.POST("/:id/accept", h.Accept) + api.POST("/:id/review", h.Review) api.DELETE("/:id", h.Delete) api.GET("/:id/evidence", h.GetEvidenceLinks) @@ -64,6 +66,8 @@ func (h *RiskHandler) RegisterSSPScoped(api *echo.Group) { api.POST("", h.CreateForSSP) api.GET("/:id", h.GetForSSP) api.PUT("/:id", h.UpdateForSSP) + api.POST("/:id/accept", h.AcceptForSSP) + api.POST("/:id/review", h.ReviewForSSP) api.DELETE("/:id", h.DeleteForSSP) } @@ -157,6 +161,18 @@ type addSubjectLinkRequest struct { SubjectID uuid.UUID `json:"subjectId"` } +type acceptRiskRequest struct { + Justification string `json:"justification"` + ReviewDeadline time.Time `json:"reviewDeadline"` +} + +type reviewRiskRequest struct { + ReviewedAt *time.Time `json:"reviewedAt"` + Decision string `json:"decision"` + Notes *string `json:"notes"` + NextReviewDeadline *time.Time `json:"nextReviewDeadline"` +} + // List godoc // // @Summary List risks @@ -505,6 +521,74 @@ func (h *RiskHandler) DeleteForSSP(ctx echo.Context) error { return h.Delete(ctx) } +// AcceptForSSP godoc +// +// @Summary Accept risk for SSP +// @Description Accepts a risk by ID scoped to an SSP. +// @Tags Risks +// @Accept json +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Param body body acceptRiskRequest true "Accept payload" +// @Success 200 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks/{id}/accept [post] +func (h *RiskHandler) AcceptForSSP(ctx echo.Context) error { + sspID, err := parsePathUUID(ctx, "sspId") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.ensureRiskBelongsToSSP(riskID, sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + return h.internalServerError(ctx, "failed to validate scoped risk", err) + } + return h.Accept(ctx) +} + +// ReviewForSSP godoc +// +// @Summary Review risk for SSP +// @Description Records a risk review by ID scoped to an SSP. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen. +// @Tags Risks +// @Accept json +// @Produce json +// @Param sspId path string true "SSP ID" +// @Param id path string true "Risk ID" +// @Param body body reviewRiskRequest true "Review payload" +// @Success 200 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /ssp/{sspId}/risks/{id}/review [post] +func (h *RiskHandler) ReviewForSSP(ctx echo.Context) error { + sspID, err := parsePathUUID(ctx, "sspId") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if err := h.ensureRiskBelongsToSSP(riskID, sspID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + return h.internalServerError(ctx, "failed to validate scoped risk", err) + } + return h.Review(ctx) +} + func (h *RiskHandler) ensureRiskBelongsToSSP(riskID, sspID uuid.UUID) error { return h.riskService.EnsureRiskInSSP(riskID, sspID) } @@ -685,6 +769,119 @@ func (h *RiskHandler) Update(ctx echo.Context) error { }) } +// Accept godoc +// +// @Summary Accept risk +// @Description Accepts a risk with required justification and a future review deadline. +// @Tags Risks +// @Accept json +// @Produce json +// @Param id path string true "Risk ID" +// @Param body body acceptRiskRequest true "Accept payload" +// @Success 200 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /risks/{id}/accept [post] +func (h *RiskHandler) Accept(ctx echo.Context) error { + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req acceptRiskRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if strings.TrimSpace(req.Justification) == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("justification is required"))) + } + if req.ReviewDeadline.IsZero() { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("reviewDeadline is required"))) + } + + return h.withActorUserID(ctx, func(actorID *uuid.UUID) error { + accepted, err := h.riskService.AcceptRisk(riskrel.AcceptRiskParams{ + RiskID: riskID, + ActorUserID: actorID, + Justification: req.Justification, + ReviewDeadline: req.ReviewDeadline, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + if riskrel.IsValidationError(err) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return h.internalServerError(ctx, "failed to accept risk", err) + } + + mapped, err := h.mapRiskToResponse(accepted) + if err != nil { + return h.internalServerError(ctx, "failed to map accepted risk", err) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[riskResponse]{Data: mapped}) + }) +} + +// Review godoc +// +// @Summary Review risk +// @Description Records a structured review for an accepted risk. nextReviewDeadline is required for decision=extend and must be omitted for decision=reopen. +// @Tags Risks +// @Accept json +// @Produce json +// @Param id path string true "Risk ID" +// @Param body body reviewRiskRequest true "Review payload" +// @Success 200 {object} GenericDataResponse[riskResponse] +// @Failure 400 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /risks/{id}/review [post] +func (h *RiskHandler) Review(ctx echo.Context) error { + riskID, err := parsePathUUID(ctx, "id") + if err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + + var req reviewRiskRequest + if err := ctx.Bind(&req); err != nil { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + if strings.TrimSpace(req.Decision) == "" { + return ctx.JSON(http.StatusBadRequest, api.NewError(fmt.Errorf("decision is required"))) + } + + return h.withActorUserID(ctx, func(actorID *uuid.UUID) error { + reviewed, err := h.riskService.ReviewRisk(riskrel.ReviewRiskParams{ + RiskID: riskID, + ActorUserID: actorID, + ReviewedAt: req.ReviewedAt, + Decision: riskrel.NormalizeRiskReviewDecision(req.Decision), + Notes: req.Notes, + NextReviewDeadline: req.NextReviewDeadline, + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(http.StatusNotFound, api.NewError(fmt.Errorf("risk not found"))) + } + if riskrel.IsValidationError(err) { + return ctx.JSON(http.StatusBadRequest, api.NewError(err)) + } + return h.internalServerError(ctx, "failed to review risk", err) + } + + mapped, err := h.mapRiskToResponse(reviewed) + if err != nil { + return h.internalServerError(ctx, "failed to map reviewed risk", err) + } + return ctx.JSON(http.StatusOK, GenericDataResponse[riskResponse]{Data: mapped}) + }) +} + // Delete godoc // // @Summary Delete risk diff --git a/internal/api/handler/risks_integration_test.go b/internal/api/handler/risks_integration_test.go index 3cc94d3f..425b89c5 100644 --- a/internal/api/handler/risks_integration_test.go +++ b/internal/api/handler/risks_integration_test.go @@ -234,6 +234,103 @@ func (suite *RiskApiIntegrationSuite) TestRiskStatusTransitions() { require.Equal(suite.T(), "Quarterly governance review", *reviews[len(reviews)-1].ReviewJustification) } +func (suite *RiskApiIntegrationSuite) TestRiskAcceptAndReviewEndpoints() { + created := suite.createRisk(map[string]any{ + "title": "Lifecycle risk", + "description": "accept and review endpoints", + "sspId": suite.newSSPID(), + "status": "investigating", + }) + + missingJustificationRec, missingJustificationReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/accept", created.ID), map[string]any{ + "reviewDeadline": time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(missingJustificationRec, missingJustificationReq) + require.Equal(suite.T(), http.StatusBadRequest, missingJustificationRec.Code) + + acceptDeadline := time.Now().Add(7 * 24 * time.Hour).UTC().Truncate(time.Second) + acceptRec, acceptReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/accept", created.ID), map[string]any{ + "justification": "business accepted for a limited period", + "reviewDeadline": acceptDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(acceptRec, acceptReq) + require.Equal(suite.T(), http.StatusOK, acceptRec.Code) + + var accepted GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(acceptRec.Body.Bytes(), &accepted)) + require.Equal(suite.T(), "risk-accepted", accepted.Data.Status) + require.NotNil(suite.T(), accepted.Data.AcceptanceJustification) + require.Equal(suite.T(), "business accepted for a limited period", *accepted.Data.AcceptanceJustification) + require.NotNil(suite.T(), accepted.Data.ReviewDeadline) + require.WithinDuration(suite.T(), acceptDeadline, *accepted.Data.ReviewDeadline, time.Second) + require.NotNil(suite.T(), accepted.Data.LastReviewedAt) + + reviewWithoutDeadlineRec, reviewWithoutDeadlineReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/review", created.ID), map[string]any{ + "decision": "extend", + }) + suite.server.E().ServeHTTP(reviewWithoutDeadlineRec, reviewWithoutDeadlineReq) + require.Equal(suite.T(), http.StatusBadRequest, reviewWithoutDeadlineRec.Code) + + reviewedAt := time.Now().Add(-90 * time.Minute).UTC().Truncate(time.Second) + nextReviewDeadline := time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Second) + reviewExtendRec, reviewExtendReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/review", created.ID), map[string]any{ + "reviewedAt": reviewedAt.Format(time.RFC3339), + "decision": "extend", + "notes": "controls are improving, keep accepted", + "nextReviewDeadline": nextReviewDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(reviewExtendRec, reviewExtendReq) + require.Equal(suite.T(), http.StatusOK, reviewExtendRec.Code) + + var extended GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(reviewExtendRec.Body.Bytes(), &extended)) + require.Equal(suite.T(), "risk-accepted", extended.Data.Status) + require.NotNil(suite.T(), extended.Data.ReviewDeadline) + require.WithinDuration(suite.T(), nextReviewDeadline, *extended.Data.ReviewDeadline, time.Second) + require.NotNil(suite.T(), extended.Data.LastReviewedAt) + require.WithinDuration(suite.T(), reviewedAt, *extended.Data.LastReviewedAt, time.Second) + + reviewReopenWithDeadlineRec, reviewReopenWithDeadlineReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/review", created.ID), map[string]any{ + "decision": "reopen", + "nextReviewDeadline": nextReviewDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(reviewReopenWithDeadlineRec, reviewReopenWithDeadlineReq) + require.Equal(suite.T(), http.StatusBadRequest, reviewReopenWithDeadlineRec.Code) + + reviewReopenRec, reviewReopenReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/risks/%s/review", created.ID), map[string]any{ + "decision": "reopen", + "notes": "mitigation can proceed now", + }) + suite.server.E().ServeHTTP(reviewReopenRec, reviewReopenReq) + require.Equal(suite.T(), http.StatusOK, reviewReopenRec.Code) + + var reopened GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(reviewReopenRec.Body.Bytes(), &reopened)) + require.Equal(suite.T(), "investigating", reopened.Data.Status) + require.Nil(suite.T(), reopened.Data.ReviewDeadline) + require.NotNil(suite.T(), reopened.Data.LastReviewedAt) + + var reviews []riskrel.RiskReview + require.NoError(suite.T(), suite.DB.Where("risk_id = ?", created.ID).Order("created_at asc").Find(&reviews).Error) + require.Len(suite.T(), reviews, 2) + require.Equal(suite.T(), "extend", reviews[0].Decision) + require.Equal(suite.T(), "reopen", reviews[1].Decision) + require.NotNil(suite.T(), reviews[0].ReviewJustification) + require.Equal(suite.T(), "controls are improving, keep accepted", *reviews[0].ReviewJustification) + + var acceptedEvents int64 + require.NoError(suite.T(), suite.DB.Model(&riskrel.RiskEvent{}). + Where("risk_id = ? AND event_type = ?", created.ID, string(riskrel.RiskEventTypeAccepted)). + Count(&acceptedEvents).Error) + require.Equal(suite.T(), int64(1), acceptedEvents) + + var reviewedEvents int64 + require.NoError(suite.T(), suite.DB.Model(&riskrel.RiskEvent{}). + Where("risk_id = ? AND event_type = ?", created.ID, string(riskrel.RiskEventTypeReviewed)). + Count(&reviewedEvents).Error) + require.Equal(suite.T(), int64(2), reviewedEvents) +} + func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskCRUD() { sspID := suite.newSSPID() otherSSPID := suite.newSSPID() @@ -296,6 +393,69 @@ func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskCRUD() { require.Equal(suite.T(), http.StatusNoContent, deleteRec.Code) } +func (suite *RiskApiIntegrationSuite) TestSSPScopedRiskAcceptAndReviewEndpoints() { + sspID := suite.newSSPID() + otherSSPID := suite.newSSPID() + + createRec, createReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks", sspID), map[string]any{ + "title": "Scoped lifecycle risk", + "description": "accept/review scoped", + "status": "investigating", + }) + suite.server.E().ServeHTTP(createRec, createReq) + require.Equal(suite.T(), http.StatusCreated, createRec.Code) + + var created GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(createRec.Body.Bytes(), &created)) + + notFoundAcceptRec, notFoundAcceptReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks/%s/accept", otherSSPID, created.Data.ID), map[string]any{ + "justification": "wrong scope", + "reviewDeadline": time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(notFoundAcceptRec, notFoundAcceptReq) + require.Equal(suite.T(), http.StatusNotFound, notFoundAcceptRec.Code) + + acceptDeadline := time.Now().Add(14 * 24 * time.Hour).UTC().Truncate(time.Second) + acceptRec, acceptReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks/%s/accept", sspID, created.Data.ID), map[string]any{ + "justification": "accepted scoped risk", + "reviewDeadline": acceptDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(acceptRec, acceptReq) + require.Equal(suite.T(), http.StatusOK, acceptRec.Code) + + var accepted GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(acceptRec.Body.Bytes(), &accepted)) + require.Equal(suite.T(), "risk-accepted", accepted.Data.Status) + + reviewDeadline := time.Now().Add(45 * 24 * time.Hour).UTC().Truncate(time.Second) + reviewRec, reviewReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks/%s/review", sspID, created.Data.ID), map[string]any{ + "decision": "extend", + "notes": "scoped extension", + "nextReviewDeadline": reviewDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(reviewRec, reviewReq) + require.Equal(suite.T(), http.StatusOK, reviewRec.Code) + + var reviewed GenericDataResponse[riskResponse] + require.NoError(suite.T(), json.Unmarshal(reviewRec.Body.Bytes(), &reviewed)) + require.Equal(suite.T(), "risk-accepted", reviewed.Data.Status) + require.NotNil(suite.T(), reviewed.Data.ReviewDeadline) + require.WithinDuration(suite.T(), reviewDeadline, *reviewed.Data.ReviewDeadline, time.Second) + + reopenWithDeadlineRec, reopenWithDeadlineReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks/%s/review", sspID, created.Data.ID), map[string]any{ + "decision": "reopen", + "nextReviewDeadline": reviewDeadline.Format(time.RFC3339), + }) + suite.server.E().ServeHTTP(reopenWithDeadlineRec, reopenWithDeadlineReq) + require.Equal(suite.T(), http.StatusBadRequest, reopenWithDeadlineRec.Code) + + notFoundReviewRec, notFoundReviewReq := suite.authedRequest(http.MethodPost, fmt.Sprintf("/api/ssp/%s/risks/%s/review", otherSSPID, created.Data.ID), map[string]any{ + "decision": "reopen", + }) + suite.server.E().ServeHTTP(notFoundReviewRec, notFoundReviewReq) + require.Equal(suite.T(), http.StatusNotFound, notFoundReviewRec.Code) +} + func (suite *RiskApiIntegrationSuite) TestEvidenceLinksAreIdempotent() { evidence := relational.Evidence{ UUID: uuid.New(), diff --git a/internal/service/relational/risks/errors.go b/internal/service/relational/risks/errors.go new file mode 100644 index 00000000..b7ee78dd --- /dev/null +++ b/internal/service/relational/risks/errors.go @@ -0,0 +1,20 @@ +package risks + +import "errors" + +type ValidationError struct { + message string +} + +func (e *ValidationError) Error() string { + return e.message +} + +func newValidationError(message string) error { + return &ValidationError{message: message} +} + +func IsValidationError(err error) bool { + var validationErr *ValidationError + return errors.As(err, &validationErr) +} diff --git a/internal/service/relational/risks/reviews.go b/internal/service/relational/risks/reviews.go index 3036ce3a..2be34575 100644 --- a/internal/service/relational/risks/reviews.go +++ b/internal/service/relational/risks/reviews.go @@ -2,6 +2,7 @@ package risks import ( "errors" + "strings" "time" "github.com/compliance-framework/api/internal/service/relational" @@ -10,6 +11,26 @@ import ( "gorm.io/gorm" ) +type RiskReviewDecision string + +const ( + RiskReviewDecisionExtend RiskReviewDecision = "extend" + RiskReviewDecisionReopen RiskReviewDecision = "reopen" +) + +func (d RiskReviewDecision) IsValid() bool { + switch d { + case RiskReviewDecisionExtend, RiskReviewDecisionReopen: + return true + default: + return false + } +} + +func NormalizeRiskReviewDecision(raw string) RiskReviewDecision { + return RiskReviewDecision(strings.ToLower(strings.TrimSpace(raw))) +} + type RiskReview struct { relational.UUIDModel CreatedAt time.Time `json:"createdAt"` diff --git a/internal/service/relational/risks/service.go b/internal/service/relational/risks/service.go index 4f8f8f04..97b12f46 100644 --- a/internal/service/relational/risks/service.go +++ b/internal/service/relational/risks/service.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/compliance-framework/api/internal/service/relational" @@ -48,6 +49,22 @@ type UpdateRiskParams struct { ReviewJustification *string } +type AcceptRiskParams struct { + RiskID uuid.UUID + ActorUserID *uuid.UUID + Justification string + ReviewDeadline time.Time +} + +type ReviewRiskParams struct { + RiskID uuid.UUID + ActorUserID *uuid.UUID + ReviewedAt *time.Time + Decision RiskReviewDecision + Notes *string + NextReviewDeadline *time.Time +} + type Associations struct { EvidenceIDs []uuid.UUID ControlLinks []RiskControlLink @@ -212,6 +229,180 @@ func (s *RiskService) Update(params UpdateRiskParams) (*Risk, error) { return s.GetByID(*params.Risk.ID) } +func (s *RiskService) AcceptRisk(params AcceptRiskParams) (*Risk, error) { + justification := strings.TrimSpace(params.Justification) + if justification == "" { + return nil, newValidationError("justification is required") + } + if params.ReviewDeadline.IsZero() { + return nil, newValidationError("reviewDeadline is required") + } + reviewDeadline := params.ReviewDeadline.UTC() + if !reviewDeadline.After(time.Now().UTC()) { + return nil, newValidationError("reviewDeadline must be in the future") + } + + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + var risk Risk + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("OwnerAssignments").First(&risk, "id = ?", params.RiskID).Error; err != nil { + tx.Rollback() + return nil, err + } + + if risk.Status != string(RiskStatusInvestigating) { + tx.Rollback() + return nil, newValidationError("only risks in status investigating can be accepted") + } + + now := time.Now().UTC() + oldStatus := risk.Status + risk.Status = string(RiskStatusRiskAccepted) + risk.AcceptanceJustification = &justification + risk.ReviewDeadline = &reviewDeadline + risk.LastReviewedAt = &now + + if err := tx.Save(&risk).Error; err != nil { + tx.Rollback() + return nil, err + } + + riskSnapshot, err := s.getRiskSnapshot(tx, *risk.ID) + if err != nil { + tx.Rollback() + return nil, err + } + + if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeStatusChange, params.ActorUserID, datatypes.JSONMap{ + "from": oldStatus, + "to": risk.Status, + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeAccepted, params.ActorUserID, datatypes.JSONMap{ + "status": risk.Status, + "justification": justification, + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + + // TODO(BCH-1182): enqueue a risk-accepted notification worker job once its type/worker is available in this branch. + + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return s.GetByID(*risk.ID) +} + +func (s *RiskService) ReviewRisk(params ReviewRiskParams) (*Risk, error) { + decision := params.Decision + if decision == "" { + return nil, newValidationError("decision is required") + } + if !decision.IsValid() { + return nil, newValidationError(fmt.Sprintf("decision must be one of: %s, %s", RiskReviewDecisionExtend, RiskReviewDecisionReopen)) + } + nextReviewDeadline := params.NextReviewDeadline + if decision == RiskReviewDecisionExtend { + if nextReviewDeadline == nil { + return nil, newValidationError("nextReviewDeadline is required when decision is extend") + } + nextUTC := nextReviewDeadline.UTC() + if !nextUTC.After(time.Now().UTC()) { + return nil, newValidationError("nextReviewDeadline must be in the future when decision is extend") + } + nextReviewDeadline = &nextUTC + } + if decision == RiskReviewDecisionReopen && nextReviewDeadline != nil { + return nil, newValidationError("nextReviewDeadline must not be provided when decision is reopen") + } + + tx, err := beginTx(s.db) + if err != nil { + return nil, err + } + defer rollbackTxOnPanic(tx) + + var risk Risk + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("OwnerAssignments").First(&risk, "id = ?", params.RiskID).Error; err != nil { + tx.Rollback() + return nil, err + } + if risk.Status != string(RiskStatusRiskAccepted) { + tx.Rollback() + return nil, newValidationError("only risks in status risk-accepted can be reviewed") + } + + reviewedAt := time.Now().UTC() + if params.ReviewedAt != nil { + reviewedAt = params.ReviewedAt.UTC() + } + if decision == RiskReviewDecisionExtend { + risk.ReviewDeadline = nextReviewDeadline + } + + if decision == RiskReviewDecisionReopen { + risk.Status = string(RiskStatusInvestigating) + risk.ReviewDeadline = nil + risk.AcceptanceJustification = nil + } + + risk.LastReviewedAt = &reviewedAt + if err := tx.Save(&risk).Error; err != nil { + tx.Rollback() + return nil, err + } + + riskSnapshot, err := s.getRiskSnapshot(tx, *risk.ID) + if err != nil { + tx.Rollback() + return nil, err + } + + if decision == RiskReviewDecisionReopen { + if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeStatusChange, params.ActorUserID, datatypes.JSONMap{ + "from": string(RiskStatusRiskAccepted), + "to": risk.Status, + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + } + + review := RiskReview{ + RiskID: *risk.ID, + ReviewedByUserID: params.ActorUserID, + ReviewedAt: reviewedAt, + Decision: string(decision), + NextReviewDeadline: nextReviewDeadline, + ReviewJustification: params.Notes, + RiskSnapshot: riskSnapshot, + } + if err := tx.Create(&review).Error; err != nil { + tx.Rollback() + return nil, err + } + if err := s.logRiskEventWithSnapshot(tx, *risk.ID, RiskEventTypeReviewed, params.ActorUserID, datatypes.JSONMap{ + "decision": string(decision), + }, riskSnapshot); err != nil { + tx.Rollback() + return nil, err + } + + if err := tx.Commit().Error; err != nil { + return nil, err + } + + return s.GetByID(*risk.ID) +} + func (s *RiskService) Delete(riskID uuid.UUID) error { tx, err := beginTx(s.db) if err != nil { diff --git a/internal/service/relational/risks/service_test.go b/internal/service/relational/risks/service_test.go index 0c660c63..eb75b34b 100644 --- a/internal/service/relational/risks/service_test.go +++ b/internal/service/relational/risks/service_test.go @@ -514,6 +514,194 @@ func TestRiskServiceAddEvidenceLinkRejectsEvidenceWithoutStreamUUID(t *testing.T require.ErrorContains(t, err, "missing stream uuid") } +func TestRiskServiceAcceptRiskValidationAndSuccess(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + riskID := uuid.New() + require.NoError(t, db.Create(&Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "accept-risk", + Description: "desc", + Status: string(RiskStatusInvestigating), + SSPID: uuid.New(), + SourceType: string(RiskSourceTypeManual), + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + }).Error) + + actorID := uuid.New() + + _, err := svc.AcceptRisk(AcceptRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Justification: " ", + ReviewDeadline: time.Now().Add(24 * time.Hour), + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + + _, err = svc.AcceptRisk(AcceptRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Justification: "accepted", + ReviewDeadline: time.Now().Add(-24 * time.Hour), + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + + deadline := time.Now().Add(7 * 24 * time.Hour).UTC().Truncate(time.Second) + accepted, err := svc.AcceptRisk(AcceptRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Justification: "accepted until controls are in place", + ReviewDeadline: deadline, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusRiskAccepted), accepted.Status) + require.NotNil(t, accepted.ReviewDeadline) + require.WithinDuration(t, deadline, *accepted.ReviewDeadline, time.Second) + require.NotNil(t, accepted.LastReviewedAt) + require.NotNil(t, accepted.AcceptanceJustification) + require.Equal(t, "accepted until controls are in place", *accepted.AcceptanceJustification) + + _, err = svc.AcceptRisk(AcceptRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Justification: "cannot accept twice", + ReviewDeadline: time.Now().Add(24 * time.Hour), + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + require.EqualError(t, err, "only risks in status investigating can be accepted") + + var reviewCount int64 + require.NoError(t, db.Model(&RiskReview{}).Where("risk_id = ?", riskID).Count(&reviewCount).Error) + require.Equal(t, int64(0), reviewCount) + + var acceptedEventCount int64 + require.NoError(t, db.Model(&RiskEvent{}). + Where("risk_id = ? AND event_type = ?", riskID, string(RiskEventTypeAccepted)). + Count(&acceptedEventCount).Error) + require.Equal(t, int64(1), acceptedEventCount) + + var statusChangeEventCount int64 + require.NoError(t, db.Model(&RiskEvent{}). + Where("risk_id = ? AND event_type = ?", riskID, string(RiskEventTypeStatusChange)). + Count(&statusChangeEventCount).Error) + require.Equal(t, int64(1), statusChangeEventCount) +} + +func TestRiskServiceReviewRiskDecisions(t *testing.T) { + db := newRiskServiceTestDB(t) + svc := NewRiskService(db) + + riskID := uuid.New() + reviewDeadline := time.Now().Add(7 * 24 * time.Hour).UTC() + acceptanceJustification := "accepted for now" + require.NoError(t, db.Create(&Risk{ + UUIDModel: relational.UUIDModel{ID: &riskID}, + Title: "review-risk", + Description: "desc", + Status: string(RiskStatusRiskAccepted), + SSPID: uuid.New(), + SourceType: string(RiskSourceTypeManual), + ReviewDeadline: &reviewDeadline, + AcceptanceJustification: &acceptanceJustification, + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + }).Error) + + actorID := uuid.New() + + _, err := svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Decision: NormalizeRiskReviewDecision("invalid"), + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + + _, err = svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Decision: RiskReviewDecisionExtend, + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + + extraneousDeadline := time.Now().Add(24 * time.Hour).UTC() + _, err = svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Decision: RiskReviewDecisionReopen, + NextReviewDeadline: &extraneousDeadline, + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + require.EqualError(t, err, "nextReviewDeadline must not be provided when decision is reopen") + + reviewedAt := time.Now().Add(-2 * time.Hour).UTC().Truncate(time.Second) + nextDeadline := time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Second) + notes := "extended after review" + extended, err := svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + ReviewedAt: &reviewedAt, + Decision: RiskReviewDecisionExtend, + Notes: ¬es, + NextReviewDeadline: &nextDeadline, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusRiskAccepted), extended.Status) + require.NotNil(t, extended.ReviewDeadline) + require.WithinDuration(t, nextDeadline, *extended.ReviewDeadline, time.Second) + require.NotNil(t, extended.LastReviewedAt) + require.WithinDuration(t, reviewedAt, *extended.LastReviewedAt, time.Second) + + reopened, err := svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Decision: RiskReviewDecisionReopen, + }) + require.NoError(t, err) + require.Equal(t, string(RiskStatusInvestigating), reopened.Status) + require.Nil(t, reopened.ReviewDeadline) + require.Nil(t, reopened.AcceptanceJustification) + require.NotNil(t, reopened.LastReviewedAt) + + reviewAfterReopenDeadline := time.Now().Add(7 * 24 * time.Hour).UTC() + _, err = svc.ReviewRisk(ReviewRiskParams{ + RiskID: riskID, + ActorUserID: &actorID, + Decision: RiskReviewDecisionExtend, + NextReviewDeadline: &reviewAfterReopenDeadline, + }) + require.Error(t, err) + require.True(t, IsValidationError(err)) + require.EqualError(t, err, "only risks in status risk-accepted can be reviewed") + + var reviews []RiskReview + require.NoError(t, db.Where("risk_id = ?", riskID).Order("created_at asc").Find(&reviews).Error) + require.Len(t, reviews, 2) + require.Equal(t, "extend", reviews[0].Decision) + require.Equal(t, "reopen", reviews[1].Decision) + require.NotNil(t, reviews[0].ReviewJustification) + require.Equal(t, notes, *reviews[0].ReviewJustification) + + var reviewedEventCount int64 + require.NoError(t, db.Model(&RiskEvent{}). + Where("risk_id = ? AND event_type = ?", riskID, string(RiskEventTypeReviewed)). + Count(&reviewedEventCount).Error) + require.Equal(t, int64(2), reviewedEventCount) + + var statusChangeEventCount int64 + require.NoError(t, db.Model(&RiskEvent{}). + Where("risk_id = ? AND event_type = ?", riskID, string(RiskEventTypeStatusChange)). + Count(&statusChangeEventCount).Error) + require.Equal(t, int64(1), statusChangeEventCount) +} + func newRiskServiceTestDB(t *testing.T) *gorm.DB { t.Helper()