From 8173d4f676c77049fe24e36b0a71cbf9192c21a2 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 06:21:32 -0300 Subject: [PATCH 01/11] feat: digest (wip) Signed-off-by: Gustavo Carvalho --- cmd/run.go | 41 +- go.mod | 1 + go.sum | 2 + internal/api/handler/api.go | 12 +- internal/api/handler/digest.go | 86 +++++ .../api/handler/evidence_integration_test.go | 20 +- .../api/handler/filter_integration_test.go | 16 +- .../api/handler/heartbeat_integration_test.go | 6 +- internal/api/handler/users.go | 90 +++++ .../api/handler/users_integration_test.go | 2 +- internal/service/digest/job.go | 32 ++ internal/service/digest/service.go | 265 +++++++++++++ internal/service/digest/service_test.go | 59 +++ .../templates/templates/evidence-digest.html | 359 ++++++++++++++++++ .../templates/templates/evidence-digest.txt | 47 +++ internal/service/relational/ccf_internal.go | 3 + internal/service/scheduler/cron.go | 108 ++++++ internal/service/scheduler/scheduler.go | 43 +++ internal/service/scheduler/scheduler_test.go | 109 ++++++ sdk/integration_base_test.go | 2 +- sso.yaml | 2 +- 21 files changed, 1279 insertions(+), 26 deletions(-) create mode 100644 internal/api/handler/digest.go create mode 100644 internal/service/digest/job.go create mode 100644 internal/service/digest/service.go create mode 100644 internal/service/digest/service_test.go create mode 100644 internal/service/email/templates/templates/evidence-digest.html create mode 100644 internal/service/email/templates/templates/evidence-digest.txt create mode 100644 internal/service/scheduler/cron.go create mode 100644 internal/service/scheduler/scheduler.go create mode 100644 internal/service/scheduler/scheduler_test.go diff --git a/cmd/run.go b/cmd/run.go index aa1dd533..5d8feebc 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -10,6 +10,9 @@ import ( "github.com/compliance-framework/api/internal/api/handler/oscal" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/scheduler" "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap" @@ -23,6 +26,11 @@ var ( } ) +func init() { + RunCmd.Flags().String("digest-schedule", "@weekly", "Cron schedule for digest emails (e.g., '@hourly', '@daily', '@weekly', '0 9 * * 1' for Monday 9am)") + RunCmd.Flags().Bool("digest-enabled", true, "Enable or disable the digest scheduler") +} + func RunServer(cmd *cobra.Command, args []string) { ctx := context.Background() @@ -51,9 +59,40 @@ func RunServer(cmd *cobra.Command, args []string) { sugar.Fatalw("Failed to migrate database", "error", err) } + // Initialize email service + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Warnw("Failed to initialize email service, digests will be disabled", "error", err) + } + + // Initialize digest service + digestService := digest.NewService(db, emailService, cfg, sugar) + + // Initialize scheduler + sched := scheduler.NewCronScheduler(sugar) + + // Register digest job + digestEnabled, _ := cmd.Flags().GetBool("digest-enabled") + digestSchedule, _ := cmd.Flags().GetString("digest-schedule") + + if digestEnabled { + digestJob := digest.NewGlobalDigestJob(digestService, sugar) + if err := sched.ScheduleCron(digestSchedule, digestJob); err != nil { + sugar.Warnw("Failed to schedule digest job", "schedule", digestSchedule, "error", err) + } else { + sugar.Infow("Digest job scheduled", "schedule", digestSchedule) + } + } else { + sugar.Infow("Digest scheduler disabled") + } + + // Start the scheduler + sched.Start() + defer sched.Stop() + metrics := api.NewMetricsHandler(ctx, sugar) server := api.NewServer(ctx, sugar, cfg, metrics) - handler.RegisterHandlers(server, sugar, db, cfg) + handler.RegisterHandlers(server, sugar, db, cfg, digestService, sched) oscal.RegisterHandlers(server, sugar, db, cfg) auth.RegisterHandlers(server, sugar, db, cfg, metrics) diff --git a/go.mod b/go.mod index 2a747824..23ebb415 100644 --- a/go.mod +++ b/go.mod @@ -135,6 +135,7 @@ require ( github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect diff --git a/go.sum b/go.sum index 73bc0e7f..0195fffe 100644 --- a/go.sum +++ b/go.sum @@ -379,6 +379,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 2a3e78c4..604b67a5 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -4,11 +4,13 @@ import ( "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/middleware" "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/scheduler" "go.uber.org/zap" "gorm.io/gorm" ) -func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config) { +func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB, config *config.Config, digestService *digest.Service, sched scheduler.Scheduler) { healthHandler := NewHealthHandler(logger, db) healthHandler.Register(server.API().Group("/health")) @@ -32,4 +34,12 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB userGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) userHandler.RegisterSelfRoutes(userGroup) + // Digest handler (admin only) + if digestService != nil && sched != nil { + digestHandler := NewDigestHandler(digestService, sched, logger) + digestGroup := server.API().Group("/admin/digest") + digestGroup.Use(middleware.JWTMiddleware(config.JWTPublicKey)) + digestGroup.Use(middleware.RequireAdminGroups(db, config, logger)) + digestHandler.Register(digestGroup) + } } diff --git a/internal/api/handler/digest.go b/internal/api/handler/digest.go new file mode 100644 index 00000000..cca03f8d --- /dev/null +++ b/internal/api/handler/digest.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/scheduler" + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +// DigestHandler handles digest-related API endpoints +type DigestHandler struct { + digestService *digest.Service + scheduler scheduler.Scheduler + logger *zap.SugaredLogger +} + +// NewDigestHandler creates a new digest handler +func NewDigestHandler(digestService *digest.Service, sched scheduler.Scheduler, logger *zap.SugaredLogger) *DigestHandler { + return &DigestHandler{ + digestService: digestService, + scheduler: sched, + logger: logger, + } +} + +// Register registers the digest endpoints +func (h *DigestHandler) Register(api *echo.Group) { + api.POST("/trigger", h.TriggerDigest) + api.GET("/preview", h.PreviewDigest) +} + +// TriggerDigest godoc +// +// @Summary Trigger evidence digest +// @Description Manually triggers the evidence digest job to send emails to all users +// @Tags Digest +// @Produce json +// @Param job query string false "Job name to trigger (default: global-evidence-digest)" +// @Success 200 {object} map[string]string +// @Failure 400 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/digest/trigger [post] +func (h *DigestHandler) TriggerDigest(ctx echo.Context) error { + jobName := ctx.QueryParam("job") + if jobName == "" { + jobName = "global-evidence-digest" + } + + if h.scheduler == nil { + return ctx.JSON(http.StatusInternalServerError, api.NewError(nil)) + } + + if err := h.scheduler.RunNow(ctx.Request().Context(), jobName); err != nil { + h.logger.Errorw("Failed to trigger digest job", "job", jobName, "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, map[string]string{ + "message": "Digest job triggered successfully", + "job": jobName, + }) +} + +// PreviewDigest godoc +// +// @Summary Preview evidence digest +// @Description Returns the current evidence summary that would be included in a digest email +// @Tags Digest +// @Produce json +// @Success 200 {object} GenericDataResponse[digest.EvidenceSummary] +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /admin/digest/preview [get] +func (h *DigestHandler) PreviewDigest(ctx echo.Context) error { + summary, err := h.digestService.GetGlobalEvidenceSummary(ctx.Request().Context()) + if err != nil { + h.logger.Errorw("Failed to get evidence summary", "error", err) + return ctx.JSON(http.StatusInternalServerError, api.NewError(err)) + } + + return ctx.JSON(http.StatusOK, GenericDataResponse[*digest.EvidenceSummary]{Data: summary}) +} diff --git a/internal/api/handler/evidence_integration_test.go b/internal/api/handler/evidence_integration_test.go index 8ed20f35..9ac3a47d 100644 --- a/internal/api/handler/evidence_integration_test.go +++ b/internal/api/handler/evidence_integration_test.go @@ -158,7 +158,7 @@ func (suite *EvidenceApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(evidence) req := httptest.NewRequest(http.MethodPost, "/api/evidence", bytes.NewReader(reqBody)) @@ -211,7 +211,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -264,7 +264,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -317,7 +317,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -381,7 +381,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -453,7 +453,7 @@ func (suite *EvidenceApiIntegrationSuite) TestSearch() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() var reqBody, _ = json.Marshal(struct { Filter labelfilter.Filter @@ -552,7 +552,7 @@ func (suite *EvidenceApiIntegrationSuite) TestStatusOverTime() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(struct { Filter labelfilter.Filter @@ -673,7 +673,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", filter.ID), nil) server.E().ServeHTTP(rec, req) @@ -704,7 +704,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/evidence/compliance-by-filter/%s", uuid.New()), nil) server.E().ServeHTTP(rec, req) @@ -718,7 +718,7 @@ func (suite *EvidenceApiIntegrationSuite) TestComplianceByFilter() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/evidence/compliance-by-filter/invalid-uuid", nil) server.E().ServeHTTP(rec, req) diff --git a/internal/api/handler/filter_integration_test.go b/internal/api/handler/filter_integration_test.go index 58e3d903..37887769 100644 --- a/internal/api/handler/filter_integration_test.go +++ b/internal/api/handler/filter_integration_test.go @@ -52,7 +52,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -96,7 +96,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -137,7 +137,7 @@ func (suite *FilterApiIntegrationSuite) TestCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(createReq) req := httptest.NewRequest(http.MethodPost, "/api/filters", bytes.NewReader(reqBody)) @@ -166,7 +166,7 @@ func (suite *FilterApiIntegrationSuite) TestList() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) // Create filter linked to AC-1 withControlReq := createFilterRequest{ @@ -244,7 +244,7 @@ func (suite *FilterApiIntegrationSuite) TestList() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) // Create filter linked to our system component withComponentReq := createFilterRequest{ @@ -341,7 +341,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) @@ -443,7 +443,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) @@ -519,7 +519,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) diff --git a/internal/api/handler/heartbeat_integration_test.go b/internal/api/handler/heartbeat_integration_test.go index 70f78cab..fa95c54e 100644 --- a/internal/api/handler/heartbeat_integration_test.go +++ b/internal/api/handler/heartbeat_integration_test.go @@ -40,7 +40,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreateValidation() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(heartbeat) req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody)) @@ -62,7 +62,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatCreate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(heartbeat) req := httptest.NewRequest(http.MethodPost, "/api/agent/heartbeat", bytes.NewReader(reqBody)) @@ -95,7 +95,7 @@ func (suite *HeartbeatApiIntegrationSuite) TestHeartbeatOverTime() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/agent/heartbeat/over-time/", nil) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index f8725c2c..ba57c637 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -42,6 +42,8 @@ func (h *UserHandler) Register(api *echo.Group) { func (h *UserHandler) RegisterSelfRoutes(api *echo.Group) { api.GET("/me", h.GetMe) api.POST("/me/change-password", h.ChangeLoggedInUserPassword) + api.GET("/me/digest-subscription", h.GetDigestSubscription) + api.PUT("/me/digest-subscription", h.UpdateDigestSubscription) } // ListUsers godoc @@ -393,6 +395,94 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error { return ctx.NoContent(204) } +// GetDigestSubscription godoc +// +// @Summary Get digest subscription status +// @Description Gets the current user's digest email subscription status +// @Tags Users +// @Produce json +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse] +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /users/me/digest-subscription [get] +func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { + type digestSubscriptionResponse struct { + Subscribed bool `json:"subscribed"` + } + + userClaims := ctx.Get("user").(*authn.UserClaims) + + email := userClaims.Subject + var user relational.User + if err := h.db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(404, api.NewError(err)) + } + h.sugar.Errorw("Failed to get user by email", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ + Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + }) +} + +// UpdateDigestSubscription godoc +// +// @Summary Update digest subscription status +// @Description Updates the current user's digest email subscription status +// @Tags Users +// @Accept json +// @Produce json +// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status" +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse] +// @Failure 400 {object} api.Error +// @Failure 401 {object} api.Error +// @Failure 404 {object} api.Error +// @Failure 500 {object} api.Error +// @Security OAuth2Password +// @Router /users/me/digest-subscription [put] +func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error { + type updateDigestSubscriptionRequest struct { + Subscribed bool `json:"subscribed"` + } + type digestSubscriptionResponse struct { + Subscribed bool `json:"subscribed"` + } + + userClaims := ctx.Get("user").(*authn.UserClaims) + + var req updateDigestSubscriptionRequest + if err := ctx.Bind(&req); err != nil { + h.sugar.Errorw("Failed to bind update digest subscription request", "error", err) + return ctx.JSON(400, api.NewError(err)) + } + + email := userClaims.Subject + var user relational.User + if err := h.db.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ctx.JSON(404, api.NewError(err)) + } + h.sugar.Errorw("Failed to get user by email", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + user.DigestSubscribed = req.Subscribed + if err := h.db.Save(&user).Error; err != nil { + h.sugar.Errorw("Failed to update user digest subscription", "error", err) + return ctx.JSON(500, api.NewError(err)) + } + + h.sugar.Infow("User digest subscription updated", "email", email, "subscribed", req.Subscribed) + + return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ + Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, + }) +} + // ChangePassword godoc // // @Summary Change password for a specific user diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go index 8d0c1013..c812266f 100644 --- a/internal/api/handler/users_integration_test.go +++ b/internal/api/handler/users_integration_test.go @@ -33,7 +33,7 @@ func (suite *UserApiIntegrationSuite) SetupSuite() { suite.logger = logger.Sugar() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config) + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, nil, nil) } func (suite *UserApiIntegrationSuite) SetupTest() { diff --git a/internal/service/digest/job.go b/internal/service/digest/job.go new file mode 100644 index 00000000..7c172f94 --- /dev/null +++ b/internal/service/digest/job.go @@ -0,0 +1,32 @@ +package digest + +import ( + "context" + + "go.uber.org/zap" +) + +// GlobalDigestJob is a scheduled job that sends global evidence digests +type GlobalDigestJob struct { + service *Service + logger *zap.SugaredLogger +} + +// NewGlobalDigestJob creates a new global digest job +func NewGlobalDigestJob(service *Service, logger *zap.SugaredLogger) *GlobalDigestJob { + return &GlobalDigestJob{ + service: service, + logger: logger, + } +} + +// Name returns the unique name of the job +func (j *GlobalDigestJob) Name() string { + return "global-evidence-digest" +} + +// Execute runs the digest job +func (j *GlobalDigestJob) Execute(ctx context.Context) error { + j.logger.Info("Executing global evidence digest job") + return j.service.SendGlobalDigest(ctx) +} diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go new file mode 100644 index 00000000..eafe1d80 --- /dev/null +++ b/internal/service/digest/service.go @@ -0,0 +1,265 @@ +package digest + +import ( + "context" + "fmt" + "time" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/email/types" + "github.com/compliance-framework/api/internal/service/relational" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// EvidenceSummary contains aggregated evidence statistics +type EvidenceSummary struct { + TotalCount int64 + SatisfiedCount int64 + NotSatisfiedCount int64 + ExpiredCount int64 + OtherCount int64 + + // Top items for the digest email + TopExpired []EvidenceItem + TopNotSatisfied []EvidenceItem +} + +// EvidenceItem represents a single evidence entry for display +type EvidenceItem struct { + ID string + UUID string + Title string + Description string + Status string + ExpiresAt *time.Time + Labels []string +} + +// Service handles digest generation and delivery +type Service struct { + db *gorm.DB + emailService *email.Service + config *config.Config + logger *zap.SugaredLogger +} + +// NewService creates a new digest service +func NewService(db *gorm.DB, emailService *email.Service, cfg *config.Config, logger *zap.SugaredLogger) *Service { + return &Service{ + db: db, + emailService: emailService, + config: cfg, + logger: logger, + } +} + +// GetGlobalEvidenceSummary retrieves evidence summary across all evidence (Phase 0) +func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummary, error) { + summary := &EvidenceSummary{} + + // Get latest evidence streams + latestQuery := relational.GetLatestEvidenceStreamsQuery(s.db) + + // Count by status + type StatusCount struct { + Count int64 `json:"count"` + Status string `json:"status"` + } + + var statusCounts []StatusCount + if err := s.db.Table("(?) as latest", latestQuery). + Select("count(*) as count, status->>'state' as status"). + Group("status->>'state'"). + Scan(&statusCounts).Error; err != nil { + return nil, fmt.Errorf("failed to count evidence by status: %w", err) + } + + for _, sc := range statusCounts { + summary.TotalCount += sc.Count + switch sc.Status { + case "satisfied": + summary.SatisfiedCount = sc.Count + case "not-satisfied": + summary.NotSatisfiedCount = sc.Count + default: + summary.OtherCount += sc.Count + } + } + + // Count expired evidence (expires <= now) + now := time.Now() + if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + Where("expires IS NOT NULL AND expires <= ?", now). + Count(&summary.ExpiredCount).Error; err != nil { + return nil, fmt.Errorf("failed to count expired evidence: %w", err) + } + + // Get top 5 expired evidence items + var expiredEvidence []relational.Evidence + if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + Where("expires IS NOT NULL AND expires <= ?", now). + Preload("Labels"). + Order("expires ASC"). + Limit(5). + Find(&expiredEvidence).Error; err != nil { + s.logger.Warnw("Failed to fetch top expired evidence", "error", err) + } else { + summary.TopExpired = s.convertToEvidenceItems(expiredEvidence) + } + + // Get top 5 not-satisfied evidence items + var notSatisfiedEvidence []relational.Evidence + if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + Where("status->>'state' = ?", "not-satisfied"). + Preload("Labels"). + Order("\"end\" DESC"). + Limit(5). + Find(¬SatisfiedEvidence).Error; err != nil { + s.logger.Warnw("Failed to fetch top not-satisfied evidence", "error", err) + } else { + summary.TopNotSatisfied = s.convertToEvidenceItems(notSatisfiedEvidence) + } + + return summary, nil +} + +func (s *Service) convertToEvidenceItems(evidences []relational.Evidence) []EvidenceItem { + items := make([]EvidenceItem, 0, len(evidences)) + for _, e := range evidences { + labels := make([]string, 0, len(e.Labels)) + for _, l := range e.Labels { + labels = append(labels, fmt.Sprintf("%s:%s", l.Name, l.Value)) + } + + status := "" + if statusData := e.Status.Data(); statusData.State != "" { + status = statusData.State + } + + items = append(items, EvidenceItem{ + ID: e.ID.String(), + UUID: e.UUID.String(), + Title: e.Title, + Description: e.Description, + Status: status, + ExpiresAt: e.Expires, + Labels: labels, + }) + } + return items +} + +// GetSubscribedUsers returns all active users who are subscribed to digest emails +func (s *Service) GetSubscribedUsers(ctx context.Context) ([]relational.User, error) { + var users []relational.User + if err := s.db.Where("is_active = ? AND is_locked = ? AND digest_subscribed = ?", true, false, true).Find(&users).Error; err != nil { + return nil, fmt.Errorf("failed to fetch subscribed users: %w", err) + } + return users, nil +} + +// GetAllActiveUsers returns all active users (for admin purposes) +func (s *Service) GetAllActiveUsers(ctx context.Context) ([]relational.User, error) { + var users []relational.User + if err := s.db.Where("is_active = ? AND is_locked = ?", true, false).Find(&users).Error; err != nil { + return nil, fmt.Errorf("failed to fetch active users: %w", err) + } + return users, nil +} + +// SendDigestEmail sends a digest email to a user +func (s *Service) SendDigestEmail(ctx context.Context, user *relational.User, summary *EvidenceSummary) error { + if s.emailService == nil || !s.emailService.IsEnabled() { + return fmt.Errorf("email service is not enabled") + } + + // Prepare template data + data := map[string]interface{}{ + "UserName": user.FirstName, + "TotalCount": summary.TotalCount, + "SatisfiedCount": summary.SatisfiedCount, + "NotSatisfiedCount": summary.NotSatisfiedCount, + "ExpiredCount": summary.ExpiredCount, + "TopExpired": summary.TopExpired, + "TopNotSatisfied": summary.TopNotSatisfied, + "WebBaseURL": s.config.WebBaseURL, + "GeneratedAt": time.Now().UTC().Format(time.RFC1123), + } + + htmlContent, textContent, err := s.emailService.UseTemplate("evidence-digest", data) + if err != nil { + return fmt.Errorf("failed to render digest template: %w", err) + } + + message := &types.Message{ + To: []string{user.Email}, + Subject: "Evidence Compliance Digest", + HTMLBody: htmlContent, + TextBody: textContent, + } + + result, err := s.emailService.Send(ctx, message) + if err != nil { + return fmt.Errorf("failed to send digest email: %w", err) + } + + if !result.Success { + return fmt.Errorf("digest email send failed: %s", result.Error) + } + + s.logger.Infow("Digest email sent", "user", user.Email, "messageId", result.MessageID) + return nil +} + +// SendGlobalDigest sends the global digest to all active users (Phase 0) +func (s *Service) SendGlobalDigest(ctx context.Context) error { + summary, err := s.GetGlobalEvidenceSummary(ctx) + if err != nil { + return fmt.Errorf("failed to get evidence summary: %w", err) + } + + // Skip if there's nothing to report + if summary.TotalCount == 0 { + s.logger.Info("No evidence found, skipping digest") + return nil + } + + // Skip if there are no issues to report + if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 { + s.logger.Info("No issues found (no expired or not-satisfied evidence), skipping digest") + return nil + } + + users, err := s.GetSubscribedUsers(ctx) + if err != nil { + return fmt.Errorf("failed to get subscribed users: %w", err) + } + + if len(users) == 0 { + s.logger.Info("No subscribed users found, skipping digest") + return nil + } + + s.logger.Infow("Sending global digest", + "totalEvidence", summary.TotalCount, + "notSatisfied", summary.NotSatisfiedCount, + "expired", summary.ExpiredCount, + "userCount", len(users), + ) + + var sendErrors []error + for _, user := range users { + if err := s.SendDigestEmail(ctx, &user, summary); err != nil { + s.logger.Errorw("Failed to send digest to user", "user", user.Email, "error", err) + sendErrors = append(sendErrors, err) + } + } + + if len(sendErrors) > 0 { + return fmt.Errorf("failed to send digest to %d users", len(sendErrors)) + } + + return nil +} diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go new file mode 100644 index 00000000..d525db88 --- /dev/null +++ b/internal/service/digest/service_test.go @@ -0,0 +1,59 @@ +package digest + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestConvertToEvidenceItems(t *testing.T) { + // Test the conversion logic without database dependencies + now := time.Now() + items := []EvidenceItem{ + { + ID: uuid.New().String(), + UUID: uuid.New().String(), + Title: "Test Evidence", + Description: "Test Description", + Status: "not-satisfied", + ExpiresAt: &now, + Labels: []string{"provider:aws", "env:prod"}, + }, + } + + assert.Len(t, items, 1) + assert.Equal(t, "Test Evidence", items[0].Title) + assert.Equal(t, "not-satisfied", items[0].Status) + assert.Len(t, items[0].Labels, 2) +} + +func TestEvidenceSummaryStructure(t *testing.T) { + summary := &EvidenceSummary{ + TotalCount: 100, + SatisfiedCount: 80, + NotSatisfiedCount: 15, + ExpiredCount: 5, + OtherCount: 0, + TopExpired: []EvidenceItem{ + {Title: "Expired 1"}, + {Title: "Expired 2"}, + }, + TopNotSatisfied: []EvidenceItem{ + {Title: "Failed 1"}, + }, + } + + assert.Equal(t, int64(100), summary.TotalCount) + assert.Equal(t, int64(80), summary.SatisfiedCount) + assert.Equal(t, int64(15), summary.NotSatisfiedCount) + assert.Equal(t, int64(5), summary.ExpiredCount) + assert.Len(t, summary.TopExpired, 2) + assert.Len(t, summary.TopNotSatisfied, 1) +} + +func TestGlobalDigestJobName(t *testing.T) { + job := &GlobalDigestJob{} + assert.Equal(t, "global-evidence-digest", job.Name()) +} diff --git a/internal/service/email/templates/templates/evidence-digest.html b/internal/service/email/templates/templates/evidence-digest.html new file mode 100644 index 00000000..9afea284 --- /dev/null +++ b/internal/service/email/templates/templates/evidence-digest.html @@ -0,0 +1,359 @@ + + + + + + Evidence Compliance Digest + + + + + + diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt new file mode 100644 index 00000000..861a33ef --- /dev/null +++ b/internal/service/email/templates/templates/evidence-digest.txt @@ -0,0 +1,47 @@ +Evidence Compliance Digest +========================== + +Hello {{.UserName}}, + +Here's your evidence compliance summary. This digest highlights evidence that requires your attention. + +SUMMARY +------- +Total Evidence: {{.TotalCount}} +Satisfied: {{.SatisfiedCount}} +Not Satisfied: {{.NotSatisfiedCount}} +Expired: {{.ExpiredCount}} + +{{if .TopNotSatisfied}} +NOT SATISFIED EVIDENCE +---------------------- +{{range .TopNotSatisfied}} +* {{.Title}} + {{if .Description}}Description: {{.Description}}{{end}} + Status: {{.Status}} + View: {{$.WebBaseURL}}/evidence/{{.UUID}} + +{{end}} +{{end}} + +{{if .TopExpired}} +EXPIRED EVIDENCE +---------------- +{{range .TopExpired}} +* {{.Title}} + {{if .Description}}Description: {{.Description}}{{end}} + Expired: {{if .ExpiresAt}}{{.ExpiresAt}}{{else}}N/A{{end}} + View: {{$.WebBaseURL}}/evidence/{{.UUID}} + +{{end}} +{{end}} + +{{if and (not .TopNotSatisfied) (not .TopExpired)}} +All evidence is in good standing! +{{end}} + +View all evidence: {{.WebBaseURL}}/evidence + +--- +Generated at {{.GeneratedAt}} +This is an automated digest from Compliance Framework. diff --git a/internal/service/relational/ccf_internal.go b/internal/service/relational/ccf_internal.go index 037480dc..ce1837df 100644 --- a/internal/service/relational/ccf_internal.go +++ b/internal/service/relational/ccf_internal.go @@ -27,6 +27,9 @@ type User struct { AuthMethod string `json:"authMethod"` UserAttributes string `json:"userAttributes"` + + // DigestSubscribed indicates if the user wants to receive evidence digest emails + DigestSubscribed bool `json:"digestSubscribed" gorm:"default:true"` } func (User) TableName() string { diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go new file mode 100644 index 00000000..b84a925a --- /dev/null +++ b/internal/service/scheduler/cron.go @@ -0,0 +1,108 @@ +package scheduler + +import ( + "context" + "fmt" + "sync" + + "github.com/robfig/cron/v3" + "go.uber.org/zap" +) + +// CronScheduler implements Scheduler using robfig/cron +type CronScheduler struct { + cron *cron.Cron + logger *zap.SugaredLogger + jobs map[string]Job + mu sync.RWMutex +} + +// NewCronScheduler creates a new cron-based scheduler +func NewCronScheduler(logger *zap.SugaredLogger) *CronScheduler { + return &CronScheduler{ + cron: cron.New(cron.WithSeconds()), + logger: logger, + jobs: make(map[string]Job), + } +} + +// Schedule adds a job to run on the given schedule +func (s *CronScheduler) Schedule(schedule Schedule, job Job) error { + var cronExpr string + switch schedule { + case ScheduleDaily: + cronExpr = "0 0 0 * * *" // Every day at midnight + case ScheduleWeekly: + cronExpr = "0 0 0 * * 0" // Every Sunday at midnight + case ScheduleMonthly: + cronExpr = "0 0 0 1 * *" // First day of every month at midnight + default: + return fmt.Errorf("unknown schedule: %s", schedule) + } + return s.ScheduleCron(cronExpr, job) +} + +// ScheduleCron adds a job with a custom cron expression +func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.jobs[job.Name()]; exists { + return fmt.Errorf("job %q already registered", job.Name()) + } + + _, err := s.cron.AddFunc(cronExpr, func() { + ctx := context.Background() + s.logger.Infow("Starting scheduled job", "job", job.Name()) + if err := job.Execute(ctx); err != nil { + s.logger.Errorw("Scheduled job failed", "job", job.Name(), "error", err) + } else { + s.logger.Infow("Scheduled job completed", "job", job.Name()) + } + }) + if err != nil { + return fmt.Errorf("failed to schedule job %q: %w", job.Name(), err) + } + + s.jobs[job.Name()] = job + s.logger.Infow("Job scheduled", "job", job.Name(), "cron", cronExpr) + return nil +} + +// Start begins processing scheduled jobs +func (s *CronScheduler) Start() { + s.logger.Info("Starting scheduler") + s.cron.Start() +} + +// Stop gracefully stops the scheduler +func (s *CronScheduler) Stop() context.Context { + s.logger.Info("Stopping scheduler") + return s.cron.Stop() +} + +// RunNow executes a job immediately by name +func (s *CronScheduler) RunNow(ctx context.Context, jobName string) error { + s.mu.RLock() + job, exists := s.jobs[jobName] + s.mu.RUnlock() + + if !exists { + return fmt.Errorf("job %q not found", jobName) + } + + s.logger.Infow("Running job manually", "job", jobName) + return job.Execute(ctx) +} + +// ListJobs returns all registered job names +func (s *CronScheduler) ListJobs() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + names := make([]string, 0, len(s.jobs)) + for name := range s.jobs { + names = append(names, name) + } + return names +} diff --git a/internal/service/scheduler/scheduler.go b/internal/service/scheduler/scheduler.go new file mode 100644 index 00000000..8ea0f0bb --- /dev/null +++ b/internal/service/scheduler/scheduler.go @@ -0,0 +1,43 @@ +package scheduler + +import ( + "context" +) + +// Job represents a scheduled job that can be executed +type Job interface { + // Name returns the unique name of the job + Name() string + // Execute runs the job + Execute(ctx context.Context) error +} + +// Schedule represents when a job should run +type Schedule string + +const ( + // ScheduleDaily runs the job once per day at midnight UTC + ScheduleDaily Schedule = "@daily" + // ScheduleWeekly runs the job once per week on Sunday at midnight UTC + ScheduleWeekly Schedule = "@weekly" + // ScheduleMonthly runs the job once per month on the 1st at midnight UTC + ScheduleMonthly Schedule = "@monthly" +) + +// Scheduler is the interface for scheduling and managing jobs +// This abstraction allows swapping the underlying scheduler implementation +// (e.g., from built-in cron to an external scheduler like Temporal or a message queue) +type Scheduler interface { + // Schedule adds a job to run on the given schedule + Schedule(schedule Schedule, job Job) error + // ScheduleCron adds a job with a custom cron expression + ScheduleCron(cronExpr string, job Job) error + // Start begins processing scheduled jobs + Start() + // Stop gracefully stops the scheduler + Stop() context.Context + // RunNow executes a job immediately by name (useful for testing/manual triggers) + RunNow(ctx context.Context, jobName string) error + // ListJobs returns all registered job names + ListJobs() []string +} diff --git a/internal/service/scheduler/scheduler_test.go b/internal/service/scheduler/scheduler_test.go new file mode 100644 index 00000000..d65340ce --- /dev/null +++ b/internal/service/scheduler/scheduler_test.go @@ -0,0 +1,109 @@ +package scheduler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type mockJob struct { + name string + executed bool + execCount int +} + +func (j *mockJob) Name() string { + return j.name +} + +func (j *mockJob) Execute(ctx context.Context) error { + j.executed = true + j.execCount++ + return nil +} + +func TestCronScheduler_Schedule(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job) + require.NoError(t, err) + + jobs := sched.ListJobs() + assert.Contains(t, jobs, "test-job") +} + +func TestCronScheduler_ScheduleDuplicate(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job1 := &mockJob{name: "test-job"} + job2 := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job1) + require.NoError(t, err) + + err = sched.Schedule(ScheduleWeekly, job2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} + +func TestCronScheduler_RunNow(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + + err := sched.Schedule(ScheduleDaily, job) + require.NoError(t, err) + + ctx := context.Background() + err = sched.RunNow(ctx, "test-job") + require.NoError(t, err) + + assert.True(t, job.executed) + assert.Equal(t, 1, job.execCount) +} + +func TestCronScheduler_RunNow_NotFound(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + ctx := context.Background() + err := sched.RunNow(ctx, "nonexistent-job") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestCronScheduler_StartStop(t *testing.T) { + logger := zap.NewNop().Sugar() + sched := NewCronScheduler(logger) + + job := &mockJob{name: "test-job"} + err := sched.ScheduleCron("* * * * * *", job) // Every second + require.NoError(t, err) + + sched.Start() + + // Wait a bit for the job to potentially execute + time.Sleep(1500 * time.Millisecond) + + ctx := sched.Stop() + <-ctx.Done() + + // Job should have executed at least once + assert.True(t, job.executed) + assert.GreaterOrEqual(t, job.execCount, 1) +} + +func TestScheduleConstants(t *testing.T) { + assert.Equal(t, Schedule("@daily"), ScheduleDaily) + assert.Equal(t, Schedule("@weekly"), ScheduleWeekly) + assert.Equal(t, Schedule("@monthly"), ScheduleMonthly) +} diff --git a/sdk/integration_base_test.go b/sdk/integration_base_test.go index 76f663b8..8d7258b7 100644 --- a/sdk/integration_base_test.go +++ b/sdk/integration_base_test.go @@ -98,7 +98,7 @@ func (suite *IntegrationBaseTestSuite) SetupSuite() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), cfg, metrics) - handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + handler.RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) suite.Server = server diff --git a/sso.yaml b/sso.yaml index aa7eaf50..e9ef2f98 100644 --- a/sso.yaml +++ b/sso.yaml @@ -27,7 +27,7 @@ providers: group_mapping: "hd:container-solutions.com": - "ccf-authorized-users" - "email:admin@example.com": + "email:gustavo.carvalho@container-solutions.com": - "ccf-admins" # Add additional group mappings as needed From e08c09584188fea66f49de190948a31e01797992 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 08:04:41 -0300 Subject: [PATCH 02/11] fix: add description to cron-6 behavior fix: evidences should always expire fix: gather only the latest evidence chore: helper functions and commands Signed-off-by: Gustavo Carvalho --- cmd/digest.go | 146 ++++++++++++++++++++++++ cmd/root.go | 3 + internal/api/handler/api.go | 2 +- internal/api/handler/evidence.go | 33 +++++- internal/api/handler/evidence_test.go | 2 +- internal/config/config.go | 64 ++++++----- internal/service/digest/service.go | 23 +++- internal/service/digest/service_test.go | 3 +- internal/service/scheduler/cron.go | 7 ++ 9 files changed, 241 insertions(+), 42 deletions(-) create mode 100644 cmd/digest.go diff --git a/cmd/digest.go b/cmd/digest.go new file mode 100644 index 00000000..c3d1203b --- /dev/null +++ b/cmd/digest.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "fmt" + "log" + + "github.com/compliance-framework/api/internal/config" + "github.com/compliance-framework/api/internal/service" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +var ( + DigestCmd = &cobra.Command{ + Use: "digest", + Short: "Digest management commands", + } + + digestTestCmd = &cobra.Command{ + Use: "test", + Short: "Test the digest by sending it immediately to all subscribed users", + Run: runDigestTest, + } + + digestPreviewCmd = &cobra.Command{ + Use: "preview", + Short: "Preview the digest summary without sending emails", + Run: runDigestPreview, + } +) + +func init() { + DigestCmd.AddCommand(digestTestCmd) + DigestCmd.AddCommand(digestPreviewCmd) +} + +func runDigestTest(cmd *cobra.Command, args []string) { + ctx := context.Background() + + var sugar *zap.SugaredLogger + if viper.GetBool("use_dev_logger") { + sugar = zap.Must(zap.NewDevelopment()).Sugar() + } else { + sugar = zap.Must(zap.NewProduction()).Sugar() + } + + defer func() { + if err := sugar.Sync(); err != nil { + log.Printf("failed to sync zap logger: %v", err) + } + }() + + cfg := config.NewConfig(sugar) + + db, err := service.ConnectSQLDb(ctx, cfg, sugar) + if err != nil { + sugar.Fatalw("Failed to connect to SQL database", "error", err) + } + + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Fatalw("Failed to initialize email service", "error", err) + } + + digestService := digest.NewService(db, emailService, cfg, sugar) + + sugar.Info("Running digest test...") + if err := digestService.SendGlobalDigest(ctx); err != nil { + sugar.Fatalw("Failed to send digest", "error", err) + } + + sugar.Info("Digest test completed successfully") +} + +func runDigestPreview(cmd *cobra.Command, args []string) { + ctx := context.Background() + + var sugar *zap.SugaredLogger + if viper.GetBool("use_dev_logger") { + sugar = zap.Must(zap.NewDevelopment()).Sugar() + } else { + sugar = zap.Must(zap.NewProduction()).Sugar() + } + + defer func() { + if err := sugar.Sync(); err != nil { + log.Printf("failed to sync zap logger: %v", err) + } + }() + + cfg := config.NewConfig(sugar) + + db, err := service.ConnectSQLDb(ctx, cfg, sugar) + if err != nil { + sugar.Fatalw("Failed to connect to SQL database", "error", err) + } + + emailService, err := email.NewService(cfg.Email, sugar) + if err != nil { + sugar.Warnw("Failed to initialize email service", "error", err) + } + + digestService := digest.NewService(db, emailService, cfg, sugar) + + summary, err := digestService.GetGlobalEvidenceSummary(ctx) + if err != nil { + sugar.Fatalw("Failed to get evidence summary", "error", err) + } + + users, err := digestService.GetSubscribedUsers(ctx) + if err != nil { + sugar.Fatalw("Failed to get subscribed users", "error", err) + } + + fmt.Println("\n=== Evidence Digest Preview ===") + fmt.Printf("Total Evidence: %d\n", summary.TotalCount) + fmt.Printf("Satisfied: %d\n", summary.SatisfiedCount) + fmt.Printf("Not Satisfied: %d\n", summary.NotSatisfiedCount) + fmt.Printf("Expired: %d\n", summary.ExpiredCount) + fmt.Printf("Other: %d\n", summary.OtherCount) + fmt.Printf("\nSubscribed Users: %d\n", len(users)) + + if len(summary.TopNotSatisfied) > 0 { + fmt.Println("\nTop Not Satisfied Evidence:") + for i, item := range summary.TopNotSatisfied { + fmt.Printf(" %d. %s (UUID: %s)\n", i+1, item.Title, item.UUID) + } + } + + if len(summary.TopExpired) > 0 { + fmt.Println("\nTop Expired Evidence:") + for i, item := range summary.TopExpired { + fmt.Printf(" %d. %s (UUID: %s, Expired: %v)\n", i+1, item.Title, item.UUID, item.ExpiresAt) + } + } + + if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 { + fmt.Println("\n✓ No issues found - digest would be skipped") + } else { + fmt.Println("\n✓ Digest would be sent to subscribed users") + } +} diff --git a/cmd/root.go b/cmd/root.go index 23a782b4..3bad88c7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,6 +26,7 @@ func setDefaultEnvironmentVariables() { viper.SetDefault("db_debug", "false") viper.SetDefault("metrics_enabled", "true") viper.SetDefault("metrics_port", ":9090") + viper.SetDefault("evidence_default_expiry_months", "1") } func bindEnvironmentVariables() { @@ -45,6 +46,7 @@ func bindEnvironmentVariables() { viper.MustBindEnv("metrics_enabled") viper.MustBindEnv("metrics_port") viper.MustBindEnv("use_dev_logger") + viper.MustBindEnv("evidence_default_expiry_months") } func init() { @@ -71,6 +73,7 @@ func init() { rootCmd.AddCommand(seed.RootCmd) rootCmd.AddCommand(newMigrateCMD()) rootCmd.AddCommand(dashboards.RootCmd) + rootCmd.AddCommand(DigestCmd) } func Execute() error { diff --git a/internal/api/handler/api.go b/internal/api/handler/api.go index 604b67a5..a65675f9 100644 --- a/internal/api/handler/api.go +++ b/internal/api/handler/api.go @@ -20,7 +20,7 @@ func RegisterHandlers(server *api.Server, logger *zap.SugaredLogger, db *gorm.DB heartbeatHandler := NewHeartbeatHandler(logger, db) heartbeatHandler.Register(server.API().Group("/agent/heartbeat")) - evidenceHandler := NewEvidenceHandler(logger, db) + evidenceHandler := NewEvidenceHandler(logger, db, config) evidenceHandler.Register(server.API().Group("/evidence")) userHandler := NewUserHandler(logger, db) diff --git a/internal/api/handler/evidence.go b/internal/api/handler/evidence.go index 79fea6f2..ae4cb4de 100644 --- a/internal/api/handler/evidence.go +++ b/internal/api/handler/evidence.go @@ -7,6 +7,7 @@ import ( "github.com/compliance-framework/api/internal" "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/converters/labelfilter" "github.com/compliance-framework/api/internal/service/relational" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" @@ -19,14 +20,16 @@ import ( ) type EvidenceHandler struct { - db *gorm.DB - sugar *zap.SugaredLogger + db *gorm.DB + sugar *zap.SugaredLogger + config *config.Config } -func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB) *EvidenceHandler { +func NewEvidenceHandler(sugar *zap.SugaredLogger, db *gorm.DB, cfg *config.Config) *EvidenceHandler { return &EvidenceHandler{ - sugar: sugar, - db: db, + sugar: sugar, + db: db, + config: cfg, } } @@ -323,6 +326,24 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { backMatter.UnmarshalOscal(*input.BackMatter) } + // Auto-set expiration if not provided + expires := input.Expires + if expires == nil { + // Use End date if available, otherwise use current time + baseDate := input.End + if baseDate.IsZero() { + baseDate = time.Now().UTC() + } + // Add configured months to base date + expiryDate := baseDate.AddDate(0, h.config.EvidenceDefaultExpiryMonths, 0) + expires = &expiryDate + h.sugar.Debugw("Auto-set evidence expiration", + "uuid", input.UUID, + "baseDate", baseDate, + "expiryMonths", h.config.EvidenceDefaultExpiryMonths, + "expiresAt", expiryDate) + } + evidence := relational.Evidence{ UUIDModel: relational.UUIDModel{ ID: internal.Pointer(uuid.New()), @@ -333,7 +354,7 @@ func (h *EvidenceHandler) Create(ctx echo.Context) error { Remarks: input.Remarks, Start: input.Start, End: input.End, - Expires: input.Expires, + Expires: expires, Props: relational.ConvertOscalToProps(&input.Props), Links: relational.ConvertOscalToLinks(&input.Links), Origins: relational.ConvertList(&input.Origins, func(ol oscalTypes_1_1_3.Origin) relational.Origin { diff --git a/internal/api/handler/evidence_test.go b/internal/api/handler/evidence_test.go index 95da6ce5..0e5ed7bb 100644 --- a/internal/api/handler/evidence_test.go +++ b/internal/api/handler/evidence_test.go @@ -25,7 +25,7 @@ func TestEvidenceHandler_Create_WithFutureDate_ReturnsError(t *testing.T) { req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() ctx := e.NewContext(req, rec) - h := NewEvidenceHandler(nil, nil) + h := NewEvidenceHandler(nil, nil, nil) // Assertions if assert.NoError(t, h.Create(ctx)) { diff --git a/internal/config/config.go b/internal/config/config.go index 857844c0..54663639 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,20 +19,21 @@ var ( ) type Config struct { - AppPort string - Environment string - DBDriver string - DBConnectionString string - DBDebug bool - JWTSecret string - JWTPrivateKey *rsa.PrivateKey - JWTPublicKey *rsa.PublicKey - APIAllowedOrigins []string - MetricsEnabled bool - MetricsPort string - WebBaseURL string - SSO *SSOConfig - Email *EmailConfig + AppPort string + Environment string + DBDriver string + DBConnectionString string + DBDebug bool + JWTSecret string + JWTPrivateKey *rsa.PrivateKey + JWTPublicKey *rsa.PublicKey + APIAllowedOrigins []string + MetricsEnabled bool + MetricsPort string + WebBaseURL string + SSO *SSOConfig + Email *EmailConfig + EvidenceDefaultExpiryMonths int // Default expiration in months for evidence without explicit expiry } func NewConfig(logger *zap.SugaredLogger) *Config { @@ -146,21 +147,28 @@ func NewConfig(logger *zap.SugaredLogger) *Config { emailConfig = &EmailConfig{Enabled: false} } + // Evidence default expiry in months (default: 1 month) + evidenceDefaultExpiryMonths := viper.GetInt("evidence_default_expiry_months") + if evidenceDefaultExpiryMonths <= 0 { + evidenceDefaultExpiryMonths = 1 + } + return &Config{ - AppPort: appPort, - Environment: environment, - DBDriver: dbDriver, - DBConnectionString: stripQuotes(viper.GetString("db_connection")), - DBDebug: viper.GetBool("db_debug"), - JWTSecret: stripQuotes(viper.GetString("jwt_secret")), - JWTPrivateKey: jwtPrivateKey, - JWTPublicKey: jwtPublicKey, - APIAllowedOrigins: allowedOrigins, - MetricsEnabled: metricsEnabled, - MetricsPort: metricsPort, - WebBaseURL: webBaseURL, - SSO: ssoConfig, - Email: emailConfig, + AppPort: appPort, + Environment: environment, + DBDriver: dbDriver, + DBConnectionString: stripQuotes(viper.GetString("db_connection")), + DBDebug: viper.GetBool("db_debug"), + JWTSecret: stripQuotes(viper.GetString("jwt_secret")), + JWTPrivateKey: jwtPrivateKey, + JWTPublicKey: jwtPublicKey, + APIAllowedOrigins: allowedOrigins, + MetricsEnabled: metricsEnabled, + MetricsPort: metricsPort, + WebBaseURL: webBaseURL, + SSO: ssoConfig, + Email: emailConfig, + EvidenceDefaultExpiryMonths: evidenceDefaultExpiryMonths, } } diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index eafe1d80..c1256b85 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -33,7 +33,7 @@ type EvidenceItem struct { Title string Description string Status string - ExpiresAt *time.Time + ExpiresAt string // Formatted expiration date string (empty if no expiration) Labels []string } @@ -89,16 +89,23 @@ func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummar } // Count expired evidence (expires <= now) + // Note: Evidence without expiration dates (expires IS NULL) are treated as never expiring + // and are excluded from the expired count. This maintains backward compatibility with + // deployments that have evidence without expiration dates. now := time.Now() - if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + expiredCountQuery := s.db.Session(&gorm.Session{}) + expiredCountQuery = relational.GetLatestEvidenceStreamsQuery(expiredCountQuery) + if err := expiredCountQuery. Where("expires IS NOT NULL AND expires <= ?", now). Count(&summary.ExpiredCount).Error; err != nil { return nil, fmt.Errorf("failed to count expired evidence: %w", err) } - // Get top 5 expired evidence items + // Get top 5 expired evidence items (only those with explicit expiration dates) var expiredEvidence []relational.Evidence - if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). + expiredItemsQuery := s.db.Session(&gorm.Session{}) + expiredItemsQuery = relational.GetLatestEvidenceStreamsQuery(expiredItemsQuery) + if err := expiredItemsQuery. Where("expires IS NOT NULL AND expires <= ?", now). Preload("Labels"). Order("expires ASC"). @@ -138,13 +145,19 @@ func (s *Service) convertToEvidenceItems(evidences []relational.Evidence) []Evid status = statusData.State } + // Format expiration date for display + expiresAt := "" + if e.Expires != nil && !e.Expires.IsZero() { + expiresAt = e.Expires.Format("2006-01-02 15:04 MST") + } + items = append(items, EvidenceItem{ ID: e.ID.String(), UUID: e.UUID.String(), Title: e.Title, Description: e.Description, Status: status, - ExpiresAt: e.Expires, + ExpiresAt: expiresAt, Labels: labels, }) } diff --git a/internal/service/digest/service_test.go b/internal/service/digest/service_test.go index d525db88..604b2af4 100644 --- a/internal/service/digest/service_test.go +++ b/internal/service/digest/service_test.go @@ -18,7 +18,7 @@ func TestConvertToEvidenceItems(t *testing.T) { Title: "Test Evidence", Description: "Test Description", Status: "not-satisfied", - ExpiresAt: &now, + ExpiresAt: now.Format("2006-01-02 15:04 MST"), Labels: []string{"provider:aws", "env:prod"}, }, } @@ -27,6 +27,7 @@ func TestConvertToEvidenceItems(t *testing.T) { assert.Equal(t, "Test Evidence", items[0].Title) assert.Equal(t, "not-satisfied", items[0].Status) assert.Len(t, items[0].Labels, 2) + assert.NotEmpty(t, items[0].ExpiresAt) } func TestEvidenceSummaryStructure(t *testing.T) { diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go index b84a925a..5e7a4ee1 100644 --- a/internal/service/scheduler/cron.go +++ b/internal/service/scheduler/cron.go @@ -43,6 +43,13 @@ func (s *CronScheduler) Schedule(schedule Schedule, job Job) error { } // ScheduleCron adds a job with a custom cron expression +// Cron format: second minute hour day month weekday (6 fields with seconds support) +// Examples: +// +// "0 0 * * * *" - Every hour at minute 0 +// "0 */5 * * * *" - Every 5 minutes +// "0 0 0 * * *" - Every day at midnight +// "@hourly" - Every hour (equivalent to "0 0 * * * *") func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { s.mu.Lock() defer s.mu.Unlock() From 032cb0a46a7a57a5ceb37703268569442dafc141 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 08:51:25 -0300 Subject: [PATCH 03/11] fix: evidence expires, email links Signed-off-by: Gustavo Carvalho --- internal/service/digest/service.go | 15 ++++++++++----- .../templates/templates/evidence-digest.html | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index c1256b85..6957fbe7 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -89,24 +89,29 @@ func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummar } // Count expired evidence (expires <= now) - // Note: Evidence without expiration dates (expires IS NULL) are treated as never expiring + // Note: Evidence without expiration dates (expires IS NULL or zero time) are treated as never expiring // and are excluded from the expired count. This maintains backward compatibility with // deployments that have evidence without expiration dates. + // We exclude zero time (1970-01-01 00:00:00 UTC / 1969-12-31 in negative timezones) which may exist in DB + // We use a subquery to properly count only the latest evidence per stream (DISTINCT ON uuid) now := time.Now() + zeroTime := time.Unix(0, 0) expiredCountQuery := s.db.Session(&gorm.Session{}) expiredCountQuery = relational.GetLatestEvidenceStreamsQuery(expiredCountQuery) - if err := expiredCountQuery. - Where("expires IS NOT NULL AND expires <= ?", now). + expiredCountQuery = expiredCountQuery.Where("expires IS NOT NULL AND expires > ? AND expires <= ?", zeroTime, now) + + // Count distinct UUIDs from the subquery + if err := s.db.Table("(?) as latest_expired", expiredCountQuery). Count(&summary.ExpiredCount).Error; err != nil { return nil, fmt.Errorf("failed to count expired evidence: %w", err) } - // Get top 5 expired evidence items (only those with explicit expiration dates) + // Get top 5 expired evidence items (only those with explicit expiration dates, excluding zero time) var expiredEvidence []relational.Evidence expiredItemsQuery := s.db.Session(&gorm.Session{}) expiredItemsQuery = relational.GetLatestEvidenceStreamsQuery(expiredItemsQuery) if err := expiredItemsQuery. - Where("expires IS NOT NULL AND expires <= ?", now). + Where("expires IS NOT NULL AND expires > ? AND expires <= ?", zeroTime, now). Preload("Labels"). Order("expires ASC"). Limit(5). diff --git a/internal/service/email/templates/templates/evidence-digest.html b/internal/service/email/templates/templates/evidence-digest.html index 9afea284..d887a274 100644 --- a/internal/service/email/templates/templates/evidence-digest.html +++ b/internal/service/email/templates/templates/evidence-digest.html @@ -307,7 +307,7 @@

⚠️ Not Satisfied Evidence

{{.Title}}
{{if .Description}}
{{.Description}}
{{end}}
Status: {{.Status}}
- View Details → + View Details → {{end}} @@ -322,7 +322,7 @@

⏰ Expired Evidence

{{.Title}}
{{if .Description}}
{{.Description}}
{{end}}
Expired: {{if .ExpiresAt}}{{.ExpiresAt}}{{else}}N/A{{end}}
- View Details → + View Details → {{end}} From 6bf610447c452cf4d8ce1a1e62487939fadb6276 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 12:53:18 -0300 Subject: [PATCH 04/11] fix: config should use viper fix: messages should be debug Signed-off-by: Gustavo Carvalho --- cmd/root.go | 4 ++++ cmd/run.go | 20 ++++++-------------- internal/api/handler/users.go | 2 +- internal/config/config.go | 13 ++++++++++++- internal/service/digest/job.go | 2 +- internal/service/digest/service.go | 10 +++++----- internal/service/relational/ccf_internal.go | 2 +- internal/service/scheduler/cron.go | 12 ++++++------ 8 files changed, 36 insertions(+), 29 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 3bad88c7..ac41393b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,6 +27,8 @@ func setDefaultEnvironmentVariables() { viper.SetDefault("metrics_enabled", "true") viper.SetDefault("metrics_port", ":9090") viper.SetDefault("evidence_default_expiry_months", "1") + viper.SetDefault("digest_enabled", "true") + viper.SetDefault("digest_schedule", "@weekly") } func bindEnvironmentVariables() { @@ -47,6 +49,8 @@ func bindEnvironmentVariables() { viper.MustBindEnv("metrics_port") viper.MustBindEnv("use_dev_logger") viper.MustBindEnv("evidence_default_expiry_months") + viper.MustBindEnv("digest_enabled") + viper.MustBindEnv("digest_schedule") } func init() { diff --git a/cmd/run.go b/cmd/run.go index 5d8feebc..fc3fbb98 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -26,11 +26,6 @@ var ( } ) -func init() { - RunCmd.Flags().String("digest-schedule", "@weekly", "Cron schedule for digest emails (e.g., '@hourly', '@daily', '@weekly', '0 9 * * 1' for Monday 9am)") - RunCmd.Flags().Bool("digest-enabled", true, "Enable or disable the digest scheduler") -} - func RunServer(cmd *cobra.Command, args []string) { ctx := context.Background() @@ -71,19 +66,16 @@ func RunServer(cmd *cobra.Command, args []string) { // Initialize scheduler sched := scheduler.NewCronScheduler(sugar) - // Register digest job - digestEnabled, _ := cmd.Flags().GetBool("digest-enabled") - digestSchedule, _ := cmd.Flags().GetString("digest-schedule") - - if digestEnabled { + // Register digest job using config + if cfg.DigestEnabled { digestJob := digest.NewGlobalDigestJob(digestService, sugar) - if err := sched.ScheduleCron(digestSchedule, digestJob); err != nil { - sugar.Warnw("Failed to schedule digest job", "schedule", digestSchedule, "error", err) + if err := sched.ScheduleCron(cfg.DigestSchedule, digestJob); err != nil { + sugar.Warnw("Failed to schedule digest job", "schedule", cfg.DigestSchedule, "error", err) } else { - sugar.Infow("Digest job scheduled", "schedule", digestSchedule) + sugar.Debugw("Digest job scheduled", "schedule", cfg.DigestSchedule) } } else { - sugar.Infow("Digest scheduler disabled") + sugar.Debugw("Digest scheduler disabled") } // Start the scheduler diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index ba57c637..315afb9c 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -476,7 +476,7 @@ func (h *UserHandler) UpdateDigestSubscription(ctx echo.Context) error { return ctx.JSON(500, api.NewError(err)) } - h.sugar.Infow("User digest subscription updated", "email", email, "subscribed", req.Subscribed) + h.sugar.Debugw("User digest subscription updated", "email", email, "subscribed", req.Subscribed) return ctx.JSON(200, GenericDataResponse[digestSubscriptionResponse]{ Data: digestSubscriptionResponse{Subscribed: user.DigestSubscribed}, diff --git a/internal/config/config.go b/internal/config/config.go index 54663639..64557bfa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,7 +33,9 @@ type Config struct { WebBaseURL string SSO *SSOConfig Email *EmailConfig - EvidenceDefaultExpiryMonths int // Default expiration in months for evidence without explicit expiry + EvidenceDefaultExpiryMonths int // Default expiration in months for evidence without explicit expiry + DigestEnabled bool // Enable or disable the digest scheduler + DigestSchedule string // Cron schedule for digest emails } func NewConfig(logger *zap.SugaredLogger) *Config { @@ -153,6 +155,13 @@ func NewConfig(logger *zap.SugaredLogger) *Config { evidenceDefaultExpiryMonths = 1 } + // Digest configuration + digestEnabled := viper.GetBool("digest_enabled") + digestSchedule := viper.GetString("digest_schedule") + if digestSchedule == "" { + digestSchedule = "@weekly" + } + return &Config{ AppPort: appPort, Environment: environment, @@ -169,6 +178,8 @@ func NewConfig(logger *zap.SugaredLogger) *Config { SSO: ssoConfig, Email: emailConfig, EvidenceDefaultExpiryMonths: evidenceDefaultExpiryMonths, + DigestEnabled: digestEnabled, + DigestSchedule: digestSchedule, } } diff --git a/internal/service/digest/job.go b/internal/service/digest/job.go index 7c172f94..67db3883 100644 --- a/internal/service/digest/job.go +++ b/internal/service/digest/job.go @@ -27,6 +27,6 @@ func (j *GlobalDigestJob) Name() string { // Execute runs the digest job func (j *GlobalDigestJob) Execute(ctx context.Context) error { - j.logger.Info("Executing global evidence digest job") + j.logger.Debug("Executing global evidence digest job") return j.service.SendGlobalDigest(ctx) } diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 6957fbe7..45aa0ae9 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -227,7 +227,7 @@ func (s *Service) SendDigestEmail(ctx context.Context, user *relational.User, su return fmt.Errorf("digest email send failed: %s", result.Error) } - s.logger.Infow("Digest email sent", "user", user.Email, "messageId", result.MessageID) + s.logger.Debugw("Digest email sent", "user", user.Email, "messageId", result.MessageID) return nil } @@ -240,13 +240,13 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { // Skip if there's nothing to report if summary.TotalCount == 0 { - s.logger.Info("No evidence found, skipping digest") + s.logger.Debug("No evidence found, skipping digest") return nil } // Skip if there are no issues to report if summary.NotSatisfiedCount == 0 && summary.ExpiredCount == 0 { - s.logger.Info("No issues found (no expired or not-satisfied evidence), skipping digest") + s.logger.Debug("No issues found (no expired or not-satisfied evidence), skipping digest") return nil } @@ -256,11 +256,11 @@ func (s *Service) SendGlobalDigest(ctx context.Context) error { } if len(users) == 0 { - s.logger.Info("No subscribed users found, skipping digest") + s.logger.Debug("No subscribed users found, skipping digest") return nil } - s.logger.Infow("Sending global digest", + s.logger.Debugw("Sending global digest", "totalEvidence", summary.TotalCount, "notSatisfied", summary.NotSatisfiedCount, "expired", summary.ExpiredCount, diff --git a/internal/service/relational/ccf_internal.go b/internal/service/relational/ccf_internal.go index ce1837df..b5cb1b48 100644 --- a/internal/service/relational/ccf_internal.go +++ b/internal/service/relational/ccf_internal.go @@ -29,7 +29,7 @@ type User struct { UserAttributes string `json:"userAttributes"` // DigestSubscribed indicates if the user wants to receive evidence digest emails - DigestSubscribed bool `json:"digestSubscribed" gorm:"default:true"` + DigestSubscribed bool `json:"digestSubscribed" gorm:"default:false"` } func (User) TableName() string { diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go index 5e7a4ee1..acfa3372 100644 --- a/internal/service/scheduler/cron.go +++ b/internal/service/scheduler/cron.go @@ -60,11 +60,11 @@ func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { _, err := s.cron.AddFunc(cronExpr, func() { ctx := context.Background() - s.logger.Infow("Starting scheduled job", "job", job.Name()) + s.logger.Debugw("Starting scheduled job", "job", job.Name()) if err := job.Execute(ctx); err != nil { s.logger.Errorw("Scheduled job failed", "job", job.Name(), "error", err) } else { - s.logger.Infow("Scheduled job completed", "job", job.Name()) + s.logger.Debugw("Scheduled job completed", "job", job.Name()) } }) if err != nil { @@ -72,19 +72,19 @@ func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { } s.jobs[job.Name()] = job - s.logger.Infow("Job scheduled", "job", job.Name(), "cron", cronExpr) + s.logger.Debugw("Job scheduled", "job", job.Name(), "cron", cronExpr) return nil } // Start begins processing scheduled jobs func (s *CronScheduler) Start() { - s.logger.Info("Starting scheduler") + s.logger.Debug("Starting scheduler") s.cron.Start() } // Stop gracefully stops the scheduler func (s *CronScheduler) Stop() context.Context { - s.logger.Info("Stopping scheduler") + s.logger.Debug("Stopping scheduler") return s.cron.Stop() } @@ -98,7 +98,7 @@ func (s *CronScheduler) RunNow(ctx context.Context, jobName string) error { return fmt.Errorf("job %q not found", jobName) } - s.logger.Infow("Running job manually", "job", jobName) + s.logger.Debugw("Running job manually", "job", jobName) return job.Execute(ctx) } From 719a22e0254526bf9d9acd7775dc9b47b961d9c0 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 13:19:42 -0300 Subject: [PATCH 05/11] fix: checkdiff Signed-off-by: Gustavo Carvalho --- docs/docs.go | 281 ++++++++++++++++++++++++++++++++++++++++++++++ docs/swagger.json | 281 ++++++++++++++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 182 ++++++++++++++++++++++++++++++ go.mod | 2 +- 4 files changed, 745 insertions(+), 1 deletion(-) diff --git a/docs/docs.go b/docs/docs.go index facf994d..1da491bf 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -21,6 +21,85 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/digest/preview": { + "get": { + "description": "Returns the current evidence summary that would be included in a digest email", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Preview evidence digest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/digest/trigger": { + "post": { + "description": "Manually triggers the evidence digest job to send emails to all users", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Trigger evidence digest", + "parameters": [ + { + "type": "string", + "description": "Job name to trigger (default: global-evidence-digest)", + "name": "job", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/users": { "get": { "description": "Lists all users in the system", @@ -16246,6 +16325,110 @@ const docTemplate = `{ ] } }, + "/users/me/digest-subscription": { + "get": { + "description": "Gets the current user's digest email subscription status", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get digest subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + }, + "put": { + "description": "Updates the current user's digest email subscription status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update digest subscription status", + "parameters": [ + { + "description": "Subscription status", + "name": "subscription", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + } + }, "/users/{id}/change-password": { "post": { "description": "Changes the password for a user by ID", @@ -16377,6 +16560,74 @@ const docTemplate = `{ "datatypes.JSONType-relational_SystemComponentStatus": { "type": "object" }, + "digest.EvidenceItem": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiresAt": { + "description": "Formatted expiration date string (empty if no expiration)", + "type": "string" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "digest.EvidenceSummary": { + "type": "object", + "properties": { + "expiredCount": { + "type": "integer", + "format": "int64" + }, + "notSatisfiedCount": { + "type": "integer", + "format": "int64" + }, + "otherCount": { + "type": "integer", + "format": "int64" + }, + "satisfiedCount": { + "type": "integer", + "format": "int64" + }, + "topExpired": { + "description": "Top items for the digest email", + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "topNotSatisfied": { + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "gorm.DeletedAt": { "type": "object", "properties": { @@ -17307,6 +17558,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-digest_EvidenceSummary": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/digest.EvidenceSummary" + } + ] + } + } + }, "handler.GenericDataResponse-handler_FilterImportResponse": { "type": "object", "properties": { @@ -17346,6 +17610,19 @@ const docTemplate = `{ } } }, + "handler.GenericDataResponse-handler_UserHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.UserHandler" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { "type": "object", "properties": { @@ -24794,6 +25071,10 @@ const docTemplate = `{ } ] }, + "digestSubscribed": { + "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 8d75d563..941ed07a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -15,6 +15,85 @@ "host": "localhost:8080", "basePath": "/api", "paths": { + "/admin/digest/preview": { + "get": { + "description": "Returns the current evidence summary that would be included in a digest email", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Preview evidence digest", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-digest_EvidenceSummary" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, + "/admin/digest/trigger": { + "post": { + "description": "Manually triggers the evidence digest job to send emails to all users", + "produces": [ + "application/json" + ], + "tags": [ + "Digest" + ], + "summary": "Trigger evidence digest", + "parameters": [ + { + "type": "string", + "description": "Job name to trigger (default: global-evidence-digest)", + "name": "job", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/api.Error" + } + } + }, + "security": [ + { + "OAuth2Password": [] + } + ] + } + }, "/admin/users": { "get": { "description": "Lists all users in the system", @@ -16240,6 +16319,110 @@ ] } }, + "/users/me/digest-subscription": { + "get": { + "description": "Gets the current user's digest email subscription status", + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Get digest subscription status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + }, + "put": { + "description": "Updates the current user's digest email subscription status", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update digest subscription status", + "parameters": [ + { + "description": "Subscription status", + "name": "subscription", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handler.UserHandler" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.GenericDataResponse-handler_UserHandler" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/api.Error" + } + }, + "401": { + "description": "Unauthorized", + "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": [] + } + ] + } + }, "/users/{id}/change-password": { "post": { "description": "Changes the password for a user by ID", @@ -16371,6 +16554,74 @@ "datatypes.JSONType-relational_SystemComponentStatus": { "type": "object" }, + "digest.EvidenceItem": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiresAt": { + "description": "Formatted expiration date string (empty if no expiration)", + "type": "string" + }, + "id": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "uuid": { + "type": "string" + } + } + }, + "digest.EvidenceSummary": { + "type": "object", + "properties": { + "expiredCount": { + "type": "integer", + "format": "int64" + }, + "notSatisfiedCount": { + "type": "integer", + "format": "int64" + }, + "otherCount": { + "type": "integer", + "format": "int64" + }, + "satisfiedCount": { + "type": "integer", + "format": "int64" + }, + "topExpired": { + "description": "Top items for the digest email", + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "topNotSatisfied": { + "type": "array", + "items": { + "$ref": "#/definitions/digest.EvidenceItem" + } + }, + "totalCount": { + "type": "integer", + "format": "int64" + } + } + }, "gorm.DeletedAt": { "type": "object", "properties": { @@ -17301,6 +17552,19 @@ } } }, + "handler.GenericDataResponse-digest_EvidenceSummary": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/digest.EvidenceSummary" + } + ] + } + } + }, "handler.GenericDataResponse-handler_FilterImportResponse": { "type": "object", "properties": { @@ -17340,6 +17604,19 @@ } } }, + "handler.GenericDataResponse-handler_UserHandler": { + "type": "object", + "properties": { + "data": { + "description": "Items from the list response", + "allOf": [ + { + "$ref": "#/definitions/handler.UserHandler" + } + ] + } + } + }, "handler.GenericDataResponse-oscalTypes_1_1_3_Activity": { "type": "object", "properties": { @@ -24788,6 +25065,10 @@ } ] }, + "digestSubscribed": { + "description": "DigestSubscribed indicates if the user wants to receive evidence digest emails", + "type": "boolean" + }, "email": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d7e51823..7e3ebe99 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -43,6 +43,53 @@ definitions: type: object datatypes.JSONType-relational_SystemComponentStatus: type: object + digest.EvidenceItem: + properties: + description: + type: string + expiresAt: + description: Formatted expiration date string (empty if no expiration) + type: string + id: + type: string + labels: + items: + type: string + type: array + status: + type: string + title: + type: string + uuid: + type: string + type: object + digest.EvidenceSummary: + properties: + expiredCount: + format: int64 + type: integer + notSatisfiedCount: + format: int64 + type: integer + otherCount: + format: int64 + type: integer + satisfiedCount: + format: int64 + type: integer + topExpired: + description: Top items for the digest email + items: + $ref: '#/definitions/digest.EvidenceItem' + type: array + topNotSatisfied: + items: + $ref: '#/definitions/digest.EvidenceItem' + type: array + totalCount: + format: int64 + type: integer + type: object gorm.DeletedAt: properties: time: @@ -698,6 +745,13 @@ definitions: - $ref: '#/definitions/auth.AuthHandler' description: Items from the list response type: object + handler.GenericDataResponse-digest_EvidenceSummary: + properties: + data: + allOf: + - $ref: '#/definitions/digest.EvidenceSummary' + description: Items from the list response + type: object handler.GenericDataResponse-handler_FilterImportResponse: properties: data: @@ -719,6 +773,13 @@ definitions: - $ref: '#/definitions/handler.OscalLikeEvidence' description: Items from the list response type: object + handler.GenericDataResponse-handler_UserHandler: + properties: + data: + allOf: + - $ref: '#/definitions/handler.UserHandler' + description: Items from the list response + type: object handler.GenericDataResponse-oscal_ImportResponse: properties: data: @@ -5557,6 +5618,10 @@ definitions: allOf: - $ref: '#/definitions/gorm.DeletedAt' description: Soft delete + digestSubscribed: + description: DigestSubscribed indicates if the user wants to receive evidence + digest emails + type: boolean email: type: string failedLogins: @@ -5588,6 +5653,57 @@ info: title: Continuous Compliance Framework API version: "1" paths: + /admin/digest/preview: + get: + description: Returns the current evidence summary that would be included in + a digest email + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-digest_EvidenceSummary' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Preview evidence digest + tags: + - Digest + /admin/digest/trigger: + post: + description: Manually triggers the evidence digest job to send emails to all + users + parameters: + - description: 'Job name to trigger (default: global-evidence-digest)' + in: query + name: job + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/api.Error' + security: + - OAuth2Password: [] + summary: Trigger evidence digest + tags: + - Digest /admin/users: get: description: Lists all users in the system @@ -16246,6 +16362,72 @@ paths: summary: Change password for logged-in user tags: - Users + /users/me/digest-subscription: + get: + description: Gets the current user's digest email subscription status + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + "401": + description: Unauthorized + 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: Get digest subscription status + tags: + - Users + put: + consumes: + - application/json + description: Updates the current user's digest email subscription status + parameters: + - description: Subscription status + in: body + name: subscription + required: true + schema: + $ref: '#/definitions/handler.UserHandler' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.GenericDataResponse-handler_UserHandler' + "400": + description: Bad Request + schema: + $ref: '#/definitions/api.Error' + "401": + description: Unauthorized + 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: Update digest subscription status + tags: + - Users produces: - application/json securityDefinitions: diff --git a/go.mod b/go.mod index 23ebb415..5c7f06d0 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/labstack/echo-contrib v0.17.4 github.com/labstack/echo/v4 v4.13.4 github.com/prometheus/client_golang v1.23.2 + github.com/robfig/cron/v3 v3.0.1 github.com/schollz/progressbar/v3 v3.18.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -135,7 +136,6 @@ require ( github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect From f2e480aea075ec7fdf3fd062564ff8aca8a6e733 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 13:28:21 -0300 Subject: [PATCH 06/11] fix: integration Signed-off-by: Gustavo Carvalho --- internal/api/handler/filter_integration_test.go | 2 +- internal/api/handler/oscal/inventory_integration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/handler/filter_integration_test.go b/internal/api/handler/filter_integration_test.go index f636a85e..b6b523a6 100644 --- a/internal/api/handler/filter_integration_test.go +++ b/internal/api/handler/filter_integration_test.go @@ -399,7 +399,7 @@ func (suite *FilterApiIntegrationSuite) TestUpdate() { logger, _ := zap.NewDevelopment() metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) server := api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) - RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config) + RegisterHandlers(server, logger.Sugar(), suite.DB, suite.Config, nil, nil) rec := httptest.NewRecorder() reqBody, _ := json.Marshal(updateReq) req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/filters/%s", filter.ID), bytes.NewReader(reqBody)) diff --git a/internal/api/handler/oscal/inventory_integration_test.go b/internal/api/handler/oscal/inventory_integration_test.go index 9b9ab0ec..4803e69f 100644 --- a/internal/api/handler/oscal/inventory_integration_test.go +++ b/internal/api/handler/oscal/inventory_integration_test.go @@ -41,7 +41,7 @@ func (suite *InventoryApiIntegrationSuite) SetupSuite() { suite.handler = NewInventoryHandler(logger, suite.DB) suite.sspHandler = NewSystemSecurityPlanHandler(logger, suite.DB) suite.poamHandler = NewPlanOfActionAndMilestonesHandler(logger, suite.DB) - suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB) + suite.evidenceHandler = handler.NewEvidenceHandler(logger, suite.DB, suite.Config) // Initialize server metrics := api.NewMetricsHandler(context.Background(), logger) From 2dac190fc05bd94f3d16a7f47541435e27654839 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 13:37:30 -0300 Subject: [PATCH 07/11] fix: copilot issues Signed-off-by: Gustavo Carvalho --- cmd/run.go | 12 +- internal/api/handler/digest.go | 3 +- .../api/handler/digest_integration_test.go | 224 ++++++++++++++++++ .../api/handler/users_integration_test.go | 83 +++++++ .../templates/templates/evidence-digest.txt | 2 +- sso.yaml | 2 +- 6 files changed, 322 insertions(+), 4 deletions(-) create mode 100644 internal/api/handler/digest_integration_test.go diff --git a/cmd/run.go b/cmd/run.go index fc3fbb98..2e7bbf74 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "context" "log" + "time" "github.com/compliance-framework/api/internal/api" "github.com/compliance-framework/api/internal/api/handler" @@ -80,7 +81,16 @@ func RunServer(cmd *cobra.Command, args []string) { // Start the scheduler sched.Start() - defer sched.Stop() + defer func() { + stopCtx := sched.Stop() + // Wait for jobs to finish gracefully with a 10-second timeout + select { + case <-stopCtx.Done(): + sugar.Debug("All scheduled jobs completed gracefully") + case <-time.After(10 * time.Second): + sugar.Warn("Scheduler shutdown timeout, some jobs may not have completed") + } + }() metrics := api.NewMetricsHandler(ctx, sugar) server := api.NewServer(ctx, sugar, cfg, metrics) diff --git a/internal/api/handler/digest.go b/internal/api/handler/digest.go index cca03f8d..bce83bda 100644 --- a/internal/api/handler/digest.go +++ b/internal/api/handler/digest.go @@ -1,6 +1,7 @@ package handler import ( + "fmt" "net/http" "github.com/compliance-framework/api/internal/api" @@ -51,7 +52,7 @@ func (h *DigestHandler) TriggerDigest(ctx echo.Context) error { } if h.scheduler == nil { - return ctx.JSON(http.StatusInternalServerError, api.NewError(nil)) + return ctx.JSON(http.StatusInternalServerError, api.NewError(fmt.Errorf("scheduler is not available"))) } if err := h.scheduler.RunNow(ctx.Request().Context(), jobName); err != nil { diff --git a/internal/api/handler/digest_integration_test.go b/internal/api/handler/digest_integration_test.go new file mode 100644 index 00000000..824486ed --- /dev/null +++ b/internal/api/handler/digest_integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "testing" + + "github.com/compliance-framework/api/internal/api" + "github.com/compliance-framework/api/internal/service/digest" + "github.com/compliance-framework/api/internal/service/email" + "github.com/compliance-framework/api/internal/service/scheduler" + "github.com/compliance-framework/api/internal/tests" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" +) + +func TestDigestApi(t *testing.T) { + suite.Run(t, new(DigestApiIntegrationSuite)) +} + +type DigestApiIntegrationSuite struct { + tests.IntegrationTestSuite + server *api.Server + logger *zap.SugaredLogger + digestHandler *DigestHandler + mockScheduler *MockScheduler + emailService *email.Service +} + +// MockScheduler implements the scheduler.Service interface for testing +type MockScheduler struct { + jobs map[string]bool +} + +func NewMockScheduler() *MockScheduler { + return &MockScheduler{ + jobs: make(map[string]bool), + } +} + +func (m *MockScheduler) Start() { + // Mock implementation +} + +func (m *MockScheduler) Stop() context.Context { + // Mock implementation + return context.Background() +} + +func (m *MockScheduler) Schedule(schedule scheduler.Schedule, job scheduler.Job) error { + m.jobs[job.Name()] = true + return nil +} + +func (m *MockScheduler) ScheduleCron(cronExpr string, job scheduler.Job) error { + m.jobs[job.Name()] = true + return nil +} + +func (m *MockScheduler) RunNow(ctx context.Context, name string) error { + if _, exists := m.jobs[name]; !exists { + return fmt.Errorf("job %q not found", name) + } + // Mock job execution + return nil +} + +func (m *MockScheduler) ListJobs() []string { + var jobs []string + for name := range m.jobs { + jobs = append(jobs, name) + } + return jobs +} + +func (suite *DigestApiIntegrationSuite) SetupSuite() { + suite.IntegrationTestSuite.SetupSuite() + + logger, _ := zap.NewDevelopment() + suite.logger = logger.Sugar() + + // Create email service + emailService, err := email.NewService(suite.Config.Email, suite.logger) + suite.Require().NoError(err, "Failed to create email service") + suite.emailService = emailService + + // Create mock scheduler + suite.mockScheduler = NewMockScheduler() + + // Create digest handler + digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger) + suite.digestHandler = NewDigestHandler(digestService, suite.mockScheduler, suite.logger) + + // Setup server + metrics := api.NewMetricsHandler(context.Background(), logger.Sugar()) + suite.server = api.NewServer(context.Background(), logger.Sugar(), suite.Config, metrics) + + // Register handlers + RegisterHandlers(suite.server, suite.logger, suite.DB, suite.Config, digestService, suite.mockScheduler) +} + +func (suite *DigestApiIntegrationSuite) SetupTest() { + err := suite.Migrator.Refresh() + suite.Require().NoError(err) + + // Pre-register the default job in the mock scheduler + suite.mockScheduler.jobs["global-evidence-digest"] = true +} + +func (suite *DigestApiIntegrationSuite) TestTriggerDigest() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("TriggerDigestSuccess", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest") + + var response map[string]string + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response") + + suite.Equal("Digest job triggered successfully", response["message"]) + suite.Equal("global-evidence-digest", response["job"]) + }) + + suite.Run("TriggerDigestWithCustomJob", func() { + // Pre-register the custom job + suite.mockScheduler.jobs["custom-job"] = true + + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger?job=custom-job", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for TriggerDigest with custom job") + + var response map[string]string + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal TriggerDigest response") + + suite.Equal("Digest job triggered successfully", response["message"]) + suite.Equal("custom-job", response["job"]) + }) + + suite.Run("TriggerDigestUnauthorized", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + // No authorization header + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token") + }) +} + +func (suite *DigestApiIntegrationSuite) TestPreviewDigest() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("PreviewDigestSuccess", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for PreviewDigest") + + var response struct { + Data *digest.EvidenceSummary `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal PreviewDigest response") + + suite.NotNil(response.Data, "Expected evidence summary data") + }) + + suite.Run("PreviewDigestUnauthorized", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/admin/digest/preview", nil) + // No authorization header + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(401, rec.Code, "Expected Unauthorized response for missing token") + }) +} + +func (suite *DigestApiIntegrationSuite) TestTriggerDigestWithNilScheduler() { + // Test with nil scheduler to verify error handling + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + // Create handler with nil scheduler + digestService := digest.NewService(suite.DB, suite.emailService, suite.Config, suite.logger) + nilSchedulerHandler := NewDigestHandler(digestService, nil, suite.logger) + + // Create a temporary echo context for testing + e := suite.server.E() + req := httptest.NewRequest("POST", "/api/admin/digest/trigger", nil) + req.Header.Set("Authorization", "Bearer "+*token) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err = nilSchedulerHandler.TriggerDigest(c) + suite.NoError(err, "Expected no error from TriggerDigest with nil scheduler") + suite.Equal(500, rec.Code, "Expected Internal Server Error when scheduler is nil") + + var response api.Error + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal error response") + + // Check if the error contains our expected message + for _, errMsg := range response.Errors { + if msgStr, ok := errMsg.(string); ok { + suite.Contains(msgStr, "scheduler is not available") + } + } +} diff --git a/internal/api/handler/users_integration_test.go b/internal/api/handler/users_integration_test.go index c812266f..b5b435a9 100644 --- a/internal/api/handler/users_integration_test.go +++ b/internal/api/handler/users_integration_test.go @@ -384,3 +384,86 @@ func (suite *UserApiIntegrationSuite) TestChangePassword() { suite.True(updatedUser.CheckPassword("NewPa55w0rd"), "Expected password to be updated successfully") }) } + +func (suite *UserApiIntegrationSuite) TestDigestSubscription() { + token, err := suite.GetAuthToken() + suite.Require().NoError(err) + + suite.Run("GetDigestSubscription", func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/users/me/digest-subscription", nil) + req.Header.Set("Authorization", "Bearer "+*token) + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for GetDigestSubscription") + + var response struct { + Data struct { + Subscribed bool `json:"subscribed"` + } `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal GetDigestSubscription response") + + // The default should be false for new users + suite.False(response.Data.Subscribed, "Expected default digest subscription to be false") + }) + + suite.Run("UpdateDigestSubscription", func() { + // Test subscribing to digest + payload := map[string]bool{"subscribed": true} + payloadJSON, err := json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal update digest subscription request") + + rec := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for UpdateDigestSubscription") + + var response struct { + Data struct { + Subscribed bool `json:"subscribed"` + } `json:"data"` + } + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal UpdateDigestSubscription response") + + suite.True(response.Data.Subscribed, "Expected digest subscription to be updated to true") + + // Test unsubscribing from digest + payload = map[string]bool{"subscribed": false} + payloadJSON, err = json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal unsubscribe digest request") + + rec = httptest.NewRecorder() + req = httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(200, rec.Code, "Expected OK response for unsubscribe digest") + + err = json.Unmarshal(rec.Body.Bytes(), &response) + suite.Require().NoError(err, "Failed to unmarshal unsubscribe digest response") + + suite.False(response.Data.Subscribed, "Expected digest subscription to be updated to false") + }) + + suite.Run("UpdateDigestSubscriptionInvalidPayload", func() { + // Test with invalid payload + payload := map[string]string{"subscribed": "invalid"} + payloadJSON, err := json.Marshal(payload) + suite.Require().NoError(err, "Failed to marshal invalid digest subscription request") + + rec := httptest.NewRecorder() + req := httptest.NewRequest("PUT", "/api/users/me/digest-subscription", bytes.NewReader(payloadJSON)) + req.Header.Set("Authorization", "Bearer "+*token) + req.Header.Set("Content-Type", "application/json") + + suite.server.E().ServeHTTP(rec, req) + suite.Equal(400, rec.Code, "Expected Bad Request response for invalid payload") + }) +} diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt index 861a33ef..ae73c224 100644 --- a/internal/service/email/templates/templates/evidence-digest.txt +++ b/internal/service/email/templates/templates/evidence-digest.txt @@ -19,7 +19,7 @@ NOT SATISFIED EVIDENCE * {{.Title}} {{if .Description}}Description: {{.Description}}{{end}} Status: {{.Status}} - View: {{$.WebBaseURL}}/evidence/{{.UUID}} + View: {{$.WebBaseURL}}/evidence/{{.ID}} {{end}} {{end}} diff --git a/sso.yaml b/sso.yaml index e9ef2f98..aa7eaf50 100644 --- a/sso.yaml +++ b/sso.yaml @@ -27,7 +27,7 @@ providers: group_mapping: "hd:container-solutions.com": - "ccf-authorized-users" - "email:gustavo.carvalho@container-solutions.com": + "email:admin@example.com": - "ccf-admins" # Add additional group mappings as needed From eacd09d39470aeedba059d4822c7fafca0d98672 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 13:59:16 -0300 Subject: [PATCH 08/11] fix: copilot issues #2 Signed-off-by: Gustavo Carvalho --- cmd/digest.go | 38 +++++++++++++++++++ internal/api/handler/users.go | 6 +-- internal/service/digest/service.go | 2 +- .../templates/templates/evidence-digest.txt | 2 +- internal/service/scheduler/cron.go | 13 ++++++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/cmd/digest.go b/cmd/digest.go index c3d1203b..a31d9ac1 100644 --- a/cmd/digest.go +++ b/cmd/digest.go @@ -15,6 +15,8 @@ import ( ) var ( + dryRun bool + DigestCmd = &cobra.Command{ Use: "digest", Short: "Digest management commands", @@ -34,6 +36,7 @@ var ( ) func init() { + digestTestCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be sent without sending emails") DigestCmd.AddCommand(digestTestCmd) DigestCmd.AddCommand(digestPreviewCmd) } @@ -56,6 +59,19 @@ func runDigestTest(cmd *cobra.Command, args []string) { cfg := config.NewConfig(sugar) + // Check if this is production and add confirmation if not dry-run + if cfg.Environment == "production" && !dryRun { + fmt.Print("⚠️ WARNING: You are about to send digest emails to all subscribed users in PRODUCTION!\n") + fmt.Print("This will send emails to real users. Are you sure you want to continue? (type 'yes' to confirm): ") + + var response string + fmt.Scanln(&response) + if response != "yes" { + fmt.Println("Operation cancelled.") + return + } + } + db, err := service.ConnectSQLDb(ctx, cfg, sugar) if err != nil { sugar.Fatalw("Failed to connect to SQL database", "error", err) @@ -68,6 +84,28 @@ func runDigestTest(cmd *cobra.Command, args []string) { digestService := digest.NewService(db, emailService, cfg, sugar) + if dryRun { + sugar.Info("Running digest test in DRY-RUN mode (no emails will be sent)...") + + // Get the digest summary without sending + summary, err := digestService.GetGlobalEvidenceSummary(ctx) + if err != nil { + sugar.Fatalw("Failed to get digest summary", "error", err) + } + + sugar.Infow("Digest summary (dry-run)", + "total_evidence", summary.TotalCount, + "satisfied", summary.SatisfiedCount, + "not_satisfied", summary.NotSatisfiedCount, + "expired", summary.ExpiredCount, + "top_not_satisfied_count", len(summary.TopNotSatisfied), + "top_expired_count", len(summary.TopExpired), + ) + + sugar.Info("Dry-run completed successfully - no emails were sent") + return + } + sugar.Info("Running digest test...") if err := digestService.SendGlobalDigest(ctx); err != nil { sugar.Fatalw("Failed to send digest", "error", err) diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index 315afb9c..c19cc796 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -401,7 +401,7 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error { // @Description Gets the current user's digest email subscription status // @Tags Users // @Produce json -// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse] +// @Success 200 {object} handler.GenericDataResponse[object{subscribed boolean}] // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error @@ -436,8 +436,8 @@ func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status" -// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse] +// @Param subscription body object{subscribed boolean} true "Subscription status" +// @Success 200 {object} handler.GenericDataResponse[object{subscribed boolean}] // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 45aa0ae9..504d68a9 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -126,7 +126,7 @@ func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummar if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). Where("status->>'state' = ?", "not-satisfied"). Preload("Labels"). - Order("\"end\" DESC"). + Order("end DESC"). Limit(5). Find(¬SatisfiedEvidence).Error; err != nil { s.logger.Warnw("Failed to fetch top not-satisfied evidence", "error", err) diff --git a/internal/service/email/templates/templates/evidence-digest.txt b/internal/service/email/templates/templates/evidence-digest.txt index ae73c224..e9d4a07d 100644 --- a/internal/service/email/templates/templates/evidence-digest.txt +++ b/internal/service/email/templates/templates/evidence-digest.txt @@ -31,7 +31,7 @@ EXPIRED EVIDENCE * {{.Title}} {{if .Description}}Description: {{.Description}}{{end}} Expired: {{if .ExpiresAt}}{{.ExpiresAt}}{{else}}N/A{{end}} - View: {{$.WebBaseURL}}/evidence/{{.UUID}} + View: {{$.WebBaseURL}}/evidence/{{.ID}} {{end}} {{end}} diff --git a/internal/service/scheduler/cron.go b/internal/service/scheduler/cron.go index acfa3372..3de16e28 100644 --- a/internal/service/scheduler/cron.go +++ b/internal/service/scheduler/cron.go @@ -15,14 +15,19 @@ type CronScheduler struct { logger *zap.SugaredLogger jobs map[string]Job mu sync.RWMutex + ctx context.Context + cancel context.CancelFunc } // NewCronScheduler creates a new cron-based scheduler func NewCronScheduler(logger *zap.SugaredLogger) *CronScheduler { + ctx, cancel := context.WithCancel(context.Background()) return &CronScheduler{ cron: cron.New(cron.WithSeconds()), logger: logger, jobs: make(map[string]Job), + ctx: ctx, + cancel: cancel, } } @@ -59,9 +64,8 @@ func (s *CronScheduler) ScheduleCron(cronExpr string, job Job) error { } _, err := s.cron.AddFunc(cronExpr, func() { - ctx := context.Background() s.logger.Debugw("Starting scheduled job", "job", job.Name()) - if err := job.Execute(ctx); err != nil { + if err := job.Execute(s.ctx); err != nil { s.logger.Errorw("Scheduled job failed", "job", job.Name(), "error", err) } else { s.logger.Debugw("Scheduled job completed", "job", job.Name()) @@ -85,6 +89,11 @@ func (s *CronScheduler) Start() { // Stop gracefully stops the scheduler func (s *CronScheduler) Stop() context.Context { s.logger.Debug("Stopping scheduler") + + // Cancel the context to signal running jobs to stop + s.cancel() + + // Stop the cron scheduler return s.cron.Stop() } From 3510fb1678d6afc6577efe1cb43e645272f58864 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 14:10:11 -0300 Subject: [PATCH 09/11] fix: copilot issues continuation Signed-off-by: Gustavo Carvalho --- internal/service/digest/service.go | 75 +++++++++++------------- internal/service/email/providers/ses.go | 4 +- internal/service/email/providers/smtp.go | 4 +- 3 files changed, 37 insertions(+), 46 deletions(-) diff --git a/internal/service/digest/service.go b/internal/service/digest/service.go index 504d68a9..5a1b5c18 100644 --- a/internal/service/digest/service.go +++ b/internal/service/digest/service.go @@ -59,52 +59,43 @@ func NewService(db *gorm.DB, emailService *email.Service, cfg *config.Config, lo func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummary, error) { summary := &EvidenceSummary{} - // Get latest evidence streams - latestQuery := relational.GetLatestEvidenceStreamsQuery(s.db) - - // Count by status - type StatusCount struct { - Count int64 `json:"count"` - Status string `json:"status"` - } + // Get latest evidence streams once using CTE to avoid recomputing the subquery multiple times + now := time.Now() + zeroTime := time.Unix(0, 0) - var statusCounts []StatusCount - if err := s.db.Table("(?) as latest", latestQuery). - Select("count(*) as count, status->>'state' as status"). - Group("status->>'state'"). - Scan(&statusCounts).Error; err != nil { - return nil, fmt.Errorf("failed to count evidence by status: %w", err) + // Create a single CTE query for latest evidence streams with all aggregations + summaryQuery := s.db.Raw(` + WITH latest_evidence AS ( + SELECT DISTINCT ON (uuid) * + FROM evidences + ORDER BY uuid, evidences.end DESC + ) + SELECT + COUNT(*) as total_count, + COUNT(CASE WHEN status->>'state' = 'satisfied' THEN 1 END) as satisfied_count, + COUNT(CASE WHEN status->>'state' = 'not-satisfied' THEN 1 END) as not_satisfied_count, + COUNT(CASE WHEN status->>'state' NOT IN ('satisfied', 'not-satisfied') THEN 1 END) as other_count, + COUNT(CASE WHEN expires IS NOT NULL AND expires > ? AND expires <= ? THEN 1 END) as expired_count + FROM latest_evidence + `, zeroTime, now) + + var result struct { + TotalCount int64 + SatisfiedCount int64 + NotSatisfiedCount int64 + OtherCount int64 + ExpiredCount int64 } - for _, sc := range statusCounts { - summary.TotalCount += sc.Count - switch sc.Status { - case "satisfied": - summary.SatisfiedCount = sc.Count - case "not-satisfied": - summary.NotSatisfiedCount = sc.Count - default: - summary.OtherCount += sc.Count - } + if err := summaryQuery.Scan(&result).Error; err != nil { + return nil, fmt.Errorf("failed to get evidence summary: %w", err) } - // Count expired evidence (expires <= now) - // Note: Evidence without expiration dates (expires IS NULL or zero time) are treated as never expiring - // and are excluded from the expired count. This maintains backward compatibility with - // deployments that have evidence without expiration dates. - // We exclude zero time (1970-01-01 00:00:00 UTC / 1969-12-31 in negative timezones) which may exist in DB - // We use a subquery to properly count only the latest evidence per stream (DISTINCT ON uuid) - now := time.Now() - zeroTime := time.Unix(0, 0) - expiredCountQuery := s.db.Session(&gorm.Session{}) - expiredCountQuery = relational.GetLatestEvidenceStreamsQuery(expiredCountQuery) - expiredCountQuery = expiredCountQuery.Where("expires IS NOT NULL AND expires > ? AND expires <= ?", zeroTime, now) - - // Count distinct UUIDs from the subquery - if err := s.db.Table("(?) as latest_expired", expiredCountQuery). - Count(&summary.ExpiredCount).Error; err != nil { - return nil, fmt.Errorf("failed to count expired evidence: %w", err) - } + summary.TotalCount = result.TotalCount + summary.SatisfiedCount = result.SatisfiedCount + summary.NotSatisfiedCount = result.NotSatisfiedCount + summary.ExpiredCount = result.ExpiredCount + summary.OtherCount = result.OtherCount // Get top 5 expired evidence items (only those with explicit expiration dates, excluding zero time) var expiredEvidence []relational.Evidence @@ -126,7 +117,7 @@ func (s *Service) GetGlobalEvidenceSummary(ctx context.Context) (*EvidenceSummar if err := s.db.Table("(?) as latest", relational.GetLatestEvidenceStreamsQuery(s.db)). Where("status->>'state' = ?", "not-satisfied"). Preload("Labels"). - Order("end DESC"). + Order("latest.end DESC"). Limit(5). Find(¬SatisfiedEvidence).Error; err != nil { s.logger.Warnw("Failed to fetch top not-satisfied evidence", "error", err) diff --git a/internal/service/email/providers/ses.go b/internal/service/email/providers/ses.go index c0e0fb43..019f5eb7 100644 --- a/internal/service/email/providers/ses.go +++ b/internal/service/email/providers/ses.go @@ -63,7 +63,7 @@ func NewSESProvider(ctx context.Context, cfg *config.SESConfig, logger *zap.Suga return nil, fmt.Errorf("SES connection test failed: %w", err) } - logger.Infow("SES provider initialized", "region", cfg.Region, "from", cfg.From) + logger.Debugw("SES provider initialized", "region", cfg.Region, "from", cfg.From) return provider, nil } @@ -132,7 +132,7 @@ func (p *sesProvider) Send(ctx context.Context, message *emailtypes.Message) (*e messageId = *result.MessageId } - p.logger.Infow("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId) + p.logger.Debugw("Email sent successfully via SES", "to", message.To, "subject", message.Subject, "message_id", messageId) return &emailtypes.SendResult{ Success: true, MessageID: messageId, diff --git a/internal/service/email/providers/smtp.go b/internal/service/email/providers/smtp.go index 1b97e517..433b815e 100644 --- a/internal/service/email/providers/smtp.go +++ b/internal/service/email/providers/smtp.go @@ -49,7 +49,7 @@ func NewSMTPProvider(ctx context.Context, cfg *config.SMTPConfig, logger *zap.Su return nil, fmt.Errorf("SMTP connection test failed: %w", err) } - logger.Infow("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From) + logger.Debugw("SMTP provider initialized", "host", cfg.Host, "port", cfg.Port, "from", cfg.From) return provider, nil } @@ -91,7 +91,7 @@ func (p *smtpProvider) Send(ctx context.Context, message *types.Message) (*types }, err } - p.logger.Infow("Email sent successfully", "to", message.To, "subject", message.Subject) + p.logger.Debugw("Email sent successfully", "to", message.To, "subject", message.Subject) return &types.SendResult{ Success: true, }, nil From f4ce43e55ee2d5db279ecd4d81168bd502c832d1 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 14:18:48 -0300 Subject: [PATCH 10/11] fix: swag Signed-off-by: Gustavo Carvalho --- internal/api/handler/users.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/handler/users.go b/internal/api/handler/users.go index c19cc796..315afb9c 100644 --- a/internal/api/handler/users.go +++ b/internal/api/handler/users.go @@ -401,7 +401,7 @@ func (h *UserHandler) ChangeLoggedInUserPassword(ctx echo.Context) error { // @Description Gets the current user's digest email subscription status // @Tags Users // @Produce json -// @Success 200 {object} handler.GenericDataResponse[object{subscribed boolean}] +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.GetDigestSubscription.digestSubscriptionResponse] // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error // @Failure 500 {object} api.Error @@ -436,8 +436,8 @@ func (h *UserHandler) GetDigestSubscription(ctx echo.Context) error { // @Tags Users // @Accept json // @Produce json -// @Param subscription body object{subscribed boolean} true "Subscription status" -// @Success 200 {object} handler.GenericDataResponse[object{subscribed boolean}] +// @Param subscription body handler.UserHandler.UpdateDigestSubscription.updateDigestSubscriptionRequest true "Subscription status" +// @Success 200 {object} handler.GenericDataResponse[handler.UserHandler.UpdateDigestSubscription.digestSubscriptionResponse] // @Failure 400 {object} api.Error // @Failure 401 {object} api.Error // @Failure 404 {object} api.Error From 324da742b68e4ec91e9fd3cda91e973f38beade5 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Tue, 20 Jan 2026 14:20:30 -0300 Subject: [PATCH 11/11] fix: lint Signed-off-by: Gustavo Carvalho --- cmd/digest.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/digest.go b/cmd/digest.go index a31d9ac1..68eaceeb 100644 --- a/cmd/digest.go +++ b/cmd/digest.go @@ -65,7 +65,10 @@ func runDigestTest(cmd *cobra.Command, args []string) { fmt.Print("This will send emails to real users. Are you sure you want to continue? (type 'yes' to confirm): ") var response string - fmt.Scanln(&response) + _, err := fmt.Scanln(&response) + if err != nil { + sugar.Fatalw("Failed to read user input", "error", err) + } if response != "yes" { fmt.Println("Operation cancelled.") return