From a2100ff949454a217cebf3d944b88980b3fc2908 Mon Sep 17 00:00:00 2001 From: Marius Goetze Date: Tue, 3 Feb 2026 12:59:20 +0100 Subject: [PATCH 1/2] change: split `originUri` into `originClass` and `originResourceID` --- .../notificationservice_docs.go | 15 ++-- .../notificationservice_swagger.yaml | 14 ++- pkg/models/notification.go | 20 +++-- .../000004_change_to_origin_class.up.sql | 18 ++++ .../notificationRepository_test.go | 88 ++++++++++++------- .../notification_db_models.go | 51 ++++++----- .../notificationController_test.go | 13 +-- 7 files changed, 140 insertions(+), 79 deletions(-) create mode 100644 pkg/repository/migrations/000004_change_to_origin_class.up.sql diff --git a/api/notificationservice/notificationservice_docs.go b/api/notificationservice/notificationservice_docs.go index 5473cf7..4fb3be7 100644 --- a/api/notificationservice/notificationservice_docs.go +++ b/api/notificationservice/notificationservice_docs.go @@ -1233,7 +1233,7 @@ const docTemplatenotificationservice = `{ ], "properties": { "customFields": { - "description": "can contain arbitrary structured information about the notification", + "description": "can contain arbitrary structured information about the event", "type": "object", "additionalProperties": {} }, @@ -1249,14 +1249,20 @@ const docTemplatenotificationservice = `{ "enum": [ "info", "warning", - "error" + "error", + "urgent" ] }, "origin": { + "description": "name of the origin, e.g. ` + "`" + `SBOM - React` + "`" + `", "type": "string" }, - "originUri": { - "description": "can be used to provide a link to the origin", + "originClass": { + "description": "unique identifier for the class of origins, e.g. ` + "`" + `/vi/SBOM` + "`" + `, for now optional for backwards compatibility, will be required in future", + "type": "string" + }, + "originResourceID": { + "description": "together with class it can be used to provide a link to the origin, e.g. ` + "`" + `\u003cid of react sbom object\u003e` + "`" + `", "type": "string" }, "timestamp": { @@ -1264,7 +1270,6 @@ const docTemplatenotificationservice = `{ "format": "date-time" }, "title": { - "description": "can also be seen as the 'type'", "type": "string" } } diff --git a/api/notificationservice/notificationservice_swagger.yaml b/api/notificationservice/notificationservice_swagger.yaml index c1c825d..eb78614 100644 --- a/api/notificationservice/notificationservice_swagger.yaml +++ b/api/notificationservice/notificationservice_swagger.yaml @@ -213,7 +213,7 @@ definitions: properties: customFields: additionalProperties: {} - description: can contain arbitrary structured information about the notification + description: can contain arbitrary structured information about the event type: object detail: type: string @@ -225,17 +225,23 @@ definitions: - info - warning - error + - urgent type: string origin: + description: name of the origin, e.g. `SBOM - React` type: string - originUri: - description: can be used to provide a link to the origin + originClass: + description: unique identifier for the class of origins, e.g. `/vi/SBOM`, + for now optional for backwards compatibility, will be required in future + type: string + originResourceID: + description: together with class it can be used to provide a link to the origin, + e.g. `` type: string timestamp: format: date-time type: string title: - description: can also be seen as the 'type' type: string required: - detail diff --git a/pkg/models/notification.go b/pkg/models/notification.go index 5472f48..cb0c9d4 100644 --- a/pkg/models/notification.go +++ b/pkg/models/notification.go @@ -4,13 +4,17 @@ package models +// 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. type Notification struct { - Id string `json:"id" readonly:"true"` - Origin string `json:"origin" binding:"required"` - OriginUri string `json:"originUri,omitempty"` // can be used to provide a link to the origin - Timestamp string `json:"timestamp" binding:"required" format:"date-time"` - Title string `json:"title" binding:"required"` // can also be seen as the 'type' - Detail string `json:"detail" binding:"required"` - Level string `json:"level" binding:"required" enums:"info,warning,error"` - CustomFields map[string]any `json:"customFields,omitempty"` // can contain arbitrary structured information about the notification + 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 } diff --git a/pkg/repository/migrations/000004_change_to_origin_class.up.sql b/pkg/repository/migrations/000004_change_to_origin_class.up.sql new file mode 100644 index 0000000..7e9195b --- /dev/null +++ b/pkg/repository/migrations/000004_change_to_origin_class.up.sql @@ -0,0 +1,18 @@ +-- split origin_uri into origin_class and origin_resource_id, separated by the last `/` + +ALTER TABLE notification_service.notifications + ADD COLUMN origin_class TEXT, + ADD COLUMN origin_resource_id TEXT; + +UPDATE notification_service.notifications +SET + origin_class = LEFT(origin_uri, LENGTH(origin_uri) - POSITION('/' IN REVERSE(origin_uri))), + origin_resource_id = CASE + WHEN POSITION('/' IN origin_uri) > 0 + THEN split_part(origin_uri, '/', -1) + ELSE '' + END; + +ALTER TABLE notification_service.notifications + DROP COLUMN origin_uri; + diff --git a/pkg/repository/notificationrepository/notificationRepository_test.go b/pkg/repository/notificationrepository/notificationRepository_test.go index 0205637..78db83e 100644 --- a/pkg/repository/notificationrepository/notificationRepository_test.go +++ b/pkg/repository/notificationrepository/notificationRepository_test.go @@ -33,13 +33,14 @@ func Test_CreateNotification_ListNotification(t *testing.T) { }{ "create notification": { notificationIn: models.Notification{ - Id: "read only, to be ignored", - Origin: "test", - OriginUri: "vi/test", - Timestamp: "2024-10-10T10:00:00Z", - Title: "Test Notification", - Detail: "This is a test notification", - Level: "info", + Id: "read only, to be ignored", + Origin: "test", + OriginClass: "vi/test", + OriginResourceID: "1", + Timestamp: "2024-10-10T10:00:00Z", + Title: "Test Notification", + Detail: "This is a test notification", + Level: "info", CustomFields: map[string]any{ "key1": "value1", "key2": int(2), @@ -47,13 +48,14 @@ func Test_CreateNotification_ListNotification(t *testing.T) { }, }, wantNotification: models.Notification{ - Id: "", // will be set after creation by db - Origin: "test", - OriginUri: "vi/test", - Timestamp: "2024-10-10T10:00:00Z", - Title: "Test Notification", - Detail: "This is a test notification", - Level: "info", + Id: "", // will be set after creation by db + Origin: "test", + OriginClass: "vi/test", + OriginResourceID: "1", + Timestamp: "2024-10-10T10:00:00Z", + Title: "Test Notification", + Detail: "This is a test notification", + Level: "info", CustomFields: map[string]any{ "key1": "value1", "key2": float64(2), // not all types are preserved by json marshal/unmarshal @@ -61,6 +63,26 @@ func Test_CreateNotification_ListNotification(t *testing.T) { }, }, }, + "create notification with only required fields": { + notificationIn: models.Notification{ + Id: "read only, to be ignored", + Origin: "test", + OriginClass: "vi/test", + Timestamp: "2024-10-10T10:00:00Z", + Title: "Test Notification", + Detail: "This is a test notification", + Level: "info", + }, + wantNotification: models.Notification{ + Id: "", // will be set after creation by db + Origin: "test", + OriginClass: "vi/test", + Timestamp: "2024-10-10T10:00:00Z", + Title: "Test Notification", + Detail: "This is a test notification", + Level: "info", + }, + }, "fail on inserting invalid notification": { notificationIn: models.Notification{}, // missing required fields wantErr: true, @@ -103,12 +125,13 @@ func Test_ListNotifications(t *testing.T) { require.NoError(t, err) notification1 := models.Notification{ - Origin: "test", - OriginUri: "vi/test", - Timestamp: "2024-10-10T10:00:00Z", - Title: "Test Notification", - Detail: "This is a test notification", - Level: "info", + Origin: "test", + OriginClass: "vi/test", + OriginResourceID: "1", + Timestamp: "2024-10-10T10:00:00Z", + Title: "Test Notification", + Detail: "This is a test notification", + Level: "info", CustomFields: map[string]any{ "key1": "value1", "key2": int(2), @@ -116,20 +139,21 @@ func Test_ListNotifications(t *testing.T) { }, } notification2 := models.Notification{ - Origin: "test", - OriginUri: "vi/test", - Timestamp: "2024-11-10T10:00:00Z", - Title: "Test Notification 2", - Detail: "This is a second test notification", - Level: "error", + Origin: "test", + OriginClass: "vi/test", + Timestamp: "2024-11-10T10:00:00Z", + Title: "Test Notification 2", + Detail: "This is a second test notification", + Level: "error", } notification3 := models.Notification{ - Origin: "test2", - OriginUri: "vi/test2", - Timestamp: "2024-12-10T10:00:00Z", - Title: "Test Notification 3", - Detail: "This is a third test notification", - Level: "warning", + Origin: "test2", + OriginClass: "vi/test", + OriginResourceID: "1", + Timestamp: "2024-12-10T10:00:00Z", + Title: "Test Notification 3", + Detail: "This is a third test notification", + Level: "warning", } wantNotification1 := notification1 diff --git a/pkg/repository/notificationrepository/notification_db_models.go b/pkg/repository/notificationrepository/notification_db_models.go index 378049b..90ae4b5 100644 --- a/pkg/repository/notificationrepository/notification_db_models.go +++ b/pkg/repository/notificationrepository/notification_db_models.go @@ -14,19 +14,20 @@ import ( const ( notificationsTable = "notification_service.notifications" - createNotificationQuery = `INSERT INTO ` + notificationsTable + ` (origin, origin_uri, timestamp, title, detail, level, custom_fields) VALUES (:origin, :origin_uri, :timestamp, :title, :detail, :level, :custom_fields) RETURNING *` + createNotificationQuery = `INSERT INTO ` + notificationsTable + ` (origin, origin_class, origin_resource_id, timestamp, title, detail, level, custom_fields) VALUES (:origin, :origin_class, :origin_resource_id, :timestamp, :title, :detail, :level, :custom_fields) RETURNING *` unfilteredListNotificationsQuery = `SELECT * FROM ` + notificationsTable ) type notificationRow struct { - Id string `db:"id"` - Origin string `db:"origin"` - OriginUri *string `db:"origin_uri"` - 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 string `db:"level"` + CustomFields []byte `db:"custom_fields"` } func notificationFieldMapping() map[string]string { @@ -48,14 +49,15 @@ func toNotificationRow(n models.Notification) (notificationRow, error) { } notificationRow := notificationRow{ - Id: n.Id, - Origin: n.Origin, - OriginUri: &n.OriginUri, - Timestamp: n.Timestamp, - Title: n.Title, - Detail: n.Detail, - Level: n.Level, - CustomFields: customFieldsSerialized, + Id: n.Id, + Origin: n.Origin, + OriginClass: n.OriginClass, + OriginResourceID: &n.OriginResourceID, + Timestamp: n.Timestamp, + Title: n.Title, + Detail: n.Detail, + Level: n.Level, + CustomFields: customFieldsSerialized, } return notificationRow, nil @@ -65,13 +67,14 @@ func (n *notificationRow) ToNotificationModel() (models.Notification, error) { var empty models.Notification notification := models.Notification{ - Id: n.Id, - Origin: n.Origin, - OriginUri: helper.SafeDereference(n.OriginUri), - Timestamp: n.Timestamp, - Title: n.Title, - Detail: n.Detail, - Level: n.Level, + Id: n.Id, + Origin: n.Origin, + OriginClass: n.OriginClass, + OriginResourceID: helper.SafeDereference(n.OriginResourceID), + Timestamp: n.Timestamp, + Title: n.Title, + Detail: n.Detail, + Level: n.Level, // CustomFields is set below } diff --git a/pkg/web/notificationcontroller/notificationController_test.go b/pkg/web/notificationcontroller/notificationController_test.go index 742d908..8e6c9fc 100644 --- a/pkg/web/notificationcontroller/notificationController_test.go +++ b/pkg/web/notificationcontroller/notificationController_test.go @@ -27,12 +27,13 @@ import ( func getNotification() models.Notification { return models.Notification{ - Id: "57fe22b8-89a4-445f-b6c7-ef9ea724ea48", - Timestamp: time.Time{}.Format(time.RFC3339Nano), - Origin: "Example Task XY", - Title: "Example Task XY failed", - Detail: "Example Task XY failed because ...", - Level: "error", + Id: "57fe22b8-89a4-445f-b6c7-ef9ea724ea48", + Timestamp: time.Time{}.Format(time.RFC3339Nano), + Origin: "Example Task XY", + OriginClass: "serviceab/exampletaskxy", + Title: "Example Task XY failed", + Detail: "Example Task XY failed because ...", + Level: "error", } } From 03672d333647afd0b50310f7303ba77913170951 Mon Sep 17 00:00:00 2001 From: Marius Goetze Date: Tue, 3 Feb 2026 13:02:45 +0100 Subject: [PATCH 2/2] add: add register origins endpoint --- .../notificationservice_docs.go | 120 ++++++++++- .../notificationservice_swagger.yaml | 85 +++++++- cmd/notification-service/main.go | 9 + docker-compose.service.yml | 2 +- pkg/entities/origin.go | 11 + pkg/models/origin.go | 32 +++ .../000005_add_origins_table.up.sql | 7 + .../originrepository/originRepository.go | 101 +++++++++ .../originrepository/originRepository_test.go | 198 ++++++++++++++++++ .../originrepository/origin_db_models.go | 32 +++ .../originservice/mocks/OriginRepository.go | 164 +++++++++++++++ pkg/services/originservice/originService.go | 32 +++ pkg/validation/validation.go | 7 + pkg/web/api.go | 3 +- .../notificationController.go | 6 +- .../origincontroller/mocks/OriginService.go | 164 +++++++++++++++ pkg/web/origincontroller/originController.go | 85 ++++++++ .../origincontroller/originController_test.go | 169 +++++++++++++++ pkg/web/testhelper/helper.go | 29 ++- 19 files changed, 1233 insertions(+), 23 deletions(-) create mode 100644 pkg/entities/origin.go create mode 100644 pkg/models/origin.go create mode 100644 pkg/repository/migrations/000005_add_origins_table.up.sql create mode 100644 pkg/repository/originrepository/originRepository.go create mode 100644 pkg/repository/originrepository/originRepository_test.go create mode 100644 pkg/repository/originrepository/origin_db_models.go create mode 100644 pkg/services/originservice/mocks/OriginRepository.go create mode 100644 pkg/services/originservice/originService.go create mode 100644 pkg/validation/validation.go create mode 100644 pkg/web/origincontroller/mocks/OriginService.go create mode 100644 pkg/web/origincontroller/originController.go create mode 100644 pkg/web/origincontroller/originController_test.go diff --git a/api/notificationservice/notificationservice_docs.go b/api/notificationservice/notificationservice_docs.go index 4fb3be7..342c43e 100644 --- a/api/notificationservice/notificationservice_docs.go +++ b/api/notificationservice/notificationservice_docs.go @@ -874,7 +874,7 @@ const docTemplatenotificationservice = `{ "KeycloakAuth": [] } ], - "description": "Create a new notification", + "description": "Create a new notification. It will always be stored by the notification service and it will possibly also trigger actions like sending mails, depending on the cofigured rules.", "consumes": [ "application/json" ], @@ -942,9 +942,105 @@ const docTemplatenotificationservice = `{ } } } + }, + "/origins/{serviceID}": { + "put": { + "security": [ + { + "KeycloakAuth": [] + } + ], + "description": "Registers a set of origins in the given service. Replaces origins of this service if they already existed. The origins can be ulitized to set trigger conditions for actions.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "origin" + ], + "summary": "Register Origins", + "parameters": [ + { + "type": "string", + "description": "serviceID of the calling service, needs to be unique among all services registering origins", + "name": "serviceID", + "in": "path", + "required": true + }, + { + "description": "origins provided by the calling service", + "name": "origins", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Origin" + } + } + } + ], + "responses": { + "204": { + "description": "origins registered", + "headers": { + "api-version": { + "type": "string", + "description": "API version" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/errorResponses.ErrorResponse" + }, + "headers": { + "api-version": { + "type": "string", + "description": "API version" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/errorResponses.ErrorResponse" + }, + "headers": { + "api-version": { + "type": "string", + "description": "API version" + } + } + } + } + } } }, "definitions": { + "errorResponses.ErrorResponse": { + "type": "object", + "properties": { + "details": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, "filter.CompareOperator": { "type": "string", "enum": [ @@ -1274,6 +1370,28 @@ const docTemplatenotificationservice = `{ } } }, + "models.Origin": { + "type": "object", + "required": [ + "class", + "name" + ], + "properties": { + "class": { + "description": "unique identifier", + "type": "string" + }, + "name": { + "description": "human readable name representation", + "type": "string" + }, + "serviceID": { + "description": "service in which this origin is defined", + "type": "string", + "readOnly": true + } + } + }, "paging.Request": { "type": "object", "properties": { diff --git a/api/notificationservice/notificationservice_swagger.yaml b/api/notificationservice/notificationservice_swagger.yaml index eb78614..652074e 100644 --- a/api/notificationservice/notificationservice_swagger.yaml +++ b/api/notificationservice/notificationservice_swagger.yaml @@ -1,5 +1,18 @@ basePath: /api/notification-service definitions: + errorResponses.ErrorResponse: + properties: + details: + type: string + errors: + additionalProperties: + type: string + type: object + title: + type: string + type: + type: string + type: object filter.CompareOperator: enum: - beginsWith @@ -250,6 +263,22 @@ definitions: - timestamp - title type: object + models.Origin: + properties: + class: + description: unique identifier + type: string + name: + description: human readable name representation + type: string + serviceID: + description: service in which this origin is defined + readOnly: true + type: string + required: + - class + - name + type: object paging.Request: properties: index: @@ -905,7 +934,9 @@ paths: post: consumes: - application/json - description: Create a new notification + description: Create a new notification. It will always be stored by the notification + service and it will possibly also trigger actions like sending mails, depending + on the cofigured rules. parameters: - description: notification to add in: body @@ -975,6 +1006,58 @@ paths: summary: Notification filter options tags: - notification + /origins/{serviceID}: + put: + consumes: + - application/json + description: Registers a set of origins in the given service. Replaces origins + of this service if they already existed. The origins can be ulitized to set + trigger conditions for actions. + parameters: + - description: serviceID of the calling service, needs to be unique among all + services registering origins + in: path + name: serviceID + required: true + type: string + - description: origins provided by the calling service + in: body + name: origins + required: true + schema: + items: + $ref: '#/definitions/models.Origin' + type: array + produces: + - application/json + responses: + "204": + description: origins registered + headers: + api-version: + description: API version + type: string + "400": + description: Bad Request + headers: + api-version: + description: API version + type: string + schema: + $ref: '#/definitions/errorResponses.ErrorResponse' + "500": + description: Internal Server Error + headers: + api-version: + description: API version + type: string + schema: + $ref: '#/definitions/errorResponses.ErrorResponse' + security: + - KeycloakAuth: [] + summary: Register Origins + tags: + - origin securityDefinitions: KeycloakAuth: authorizationUrl: '{{.KeycloakAuthUrl}}/realms/{{.KeycloakRealm}}/protocol/openid-connect/auth' diff --git a/cmd/notification-service/main.go b/cmd/notification-service/main.go index cafd391..bddfb3a 100644 --- a/cmd/notification-service/main.go +++ b/cmd/notification-service/main.go @@ -33,11 +33,14 @@ import ( "github.com/greenbone/opensight-notification-service/pkg/config/secretfiles" "github.com/greenbone/opensight-notification-service/pkg/repository" "github.com/greenbone/opensight-notification-service/pkg/repository/notificationrepository" + "github.com/greenbone/opensight-notification-service/pkg/repository/originrepository" "github.com/greenbone/opensight-notification-service/pkg/services/healthservice" "github.com/greenbone/opensight-notification-service/pkg/services/notificationservice" + "github.com/greenbone/opensight-notification-service/pkg/services/originservice" "github.com/greenbone/opensight-notification-service/pkg/web" "github.com/greenbone/opensight-notification-service/pkg/web/healthcontroller" "github.com/greenbone/opensight-notification-service/pkg/web/notificationcontroller" + "github.com/greenbone/opensight-notification-service/pkg/web/origincontroller" ) func main() { @@ -102,6 +105,10 @@ func run(config config.Config) error { if err != nil { return fmt.Errorf("error creating Notification Repository: %w", err) } + originsRepository, err := originrepository.NewOriginRepository(pgClient) + if err != nil { + return err + } // Encrypt manager := security.NewEncryptManager() @@ -121,6 +128,7 @@ func run(config config.Config) error { notificationTransport := http.Client{Timeout: 15 * time.Second} teamsChannelService := notificationchannelservice.NewTeamsChannelService( notificationChannelService, config.ChannelLimit.TeamsLimit, notificationTransport) + originService := originservice.NewOriginService(originsRepository) healthService := healthservice.NewHealthService(pgClient) // scheduler @@ -153,6 +161,7 @@ func run(config config.Config) error { mailcontroller.AddCheckMailServerController(notificationServiceRouter, mailChannelService, authMiddleware, registry) mattermostcontroller.NewMattermostController(notificationServiceRouter, notificationChannelService, mattermostChannelService, authMiddleware, registry) teamsController.AddTeamsController(notificationServiceRouter, notificationChannelRepository, teamsChannelService, authMiddleware, registry) + origincontroller.NewOriginController(notificationServiceRouter, originService, authMiddleware) // health router rootRouter := router.Group("/") diff --git a/docker-compose.service.yml b/docker-compose.service.yml index 24bca37..7b9cb4c 100644 --- a/docker-compose.service.yml +++ b/docker-compose.service.yml @@ -21,7 +21,7 @@ services: ports: - 8085:8085 networks: - - notification-service-net + - opensight-notification-net depends_on: postgres: condition: service_healthy diff --git a/pkg/entities/origin.go b/pkg/entities/origin.go new file mode 100644 index 0000000..bab9a65 --- /dev/null +++ b/pkg/entities/origin.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package entities + +type Origin struct { + Name string + Class string + ServiceID string // read-only +} diff --git a/pkg/models/origin.go b/pkg/models/origin.go new file mode 100644 index 0000000..ec707ce --- /dev/null +++ b/pkg/models/origin.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2025 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package models + +import ( + "github.com/greenbone/opensight-notification-service/pkg/entities" + "github.com/greenbone/opensight-notification-service/pkg/validation" +) + +// Origin of an event/notification. +type Origin struct { + Name string `json:"name" validate:"required"` // human readable name representation + Class string `json:"class" validate:"required"` // unique identifier + ServiceID string `json:"serviceID" readonly:"true"` // service in which this origin is defined +} + +// ToEntity transforms the rest model to the entity for use in the service +func (o Origin) ToEntity() entities.Origin { + return entities.Origin(o) +} + +type OriginList []Origin + +func (o OriginList) Validate() ValidationErrors { + err := validation.Validate.Var(o, "omitempty,dive") + if err != nil { + return ValidationErrors{"$": err.Error()} + } + return nil +} diff --git a/pkg/repository/migrations/000005_add_origins_table.up.sql b/pkg/repository/migrations/000005_add_origins_table.up.sql new file mode 100644 index 0000000..fb83c13 --- /dev/null +++ b/pkg/repository/migrations/000005_add_origins_table.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE notification_service.origins ( + "name" TEXT NOT NULL, + "class" TEXT NOT NULL CONSTRAINT origins_class_unique UNIQUE, + "service_id" TEXT NOT NULL +); + +CREATE INDEX idx_origins_service_id ON notification_service.origins(service_id); diff --git a/pkg/repository/originrepository/originRepository.go b/pkg/repository/originrepository/originRepository.go new file mode 100644 index 0000000..41284ba --- /dev/null +++ b/pkg/repository/originrepository/originRepository.go @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package originrepository + +import ( + "context" + "errors" + "fmt" + + "github.com/greenbone/opensight-notification-service/pkg/entities" + "github.com/greenbone/opensight-notification-service/pkg/errs" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" +) + +// see https://github.com/lib/pq/blob/3d613208bca2e74f2a20e04126ed30bcb5c4cc27/error.go#L78 +const pgErrCodeConflict = "23505" + +type OriginRepository struct { + client *sqlx.DB +} + +func NewOriginRepository(db *sqlx.DB) (*OriginRepository, error) { + if db == nil { + return nil, errors.New("nil db reference") + } + r := &OriginRepository{ + client: db, + } + return r, nil +} + +// UpsertOrigins replaces all origins for the given serviceID with the provided ones. +// Note: `origins.ServiceID` is ignored, only the provided `serviceID` parameter is used. +func (r *OriginRepository) UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) (err error) { + if serviceID == "" { + return errors.New("serviceID must not be empty") + } + + var originRows []originRow + for _, o := range origins { + originRows = append(originRows, toOriginRow(o, serviceID)) + } + + tx, err := r.client.BeginTxx(ctx, nil) // replacement of existing entries must be atomic + if err != nil { + return fmt.Errorf("could not begin transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() // note: rollback after successful commit is a no-op + + // acquire exclusive lock, as concurrent upserts for the same serviceID can result + // extra data or unique constraint violation + _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(hashtext($1))", serviceID) + if err != nil { + return fmt.Errorf("could not acquire lock: %w", err) + } + + _, err = tx.Exec(deleteOriginsQuery, serviceID) + if err != nil { + return fmt.Errorf("could not delete existing origins: %w", err) + } + + if len(originRows) != 0 { + _, err = tx.NamedExec(createOriginsQuery, originRows) + if err != nil { + var pgErr *pq.Error + if errors.As(err, &pgErr) { // postgres specific error handling + if pgErr.Code == pgErrCodeConflict { + err = &errs.ErrConflict{Message: "duplicate origin class"} + } + } + return fmt.Errorf("could not insert origins: %w", err) + } + } + + err = tx.Commit() + if err != nil { + return fmt.Errorf("could not commit transaction: %w", err) + } + + return nil // so far we have no usecase to return the created origins +} + +// ListOrigins returns all origins in the database. +// The origins are ordered by serviceID and name. +func (r *OriginRepository) ListOrigins(ctx context.Context) ([]entities.Origin, error) { + var originRows []originRow + err := r.client.SelectContext(ctx, &originRows, listOriginsQuery) + if err != nil { + return nil, fmt.Errorf("could not get origins: %w", err) + } + + origins := make([]entities.Origin, 0, len(originRows)) + for _, row := range originRows { + origins = append(origins, row.toOriginEntity()) + } + + return origins, nil +} diff --git a/pkg/repository/originrepository/originRepository_test.go b/pkg/repository/originrepository/originRepository_test.go new file mode 100644 index 0000000..c1e0d51 --- /dev/null +++ b/pkg/repository/originrepository/originRepository_test.go @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package originrepository + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/greenbone/opensight-notification-service/pkg/entities" + "github.com/greenbone/opensight-notification-service/pkg/pgtesting" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_UpsertOrigins_ListOrigins(t *testing.T) { + type input struct { + origins []entities.Origin + serviceID string + } + + tests := map[string]struct { + inputs []input + wantOrigins []entities.Origin + wantErr bool + }{ + "create origins from single service (no prior data)": { + inputs: []input{ + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA", ServiceID: "read only, to be ignored"}, + {Name: "origin2", Class: "classB"}, + }, + }, + }, + wantOrigins: []entities.Origin{ + {Name: "origin1", Class: "classA", ServiceID: "service1"}, + {Name: "origin2", Class: "classB", ServiceID: "service1"}, + }, + }, + "create origins from multiple services": { + inputs: []input{ + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + }, + }, + { + serviceID: "service2", + origins: []entities.Origin{ + {Name: "origin2", Class: "classB"}, + }, + }, + }, + wantOrigins: []entities.Origin{ + {Name: "origin1", Class: "classA", ServiceID: "service1"}, + {Name: "origin2", Class: "classB", ServiceID: "service2"}, + }, + }, + "upsert origins replaces the entries from the same service": { + inputs: []input{ + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + {Name: "origin2", Class: "classB"}, + }, + }, + { + serviceID: "service2", + origins: []entities.Origin{ + {Name: "origin3", Class: "classC"}, + }, + }, + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin4", Class: "classD"}, + }, + }, + }, + wantOrigins: []entities.Origin{ + {Name: "origin4", Class: "classD", ServiceID: "service1"}, + {Name: "origin3", Class: "classC", ServiceID: "service2"}, + }, + }, + "delete all origins from a service when upserting empty list": { + inputs: []input{ + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + }, + }, + { + serviceID: "service1", + origins: []entities.Origin{}, + }, + }, + wantOrigins: []entities.Origin{}, + }, + "error on duplicate origin classes (class must be unique across all services)": { + inputs: []input{ + { + serviceID: "service1", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + }, + }, + { + serviceID: "service2", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + }, + }, + }, + wantErr: true, + }, + "error on empty serviceID": { + inputs: []input{ + { + serviceID: "", + origins: []entities.Origin{ + {Name: "origin1", Class: "classA"}, + }, + }, + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + db := pgtesting.NewDB(t) + + repo, err := NewOriginRepository(db) + require.NoError(t, err) + + ctx := context.Background() + + var errs []error + for _, input := range tt.inputs { + err := repo.UpsertOrigins(ctx, input.serviceID, input.origins) + errs = append(errs, err) + } + err = errors.Join(errs...) + if tt.wantErr { + require.Error(t, err) + return + } + + // if all operarions were successful, verify final state + require.NoError(t, err) + gotOrigins, err := repo.ListOrigins(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantOrigins, gotOrigins) // order so far not guaranteed or relevant + }) + } +} + +func Test_UpsertOrigins_Concurrency(t *testing.T) { + db := pgtesting.NewDB(t) + + repo, err := NewOriginRepository(db) + require.NoError(t, err) + + serviceID := "some-service" + origins := []entities.Origin{ + {Name: "origin4", Class: "classD", ServiceID: "read-only,ignored"}, + {Name: "origin3", Class: "classC", ServiceID: "read-only,ignored"}, + } + + var wg sync.WaitGroup + iterations := 200 + + for i := range iterations { + wg.Add(1) + go func(val int) { + defer wg.Done() + err := repo.UpsertOrigins(context.Background(), serviceID, origins) + if err != nil { + t.Logf("Failed at iteration %d: %v", val, err) + } + }(i) + } + wg.Wait() + + var count int + err = db.QueryRow("SELECT COUNT(*) FROM notification_service.origins WHERE service_id = $1", serviceID).Scan(&count) + require.NoError(t, err) + + require.Equal(t, 2, count, "Data was duplicated due to race condition!") +} diff --git a/pkg/repository/originrepository/origin_db_models.go b/pkg/repository/originrepository/origin_db_models.go new file mode 100644 index 0000000..d8c81d4 --- /dev/null +++ b/pkg/repository/originrepository/origin_db_models.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package originrepository + +import "github.com/greenbone/opensight-notification-service/pkg/entities" + +const ( + originsTable = "notification_service.origins" + deleteOriginsQuery = `DELETE FROM ` + originsTable + ` WHERE service_id = $1` + createOriginsQuery = `INSERT INTO ` + originsTable + ` (name, class, service_id) VALUES (:name, :class, :service_id)` + listOriginsQuery = `SELECT * FROM ` + originsTable + ` ORDER BY service_id, name` +) + +type originRow struct { + Name string `db:"name"` + Class string `db:"class"` + ServiceID string `db:"service_id"` +} + +func toOriginRow(o entities.Origin, serviceID string) originRow { + return originRow{ + Name: o.Name, + Class: o.Class, + ServiceID: serviceID, + } +} + +func (r *originRow) toOriginEntity() entities.Origin { + return entities.Origin(*r) +} diff --git a/pkg/services/originservice/mocks/OriginRepository.go b/pkg/services/originservice/mocks/OriginRepository.go new file mode 100644 index 0000000..03fbf08 --- /dev/null +++ b/pkg/services/originservice/mocks/OriginRepository.go @@ -0,0 +1,164 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/greenbone/opensight-notification-service/pkg/entities" + mock "github.com/stretchr/testify/mock" +) + +// NewOriginRepository creates a new instance of OriginRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOriginRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *OriginRepository { + mock := &OriginRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// OriginRepository is an autogenerated mock type for the OriginRepository type +type OriginRepository struct { + mock.Mock +} + +type OriginRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *OriginRepository) EXPECT() *OriginRepository_Expecter { + return &OriginRepository_Expecter{mock: &_m.Mock} +} + +// ListOrigins provides a mock function for the type OriginRepository +func (_mock *OriginRepository) ListOrigins(ctx context.Context) ([]entities.Origin, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListOrigins") + } + + var r0 []entities.Origin + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]entities.Origin, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []entities.Origin); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]entities.Origin) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// OriginRepository_ListOrigins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrigins' +type OriginRepository_ListOrigins_Call struct { + *mock.Call +} + +// ListOrigins is a helper method to define mock.On call +// - ctx context.Context +func (_e *OriginRepository_Expecter) ListOrigins(ctx interface{}) *OriginRepository_ListOrigins_Call { + return &OriginRepository_ListOrigins_Call{Call: _e.mock.On("ListOrigins", ctx)} +} + +func (_c *OriginRepository_ListOrigins_Call) Run(run func(ctx context.Context)) *OriginRepository_ListOrigins_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 *OriginRepository_ListOrigins_Call) Return(origins []entities.Origin, err error) *OriginRepository_ListOrigins_Call { + _c.Call.Return(origins, err) + return _c +} + +func (_c *OriginRepository_ListOrigins_Call) RunAndReturn(run func(ctx context.Context) ([]entities.Origin, error)) *OriginRepository_ListOrigins_Call { + _c.Call.Return(run) + return _c +} + +// UpsertOrigins provides a mock function for the type OriginRepository +func (_mock *OriginRepository) UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) error { + ret := _mock.Called(ctx, serviceID, origins) + + if len(ret) == 0 { + panic("no return value specified for UpsertOrigins") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []entities.Origin) error); ok { + r0 = returnFunc(ctx, serviceID, origins) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// OriginRepository_UpsertOrigins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertOrigins' +type OriginRepository_UpsertOrigins_Call struct { + *mock.Call +} + +// UpsertOrigins is a helper method to define mock.On call +// - ctx context.Context +// - serviceID string +// - origins []entities.Origin +func (_e *OriginRepository_Expecter) UpsertOrigins(ctx interface{}, serviceID interface{}, origins interface{}) *OriginRepository_UpsertOrigins_Call { + return &OriginRepository_UpsertOrigins_Call{Call: _e.mock.On("UpsertOrigins", ctx, serviceID, origins)} +} + +func (_c *OriginRepository_UpsertOrigins_Call) Run(run func(ctx context.Context, serviceID string, origins []entities.Origin)) *OriginRepository_UpsertOrigins_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []entities.Origin + if args[2] != nil { + arg2 = args[2].([]entities.Origin) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *OriginRepository_UpsertOrigins_Call) Return(err error) *OriginRepository_UpsertOrigins_Call { + _c.Call.Return(err) + return _c +} + +func (_c *OriginRepository_UpsertOrigins_Call) RunAndReturn(run func(ctx context.Context, serviceID string, origins []entities.Origin) error) *OriginRepository_UpsertOrigins_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/services/originservice/originService.go b/pkg/services/originservice/originService.go new file mode 100644 index 0000000..1939fb5 --- /dev/null +++ b/pkg/services/originservice/originService.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package originservice + +import ( + "context" + + "github.com/greenbone/opensight-notification-service/pkg/entities" +) + +type OriginRepository interface { + UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) error + ListOrigins(ctx context.Context) ([]entities.Origin, error) +} + +type OriginService struct { + store OriginRepository +} + +func NewOriginService(store OriginRepository) *OriginService { + return &OriginService{store: store} +} + +func (s *OriginService) UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) error { + return s.store.UpsertOrigins(ctx, serviceID, origins) +} + +func (s *OriginService) ListOrigins(ctx context.Context) ([]entities.Origin, error) { + return s.store.ListOrigins(ctx) +} diff --git a/pkg/validation/validation.go b/pkg/validation/validation.go new file mode 100644 index 0000000..2d849f7 --- /dev/null +++ b/pkg/validation/validation.go @@ -0,0 +1,7 @@ +package validation + +import ( + "github.com/go-playground/validator/v10" +) + +var Validate = validator.New(validator.WithRequiredStructEnabled()) // singleton validator instance to be used throughout the application diff --git a/pkg/web/api.go b/pkg/web/api.go index 1870f0d..831ca1b 100644 --- a/pkg/web/api.go +++ b/pkg/web/api.go @@ -5,11 +5,12 @@ package web import ( + "strings" + "github.com/gin-gonic/gin" docs "github.com/greenbone/opensight-notification-service/api/notificationservice" "github.com/greenbone/opensight-notification-service/pkg/config" "github.com/greenbone/opensight-notification-service/pkg/swagger" - "strings" ) // comment block for api docs generation via swag: diff --git a/pkg/web/notificationcontroller/notificationController.go b/pkg/web/notificationcontroller/notificationController.go index d599938..a9aced7 100644 --- a/pkg/web/notificationcontroller/notificationController.go +++ b/pkg/web/notificationcontroller/notificationController.go @@ -46,7 +46,7 @@ func AddNotificationController( // CreateNotification // // @Summary Create Notification -// @Description Create a new notification +// @Description Create a new notification. It will always be stored by the notification service and it will possibly also trigger actions like sending mails, depending on the cofigured rules. // @Tags notification // @Accept json // @Produce json @@ -113,8 +113,8 @@ func (c *NotificationController) ListNotifications(gc *gin.Context) { // @Tags notification // @Produce json // @Security KeycloakAuth -// @Success 200 {object} query.ResponseWithMetadata[[]query.FilterOption] -// @Header all {string} api-version "API version" +// @Success 200 {object} query.ResponseWithMetadata[[]query.FilterOption] +// @Header all {string} api-version "API version" // @Router /notifications/options [get] func (c *NotificationController) GetOptions(gc *gin.Context) { gc.Header(web.APIVersionKey, web.APIVersion) diff --git a/pkg/web/origincontroller/mocks/OriginService.go b/pkg/web/origincontroller/mocks/OriginService.go new file mode 100644 index 0000000..cebdee1 --- /dev/null +++ b/pkg/web/origincontroller/mocks/OriginService.go @@ -0,0 +1,164 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/greenbone/opensight-notification-service/pkg/entities" + mock "github.com/stretchr/testify/mock" +) + +// NewOriginService creates a new instance of OriginService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOriginService(t interface { + mock.TestingT + Cleanup(func()) +}) *OriginService { + mock := &OriginService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// OriginService is an autogenerated mock type for the OriginService type +type OriginService struct { + mock.Mock +} + +type OriginService_Expecter struct { + mock *mock.Mock +} + +func (_m *OriginService) EXPECT() *OriginService_Expecter { + return &OriginService_Expecter{mock: &_m.Mock} +} + +// ListOrigins provides a mock function for the type OriginService +func (_mock *OriginService) ListOrigins(ctx context.Context) ([]entities.Origin, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ListOrigins") + } + + var r0 []entities.Origin + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]entities.Origin, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []entities.Origin); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]entities.Origin) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// OriginService_ListOrigins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOrigins' +type OriginService_ListOrigins_Call struct { + *mock.Call +} + +// ListOrigins is a helper method to define mock.On call +// - ctx context.Context +func (_e *OriginService_Expecter) ListOrigins(ctx interface{}) *OriginService_ListOrigins_Call { + return &OriginService_ListOrigins_Call{Call: _e.mock.On("ListOrigins", ctx)} +} + +func (_c *OriginService_ListOrigins_Call) Run(run func(ctx context.Context)) *OriginService_ListOrigins_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 *OriginService_ListOrigins_Call) Return(origins []entities.Origin, err error) *OriginService_ListOrigins_Call { + _c.Call.Return(origins, err) + return _c +} + +func (_c *OriginService_ListOrigins_Call) RunAndReturn(run func(ctx context.Context) ([]entities.Origin, error)) *OriginService_ListOrigins_Call { + _c.Call.Return(run) + return _c +} + +// UpsertOrigins provides a mock function for the type OriginService +func (_mock *OriginService) UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) error { + ret := _mock.Called(ctx, serviceID, origins) + + if len(ret) == 0 { + panic("no return value specified for UpsertOrigins") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, []entities.Origin) error); ok { + r0 = returnFunc(ctx, serviceID, origins) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// OriginService_UpsertOrigins_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpsertOrigins' +type OriginService_UpsertOrigins_Call struct { + *mock.Call +} + +// UpsertOrigins is a helper method to define mock.On call +// - ctx context.Context +// - serviceID string +// - origins []entities.Origin +func (_e *OriginService_Expecter) UpsertOrigins(ctx interface{}, serviceID interface{}, origins interface{}) *OriginService_UpsertOrigins_Call { + return &OriginService_UpsertOrigins_Call{Call: _e.mock.On("UpsertOrigins", ctx, serviceID, origins)} +} + +func (_c *OriginService_UpsertOrigins_Call) Run(run func(ctx context.Context, serviceID string, origins []entities.Origin)) *OriginService_UpsertOrigins_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 []entities.Origin + if args[2] != nil { + arg2 = args[2].([]entities.Origin) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *OriginService_UpsertOrigins_Call) Return(err error) *OriginService_UpsertOrigins_Call { + _c.Call.Return(err) + return _c +} + +func (_c *OriginService_UpsertOrigins_Call) RunAndReturn(run func(ctx context.Context, serviceID string, origins []entities.Origin) error) *OriginService_UpsertOrigins_Call { + _c.Call.Return(run) + return _c +} diff --git a/pkg/web/origincontroller/originController.go b/pkg/web/origincontroller/originController.go new file mode 100644 index 0000000..d8932d0 --- /dev/null +++ b/pkg/web/origincontroller/originController.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package origincontroller + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + _ "github.com/greenbone/opensight-golang-libraries/pkg/query" + "github.com/greenbone/opensight-notification-service/pkg/entities" + "github.com/greenbone/opensight-notification-service/pkg/models" + _ "github.com/greenbone/opensight-notification-service/pkg/models" + "github.com/greenbone/opensight-notification-service/pkg/web" + "github.com/greenbone/opensight-notification-service/pkg/web/ginEx" + "github.com/greenbone/opensight-notification-service/pkg/web/middleware" +) + +type OriginService interface { + UpsertOrigins(ctx context.Context, serviceID string, origins []entities.Origin) error + ListOrigins(ctx context.Context) ([]entities.Origin, error) +} + +type OriginController struct { + originService OriginService +} + +func NewOriginController( + router gin.IRouter, + originService OriginService, + auth gin.HandlerFunc, +) *OriginController { + ctrl := &OriginController{ + originService: originService, + } + ctrl.RegisterRoutes(router, auth) + + return ctrl +} + +func (c *OriginController) RegisterRoutes(router gin.IRouter, auth gin.HandlerFunc) { + group := router.Group("/origins"). + Use(middleware.AuthorizeRoles(auth, middleware.NotificationRole)...) + group.PUT("/:serviceID", c.RegisterOrigins) +} + +// RegisterOrigins +// +// @Summary Register Origins +// @Description Registers a set of origins in the given service. Replaces origins of this service if they already existed. The origins can be ulitized to set trigger conditions for actions. +// @Tags origin +// @Accept json +// @Produce json +// @Security KeycloakAuth +// @Param serviceID path string true "serviceID of the calling service, needs to be unique among all services registering origins" +// @Param origins body []models.Origin true "origins provided by the calling service" +// @Success 204 "origins registered" +// @Failure 400 {object} errorResponses.ErrorResponse +// @Failure 500 {object} errorResponses.ErrorResponse +// @Header all {string} api-version "API version" +// @Router /origins/{serviceID} [put] +func (c *OriginController) RegisterOrigins(gc *gin.Context) { + gc.Header(web.APIVersionKey, web.APIVersion) + + serviceID := gc.Param("serviceID") + var origins models.OriginList + if !ginEx.BindAndValidateBody(gc, &origins) { + return + } + + originsEntities := make([]entities.Origin, 0, len(origins)) + for _, origin := range origins { + originsEntities = append(originsEntities, origin.ToEntity()) + } + + err := c.originService.UpsertOrigins(gc.Request.Context(), serviceID, originsEntities) + if err != nil { + ginEx.AddError(gc, err) + return + } + + gc.Status(http.StatusNoContent) +} diff --git a/pkg/web/origincontroller/originController_test.go b/pkg/web/origincontroller/originController_test.go new file mode 100644 index 0000000..97568d6 --- /dev/null +++ b/pkg/web/origincontroller/originController_test.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: 2026 Greenbone AG +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package origincontroller + +import ( + "errors" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/greenbone/opensight-golang-libraries/pkg/httpassert" + "github.com/greenbone/opensight-notification-service/pkg/entities" + "github.com/greenbone/opensight-notification-service/pkg/models" + "github.com/greenbone/opensight-notification-service/pkg/web/errmap" + "github.com/greenbone/opensight-notification-service/pkg/web/origincontroller/mocks" + "github.com/greenbone/opensight-notification-service/pkg/web/testhelper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRegisterOrigins(t *testing.T) { + type ServiceCall struct { + wantServiceID string + wantOrigins []entities.Origin + err error + } + + tests := map[string]struct { + serviceID string + origins []models.Origin + serviceCalls []ServiceCall + wantResponseCode int + wantBodyContains string + }{ + "valid request": { + serviceID: "serviceA", + origins: []models.Origin{ + {Name: "Origin 1", Class: "origin/1"}, + {Name: "Origin 2", Class: "origin/2"}, + }, + serviceCalls: []ServiceCall{ + { + wantServiceID: "serviceA", + wantOrigins: []entities.Origin{ + {Name: "Origin 1", Class: "origin/1"}, + {Name: "Origin 2", Class: "origin/2"}, + }, + err: nil, + }, + }, + wantResponseCode: http.StatusNoContent, + }, + "service error": { + serviceID: "serviceB", + origins: []models.Origin{ + {Name: "Origin X", Class: "origin/x"}, + }, + serviceCalls: []ServiceCall{ + { + wantServiceID: "serviceB", + wantOrigins: []entities.Origin{ + {Name: "Origin X", Class: "origin/x"}, + }, + err: errors.New("internal error"), + }, + }, + wantResponseCode: http.StatusInternalServerError, + wantBodyContains: "internal", + }, + "invalid body (missing name)": { + serviceID: "serviceC", + origins: []models.Origin{ + {Name: "", Class: "origin/y"}, + }, + serviceCalls: []ServiceCall{}, + wantResponseCode: http.StatusBadRequest, + wantBodyContains: "Name", + }, + "invalid body (missing class)": { + serviceID: "serviceC", + origins: []models.Origin{ + {Name: "Origin Y", Class: ""}, + }, + serviceCalls: []ServiceCall{}, + wantResponseCode: http.StatusBadRequest, + wantBodyContains: "Class", + }, + "empty origins list": { + serviceID: "serviceD", + origins: []models.Origin{}, + serviceCalls: []ServiceCall{ + { + wantServiceID: "serviceD", + wantOrigins: []entities.Origin{}, + err: nil, + }, + }, + wantResponseCode: http.StatusNoContent, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockService := mocks.NewOriginService(t) + + for _, call := range tt.serviceCalls { + mockService.EXPECT().UpsertOrigins(mock.Anything, call.wantServiceID, call.wantOrigins).Return(call.err).Once() + } + + registry := errmap.NewRegistry() + router := testhelper.NewTestWebEngine(registry) + + _ = NewOriginController(router, mockService, testhelper.MockAuthMiddlewareWithNotificationUser) + + request := httpassert.New(t, router) + + resp := request.Put("/origins/" + tt.serviceID). + JsonContentObject(tt.origins). + Expect(). + StatusCode(tt.wantResponseCode) + if tt.wantBodyContains != "" { + assert.Contains(t, resp.GetBody(), tt.wantBodyContains) + } + }) + } +} + +func TestRegisterOrigins_Auth(t *testing.T) { + tests := map[string]struct { + authMiddleware gin.HandlerFunc + wantResponseCode int + }{ + "notification user is allowed to register origins": { + authMiddleware: testhelper.MockAuthMiddlewareWithNotificationUser, + wantResponseCode: http.StatusNoContent, + }, + "normal users are not allowed to register origins": { + authMiddleware: testhelper.MockAuthMiddleware, + wantResponseCode: http.StatusForbidden, + }, + "admins are not allowed to register origins": { + authMiddleware: testhelper.MockAuthMiddlewareWithAdmin, + wantResponseCode: http.StatusForbidden, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockService := mocks.NewOriginService(t) + if tt.wantResponseCode == http.StatusNoContent { + mockService.EXPECT().UpsertOrigins(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + } + + registry := errmap.NewRegistry() + router := testhelper.NewTestWebEngine(registry) + + _ = NewOriginController(router, mockService, tt.authMiddleware) + + request := httpassert.New(t, router) + + request.Put("/origins/serviceX"). + JsonContentObject([]models.Origin{{Name: "Origin", Class: "origin/class"}}). + Expect(). + StatusCode(tt.wantResponseCode) + }) + } +} diff --git a/pkg/web/testhelper/helper.go b/pkg/web/testhelper/helper.go index 738c0d6..ce7e859 100644 --- a/pkg/web/testhelper/helper.go +++ b/pkg/web/testhelper/helper.go @@ -61,34 +61,31 @@ func NewJSONRequest(method, url string, bodyAsStruct any) (*http.Request, error) // MockAuthMiddleware mocks authentication by setting a default user context in the Gin context for testing purposes. func MockAuthMiddleware(ctx *gin.Context) { - const userContextKey = "USER_CONTEXT_DATA" const iamRoleUser = "user" - - userContext := auth.UserContext{ - Realm: "", - UserID: "", - UserName: "", - EmailAddress: "", - Roles: []string{iamRoleUser}, - Groups: nil, - AllowedOrigins: nil, - } - - ctx.Set(userContextKey, userContext) - ctx.Next() + mockAuthMiddlewareWithRole(ctx, iamRoleUser) } // MockAuthMiddleware mocks authentication by setting a admin user context in the Gin context for testing purposes. func MockAuthMiddlewareWithAdmin(ctx *gin.Context) { - const userContextKey = "USER_CONTEXT_DATA" const iamRoleUser = "admin" + mockAuthMiddlewareWithRole(ctx, iamRoleUser) +} + +// MockAuthMiddleware mocks authentication by setting a notification user context in the Gin context for testing purposes. +func MockAuthMiddlewareWithNotificationUser(ctx *gin.Context) { + const iamRoleUser = "opensight_notification_role" + mockAuthMiddlewareWithRole(ctx, iamRoleUser) +} + +func mockAuthMiddlewareWithRole(ctx *gin.Context, role string) { + const userContextKey = "USER_CONTEXT_DATA" userContext := auth.UserContext{ Realm: "", UserID: "", UserName: "", EmailAddress: "", - Roles: []string{iamRoleUser}, + Roles: []string{role}, Groups: nil, AllowedOrigins: nil, }