diff --git a/api/notificationservice/notificationservice_docs.go b/api/notificationservice/notificationservice_docs.go index cb93bb1..986fe3b 100644 --- a/api/notificationservice/notificationservice_docs.go +++ b/api/notificationservice/notificationservice_docs.go @@ -1115,6 +1115,40 @@ const docTemplatenotificationservice = `{ } } }, + "/rules/ruleoptions": { + "get": { + "security": [ + { + "KeycloakAuth": [] + } + ], + "description": "Returns all required options to create a new alert rule", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rule" + ], + "summary": "Options to create a new alert rule", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.RuleOptions" + }, + "headers": { + "api-version": { + "type": "string", + "description": "API version" + } + } + } + } + } + }, "/rules/{id}": { "put": { "security": [ @@ -1672,12 +1706,16 @@ const docTemplatenotificationservice = `{ "readOnly": true }, "level": { - "type": "string", "enum": [ "info", "warning", "error", "urgent" + ], + "allOf": [ + { + "$ref": "#/definitions/notifications.Level" + } ] }, "origin": { @@ -1777,6 +1815,50 @@ const docTemplatenotificationservice = `{ } } }, + "models.RuleOptionChannel": { + "type": "object", + "required": [ + "channelType" + ], + "properties": { + "channelName": { + "type": "string" + }, + "channelType": { + "$ref": "#/definitions/models.ChannelType" + }, + "hasRecipient": { + "type": "boolean" + }, + "id": { + "type": "string", + "readOnly": true + } + } + }, + "models.RuleOptions": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "items": { + "$ref": "#/definitions/models.RuleOptionChannel" + } + }, + "levels": { + "type": "array", + "items": { + "$ref": "#/definitions/notifications.Level" + } + }, + "origins": { + "type": "array", + "items": { + "$ref": "#/definitions/models.OriginReference" + } + } + } + }, "models.Trigger": { "type": "object", "required": [ @@ -1787,7 +1869,7 @@ const docTemplatenotificationservice = `{ "levels": { "type": "array", "items": { - "type": "string" + "$ref": "#/definitions/notifications.Level" } }, "origins": { @@ -1804,6 +1886,21 @@ const docTemplatenotificationservice = `{ "type": "string" } }, + "notifications.Level": { + "type": "string", + "enum": [ + "info", + "warning", + "error", + "urgent" + ], + "x-enum-varnames": [ + "LevelInfo", + "LevelWarning", + "LevelError", + "LevelUrgent" + ] + }, "paging.Request": { "type": "object", "properties": { diff --git a/api/notificationservice/notificationservice_swagger.yaml b/api/notificationservice/notificationservice_swagger.yaml index 636660a..c6f8908 100644 --- a/api/notificationservice/notificationservice_swagger.yaml +++ b/api/notificationservice/notificationservice_swagger.yaml @@ -269,12 +269,13 @@ definitions: readOnly: true type: string level: + allOf: + - $ref: '#/definitions/notifications.Level' enum: - info - warning - error - urgent - type: string origin: description: name of the origin, e.g. `SBOM - React` type: string @@ -351,11 +352,40 @@ definitions: - name - trigger type: object + models.RuleOptionChannel: + properties: + channelName: + type: string + channelType: + $ref: '#/definitions/models.ChannelType' + hasRecipient: + type: boolean + id: + readOnly: true + type: string + required: + - channelType + type: object + models.RuleOptions: + properties: + channels: + items: + $ref: '#/definitions/models.RuleOptionChannel' + type: array + levels: + items: + $ref: '#/definitions/notifications.Level' + type: array + origins: + items: + $ref: '#/definitions/models.OriginReference' + type: array + type: object models.Trigger: properties: levels: items: - type: string + $ref: '#/definitions/notifications.Level' type: array origins: items: @@ -369,6 +399,18 @@ definitions: additionalProperties: type: string type: object + notifications.Level: + enum: + - info + - warning + - error + - urgent + type: string + x-enum-varnames: + - LevelInfo + - LevelWarning + - LevelError + - LevelUrgent paging.Request: properties: index: @@ -1287,6 +1329,27 @@ paths: summary: Update Rule tags: - rule + /rules/ruleoptions: + get: + consumes: + - application/json + description: Returns all required options to create a new alert rule + produces: + - application/json + responses: + "200": + description: OK + headers: + api-version: + description: API version + type: string + schema: + $ref: '#/definitions/models.RuleOptions' + security: + - KeycloakAuth: [] + summary: Options to create a new alert rule + tags: + - rule /rulse/{id}: get: description: Returns the rule diff --git a/go.mod b/go.mod index f93b679..95e0a71 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/greenbone/keycloak-client-golang v0.2.3 - github.com/greenbone/opensight-golang-libraries v1.31.1 + github.com/greenbone/opensight-golang-libraries v1.31.2-alpha1 github.com/jmoiron/sqlx v1.4.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/lib/pq v1.11.2 @@ -51,6 +51,8 @@ require ( github.com/go-resty/resty/v2 v2.17.2 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect diff --git a/go.sum b/go.sum index e9c5ba2..6862b12 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -114,8 +116,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/greenbone/keycloak-client-golang v0.2.3 h1:gtTTE844ub9iwOADwPJqbrF/edmEulO2sl2rBamSOSA= github.com/greenbone/keycloak-client-golang v0.2.3/go.mod h1:Ws0Dc9j7g3OFupux3kHDTlTd+xelygEvvRa144/+Wf8= -github.com/greenbone/opensight-golang-libraries v1.31.1 h1:H2M7TMd2tvUFr4WFC0dYGBFrhZE/EuzygYzyiVIUFho= -github.com/greenbone/opensight-golang-libraries v1.31.1/go.mod h1:69rfVIgQmz8CFpkUIB/+qexwPNa0ENRCgq9OoTlQzbI= +github.com/greenbone/opensight-golang-libraries v1.31.2-alpha1 h1:WlsHC2o6xamHXXtxjQe4hBuvhdSS3RvLTpNeP2SSlw4= +github.com/greenbone/opensight-golang-libraries v1.31.2-alpha1/go.mod h1:S0Tf7VCSVstkIqqBiCV3+8DXyjDIUD0YfQ/PgDzXanY= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/pkg/models/channelType.go b/pkg/models/channelType.go index 8795f5b..dd3ece7 100644 --- a/pkg/models/channelType.go +++ b/pkg/models/channelType.go @@ -7,3 +7,10 @@ const ( ChannelTypeMattermost ChannelType = "mattermost" ChannelTypeTeams ChannelType = "teams" ) + +var AllowedChannels = []ChannelType{ChannelTypeMail, ChannelTypeMattermost, ChannelTypeTeams} + +// HasRecipient returns true if the channel type requires/supports an explicit recipient. +func (ct ChannelType) HasRecipient() bool { + return ct == ChannelTypeMail +} diff --git a/pkg/models/notification.go b/pkg/models/notification.go index cb0c9d4..0608449 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -4,17 +4,19 @@ package models +import "github.com/greenbone/opensight-golang-libraries/pkg/notifications" + // Notification is sent by a backend service. It will always be stored by the // notification service and it will possibly also trigger actions like sending mails, -// depending on the cofigured rules. +// depending on the configured rules. type Notification struct { - Id string `json:"id" readonly:"true"` - Origin string `json:"origin" binding:"required"` // name of the origin, e.g. `SBOM - React` - OriginClass string `json:"originClass"` // unique identifier for the class of origins, e.g. `/vi/SBOM`, for now optional for backwards compatibility, will be required in future - OriginResourceID string `json:"originResourceID,omitempty"` // together with class it can be used to provide a link to the origin, e.g. `` - Timestamp string `json:"timestamp" binding:"required" format:"date-time"` - Title string `json:"title" binding:"required"` - Detail string `json:"detail" binding:"required"` - Level string `json:"level" binding:"required" enums:"info,warning,error,urgent"` - CustomFields map[string]any `json:"customFields,omitempty"` // can contain arbitrary structured information about the event + Id string `json:"id" readonly:"true"` + Origin string `json:"origin" binding:"required"` // name of the origin, e.g. `SBOM - React` + OriginClass string `json:"originClass"` // unique identifier for the class of origins, e.g. `/vi/SBOM`, for now optional for backwards compatibility, will be required in future + OriginResourceID string `json:"originResourceID,omitempty"` // together with class it can be used to provide a link to the origin, e.g. `` + Timestamp string `json:"timestamp" binding:"required" format:"date-time"` + Title string `json:"title" binding:"required"` + Detail string `json:"detail" binding:"required"` + Level notifications.Level `json:"level" binding:"required" enums:"info,warning,error,urgent"` + CustomFields map[string]any `json:"customFields,omitempty"` // can contain arbitrary structured information about the event } diff --git a/pkg/models/rule.go b/pkg/models/rule.go index 976d53b..8cb902a 100644 --- a/pkg/models/rule.go +++ b/pkg/models/rule.go @@ -6,8 +6,11 @@ package models import ( "fmt" + "slices" "strings" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" + "github.com/greenbone/opensight-notification-service/pkg/entities" "github.com/greenbone/opensight-notification-service/pkg/translation" "github.com/greenbone/opensight-notification-service/pkg/validation" ) @@ -24,10 +27,24 @@ type Rule struct { Errors ValidationErrors `json:"errors,omitempty" readonly:"true"` // populated if the rule is invalid, this can be useful to highlight rules which need action from the user. } +// RuleOptions Represents a list of all options required for the creation of a Rule +type RuleOptions struct { + Origins []OriginReference `json:"origins"` + Levels []notifications.Level `json:"levels"` + Channels []RuleOptionChannel `json:"channels"` +} + +type RuleOptionChannel struct { + Id *string `json:"id" readonly:"true"` + ChannelType ChannelType `json:"channelType" binding:"required"` + ChannelName *string `json:"channelName,omitempty"` + HasRecipient bool `json:"hasRecipient"` +} + // Trigger condition, fulfilled if both one of `origins` and `levels` match the ones from the incoming event. type Trigger struct { - Origins []OriginReference `json:"origins" validate:"required"` - Levels []string `json:"levels" validate:"required"` + Origins []OriginReference `json:"origins" validate:"required"` + Levels []notifications.Level `json:"levels" validate:"required"` } // Action determines to which channel the event is forwarded. @@ -49,6 +66,31 @@ type ChannelReference struct { Type ChannelType `json:"type" readonly:"true"` } +func ToRuleOptionChannels(channels []NotificationChannel) []RuleOptionChannel { + result := make([]RuleOptionChannel, len(channels)) + for i, channel := range channels { + result[i] = RuleOptionChannel{ + Id: channel.Id, + ChannelType: channel.ChannelType, + ChannelName: channel.ChannelName, + HasRecipient: channel.ChannelType.HasRecipient(), + } + } + return result +} + +func ToOriginReferences(origins []entities.Origin) []OriginReference { + result := make([]OriginReference, len(origins)) + for i, origin := range origins { + result[i] = OriginReference{ + Name: origin.Name, + Class: origin.Class, + ServiceID: origin.ServiceID, + } + } + return result +} + func (r *Rule) Cleanup() { r.Name = strings.TrimSpace(r.Name) } @@ -78,6 +120,8 @@ func (r *Rule) Validate() ValidationErrors { for i, level := range r.Trigger.Levels { if level == "" { errs[fmt.Sprintf("trigger.levels[%d]", i)] = translation.LevelIsRequired + } else if !slices.Contains(notifications.AllowedLevels, level) { + errs[fmt.Sprintf("trigger.levels[%d]", i)] = translation.InvalidLevel } } } diff --git a/pkg/repository/notificationrepository/notification_db_models.go b/pkg/repository/notificationrepository/notification_db_models.go index 90ae4b5..bf862f0 100644 --- a/pkg/repository/notificationrepository/notification_db_models.go +++ b/pkg/repository/notificationrepository/notification_db_models.go @@ -7,6 +7,7 @@ package notificationrepository import ( "encoding/json" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/helper" "github.com/greenbone/opensight-notification-service/pkg/models" "github.com/greenbone/opensight-notification-service/pkg/services/notificationservice/dtos" @@ -19,15 +20,15 @@ const ( ) type notificationRow struct { - Id string `db:"id"` - Origin string `db:"origin"` - OriginClass string `db:"origin_class"` - OriginResourceID *string `db:"origin_resource_id"` - Timestamp string `db:"timestamp"` - Title string `db:"title"` - Detail string `db:"detail"` - Level string `db:"level"` - CustomFields []byte `db:"custom_fields"` + Id string `db:"id"` + Origin string `db:"origin"` + OriginClass string `db:"origin_class"` + OriginResourceID *string `db:"origin_resource_id"` + Timestamp string `db:"timestamp"` + Title string `db:"title"` + Detail string `db:"detail"` + Level notifications.Level `db:"level"` + CustomFields []byte `db:"custom_fields"` } func notificationFieldMapping() map[string]string { diff --git a/pkg/repository/rulerepository/ruleRepository_test.go b/pkg/repository/rulerepository/ruleRepository_test.go index cfa34f9..73a012c 100644 --- a/pkg/repository/rulerepository/ruleRepository_test.go +++ b/pkg/repository/rulerepository/ruleRepository_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/config" "github.com/greenbone/opensight-notification-service/pkg/entities" "github.com/greenbone/opensight-notification-service/pkg/errs" @@ -90,7 +91,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Test Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ {Class: "class1", Name: "read-only,ignored", ServiceID: "read-only,ignored"}, }, @@ -103,7 +104,7 @@ func Test_CreateRule_GetRule(t *testing.T) { wantRule: models.Rule{ Name: "Test Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin1", @@ -132,7 +133,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Security Alerts", Trigger: models.Trigger{ - Levels: []string{"high", "critical"}, + Levels: []notifications.Level{notifications.LevelError, notifications.LevelUrgent}, Origins: []models.OriginReference{ {Class: "vuln", ServiceID: "read-only,ignored", Name: "read-only,ignored"}, {Class: "compliance", ServiceID: "read-only,ignored", Name: "read-only,ignored"}, @@ -147,7 +148,7 @@ func Test_CreateRule_GetRule(t *testing.T) { wantRule: models.Rule{ Name: "Security Alerts", Trigger: models.Trigger{ - Levels: []string{"high", "critical"}, + Levels: []notifications.Level{notifications.LevelError, notifications.LevelUrgent}, Origins: []models.OriginReference{ { Name: "Vulnerability", @@ -181,7 +182,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Test Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ {Class: "class1", Name: "read-only,ignored", ServiceID: "read-only,ignored"}, }, @@ -194,7 +195,7 @@ func Test_CreateRule_GetRule(t *testing.T) { wantRule: models.Rule{ Name: "Test Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin1", @@ -221,7 +222,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Invalid Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{{Class: "non-existent"}}, }, Action: models.Action{ @@ -232,7 +233,7 @@ func Test_CreateRule_GetRule(t *testing.T) { wantRule: models.Rule{ Name: "Invalid Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{}, // origins not found, so empty origins expected }, Action: models.Action{ @@ -250,7 +251,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Invalid Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -261,7 +262,7 @@ func Test_CreateRule_GetRule(t *testing.T) { wantRule: models.Rule{ Name: "Invalid Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{ { Name: "name1", @@ -285,7 +286,7 @@ func Test_CreateRule_GetRule(t *testing.T) { existingRule := models.Rule{ Name: "Existing Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -300,7 +301,7 @@ func Test_CreateRule_GetRule(t *testing.T) { rule: models.Rule{ Name: "Existing Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -361,7 +362,7 @@ func Test_UpdateRule_NotFound(t *testing.T) { rule := models.Rule{ Name: "Non-existent Rule", Trigger: models.Trigger{ - Levels: []string{"low"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -387,7 +388,7 @@ func Test_UpdateRule(t *testing.T) { existingUntouchedRule := models.Rule{ Name: "Existing untouched Rule", Trigger: models.Trigger{ - Levels: []string{"low"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -398,7 +399,7 @@ func Test_UpdateRule(t *testing.T) { existingRule := models.Rule{ Name: "Existing Rule", Trigger: models.Trigger{ - Levels: []string{"medium"}, + Levels: []notifications.Level{notifications.LevelWarning}, Origins: []models.OriginReference{{Class: "class1", Name: "read-only,ignored", ServiceID: "read-only,ignored"}}, }, Action: models.Action{ @@ -442,7 +443,7 @@ func Test_UpdateRule(t *testing.T) { rule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ {Class: "class2", Name: "read-only,ignored", ServiceID: "read-only,ignored"}, }, @@ -456,7 +457,7 @@ func Test_UpdateRule(t *testing.T) { wantRule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin2", @@ -477,7 +478,7 @@ func Test_UpdateRule(t *testing.T) { rule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "non-existent"}}, }, Action: models.Action{ @@ -487,7 +488,7 @@ func Test_UpdateRule(t *testing.T) { wantRule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{}, // origins not found, so empty origins expected }, Action: models.Action{ @@ -503,7 +504,7 @@ func Test_UpdateRule(t *testing.T) { rule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "class2"}}, }, Action: models.Action{ @@ -513,7 +514,7 @@ func Test_UpdateRule(t *testing.T) { wantRule: models.Rule{ Name: "Updated Rule", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin2", @@ -532,7 +533,7 @@ func Test_UpdateRule(t *testing.T) { rule: models.Rule{ Name: existingUntouchedRule.Name, // duplicate name Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "class2"}}, }, Action: models.Action{ @@ -611,7 +612,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 2", Trigger: models.Trigger{ - Levels: []string{"low"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{{Class: "class2"}}, }, Action: models.Action{ @@ -622,7 +623,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 1", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -633,7 +634,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 3", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ @@ -646,7 +647,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 1", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin1", @@ -667,7 +668,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 2", Trigger: models.Trigger{ - Levels: []string{"low"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{ { Name: "Origin2", @@ -688,7 +689,7 @@ func Test_ListRules(t *testing.T) { { Name: "Rule 3", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelError}, Origins: []models.OriginReference{ { Name: "Origin1", @@ -741,7 +742,7 @@ func Test_DeleteRule(t *testing.T) { rule := models.Rule{ Name: "Test Rule 11", Trigger: models.Trigger{ - Levels: []string{"high"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{{Class: "class1"}}, }, Action: models.Action{ diff --git a/pkg/repository/rulerepository/rule_db_models.go b/pkg/repository/rulerepository/rule_db_models.go index 32a5242..e6bbf4b 100644 --- a/pkg/repository/rulerepository/rule_db_models.go +++ b/pkg/repository/rulerepository/rule_db_models.go @@ -8,6 +8,7 @@ import ( "bytes" "encoding/json" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/helper" "github.com/greenbone/opensight-notification-service/pkg/models" "github.com/lib/pq" @@ -119,12 +120,19 @@ func (r ruleRow) ToModel() (models.Rule, error) { channelID = "" // don't set the channel ID if the channel doesn't exist anymore } + var levels []notifications.Level + if r.TriggerLevels != nil { + for _, l := range r.TriggerLevels { + levels = append(levels, notifications.Level(l)) + } + } + rule := models.Rule{ ID: r.ID, Name: r.Name, Trigger: models.Trigger{ Origins: originsParsed, - Levels: []string(r.TriggerLevels), + Levels: levels, }, Action: models.Action{ Channel: models.ChannelReference{ @@ -148,10 +156,15 @@ func toRuleRow(rule models.Rule) ruleRow { originClasses = append(originClasses, origin.Class) } + triggerLevels := make(pq.StringArray, len(rule.Trigger.Levels)) + for i, l := range rule.Trigger.Levels { + triggerLevels[i] = string(l) + } + row := ruleRow{ Name: rule.Name, TriggerOrigins: originClasses, - TriggerLevels: rule.Trigger.Levels, + TriggerLevels: triggerLevels, ActionChannelID: helper.ToNullablePtr(rule.Action.Channel.ID), // take only the writable field ActionRecipient: helper.ToPtr(rule.Action.Recipient), Active: rule.Active, diff --git a/pkg/services/ruleservice/mocks/NotificationChannelRepository.go b/pkg/services/ruleservice/mocks/NotificationChannelRepository.go index a28b001..ee6abf0 100644 --- a/pkg/services/ruleservice/mocks/NotificationChannelRepository.go +++ b/pkg/services/ruleservice/mocks/NotificationChannelRepository.go @@ -103,3 +103,71 @@ func (_c *NotificationChannelRepository_GetNotificationChannelById_Call) RunAndR _c.Call.Return(run) return _c } + +// ListNotificationChannelsByType provides a mock function for the type NotificationChannelRepository +func (_mock *NotificationChannelRepository) ListNotificationChannelsByType(ctx context.Context, channelType models.ChannelType) ([]models.NotificationChannel, error) { + ret := _mock.Called(ctx, channelType) + + if len(ret) == 0 { + panic("no return value specified for ListNotificationChannelsByType") + } + + var r0 []models.NotificationChannel + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, models.ChannelType) ([]models.NotificationChannel, error)); ok { + return returnFunc(ctx, channelType) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, models.ChannelType) []models.NotificationChannel); ok { + r0 = returnFunc(ctx, channelType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.NotificationChannel) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, models.ChannelType) error); ok { + r1 = returnFunc(ctx, channelType) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// NotificationChannelRepository_ListNotificationChannelsByType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationChannelsByType' +type NotificationChannelRepository_ListNotificationChannelsByType_Call struct { + *mock.Call +} + +// ListNotificationChannelsByType is a helper method to define mock.On call +// - ctx context.Context +// - channelType models.ChannelType +func (_e *NotificationChannelRepository_Expecter) ListNotificationChannelsByType(ctx interface{}, channelType interface{}) *NotificationChannelRepository_ListNotificationChannelsByType_Call { + return &NotificationChannelRepository_ListNotificationChannelsByType_Call{Call: _e.mock.On("ListNotificationChannelsByType", ctx, channelType)} +} + +func (_c *NotificationChannelRepository_ListNotificationChannelsByType_Call) Run(run func(ctx context.Context, channelType models.ChannelType)) *NotificationChannelRepository_ListNotificationChannelsByType_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 models.ChannelType + if args[1] != nil { + arg1 = args[1].(models.ChannelType) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *NotificationChannelRepository_ListNotificationChannelsByType_Call) Return(notificationChannels []models.NotificationChannel, err error) *NotificationChannelRepository_ListNotificationChannelsByType_Call { + _c.Call.Return(notificationChannels, err) + return _c +} + +func (_c *NotificationChannelRepository_ListNotificationChannelsByType_Call) RunAndReturn(run func(ctx context.Context, channelType models.ChannelType) ([]models.NotificationChannel, error)) *NotificationChannelRepository_ListNotificationChannelsByType_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/services/ruleservice/ruleService.go b/pkg/services/ruleservice/ruleService.go index 86f0fec..98f98b4 100644 --- a/pkg/services/ruleservice/ruleService.go +++ b/pkg/services/ruleservice/ruleService.go @@ -10,6 +10,7 @@ import ( "fmt" "slices" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/entities" "github.com/greenbone/opensight-notification-service/pkg/errs" "github.com/greenbone/opensight-notification-service/pkg/models" @@ -31,6 +32,7 @@ type RuleRepository interface { type NotificationChannelRepository interface { GetNotificationChannelById(ctx context.Context, id string) (models.NotificationChannel, error) + ListNotificationChannelsByType(ctx context.Context, channelType models.ChannelType) ([]models.NotificationChannel, error) } type OriginRepository interface { @@ -111,6 +113,28 @@ func (s *RuleService) Delete(ctx context.Context, id string) error { return s.store.Delete(ctx, id) } +func (s *RuleService) GetAllRuleOptions(ctx context.Context) (*models.RuleOptions, error) { + origins, err := s.originStore.ListOrigins(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list origins: %w", err) + } + + var channels []models.NotificationChannel + for _, channelType := range models.AllowedChannels { + ch, err := s.channelStore.ListNotificationChannelsByType(ctx, channelType) + if err != nil { + return nil, fmt.Errorf("failed to list channels of type %s: %w", channelType, err) + } + channels = append(channels, ch...) + } + + return &models.RuleOptions{ + Origins: models.ToOriginReferences(origins), + Levels: notifications.AllowedLevels, + Channels: models.ToRuleOptionChannels(channels), + }, nil +} + func (s *RuleService) validateRule(ctx context.Context, rule models.Rule) error { err1 := s.validateAction(ctx, rule.Action) err2 := s.validateOrigins(ctx, rule.Trigger.Origins) @@ -126,7 +150,11 @@ func (s *RuleService) validateAction(ctx context.Context, action models.Action) return fmt.Errorf("failed to get notification channel: %w", err) } - if channel.ChannelType == models.ChannelTypeMail { + if !slices.Contains(models.AllowedChannels, channel.ChannelType) { + return ErrChannelNotFound + } + + if channel.ChannelType.HasRecipient() { if action.Recipient == "" { return ErrRecipientRequired } diff --git a/pkg/services/ruleservice/ruleService_test.go b/pkg/services/ruleservice/ruleService_test.go index 82e80ad..a933a4a 100644 --- a/pkg/services/ruleservice/ruleService_test.go +++ b/pkg/services/ruleservice/ruleService_test.go @@ -6,8 +6,10 @@ package ruleservice import ( "context" + "fmt" "testing" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/entities" "github.com/greenbone/opensight-notification-service/pkg/errs" "github.com/greenbone/opensight-notification-service/pkg/models" @@ -21,7 +23,7 @@ func getValidRule() models.Rule { Name: "Valid Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -60,6 +62,13 @@ type mockOriginListCall struct { err error } +type mockChannelListByTypeCall struct { + channels []models.NotificationChannel + err error +} + +func strPtr(s string) *string { return &s } + func TestRuleService_Create(t *testing.T) { t.Parallel() tests := map[string]struct { @@ -73,7 +82,7 @@ func TestRuleService_Create(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "non-existent-channel"}, @@ -95,7 +104,7 @@ func TestRuleService_Create(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "non-existent-origin"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -121,7 +130,7 @@ func TestRuleService_Create(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -145,7 +154,7 @@ func TestRuleService_Create(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -164,6 +173,29 @@ func TestRuleService_Create(t *testing.T) { }, wantErr: ErrRecipientNotSupported, }, + "channel with disallowed type should fail": { + rule: models.Rule{ + Name: "Test Rule", + Trigger: models.Trigger{ + Origins: []models.OriginReference{{Class: "test"}}, + Levels: []notifications.Level{notifications.LevelInfo}, + }, + Action: models.Action{ + Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, + }, + Active: true, + }, + mockChannelRepoGet: mockChannelGetCall{ + channelID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + channel: models.NotificationChannel{ChannelType: "unsupported-channel-type"}, + err: nil, + }, + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{{Class: "test"}}, + err: nil, + }, + wantErr: ErrChannelNotFound, + }, } for name, tt := range tests { @@ -205,7 +237,7 @@ func TestRuleService_Get_InvalidRuleDeactivated(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{}, // empty origins - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -221,7 +253,7 @@ func TestRuleService_Get_InvalidRuleDeactivated(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: ""}, // missing channel ID @@ -237,7 +269,7 @@ func TestRuleService_Get_InvalidRuleDeactivated(t *testing.T) { Name: "Valid Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -292,7 +324,7 @@ func TestRuleService_List_InvalidRulesDeactivated(t *testing.T) { Name: "Valid Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -304,7 +336,7 @@ func TestRuleService_List_InvalidRulesDeactivated(t *testing.T) { Name: "Rule 2", Trigger: models.Trigger{ Origins: []models.OriginReference{}, // invalid - missing origins - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -316,7 +348,7 @@ func TestRuleService_List_InvalidRulesDeactivated(t *testing.T) { Name: "Rule 3", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{}, // invalid - missing channel ID @@ -367,7 +399,7 @@ func TestRuleService_Update(t *testing.T) { Name: "Updated Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "non-existent-channel"}, @@ -389,7 +421,7 @@ func TestRuleService_Update(t *testing.T) { Name: "Updated Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "non-existent-origin"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -415,7 +447,7 @@ func TestRuleService_Update(t *testing.T) { Name: "Updated Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -439,7 +471,7 @@ func TestRuleService_Update(t *testing.T) { Name: "Updated Rule", Trigger: models.Trigger{ Origins: []models.OriginReference{{Class: "test"}}, - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, }, Action: models.Action{ Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, @@ -458,6 +490,29 @@ func TestRuleService_Update(t *testing.T) { }, wantErr: ErrRecipientNotSupported, }, + "channel with disallowed type should fail": { + ruleID: "rule-1", + rule: models.Rule{ + Name: "Updated Rule", + Trigger: models.Trigger{ + Origins: []models.OriginReference{{Class: "test"}}, + Levels: []notifications.Level{notifications.LevelInfo}, + }, + Action: models.Action{ + Channel: models.ChannelReference{ID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}, + }, + }, + mockChannelRepoGet: mockChannelGetCall{ + channelID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + channel: models.NotificationChannel{ChannelType: "unsupported-channel-type"}, + err: nil, + }, + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{{Class: "test"}}, + err: nil, + }, + wantErr: ErrChannelNotFound, + }, } for name, tt := range tests { @@ -484,3 +539,139 @@ func TestRuleService_Update(t *testing.T) { }) } } + +func TestRuleService_GetAllRuleOptionsFiltered(t *testing.T) { + t.Parallel() + tests := map[string]struct { + mockOriginRepoList mockOriginListCall + mockChannelListByType map[models.ChannelType]mockChannelListByTypeCall + wantErr bool + wantOriginCount int + wantChannelCount int + wantLevels []notifications.Level + }{ + "returns origins, levels and channels of different types": { + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{ + {Class: "origin-1"}, + {Class: "origin-2"}, + }, + err: nil, + }, + mockChannelListByType: map[models.ChannelType]mockChannelListByTypeCall{ + models.ChannelTypeMail: { + channels: []models.NotificationChannel{ + {ChannelType: models.ChannelTypeMail, ChannelName: strPtr("Mail Channel 1")}, + }, + }, + models.ChannelTypeMattermost: { + channels: []models.NotificationChannel{ + {ChannelType: models.ChannelTypeMattermost, ChannelName: strPtr("Mattermost Channel 1")}, + {ChannelType: models.ChannelTypeMattermost, ChannelName: strPtr("Mattermost Channel 2")}, + }, + }, + models.ChannelTypeTeams: { + channels: []models.NotificationChannel{ + {ChannelType: models.ChannelTypeTeams, ChannelName: strPtr("Teams Channel 1")}, + }, + }, + }, + wantErr: false, + wantOriginCount: 2, + wantChannelCount: 4, + wantLevels: notifications.AllowedLevels, + }, + "returns empty origins and no channels": { + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{}, + err: nil, + }, + mockChannelListByType: map[models.ChannelType]mockChannelListByTypeCall{ + models.ChannelTypeMail: {channels: []models.NotificationChannel{}}, + models.ChannelTypeMattermost: {channels: []models.NotificationChannel{}}, + models.ChannelTypeTeams: {channels: []models.NotificationChannel{}}, + }, + wantErr: false, + wantOriginCount: 0, + wantChannelCount: 0, + wantLevels: notifications.AllowedLevels, + }, + "only mattermost channels configured": { + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{{Class: "origin-1"}}, + err: nil, + }, + mockChannelListByType: map[models.ChannelType]mockChannelListByTypeCall{ + models.ChannelTypeMail: {channels: []models.NotificationChannel{}}, + models.ChannelTypeMattermost: { + channels: []models.NotificationChannel{ + {ChannelType: models.ChannelTypeMattermost, ChannelName: strPtr("Mattermost Only")}, + }, + }, + models.ChannelTypeTeams: {channels: []models.NotificationChannel{}}, + }, + wantErr: false, + wantOriginCount: 1, + wantChannelCount: 1, + wantLevels: notifications.AllowedLevels, + }, + "origin repo error": { + mockOriginRepoList: mockOriginListCall{ + origins: nil, + err: fmt.Errorf("database error"), + }, + mockChannelListByType: nil, + wantErr: true, + }, + "channel repo error for mail type": { + mockOriginRepoList: mockOriginListCall{ + origins: []entities.Origin{{Class: "origin-1"}}, + err: nil, + }, + mockChannelListByType: map[models.ChannelType]mockChannelListByTypeCall{ + models.ChannelTypeMail: {err: fmt.Errorf("mail channel db error")}, + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mockRuleRepo := mocks.NewRuleRepository(t) + mockChannelRepo := mocks.NewNotificationChannelRepository(t) + mockOriginRepo := mocks.NewOriginRepository(t) + + service := NewRuleService(mockRuleRepo, mockChannelRepo, mockOriginRepo, 10) + + mockOriginRepo.EXPECT().ListOrigins(mock.Anything).Return(tt.mockOriginRepoList.origins, tt.mockOriginRepoList.err).Once() + + if tt.mockChannelListByType != nil { + for _, channelType := range []models.ChannelType{models.ChannelTypeMail, models.ChannelTypeMattermost, models.ChannelTypeTeams} { + if call, ok := tt.mockChannelListByType[channelType]; ok { + mockChannelRepo.EXPECT().ListNotificationChannelsByType(mock.Anything, channelType). + Return(call.channels, call.err).Once() + if call.err != nil { + break // service stops iterating on first error + } + } + } + } + + ctx := context.Background() + result, err := service.GetAllRuleOptions(ctx) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result.Origins, tt.wantOriginCount) + assert.Len(t, result.Channels, tt.wantChannelCount) + assert.Equal(t, tt.wantLevels, result.Levels) + assert.Equal(t, models.ToOriginReferences(tt.mockOriginRepoList.origins), result.Origins) + } + }) + } +} diff --git a/pkg/translation/translation.go b/pkg/translation/translation.go index 4f42669..95e513c 100644 --- a/pkg/translation/translation.go +++ b/pkg/translation/translation.go @@ -26,6 +26,7 @@ const ( const ( NameIsRequired = "A name is required." LevelIsRequired = "A level is required." + InvalidLevel = "Invalid level." OriginClassIsRequired = "An origin class is required." ChannelIsRequired = "A channel is required." LevelsAreRequired = "At least one level is required." diff --git a/pkg/web/rulecontroller/mocks/RuleService.go b/pkg/web/rulecontroller/mocks/RuleService.go index fa59668..6feb918 100644 --- a/pkg/web/rulecontroller/mocks/RuleService.go +++ b/pkg/web/rulecontroller/mocks/RuleService.go @@ -227,6 +227,68 @@ func (_c *RuleService_Get_Call) RunAndReturn(run func(ctx context.Context, id st return _c } +// GetAllRuleOptions provides a mock function for the type RuleService +func (_mock *RuleService) GetAllRuleOptions(ctx context.Context) (*models.RuleOptions, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetAllRuleOptions") + } + + var r0 *models.RuleOptions + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) (*models.RuleOptions, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) *models.RuleOptions); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.RuleOptions) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// RuleService_GetAllRuleOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllRuleOptions' +type RuleService_GetAllRuleOptions_Call struct { + *mock.Call +} + +// GetAllRuleOptions is a helper method to define mock.On call +// - ctx context.Context +func (_e *RuleService_Expecter) GetAllRuleOptions(ctx interface{}) *RuleService_GetAllRuleOptions_Call { + return &RuleService_GetAllRuleOptions_Call{Call: _e.mock.On("GetAllRuleOptions", ctx)} +} + +func (_c *RuleService_GetAllRuleOptions_Call) Run(run func(ctx context.Context)) *RuleService_GetAllRuleOptions_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *RuleService_GetAllRuleOptions_Call) Return(ruleOptions *models.RuleOptions, err error) *RuleService_GetAllRuleOptions_Call { + _c.Call.Return(ruleOptions, err) + return _c +} + +func (_c *RuleService_GetAllRuleOptions_Call) RunAndReturn(run func(ctx context.Context) (*models.RuleOptions, error)) *RuleService_GetAllRuleOptions_Call { + _c.Call.Return(run) + return _c +} + // List provides a mock function for the type RuleService func (_mock *RuleService) List(ctx context.Context) ([]models.Rule, error) { ret := _mock.Called(ctx) diff --git a/pkg/web/rulecontroller/ruleController.go b/pkg/web/rulecontroller/ruleController.go index 47fb131..5ca0f1b 100644 --- a/pkg/web/rulecontroller/ruleController.go +++ b/pkg/web/rulecontroller/ruleController.go @@ -28,6 +28,7 @@ type RuleService interface { Create(ctx context.Context, rule models.Rule) (models.Rule, error) Update(ctx context.Context, id string, rule models.Rule) (models.Rule, error) Delete(ctx context.Context, id string) error + GetAllRuleOptions(ctx context.Context) (*models.RuleOptions, error) } type RuleController struct { @@ -57,6 +58,7 @@ func (c *RuleController) RegisterRoutes(router gin.IRouter, auth gin.HandlerFunc group.PUT("/:id", c.UpdateRule) group.DELETE("/:id", c.DeleteRule) group.GET("", c.ListRules) + group.GET("/ruleoptions", c.RuleOptions) } func (c *RuleController) configureMappings(r *errmap.Registry) { @@ -230,3 +232,23 @@ func (c *RuleController) ListRules(gc *gin.Context) { gc.JSON(http.StatusOK, rules) } + +// RuleOptions +// +// @Summary Options to create a new alert rule +// @Description Returns all required options to create a new alert rule +// @Tags rule +// @Accept json +// @Produce json +// @Security KeycloakAuth +// @Success 200 {object} models.RuleOptions +// @Header all {string} api-version "API version" +// @Router /rules/ruleoptions [get] +func (c *RuleController) RuleOptions(gc *gin.Context) { + result, err := c.ruleService.GetAllRuleOptions(gc.Request.Context()) + if ginEx.AddError(gc, err) { + return + } + + gc.JSON(http.StatusOK, result) +} diff --git a/pkg/web/rulecontroller/usecases/updateRule_test.go b/pkg/web/rulecontroller/usecases/updateRule_test.go index c790b31..468f77c 100644 --- a/pkg/web/rulecontroller/usecases/updateRule_test.go +++ b/pkg/web/rulecontroller/usecases/updateRule_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/greenbone/opensight-golang-libraries/pkg/httpassert" + "github.com/greenbone/opensight-golang-libraries/pkg/notifications" "github.com/greenbone/opensight-notification-service/pkg/entities" "github.com/greenbone/opensight-notification-service/pkg/models" "github.com/greenbone/opensight-notification-service/pkg/web/iam" @@ -443,7 +444,7 @@ func Test_UpdateRule(t *testing.T) { Name: "Test Rule", Trigger: models.Trigger{ - Levels: []string{"info"}, + Levels: []notifications.Level{notifications.LevelInfo}, Origins: []models.OriginReference{{Class: "serviceA/origin0"}}, }, Action: models.Action{