diff --git a/documentation/revision-history.md b/documentation/revision-history.md index 5bd6b5e795..4aa91dd775 100644 --- a/documentation/revision-history.md +++ b/documentation/revision-history.md @@ -733,6 +733,7 @@ Not supported any longer are: - changes to workflow tickets are recorded in the same table after ticket creation, with the workflow phase and the previous and new values. Content changes made in the user interface by a user other than the requester are marked as audit proof critical. The recording is available to auditors via the API and is not shown in the user interface. - change_history entries carry a module column naming the subsystem that wrote them, currently modelling or workflow. It selects which enum the object_type column uses and limits the modelling roles to modelling entries. Existing entries are migrated as modelling. - change history entries written through the REST workflow endpoints name the authenticated caller instead of the middleware server, and carry that caller's user id in changer_id. changer_id therefore stays empty only for changes made by automation - background jobs and unauthenticated internal callers - so an audit can tell an automated change from a human one and resolve the human one to a user record even after a directory rename. + - new REST endpoint workflow/getAuditProofCriticalChanges returns the audit proof critical changes of a workflow ticket: the change history entries of that ticket which are marked as audit proof critical, meaning a content change made in a user session by someone other than the requester. It is available to admins and auditors and reports change time, change user name, change user id and the recorded change text, newest first. The change user id is the trustworthy attribution, as the change user name is free text supplied by the writer of the change; it stays empty for changes made by automation. Change times carry the wall clock of the installation and no offset, because the underlying column is timezone naive. A ticketId that names no workflow ticket is answered with 404 and the error message "Workflow ticket with 'ticketId' does not exist." instead of an empty changes list, so a mistyped or already deleted ticket id cannot be read as a ticket without audit proof critical changes; an existing ticket without such changes, or one whose changes the supplied filter excludes, still answers 200 with an empty list. - matrix import now stores the network zone tree paths it already validated: for every subnet of a zone, the firewalls on its path to root and to the internet are written to network_zone.device_ip_range_root and network_zone.device_ip_range_internet, in the order the import file lists them. An import replaces all paths of the matrix it imports. A subnet listed twice within one zone is now rejected instead of imported. @@ -759,3 +760,7 @@ Not supported any longer are: affected rules and what caused a difference - rule owner mapping: new setting for the log level of mapping issues - rule owner mapping: new AlertCode RuleOwnerMapping (52) for every alert of this area + +## 9.5.4 - 23.09.2026 +- rework modelling notifications and move interface-request notifications to centralized notification entries +- interface-request notifications now support separate request and reminder bodies plus optional CC to the requester diff --git a/inventory/group_vars/all.yml b/inventory/group_vars/all.yml index a603778be4..5e8bcf3d8b 100644 --- a/inventory/group_vars/all.yml +++ b/inventory/group_vars/all.yml @@ -1,5 +1,5 @@ ### general settings -product_version: "9.5.3" +product_version: "9.5.4" # The oldest product version an upgrade may start from, enforced by # roles/common/tasks/validate-upgrade-source-version.yml before anything touches the # database. It exists because 9.5.0 removed the migration files below 8.0: diff --git a/roles/api/files/replace_metadata.json b/roles/api/files/replace_metadata.json index 8db52cec9c..071cd714d8 100644 --- a/roles/api/files/replace_metadata.json +++ b/roles/api/files/replace_metadata.json @@ -12510,7 +12510,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12545,7 +12547,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12580,7 +12584,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12615,7 +12621,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12650,7 +12658,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12685,7 +12695,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ] }, "comment": "" @@ -12721,7 +12733,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "notification_client": { @@ -12760,7 +12774,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": {} }, @@ -12795,7 +12811,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -12843,7 +12861,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "notification_client": { @@ -12882,7 +12902,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": {} }, @@ -12917,7 +12939,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -12965,7 +12989,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "notification_client": { @@ -13004,7 +13030,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13052,7 +13080,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13100,7 +13130,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13148,7 +13180,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "notification_client": { @@ -13187,7 +13221,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "notification_client": { @@ -13228,7 +13264,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_and": [ @@ -13308,7 +13346,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13345,7 +13385,9 @@ "role": "middleware-server", "permission": { "columns": [ - "last_sent" + "last_sent", + "logging", + "active" ], "filter": {}, "check": {} @@ -13381,7 +13423,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13443,7 +13487,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13505,7 +13551,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13567,7 +13615,9 @@ "repetitions_after_deadline", "schedule_id", "user_id", - "last_sent" + "last_sent", + "logging", + "active" ], "filter": { "_or": [ @@ -13733,6 +13783,149 @@ } ] }, + { + "table": { + "name": "notification_log", + "schema": "public" + }, + "insert_permissions": [ + { + "role": "fw-admin", + "permission": { + "check": {}, + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject", + "deadline_type", + "deadline", + "status", + "error" + ] + }, + "comment": "" + }, + { + "role": "middleware-server", + "permission": { + "check": {}, + "columns": [ + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject", + "deadline_type", + "deadline", + "status", + "error" + ] + }, + "comment": "" + } + ], + "update_permissions": [ + { + "role": "fw-admin", + "permission": { + "columns": ["status", "error", "timestamp"], + "filter": {}, + "check": {} + }, + "comment": "" + }, + { + "role": "middleware-server", + "permission": { + "columns": ["status", "error", "timestamp"], + "filter": {}, + "check": {} + }, + "comment": "" + } + ], + "select_permissions": [ + { + "role": "auditor", + "permission": { + "columns": [ + "id", + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject", + "deadline_type", + "deadline", + "status", + "error" + ], + "filter": {} + }, + "comment": "" + }, + { + "role": "fw-admin", + "permission": { + "columns": [ + "id", + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject", + "deadline_type", + "deadline", + "status", + "error" + ], + "filter": {} + }, + "comment": "" + }, + { + "role": "middleware-server", + "permission": { + "columns": [ + "id", + "timestamp", + "notification_id", + "notification_type", + "to", + "cc", + "bcc", + "subject", + "deadline_type", + "deadline", + "status", + "error" + ], + "filter": {} + }, + "comment": "" + }, + { + "role": "modeller", + "permission": { + "columns": [ + "id" + ], + "filter": {} + }, + "comment": "" + } + ] + }, { "table": { "name": "nw_object", diff --git a/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql b/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql index 4e528c2add..f287cdd62a 100644 --- a/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql +++ b/roles/common/files/fwo-api-calls/config/subscribeDailyCheckConfigChanges.graphql @@ -23,7 +23,6 @@ {config_key: {_eq: "notificationLanguage"}}, {config_key: {_eq: "maxImportDuration"}}, {config_key: {_eq: "maxImportInterval"}}, - {config_key: {_eq: "modUnansweredReqEmailBody"}}, {config_key: {_eq: "ruleExpiryEmailBody"}}, {config_key: {_eq: "ownerActiveRuleEmailBody"}}, {config_key: {_eq: "ruleExpiryInitiatorKeys"}}, diff --git a/roles/common/files/fwo-api-calls/modelling/addPermittedOwner.graphql b/roles/common/files/fwo-api-calls/modelling/addPermittedOwner.graphql index 876bbbd101..b757aa07b6 100644 --- a/roles/common/files/fwo-api-calls/modelling/addPermittedOwner.graphql +++ b/roles/common/files/fwo-api-calls/modelling/addPermittedOwner.graphql @@ -5,6 +5,9 @@ mutation addPermittedOwner( insert_modelling_permitted_owners(objects: { connection_id: $connectionId app_id: $appId + }, on_conflict: { + constraint: permitted_owners_pkey + update_columns: [] }) { returning { insertedConnectionId: connection_id diff --git a/roles/common/files/fwo-api-calls/modelling/addSelectedConnection.graphql b/roles/common/files/fwo-api-calls/modelling/addSelectedConnection.graphql index a137d7b71f..b5aa77e8b3 100644 --- a/roles/common/files/fwo-api-calls/modelling/addSelectedConnection.graphql +++ b/roles/common/files/fwo-api-calls/modelling/addSelectedConnection.graphql @@ -2,10 +2,16 @@ mutation addSelectedConnection( $appId: Int! $connectionId: Int! ) { - insert_modelling_selected_connections(objects: { - app_id: $appId - connection_id: $connectionId - }) { + insert_modelling_selected_connections( + objects: { + app_id: $appId + connection_id: $connectionId + } + on_conflict: { + constraint: selected_connections_pkey + update_columns: [] + } + ) { returning { insertedId: connection_id } diff --git a/roles/common/files/fwo-api-calls/modelling/getConnectionForNotification.graphql b/roles/common/files/fwo-api-calls/modelling/getConnectionForNotification.graphql new file mode 100644 index 0000000000..5a0a6f741d --- /dev/null +++ b/roles/common/files/fwo-api-calls/modelling/getConnectionForNotification.graphql @@ -0,0 +1,27 @@ +query getConnectionForNotification($id: Int!) { + modelling_connection(where: { id: { _eq: $id } }) { + id + app_id + proposed_app_id + name + reason + conn_prop + is_interface + is_published + interface_permission + is_requested + ticket_id + creator + removed + owner { + id + name + app_id_external + } + ownerByProposedAppId { + id + name + app_id_external + } + } +} diff --git a/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql b/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql new file mode 100644 index 0000000000..70ad32fd99 --- /dev/null +++ b/roles/common/files/fwo-api-calls/monitor/getNotificationLogEntrys.graphql @@ -0,0 +1,16 @@ +query getNotificationLogEntrys($limit: Int!, $offset: Int!) { + notification_log(limit: $limit, offset: $offset, order_by: [{ timestamp: desc }, { id: desc }]) { + id + timestamp + notification_id + notification_type + to + cc + bcc + subject + deadline_type + deadline + status + error + } +} diff --git a/roles/common/files/fwo-api-calls/notification/addNotification.graphql b/roles/common/files/fwo-api-calls/notification/addNotification.graphql index a9c24e93ea..62847a61e7 100644 --- a/roles/common/files/fwo-api-calls/notification/addNotification.graphql +++ b/roles/common/files/fwo-api-calls/notification/addNotification.graphql @@ -22,6 +22,8 @@ mutation addNotification( $initialOffsetAfterDeadline: Int $offsetAfterDeadline: Int $repetitionsAfterDeadline: Int + $logging: String + $active: Boolean! ) { insert_notification(objects: { notification_client: $client @@ -47,6 +49,8 @@ mutation addNotification( initial_offset_after_deadline: $initialOffsetAfterDeadline repeat_offset_after_deadline: $offsetAfterDeadline repetitions_after_deadline: $repetitionsAfterDeadline + logging: $logging + active: $active }) { returning { newId: id diff --git a/roles/common/files/fwo-api-calls/notification/getNotificationById.graphql b/roles/common/files/fwo-api-calls/notification/getNotificationById.graphql new file mode 100644 index 0000000000..9630124478 --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification/getNotificationById.graphql @@ -0,0 +1,9 @@ +query getNotificationById($id: Int!) { + notification(where: { id: { _eq: $id } }) { + id + notification_client + deadline + logging + active + } +} diff --git a/roles/common/files/fwo-api-calls/notification/getNotifications.graphql b/roles/common/files/fwo-api-calls/notification/getNotifications.graphql index 7274551dfc..294cb5ebf7 100644 --- a/roles/common/files/fwo-api-calls/notification/getNotifications.graphql +++ b/roles/common/files/fwo-api-calls/notification/getNotifications.graphql @@ -27,5 +27,7 @@ query getNotifications ($client: String! ){ repeat_offset_after_deadline repetitions_after_deadline last_sent + logging + active } } diff --git a/roles/common/files/fwo-api-calls/notification/updateNotification.graphql b/roles/common/files/fwo-api-calls/notification/updateNotification.graphql index cbd80767ee..e0f2f530ba 100644 --- a/roles/common/files/fwo-api-calls/notification/updateNotification.graphql +++ b/roles/common/files/fwo-api-calls/notification/updateNotification.graphql @@ -22,6 +22,8 @@ mutation updateNotification( $initialOffsetAfterDeadline: Int $offsetAfterDeadline: Int $repetitionsAfterDeadline: Int + $logging: String + $active: Boolean! ) { update_notification_by_pk( pk_columns: { id: $id } @@ -48,6 +50,8 @@ mutation updateNotification( initial_offset_after_deadline: $initialOffsetAfterDeadline repeat_offset_after_deadline: $offsetAfterDeadline repetitions_after_deadline: $repetitionsAfterDeadline + logging: $logging + active: $active }) { updatedId: id } diff --git a/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogs.graphql b/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogs.graphql new file mode 100644 index 0000000000..a3d3dca6d4 --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogs.graphql @@ -0,0 +1,20 @@ +query getNoRecipientNotificationLogs($notificationId: Int!, $status: String!, $error: String!, $subject: String!, $deadlineType: String!, $deadline: timestamp!) { + notification_log( + where: { + notification_id: { _eq: $notificationId } + status: { _eq: $status } + error: { _eq: $error } + subject: { _eq: $subject } + deadline_type: { _eq: $deadlineType } + deadline: { _eq: $deadline } + } + order_by: [{ timestamp: desc }, { id: desc }] + limit: 1 + ) { + id + timestamp + subject + deadline_type + deadline + } +} diff --git a/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogsWithoutDeadline.graphql b/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogsWithoutDeadline.graphql new file mode 100644 index 0000000000..f5ddab0d3e --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/getNoRecipientNotificationLogsWithoutDeadline.graphql @@ -0,0 +1,20 @@ +query getNoRecipientNotificationLogsWithoutDeadline($notificationId: Int!, $status: String!, $error: String!, $subject: String!, $deadlineType: String!) { + notification_log( + where: { + notification_id: { _eq: $notificationId } + status: { _eq: $status } + error: { _eq: $error } + subject: { _eq: $subject } + deadline_type: { _eq: $deadlineType } + deadline: { _is_null: true } + } + order_by: [{ timestamp: desc }, { id: desc }] + limit: 1 + ) { + id + timestamp + subject + deadline_type + deadline + } +} diff --git a/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql b/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql new file mode 100644 index 0000000000..b8ba01178c --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/insertNotificationLog.graphql @@ -0,0 +1,8 @@ +mutation insertNotificationLog($entries: [notification_log_insert_input!]!) { + insert_notification_log(objects: $entries) { + affected_rows + returning { + id + } + } +} diff --git a/roles/common/files/fwo-api-calls/notification_log/refreshNotificationLog.graphql b/roles/common/files/fwo-api-calls/notification_log/refreshNotificationLog.graphql new file mode 100644 index 0000000000..1700ab9d9f --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/refreshNotificationLog.graphql @@ -0,0 +1,10 @@ +mutation refreshNotificationLog($id: Int!, $timestamp: timestamp!) { + update_notification_log( + where: { id: { _eq: $id } } + _set: { timestamp: $timestamp } + ) { + returning { + id + } + } +} diff --git a/roles/common/files/fwo-api-calls/notification_log/updateNotificationLog.graphql b/roles/common/files/fwo-api-calls/notification_log/updateNotificationLog.graphql new file mode 100644 index 0000000000..341d46df1b --- /dev/null +++ b/roles/common/files/fwo-api-calls/notification_log/updateNotificationLog.graphql @@ -0,0 +1,8 @@ +mutation updateNotificationLog($id: Int!, $status: String!, $error: String!) { + update_notification_log( + where: { id: { _eq: $id } } + _set: { status: $status, error: $error } + ) { + affected_rows + } +} diff --git a/roles/common/files/fwo-api-calls/owner/getOwnerForNotification.graphql b/roles/common/files/fwo-api-calls/owner/getOwnerForNotification.graphql new file mode 100644 index 0000000000..23910172c3 --- /dev/null +++ b/roles/common/files/fwo-api-calls/owner/getOwnerForNotification.graphql @@ -0,0 +1,11 @@ +query getOwnerForNotification ($id: Int!) { + owner_by_pk (id: $id) { + id + name + app_id_external + owner_responsibles { + dn + responsible_type + } + } +} diff --git a/roles/common/files/fwo-api-calls/request/getTicketRequesterId.graphql b/roles/common/files/fwo-api-calls/request/getTicketRequesterId.graphql new file mode 100644 index 0000000000..744b822c95 --- /dev/null +++ b/roles/common/files/fwo-api-calls/request/getTicketRequesterId.graphql @@ -0,0 +1,7 @@ +query getTicketRequesterId($id: bigint!) { + request_ticket_by_pk(id: $id) { + requester: uiuser { + uiuser_id + } + } +} diff --git a/roles/database/files/sql/creation/fworch-create-tables-base.sql b/roles/database/files/sql/creation/fworch-create-tables-base.sql index 4dd262d5eb..913ce1aad1 100755 --- a/roles/database/files/sql/creation/fworch-create-tables-base.sql +++ b/roles/database/files/sql/creation/fworch-create-tables-base.sql @@ -336,9 +336,30 @@ create table notification bundle_type Varchar, bundle_id Varchar, recipient_bcc Varchar, - email_address_bcc Varchar + email_address_bcc Varchar, + logging Varchar NOT NULL DEFAULT 'send_only', + active Boolean NOT NULL DEFAULT TRUE ); +create table notification_log +( + id SERIAL PRIMARY KEY, + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + notification_id INTEGER NOT NULL, + notification_type Varchar NOT NULL, + "to" Varchar NOT NULL DEFAULT '', + cc Varchar NOT NULL DEFAULT '', + bcc Varchar NOT NULL DEFAULT '', + subject Varchar NOT NULL DEFAULT '', + deadline_type Varchar NOT NULL DEFAULT 'None', + deadline TIMESTAMP WITH TIME ZONE, + status Varchar NOT NULL DEFAULT 'Pending', + error Varchar NOT NULL DEFAULT '' + ); + +create index if not exists notification_log_timestamp_id_idx + on notification_log ("timestamp" desc, id desc); + -- configuration Create table "ldap_connection" diff --git a/roles/database/files/sql/creation/fworch-fill-config.sql b/roles/database/files/sql/creation/fworch-fill-config.sql index 2de7bcfc62..a7a0ec4a9d 100644 --- a/roles/database/files/sql/creation/fworch-fill-config.sql +++ b/roles/database/files/sql/creation/fworch-fill-config.sql @@ -82,17 +82,8 @@ insert into config (config_key, config_value, config_user) VALUES ('modNamingCon insert into config (config_key, config_value, config_user) VALUES ('modCommonAreas', '[]', 0); insert into config (config_key, config_value, config_user) VALUES ('modAppServerTypes', '[{"Id":0,"Name":"Default"}]', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqInterfaceName', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailReceiver', 'OwnerGroupOnly', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailRequesterInCc', 'true', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailOtherAddresses', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailSubject', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modReqEmailBody', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqTicketTitle', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modReqTaskTitle', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailReceiver', 'None', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailOtherAddresses', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailSubject', '', 0); -insert into config (config_key, config_value, config_user) VALUES ('modDecommEmailBody', '', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationMode', 'FullyIntegrated', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationStates', '[]', 0); insert into config (config_key, config_value, config_user) VALUES ('modIntegrationStateMarker', 'ImplementationState', 0); diff --git a/roles/database/files/sql/idempotent/fworch-texts.sql b/roles/database/files/sql/idempotent/fworch-texts.sql index c724585594..0fecc2e045 100644 --- a/roles/database/files/sql/idempotent/fworch-texts.sql +++ b/roles/database/files/sql/idempotent/fworch-texts.sql @@ -75,6 +75,14 @@ INSERT INTO txt VALUES ('AnyActivity', 'German', 'Beliebige Aktivit&a INSERT INTO txt VALUES ('AnyActivity', 'English', 'Any Activity'); INSERT INTO txt VALUES ('None', 'German', 'Keine(r/s)'); INSERT INTO txt VALUES ('None', 'English', 'None'); +INSERT INTO txt VALUES ('Pending', 'German', 'Ausstehend'); +INSERT INTO txt VALUES ('Pending', 'English', 'Pending'); +INSERT INTO txt VALUES ('Sent', 'German', 'Gesendet'); +INSERT INTO txt VALUES ('Sent', 'English', 'Sent'); +INSERT INTO txt VALUES ('Failed', 'German', 'Fehlgeschlagen'); +INSERT INTO txt VALUES ('Failed', 'English', 'Failed'); +INSERT INTO txt VALUES ('Suppressed', 'German', 'Unterdrückt'); +INSERT INTO txt VALUES ('Suppressed', 'English', 'Suppressed'); INSERT INTO txt VALUES ('note_prefix', 'German', 'Hinweis'); INSERT INTO txt VALUES ('note_prefix', 'English', 'Note'); INSERT INTO txt VALUES ('FromFlowDb', 'German', 'aus Flow DB'); @@ -113,6 +121,22 @@ INSERT INTO txt VALUES ('SendEmail', 'German', 'Email verschicken'); INSERT INTO txt VALUES ('SendEmail', 'English', 'Send email'); INSERT INTO txt VALUES ('WfAction', 'German', 'Workflow-Aktion'); INSERT INTO txt VALUES ('WfAction', 'English', 'Workflow action'); +INSERT INTO txt VALUES ('ImportChange', 'German', 'Importänderung'); +INSERT INTO txt VALUES ('ImportChange', 'English', 'Import change'); +INSERT INTO txt VALUES ('Compliance', 'German', 'Compliance'); +INSERT INTO txt VALUES ('Compliance', 'English', 'Compliance'); +INSERT INTO txt VALUES ('InterfaceRequest', 'German', 'Schnittstellenanfrage'); +INSERT INTO txt VALUES ('InterfaceRequest', 'English', 'Interface request'); +INSERT INTO txt VALUES ('RuleTimer', 'German', 'Regelablauf'); +INSERT INTO txt VALUES ('RuleTimer', 'English', 'Rule expiry'); +INSERT INTO txt VALUES ('AppDecomm', 'German', 'Applikationsstilllegung'); +INSERT INTO txt VALUES ('AppDecomm', 'English', 'Application decommissioning'); +INSERT INTO txt VALUES ('InterfaceDecomm', 'German', 'Schnittstellenstilllegung'); +INSERT INTO txt VALUES ('InterfaceDecomm', 'English', 'Interface decommissioning'); +INSERT INTO txt VALUES ('RuleRecertification', 'German', 'Regelweise Rezertifizierung'); +INSERT INTO txt VALUES ('RuleRecertification', 'English', 'Rule-by-rule recertification'); +INSERT INTO txt VALUES ('Report', 'German', 'Bericht'); +INSERT INTO txt VALUES ('Report', 'English', 'Report'); INSERT INTO txt VALUES ('CreateFlow', 'German', 'Flow erzeugen'); INSERT INTO txt VALUES ('CreateFlow', 'English', 'Create flow'); INSERT INTO txt VALUES ('BundleTasks', 'German', 'Aufgaben bündeln'); @@ -552,6 +576,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'German', '
  • Internal-Work-Regeländerungen laufen in die Genehmigungsphase statt direkt in die Planung; Email-Aktionen für Request Tasks können pro Task-Typ gebündelt werden, sodass eine Email alle passenden Request Tasks enthält.
  • Matrix Import nimmt und validiert neue Felder für Network Zone Tree und speichert sie in der Datenbank.
  • Die automatische Eigentümerzuordnung von Regeln kann nun auf "Deaktiviert" gesetzt werden und ist nach einer Neuinstallation so voreingestellt. Damit lassen sich die übrigen Importer-Einstellungen speichern, ohne vorher eine Zuordnungsquelle einzurichten. Beim Umstellen auf "Deaktiviert" werden die bereits berechneten Zuordnungen entfernt.
  • +
  • Modellierungs-Benachrichtigungen für Schnittstellenanfragen, Erinnerungen und Stilllegungen werden zentral verwaltet. Der Protokollierungsmodus kann auf "nur senden", "senden und protokollieren" oder "nur protokollieren" gesetzt werden; das E-Mail-Protokoll ist unter Monitoring verfügbar.
  • Der neue REST-Endpunkt workflow/getAuditProofCriticalChanges liefert die revisionskritischen Änderungen eines Workflow-Tickets: die als revisionskritisch markierten Einträge der Änderungshistorie, also inhaltliche Änderungen, die in einer Benutzersitzung von jemand anderem als dem Antragsteller vorgenommen wurden. Er steht Administratoren und Auditoren zur Verfügung und nennt neben dem Namen auch die Benutzer-ID des Ändernden, da nur diese fälschungssicher ist.
  • Die Protokollierung der Eigentümerzuordnung lässt sich nun in fünf Stufen einstellen. Auf Installationen mit vielen Altregeln, die nie zugeordnet werden können, erzeugte bisher jeder Lauf eine Meldung pro Regel. Die Zusammenfassung jedes Laufs und fehlgeschlagene Importe werden unabhängig davon immer protokolliert.
  • Probleme der Eigentümerzuordnung erscheinen nun als Alarm unter Monitoring statt nur im Logfile: nicht verarbeitete Importe, eine Quelle die keine Regel mehr trifft, und Abweichungen zwischen laufender Aktualisierung und vollständiger Neuberechnung. Ein Import, der zweimal hintereinander fehlschlägt, wird durch eine vollständige Neuberechnung automatisch repariert.
  • @@ -573,6 +598,7 @@ INSERT INTO txt VALUES ('whats_new_facts', 'English', '
  • Internal work rule changes are routed into the approval phase instead of directly into planning; request task email actions can be bundled by task type so that one email covers all matching request tasks.
  • Matrix import takes and validates new fields for Network Zone Tree and stores them in the database.
  • The automatic owner mapping of rules can now be set to "Disabled" and a new installation starts with it. The remaining importer settings can therefore be saved without setting up a mapping source first. Switching to "Disabled" removes the mappings calculated so far.
  • +
  • Modelling notifications for interface requests, reminders and decommissioning are now managed centrally. The logging mode can be set to "send only", "send and log" or "log only"; the email log is available under Monitoring.
  • The new REST endpoint workflow/getAuditProofCriticalChanges returns the audit proof critical changes of a workflow ticket: the change history entries marked as audit proof critical, that is content changes made in a user session by someone other than the requester. It is available to administrators and auditors and reports the changing user''s id next to the name, as only the id is tamper proof.
  • Logging of the owner mapping can now be set to one of five levels. On installations with many legacy rules that can never be mapped, every run used to produce one message per rule. The summary of each run and failed imports are always logged regardless.
  • Problems of the owner mapping now appear as an alert under Monitoring instead of only in the log file: imports that could not be processed, a source that no longer matches any rule, and deviations between the running update and a full recalculation. An import that fails twice in a row is repaired automatically by a full recalculation.
  • @@ -1241,6 +1267,8 @@ INSERT INTO txt VALUES ('opened', 'German', 'Geöffnet'); INSERT INTO txt VALUES ('opened', 'English', 'Opened'); INSERT INTO txt VALUES ('deadline', 'German', 'Deadline'); INSERT INTO txt VALUES ('deadline', 'English', 'Deadline'); +INSERT INTO txt VALUES ('deadline_type', 'German', 'Deadline-Typ'); +INSERT INTO txt VALUES ('deadline_type', 'English', 'Deadline type'); INSERT INTO txt VALUES ('assign1', 'German', 'Zuweisen'); INSERT INTO txt VALUES ('assign1', 'English', 'Assign'); INSERT INTO txt VALUES ('assign_to', 'German', 'Weiterleiten an'); @@ -1791,6 +1819,8 @@ INSERT INTO txt VALUES ('display_interface', 'German', 'Schnittstelle darstel INSERT INTO txt VALUES ('display_interface', 'English', 'Display Interface'); INSERT INTO txt VALUES ('interface_permission', 'German', 'Berechtigung'); INSERT INTO txt VALUES ('interface_permission', 'English', 'Permission'); +INSERT INTO txt VALUES ('initial_request', 'German', 'Antragsbenachrichtigung'); +INSERT INTO txt VALUES ('initial_request', 'English', 'Initial request'); INSERT INTO txt VALUES ('request_interface', 'German', 'Schnittstelle anfordern'); INSERT INTO txt VALUES ('request_interface', 'English', 'Request Interface'); INSERT INTO txt VALUES ('requested_interface', 'German', 'Angeforderte Schnittstelle'); @@ -1991,6 +2021,8 @@ INSERT INTO txt VALUES ('decommission', 'German', 'Stilllegen'); INSERT INTO txt VALUES ('decommission', 'English', 'Decommission'); INSERT INTO txt VALUES ('decomm_interface', 'German', 'Schnittstelle stilllegen'); INSERT INTO txt VALUES ('decomm_interface', 'English', 'Decommission interface'); +INSERT INTO txt VALUES ('decomm_owner', 'German', 'Eigentümer stilllegen'); +INSERT INTO txt VALUES ('decomm_owner', 'English', 'Decommission Owner'); INSERT INTO txt VALUES ('on', 'German', ' auf '); INSERT INTO txt VALUES ('on', 'English', ' on '); INSERT INTO txt VALUES ('create_rule', 'German', 'Regeln anlegen'); @@ -3211,8 +3243,6 @@ INSERT INTO txt VALUES ('modReqInterfaceName', 'German', 'Name der beantragte INSERT INTO txt VALUES ('modReqInterfaceName', 'English', 'Name of requested interface'); INSERT INTO txt VALUES ('modReqEmailReceiver', 'German', 'Empfänger der Antragsbenachrichtigung'); INSERT INTO txt VALUES ('modReqEmailReceiver', 'English', 'Receiver of request email'); -INSERT INTO txt VALUES ('modReqEmailRequesterInCc','German','Antragssteller in CC'); -INSERT INTO txt VALUES ('modReqEmailRequesterInCc','English','Requester in CC'); INSERT INTO txt VALUES ('modReqEmailOtherAddresses','German','Weitere Email-Adressen'); INSERT INTO txt VALUES ('modReqEmailOtherAddresses','English','Additional email addresses'); INSERT INTO txt VALUES ('modEnsureAtLeastOneEmailNotification','German','Mindestens eine Email-Benachrichtigung sicherstellen'); @@ -3351,6 +3381,8 @@ INSERT INTO txt VALUES ('use_in_dst', 'German', 'in Ziel'); INSERT INTO txt VALUES ('use_in_dst', 'English', 'in Destination'); INSERT INTO txt VALUES ('email_subject', 'German', 'Email-Betreff'); INSERT INTO txt VALUES ('email_subject', 'English', 'Email subject'); +INSERT INTO txt VALUES ('email_log', 'German', 'Email-Protokoll'); +INSERT INTO txt VALUES ('email_log', 'English', 'Email log'); INSERT INTO txt VALUES ('email_body', 'German', 'Email-Inhalt'); INSERT INTO txt VALUES ('email_body', 'English', 'Email body'); INSERT INTO txt VALUES ('email_to', 'German', 'An'); @@ -3462,42 +3494,52 @@ INSERT INTO txt VALUES ('importedMatrixReadOnly','English','Imported matrices re INSERT INTO txt VALUES ('filterOutInitialViolations','German', 'Filtere initiale Violations raus'); INSERT INTO txt VALUES ('filterOutInitialViolations','English','Filter out initial violations'); -INSERT INTO txt VALUES ('availableModules', 'German', 'Verfügbare Module'); -INSERT INTO txt VALUES ('availableModules', 'English', 'Available Modules'); -INSERT INTO txt VALUES ('notification', 'German', 'Benachrichtigung'); -INSERT INTO txt VALUES ('notification', 'English', 'Notification'); -INSERT INTO txt VALUES ('notifications', 'German', 'Benachrichtigungen'); -INSERT INTO txt VALUES ('notifications', 'English', 'Notifications'); -INSERT INTO txt VALUES ('reminders', 'German', 'Erinnerungsbenachrichtigungen'); -INSERT INTO txt VALUES ('reminders', 'English', 'Reminders'); -INSERT INTO txt VALUES ('edit_notification', 'German', 'Benachrichtigung bearbeiten'); -INSERT INTO txt VALUES ('edit_notification', 'English', 'Edit Notification'); -INSERT INTO txt VALUES ('delete_notification', 'German', 'Benachrichtigung löschen'); -INSERT INTO txt VALUES ('delete_notification', 'English', 'Delete Notification'); -INSERT INTO txt VALUES ('color_scheme', 'German', 'Farbschema'); -INSERT INTO txt VALUES ('color_scheme', 'English', 'Color Scheme'); -INSERT INTO txt VALUES ('color_scheme_blue', 'German', 'Blaues Farbschema'); -INSERT INTO txt VALUES ('color_scheme_blue', 'English', 'Blue Color Scheme'); -INSERT INTO txt VALUES ('color_scheme_green', 'German', 'Grünes Farbschema'); -INSERT INTO txt VALUES ('color_scheme_green', 'English', 'Green Color Scheme'); -INSERT INTO txt VALUES ('color_scheme_red', 'German', 'Rotes Farbschema'); -INSERT INTO txt VALUES ('color_scheme_red', 'English', 'Red Color Scheme'); -INSERT INTO txt VALUES ('color_scheme_purple', 'German', 'Violettes Farbschema'); -INSERT INTO txt VALUES ('color_scheme_purple', 'English', 'Purple Color Scheme'); -INSERT INTO txt VALUES ('last_sent', 'German', 'Zuletzt gesendet'); -INSERT INTO txt VALUES ('last_sent', 'English', 'Last sent'); -INSERT INTO txt VALUES ('reset_notification', 'German', 'Zurücksetzen'); -INSERT INTO txt VALUES ('reset_notification', 'English', 'Reset'); -INSERT INTO txt VALUES ('set_roles_with_import', 'German', 'Beim Import zu setzende Rollen'); -INSERT INTO txt VALUES ('set_roles_with_import', 'English', 'Set Roles with import'); -INSERT INTO txt VALUES ('no_owners', 'German', 'Keine Eigentümer zur Auswahl'); -INSERT INTO txt VALUES ('no_owners', 'English', 'No owners to be selected'); -INSERT INTO txt VALUES ('accesstoken_lifetime', 'German', 'Access Token Gültigkeitsdauer'); -INSERT INTO txt VALUES ('accesstoken_lifetime', 'English', 'Access Token Lifetime'); -INSERT INTO txt VALUES ('refreshtoken_lifetime', 'German', 'Refresh Token Gültigkeitsdauer'); -INSERT INTO txt VALUES ('refreshtoken_lifetime', 'English', 'Refresh Token Lifetime'); -INSERT INTO txt VALUES ('path_analysis_algorithm', 'German', 'Pfadanalyse Algorithmus'); -INSERT INTO txt VALUES ('path_analysis_algorithm', 'English', 'Path Analysis Algorithm'); +INSERT INTO txt VALUES ('availableModules', 'German', 'Verfügbare Module'); +INSERT INTO txt VALUES ('availableModules', 'English', 'Available Modules'); +INSERT INTO txt VALUES ('notification', 'German', 'Benachrichtigung'); +INSERT INTO txt VALUES ('notification', 'English', 'Notification'); +INSERT INTO txt VALUES ('notifications', 'German', 'Benachrichtigungen'); +INSERT INTO txt VALUES ('notifications', 'English', 'Notifications'); +INSERT INTO txt VALUES ('reminder', 'German', 'Erinnerung'); +INSERT INTO txt VALUES ('reminder', 'English', 'Reminder'); +INSERT INTO txt VALUES ('reminders', 'German', 'Erinnerungsbenachrichtigungen'); +INSERT INTO txt VALUES ('reminders', 'English', 'Reminders'); +INSERT INTO txt VALUES ('logging_mode', 'German', 'Protokollierungsmodus'); +INSERT INTO txt VALUES ('logging_mode', 'English', 'Logging mode'); +INSERT INTO txt VALUES ('send_only', 'German', 'Nur senden'); +INSERT INTO txt VALUES ('send_only', 'English', 'Send only'); +INSERT INTO txt VALUES ('send_and_log', 'German', 'Senden und protokollieren'); +INSERT INTO txt VALUES ('send_and_log', 'English', 'Send and log'); +INSERT INTO txt VALUES ('log_only', 'German', 'Nur protokollieren'); +INSERT INTO txt VALUES ('log_only', 'English', 'Log only'); +INSERT INTO txt VALUES ('edit_notification', 'German', 'Benachrichtigung bearbeiten'); +INSERT INTO txt VALUES ('edit_notification', 'English', 'Edit Notification'); +INSERT INTO txt VALUES ('delete_notification', 'German', 'Benachrichtigung löschen'); +INSERT INTO txt VALUES ('delete_notification', 'English', 'Delete Notification'); +INSERT INTO txt VALUES ('color_scheme', 'German', 'Farbschema'); +INSERT INTO txt VALUES ('color_scheme', 'English', 'Color Scheme'); +INSERT INTO txt VALUES ('color_scheme_blue', 'German', 'Blaues Farbschema'); +INSERT INTO txt VALUES ('color_scheme_blue', 'English', 'Blue Color Scheme'); +INSERT INTO txt VALUES ('color_scheme_green', 'German', 'Grünes Farbschema'); +INSERT INTO txt VALUES ('color_scheme_green', 'English', 'Green Color Scheme'); +INSERT INTO txt VALUES ('color_scheme_red', 'German', 'Rotes Farbschema'); +INSERT INTO txt VALUES ('color_scheme_red', 'English', 'Red Color Scheme'); +INSERT INTO txt VALUES ('color_scheme_purple', 'German', 'Violettes Farbschema'); +INSERT INTO txt VALUES ('color_scheme_purple', 'English', 'Purple Color Scheme'); +INSERT INTO txt VALUES ('last_sent', 'German', 'Zuletzt gesendet'); +INSERT INTO txt VALUES ('last_sent', 'English', 'Last sent'); +INSERT INTO txt VALUES ('reset_notification', 'German', 'Zurücksetzen'); +INSERT INTO txt VALUES ('reset_notification', 'English', 'Reset'); +INSERT INTO txt VALUES ('set_roles_with_import','German', 'Beim Import zu setzende Rollen'); +INSERT INTO txt VALUES ('set_roles_with_import','English', 'Set Roles with import'); +INSERT INTO txt VALUES ('no_owners', 'German', 'Keine Eigentümer zur Auswahl'); +INSERT INTO txt VALUES ('no_owners', 'English', 'No owners to be selected'); +INSERT INTO txt VALUES ('accesstoken_lifetime', 'German', 'Access Token Gültigkeitsdauer'); +INSERT INTO txt VALUES ('accesstoken_lifetime', 'English', 'Access Token Lifetime'); +INSERT INTO txt VALUES ('refreshtoken_lifetime','German', 'Refresh Token Gültigkeitsdauer'); +INSERT INTO txt VALUES ('refreshtoken_lifetime','English', 'Refresh Token Lifetime'); +INSERT INTO txt VALUES ('path_analysis_algorithm','German', 'Pfadanalyse Algorithmus'); +INSERT INTO txt VALUES ('path_analysis_algorithm','English','Path Analysis Algorithm'); -- monitoring INSERT INTO txt VALUES ('system', 'German', 'System'); @@ -3810,6 +3852,8 @@ INSERT INTO txt VALUES ('network_area', 'German', 'Netzwerk-Area'); INSERT INTO txt VALUES ('network_area', 'English', 'Network Area'); INSERT INTO txt VALUES ('network_areas', 'German', 'Netzwerk-Areas'); INSERT INTO txt VALUES ('network_areas', 'English', 'Network Areas'); +INSERT INTO txt VALUES ('error', 'German', 'Fehler'); +INSERT INTO txt VALUES ('error', 'English', 'Error'); -- help pages INSERT INTO txt VALUES ('report_types', 'German', 'Report-Typen'); @@ -4002,6 +4046,10 @@ INSERT INTO txt VALUES ('token_revoke_success', 'German', 'Token erfolgreich z INSERT INTO txt VALUES ('token_revoke_success', 'English', 'Successful Token Revocation'); INSERT INTO txt VALUES ('empty_response', 'German', 'Leere Antwort'); INSERT INTO txt VALUES ('empty_response', 'English', 'Empty Response'); +INSERT INTO txt VALUES ('interface_ticket', 'German', 'Schnittstellenticket'); +INSERT INTO txt VALUES ('interface_ticket', 'English', 'Interface ticket'); +INSERT INTO txt VALUES ('common_notification_settings', 'German', 'Gemeinsame Einstellungen für Benachrichtigungen'); +INSERT INTO txt VALUES ('common_notification_settings', 'English', 'Common notification settings'); -- user messages INSERT INTO txt VALUES ('U0001', 'German', 'Eingabetext wurde um nicht erlaubte Zeichen gekürzt'); @@ -4374,6 +4422,8 @@ INSERT INTO txt VALUES ('U9042', 'German', 'Bitte beschreiben Sie Ihre Anforder INSERT INTO txt VALUES ('U9042', 'English', 'Please describe your requirement as completely and clearly as possible so that the application owners can understand the need and provide the appropriate interface. If known, please include relevant technical details such as IP addresses, ports, or protocols.'); INSERT INTO txt VALUES ('U9043', 'German', 'Keine Logdaten für nicht modellierte Verbindungen vorhanden.'); INSERT INTO txt VALUES ('U9043', 'English', 'no log data for unmodelled connections available'); +INSERT INTO txt VALUES ('U9044', 'German', 'Benachrichtigungen an App-Verantwortliche wurden gesendet.'); +INSERT INTO txt VALUES ('U9044', 'English', 'Notifications were sent to App responsibles.'); -- error messages INSERT INTO txt VALUES ('E0001', 'German', 'Nicht klassifizierter Fehler: '); @@ -4781,6 +4831,8 @@ INSERT INTO txt VALUES ('E9029', 'German', 'Ticket enthält mehrere Anforde INSERT INTO txt VALUES ('E9029', 'English', 'Ticket has multiple request tasks'); INSERT INTO txt VALUES ('E9030', 'German', 'App Rollen dieser Applikation dürfen nur von deren Verantwortlichen mit der Rolle Modellierer geändert werden.'); INSERT INTO txt VALUES ('E9030', 'English', 'Application roles of this application may only be changed by its owners with the modeller role.'); +INSERT INTO txt VALUES ('E9031', 'German', 'Nicht alle Benachrichtigungen an App-Verantwortliche konnten gesendet werden.'); +INSERT INTO txt VALUES ('E9031', 'English', 'Not all notifications could be sent to App responsibles.'); INSERT INTO txt VALUES ('E9101', 'German', 'Fehler bei externer Auftragserzeugung.'); INSERT INTO txt VALUES ('E9101', 'English', 'Error at external ticket creation.'); INSERT INTO txt VALUES ('E9102', 'German', 'Fehler beim Ändern des externen Auftragsstatus.'); @@ -5921,8 +5973,8 @@ INSERT INTO txt VALUES ('H4105', 'German', 'Benachrichtigungen nach Termin: Def INSERT INTO txt VALUES ('H4105', 'English', 'Notifications after Deadline: Defines a time interval for recurring notifications after reaching the reference date with a maximum number of repetitions. The initial offset is added only to the first interval (also negative numbers possible). '); -INSERT INTO txt VALUES ('H4106', 'German', 'Email-Betreff: Betreff der Benachrichtigung. Verfügbare Platzhalter: @@APPNAME@@, @@APPID@@.'); -INSERT INTO txt VALUES ('H4106', 'English', 'Email subject: Subject line of the notification. Available placeholders: @@APPNAME@@, @@APPID@@.'); +INSERT INTO txt VALUES ('H4106', 'German', 'Email-Betreff: Betreff der Benachrichtigung. Verfügbare Platzhalter: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_NAME@@, @@INTERFACE_LINK@@, @@NEW_INTERFACE_NAME@@, @@NEW_INTERFACE_LINK@@, @@REASON@@, @@USER_NAME@@, @@REQUESTDATE@@.'); +INSERT INTO txt VALUES ('H4106', 'English', 'Email subject: Subject line of the notification. Available placeholders: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_NAME@@, @@INTERFACE_LINK@@, @@NEW_INTERFACE_NAME@@, @@NEW_INTERFACE_LINK@@, @@REASON@@, @@USER_NAME@@, @@REQUESTDATE@@.'); INSERT INTO txt VALUES ('H4107', 'German', 'Empfänger (To): Es werden verschiedene Rollen (je nach Kontext) als Empfänger angeboten, zu denen dann automatisch die entsprechenden Adressen ermittelt werden. Bei "Andere Adressen" können manuell Adressen eingegeben werden.'); INSERT INTO txt VALUES ('H4107', 'English', 'Recipient (To): Different roles are offered as recipients (depending on context), for which the addresses are determined automatically. With "Other Addresses" addreesses can be inserted manually.'); INSERT INTO txt VALUES ('H4108', 'German', 'Empfänger (Cc). Wie "Empfänger (To)" für Empfänger im CC.'); @@ -5931,8 +5983,10 @@ INSERT INTO txt VALUES ('H4108a','German', 'Empfänger (Bcc). Wie "Empf&aum INSERT INTO txt VALUES ('H4108a','English', 'Recipient (Bcc). As "Recipient (To)" for blind-copy recipients.'); INSERT INTO txt VALUES ('H4109', 'German', 'Name: Name der Benachrichtigung, nur für Darstellungszwecke.'); INSERT INTO txt VALUES ('H4109', 'English', 'Name: Name of notification, only for display purposes.'); -INSERT INTO txt VALUES ('H4110', 'German', 'Email-Inhalt: Text der Benachrichtigung. Bei Layouts ohne Anhang kann der Platzhalter @@CONTENT@@ verwendet werden, um den erzeugten Inhalt an dieser Stelle einzufügen. Wenn ein App-Kontext vorhanden ist, werden auch @@APPNAME@@ und @@APPID@@ ersetzt. In Workflow-Aktionsmails wird zusätzlich @@REQUESTER@@ ersetzt, wenn das Ticket bekannt ist.'); -INSERT INTO txt VALUES ('H4110', 'English', 'Email body: Text of the notification. For layouts without attachment, the @@CONTENT@@ placeholder can be used to insert the generated content at this position. If an app context is available, @@APPNAME@@ and @@APPID@@ are replaced as well. In workflow action emails, @@REQUESTER@@ is also replaced when the ticket is known.'); +INSERT INTO txt VALUES ('H4110', 'German', 'Email-Inhalt: Text der Benachrichtigung. Bei Layouts ohne Anhang kann der Platzhalter @@CONTENT@@ verwendet werden, um vom Aufrufer bereitgestellten Inhalt, z.B. einen Ablehnungsgrund, an dieser Stelle einzufügen. Die Platzhalter @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_NAME@@, @@INTERFACE_LINK@@, @@NEW_INTERFACE_NAME@@, @@NEW_INTERFACE_LINK@@, @@REASON@@, @@USER_NAME@@ und @@REQUESTDATE@@ werden ersetzt, wenn der jeweilige Kontext vom Aufrufer bereitgestellt wird.'); +INSERT INTO txt VALUES ('H4110', 'English', 'Email body: Text of the notification. For layouts without attachment, the @@CONTENT@@ placeholder can be used to insert caller-provided content, for example a rejection reason. The placeholders @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_NAME@@, @@INTERFACE_LINK@@, @@NEW_INTERFACE_NAME@@, @@NEW_INTERFACE_LINK@@, @@REASON@@, @@USER_NAME@@ and @@REQUESTDATE@@ are replaced when the caller provides the respective context.'); +INSERT INTO txt VALUES ('H4111', 'German', 'Protokollierungsmodus: Legt fest, ob eine Benachrichtigung nur gesendet, nur protokolliert oder beides gemacht wird.'); +INSERT INTO txt VALUES ('H4111', 'English', 'Logging mode: Defines whether a notification is sent only, logged only, or both.'); INSERT INTO txt VALUES ('H5001', 'German', 'In diesem Abschnitt werden die Setup- und Verwaltungseinstellungen behandelt. Die meisten Einstellungen können nur von Nutzern mit der Administrator-Rolle gesehen und geändert werden. @@ -6586,8 +6640,8 @@ INSERT INTO txt VALUES ('H5430', 'German', 'Rezert Check - Email Betreff: Betre INSERT INTO txt VALUES ('H5430', 'English', 'Recert Check - Email subject: Subject line of the notification email.'); INSERT INTO txt VALUES ('H5431', 'German', 'Der Administrator kann Vorgaben für Passwörter definieren, gegen die alle neuen Passwörter aller (internen) Nutzer geprüft werden.'); INSERT INTO txt VALUES ('H5431', 'English', 'The admin user can define a password policy, against which all new passwords of all (internal) users are checked.'); -INSERT INTO txt VALUES ('H5432', 'German', 'Rezert Check - Text überfällig: Textinhalt der Benachrichtigungsmail bei überfälligen Rezertifizierungen (Eskalation).'); -INSERT INTO txt VALUES ('H5432', 'English', 'Recert Check - text overdue: Email body of the notification email for overdue recertifications (escalation).'); +INSERT INTO txt VALUES ('H5432', 'German', 'Rezert Check - Text überfällig: Text für überfällige Rezertifizierungen (Eskalation). Dieser Text wird als @@CONTENT@@ in den Benachrichtigungstext eingesetzt.'); +INSERT INTO txt VALUES ('H5432', 'English', 'Recert Check - text overdue: Text for overdue recertifications (escalation). This text is inserted as @@CONTENT@@ into the notification body.'); INSERT INTO txt VALUES ('H5433', 'German', 'Autom. Anlegen Löschantrag: Soll automatisch ein Lösch-Ticket erzeugt werden, wenn eine Regel vollständig dezertifiziert wurde?'); INSERT INTO txt VALUES ('H5433', 'English', 'Autocreate delete rule ticket: When a rule has been fully de-certified, should a delete ticket be automatically generated?'); @@ -6616,8 +6670,8 @@ INSERT INTO txt VALUES ('H5444', 'German', 'Ziffern enthalten: Das Passwort mus INSERT INTO txt VALUES ('H5444', 'English', 'Number Required: There has to be at least one number in the password.'); INSERT INTO txt VALUES ('H5445', 'German', 'Sonderzeichen enthalten: Das Passwort muss mindestens ein Sonderzeichen enthalten. Mögliche Werte: !?(){}=~$%&#*-+.,_'); INSERT INTO txt VALUES ('H5445', 'English', 'Special Characters Required: There has to be at least one special character in the password. Possible values are: !?(){}=~$%&#*-+.,_'); -INSERT INTO txt VALUES ('H5446', 'German', 'Rezert Check - Text anstehend: Textinhalt der Benachrichtigungsmail bei demnächst anstehenden Rezertifizierungen.'); -INSERT INTO txt VALUES ('H5446', 'English', 'Recert Check - text upcoming: Email body of the notification email for upcoming recertifications.'); +INSERT INTO txt VALUES ('H5446', 'German', 'Rezert Check - Text anstehend: Text für demnächst anstehende Rezertifizierungen. Dieser Text wird als @@CONTENT@@ in den Benachrichtigungstext eingesetzt.'); +INSERT INTO txt VALUES ('H5446', 'English', 'Recert Check - text upcoming: Text for upcoming recertifications. This text is inserted as @@CONTENT@@ into the notification body.'); INSERT INTO txt VALUES ('H5447', 'German', 'Als unbenutzt gewertet nach (in Tagen): Gibt den Zeitpunkt an, vor dem die letzte Nutzung der Regel für den Unbenutzte-Regel-Report in der Vergangenheit liegen muss.'); INSERT INTO txt VALUES ('H5447', 'English', 'Regarded as unused from (in days): Defines the point in time, before which the last usage has to be in the past for the Unused Rules Report.'); INSERT INTO txt VALUES ('H5448', 'German', 'Toleranz ab Erzeugungsdatum (in Tagen): Noch niemals benutzte Regeln werden im Unbenutzte-Regel-Report nur berücksichtigt, wenn sie vor dem durch den hier definierten Toleranzwert festgelegten Zeitpunkt erzeugt wurden.'); @@ -6650,8 +6704,8 @@ INSERT INTO txt VALUES ('H5457', 'German', 'Verfügbare Module: Es kann aus INSERT INTO txt VALUES ('H5457', 'English', 'Available Modules: It can be selected, which of the technically available modules should be offered for use in the UI. The number of offered modules to the user can be smaller depending on the respective role.'); INSERT INTO txt VALUES ('H5458', 'German', 'Rezertifizierungsmodus: Methode der Rezertifizierung.'); INSERT INTO txt VALUES ('H5458', 'English', 'Recertification Mode: Type of recertification.'); -INSERT INTO txt VALUES ('H5459', 'German', 'Rezert Check - Benachrichtigungen: Legt fest, wer in welchem Zeitintervall vor dem Rezert-Termin benachrichtigt wird.'); -INSERT INTO txt VALUES ('H5459', 'English', 'Recert Check - Notifications: Defines who will be notified in which time interval before the recert deadline.'); +INSERT INTO txt VALUES ('H5459', 'German', 'Rezert Check - Benachrichtigungen: Legt Empfänger und Zeitplan der Benachrichtigungen fest. Der Text aus den Einstellungen für anstehende bzw. überfällige Rezertifizierungen wird als @@CONTENT@@ in den Benachrichtigungstext eingesetzt. Die Eigentümer-Rezertifizierungstabelle wird unabhängig davon erzeugt: Bei HtmlInBody wird sie an den Emailtext angehängt, bei einem Anhang-Layout als Datei angehängt.'); +INSERT INTO txt VALUES ('H5459', 'English', 'Recert Check - Notifications: Defines the recipients and schedule of the notifications. The text from the upcoming or overdue recertification settings is inserted as @@CONTENT@@ into the notification body. The owner recertification table is generated independently: HtmlInBody appends it to the email body; an attachment layout sends it as a file.'); INSERT INTO txt VALUES ('H5461', 'German', 'Jeder Nutzer kann seine eigene bevorzugte Sprache für die Anwendung einstellen.
    Alle Texte werden in dieser Sprache dargestellt, soweit verfügbar. Wenn nicht, wird die Standardsprache verwendet. Wenn der Text auch dort nicht verfügbar ist, wird Englisch genutzt. Die Standardsprache beim ersten Anmelden kann vom Admin für alle Nutzer in den Standardeinstellungen definiert werden.

    @@ -7211,24 +7265,28 @@ INSERT INTO txt VALUES ('H5621', 'English', 'A modeller can overwrite some perso '); INSERT INTO txt VALUES ('H5622', 'German', 'Name der beantragten Schnittstelle: Namensvorschlag bei der Beantragung einer Schnittstelle. Kann vom Antragsteller noch geändert werden.'); INSERT INTO txt VALUES ('H5622', 'English', 'Name of requested interface: Proposed name of the requested interface. Can be changed by the requester.'); -INSERT INTO txt VALUES ('H5623', 'German', 'Titel der Antragsbenachrichtigung: Betreff der Email-Benachrichtigung an die Beauftragten.'); -INSERT INTO txt VALUES ('H5623', 'English', 'Subject of request emails: Subject of the email to the addressed owners.'); -INSERT INTO txt VALUES ('H5624', 'German', 'Text der Antragsbenachrichtigung: Text der Email-Benachrichtigung an die Beauftragten. Wird noch durch Antragsteller (zu Beginn) und Beauftragtem (am Ende) ergänzt. - Hinzu kommt noch jeweils ein Link auf den Auftrag im Workflowmodul und auf die beauftragte Schnittstelle im Modellierungsmodul. +INSERT INTO txt VALUES ('H5623', 'German', 'Titel der Antragsbenachrichtigung: Betreff der Email-Benachrichtigung für die initiale Schnittstellenanfrage.'); +INSERT INTO txt VALUES ('H5623', 'English', 'Subject of initial request emails: Subject of the email notification for the initial interface request.'); +INSERT INTO txt VALUES ('H5624', 'German', 'Text der Antragsbenachrichtigung: Text der Email-Benachrichtigung für die initiale Schnittstellenanfrage. Sie wird sofort ohne Frist versendet. + Verfügbare Platzhalter: + @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624', 'English', 'Body of request emails: Text of the email notification to the addressed owners. Will be appended by the requester (at the beginning) and the addressed owner (at the end). - Additionally links to the request in the Workflow module and the requested interface in the Modelling module are added. +INSERT INTO txt VALUES ('H5624', 'English', 'Body of initial request emails: Text of the email notification for the initial interface request. It is sent immediately without a deadline. + Available placeholders: + @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624a', 'German', 'Text der Erinnerungsbenachrichtigung: Text der Email-Benachrichtigung an die Beauftragten. Verfügbare Platzhalter: +INSERT INTO txt VALUES ('H5624a', 'German', 'Text der Erinnerungsbenachrichtigung: Text der Email-Benachrichtigung für Erinnerungen an offene Schnittstellenanfragen. + Verfügbare Platzhalter: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); -INSERT INTO txt VALUES ('H5624a', 'English', 'Body of unanswered request emails: Text of the email notification to the addressed owners. Available placeholders: +INSERT INTO txt VALUES ('H5624a', 'English', 'Body of reminder emails: Text of the email notification for reminders about open interface requests. + Available placeholders: @@APPNAME@@, @@APPID@@, @@REQUESTER@@, @@REQUESTDATE@@, @@REQUESTING_APPNAME@@, @@REQUESTING_APPID@@, @@INTERFACE_LINK@@. '); INSERT INTO txt VALUES ('H5625', 'German', 'Titel des Schnittstellentickets: Titel, mit dem ein neues Ticket zur Beantragung einer Schnittstelle angelegt wird.'); INSERT INTO txt VALUES ('H5625', 'English', 'Title of interface request ticket: Title used for the new interface request ticket.'); INSERT INTO txt VALUES ('H5626', 'German', 'Titel des Schnittstellenauftrags: Titel, mit dem ein neuer Auftrag im Ticket zur Beantragung einer Schnittstelle angelegt wird.'); -INSERT INTO txt VALUES ('H5626', 'English', 'Title of interface request ticket: Title used for the Task in the new interface request ticket.'); +INSERT INTO txt VALUES ('H5626', 'English', 'Title of interface request task: Title used for the task in the new interface request ticket.'); INSERT INTO txt VALUES ('H5627', 'German', 'App-Server-Typen: Hier können beliebige App-Server-Typen mit Namen und Id definiert werden. Bitte jeweils neue Id vergeben. Vorsicht beim Löschen bereits verwendeter Typen! Der Standard-typ ist immer vorhanden und wird beim Datenimport verwendet. Hier kann nur der dargestellte Name gesetzt werden. Zur manuellen Zuweisung zu einem App-Server kann er nicht verwendet werden. '); @@ -7241,17 +7299,17 @@ INSERT INTO txt VALUES ('H5628', 'German', 'Vordefinierte Dienste: Hier wird de INSERT INTO txt VALUES ('H5628', 'English', 'Predefined Services: Offers a menu to the administrator to define, change or delete predefined services or service groups. These services are available for all applications. '); -INSERT INTO txt VALUES ('H5629', 'German', 'Erinnerungsbenachrichtigungen: Legt fest, wer wie oft in welchem Zeitintervall über offene Schnittstellenanträge benachrichtigt wird.'); -INSERT INTO txt VALUES ('H5629', 'English', 'Reminders: Defines who will be notified how often and in which time interval about open interface requests.'); -INSERT INTO txt VALUES ('H5630', 'German', 'Empfänger der Antragsbenachrichtigung: Mehrfachauswahl, an wen die Email geschickt werden soll. +INSERT INTO txt VALUES ('H5629', 'German', 'Erinnerungsbenachrichtigungen: Legt fest, welche Erinnerungsbenachrichtigungen für offene Schnittstellenanfragen mit der Frist RequestDate versendet werden.'); +INSERT INTO txt VALUES ('H5629', 'English', 'Reminders: Defines which reminder notifications are sent for open interface requests with deadline RequestDate.'); +INSERT INTO txt VALUES ('H5630', 'German', 'Empfänger der Antragsbenachrichtigung: Mehrfachauswahl, an wen die initiale Benachrichtigung als To, Cc oder Bcc geschickt werden soll. Verfügbare Optionen sind Keine, Andere Adressen, die Option "Mindestens eine Email-Benachrichtigung sicherstellen" und alle aktiven Verantwortlichkeitsstufen. Ist "Keine" gesetzt, sind alle anderen Optionen deaktiviert. Wenn "Mindestens eine Email-Benachrichtigung sicherstellen" aktiv ist, werden zuerst die ausgewählten Optionen verwendet. Nur falls daraus keine Empfänger entstehen, werden nicht ausgewählte Verantwortlichkeitsstufen von hoher zu niedriger Sortierreihenfolge durchsucht, bis eine Stufe mit Empfängern gefunden wird. - Zusätzlich kann festgelegt werden, ob der Antragsteller die Nachricht im Cc bekommen soll (Default: ja). + Der Antragsteller kann dabei als Empfänger in der gewünschten Spalte ausgewählt werden. '); -INSERT INTO txt VALUES ('H5630', 'English', 'Receiver of request emails: Multi-select list defining who receives the request notification email. +INSERT INTO txt VALUES ('H5630', 'English', 'Receiver of initial request emails: Multi-select list defining who receives the initial notification in To, Cc or Bcc. Available options are None, Other addresses, the option "Ensure at least one email notification can be sent", and all active responsible levels. If "None" is selected, all other options are disabled. If "Ensure at least one email notification can be sent" is active, selected options are used first. Only if they yield no recipients, non-selected responsible levels are checked from highest to lowest sort order until a level with recipients is found. - It can also be configured whether the requester receives the email in Cc (default: yes). + The requester can also be selected as recipient in the desired column. '); INSERT INTO txt VALUES ('H5631', 'German', 'Servicegruppen auflösen: Ersetze beim Erzeugen eines externen Auftrags die Servicegruppen durch die einzelnen Mitglieder.'); INSERT INTO txt VALUES ('H5631', 'English', 'Resolve service groups: Replace service groups by its members when creating an external request.'); @@ -7277,6 +7335,12 @@ INSERT INTO txt VALUES ('H5638', 'German', 'Hier wird der Anweisungstext fü INSERT INTO txt VALUES ('H5638', 'English', 'Insert text for extra configurations.'); INSERT INTO txt VALUES ('H5639', 'German', 'Hier wird der Meldungstext für Fehler im externen Beantragungsprozess eingetragen.'); INSERT INTO txt VALUES ('H5639', 'English', 'Insert customized text for errors in requests to external ticket system.'); +INSERT INTO txt VALUES ('H5640', 'German', 'Antragsbenachrichtigung: Benachrichtigt die konfigurierten Empfänger sofort, wenn ein neues Schnittstellenticket angelegt wurde. Sie verwendet die Frist "None"; deshalb werden keine Vor- oder Nachfrist-Offsets ausgewertet.'); +INSERT INTO txt VALUES ('H5640', 'English', 'Initial request: Notifies the configured recipients immediately when a new interface request ticket is created. It uses deadline "None", so no before- or after-deadline offsets are evaluated.'); +INSERT INTO txt VALUES ('H5641', 'German', 'Erinnerung: Benachrichtigt über offene Schnittstellenanfragen. Sie verwendet die Frist "RequestDate"; die Vorfrist, Nachfrist und Wiederholungen bestimmen, wann Erinnerungen gesendet werden.'); +INSERT INTO txt VALUES ('H5641', 'English', 'Reminder: Notifies about open interface requests. It uses deadline "RequestDate"; the before-deadline, after-deadline and repetition settings determine when reminders are sent.'); +INSERT INTO txt VALUES ('H5642', 'German', 'Schnittstellenstilllegung: Benachrichtigt die Verantwortlichen der Anwendungen, die eine stillgelegte Schnittstelle verwenden. Sie wird sofort mit der Frist "None" gesendet. Falls eine Ersatzschnittstelle angegeben ist, werden deren Name und Link eingesetzt, gefolgt von den betroffenen Verbindungen.'); +INSERT INTO txt VALUES ('H5642', 'English', 'Interface decommission: Notifies the responsible users of applications using a decommissioned interface. It is sent immediately with deadline "None". If a replacement interface is provided, its name and link are inserted, followed by the affected connections.'); INSERT INTO txt VALUES ('H5643', 'German', 'DNS-Suche: Wenn gesetzt, werden App-Server-Namen aus dem DNS ergänzt und sind nicht manuell editierbar. Falls kein Name aufgelöst werden kann, wird er aus Präfix und IP-Adresse konstruiert.'); INSERT INTO txt VALUES ('H5643', 'English', 'DNS Look-up: When set, App Server names are resolved from DNS and are not manually editable. If no name can be resolved, it is constructed from prefix and IP address.'); INSERT INTO txt VALUES ('H5644', 'German', 'Existierende Namen überschreiben: Wenn gesetzt, werden bereits existierende Namen auch überschrieben, wenn sie nicht im DNS aufgelöst werden können.'); @@ -7331,19 +7395,19 @@ INSERT INTO txt VALUES ('H5658', 'German', 'Rezertifizierungstext: Text, der zu INSERT INTO txt VALUES ('H5658', 'English', 'Recertification Text: Text to be shown to confirm recertification. If the text contains a placeholder "@@APPNAME@@", it is replaced by the app name.'); INSERT INTO txt VALUES ('H5659', 'German', 'Netzwerkareas für Updatable Objects: Vom Administrator vorgegebene Netzwerkareas, welche für die Zuordnung über die Sonderkonfiguration "Updatable Objects" genutzt werden dürfen. Die beiden Auswahlfelder "in Quelle" und "in Ziel" legen fest, wo die Netzwerkarea genutzt werden darf.'); INSERT INTO txt VALUES ('H5659', 'English', 'Network Areas for Updatable Objects: Network areas defined by the administrator, which are permitted to be used for assignment via the Extra Configurations "Updatable Objects". The flags "in Source" and "in Destination" determine, where the Network Areas are allowed to be used.'); -INSERT INTO txt VALUES ('H5660', 'German', 'Empfänger der Stilllegungsbenachrichtigung: Mehrfachauswahl, an wen die Email geschickt werden soll. +INSERT INTO txt VALUES ('H5660', 'German', 'Empfänger der Stilllegungsbenachrichtigung: Mehrfachauswahl, an wen die Stilllegungsbenachrichtigung als To, Cc oder Bcc geschickt werden soll. Verfügbare Optionen sind Keine, Andere Adressen, die Option "Mindestens eine Email-Benachrichtigung sicherstellen" und alle aktiven Verantwortlichkeitsstufen. Ist "Keine" gesetzt, sind alle anderen Optionen deaktiviert. Wenn "Mindestens eine Email-Benachrichtigung sicherstellen" aktiv ist, werden zuerst die ausgewählten Optionen verwendet. Nur falls daraus keine Empfänger entstehen, werden nicht ausgewählte Verantwortlichkeitsstufen von hoher zu niedriger Sortierreihenfolge durchsucht, bis eine Stufe mit Empfängern gefunden wird.'); -INSERT INTO txt VALUES ('H5660', 'English', 'Receiver of decommission emails: Multi-select list defining who receives the decommission notification email. +INSERT INTO txt VALUES ('H5660', 'English', 'Receiver of decommission emails: Multi-select list defining who receives the decommission notification in To, Cc or Bcc. Available options are None, Other addresses, the option "Ensure at least one email notification can be sent", and all active responsible levels. If "None" is selected, all other options are disabled. If "Ensure at least one email notification can be sent" is active, selected options are used first. Only if they yield no recipients, non-selected responsible levels are checked from highest to lowest sort order until a level with recipients is found.'); -INSERT INTO txt VALUES ('H5661', 'German', 'Titel der Stilllegungsbenachrichtigung: Betreff der Email-Benachrichtigung an die betroffenen Eigentümer. Platzhalter @@INTERFACE_NAME@@ werden mit dem Namen der zu löschenden Schnittstelle ersetzt.'); -INSERT INTO txt VALUES ('H5661', 'English', 'Subject of decommission emails: Subject of the email to the addressed owners. Placeholders @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned.'); -INSERT INTO txt VALUES ('H5662', 'German', 'Text der Stilllegungsbenachrichtigung: Text der Email-Benachrichtigung an die Nutzer der Schnittstelle, gefolgt von der Liste der betroffenen Verbindungen. Es können folgende Platzhalter genutzt werden: +INSERT INTO txt VALUES ('H5661', 'German', 'Titel der Stilllegungsbenachrichtigung: Betreff der Email-Benachrichtigung für die Stilllegung eines Eigentümers. Platzhalter @@INTERFACE_NAME@@ werden mit dem Namen der stillzulegenden Schnittstelle ersetzt.'); +INSERT INTO txt VALUES ('H5661', 'English', 'Subject of decommission emails: Subject of the email notification for decommissioning an owner. Placeholders @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned.'); +INSERT INTO txt VALUES ('H5662', 'German', 'Text der Stilllegungsbenachrichtigung: Text der Email-Benachrichtigung für die Stilllegung eines Eigentümers, gefolgt von der Liste der betroffenen Verbindungen. Es können folgende Platzhalter genutzt werden: @@INTERFACE_NAME@@ wird durch den Namen der stillzulegenden Schnittstelle ersetzt, @@NEW_INTERFACE_NAME@@ mit dem Namen der vorgeschlagenen Ersatzschnittstelle, @@NEW_INTERFACE_LINK@@ mit einem Link auf diese, - @@REASON@@ mit dem Begründungstext, der im Stillegungsformular eingegeben wurde, @@USER_NAME@@ mit dem Nutzer, der die Stillegung veranlasst hat. + @@REASON@@ mit dem Begründungstext, der im Stilllegungsformular eingegeben wurde, @@USER_NAME@@ mit dem Nutzer, der die Stilllegung veranlasst hat. '); -INSERT INTO txt VALUES ('H5662', 'English', 'Body of decommission emails: Text of the email notification to the addressed owners, followed by a list of the affected connections. Some placeholders can be used: +INSERT INTO txt VALUES ('H5662', 'English', 'Body of decommission emails: Text of the email notification for decommissioning an owner, followed by a list of the affected connections. Some placeholders can be used: @@INTERFACE_NAME@@ will be replaced by the name of the interface to be decommissioned, @@NEW_INTERFACE_NAME@@ by the name of the proposed new interface, @@NEW_INTERFACE_LINK@@ by a link to this interface, @@REASON@@ by the reason text filled in the decommission form, @@USER_NAME@@ by the user initiating the decommissioning. '); @@ -8191,6 +8255,11 @@ INSERT INTO txt VALUES ('H7413', 'German', 'Die Tabellen sind in Abschnitte gegl INSERT INTO txt VALUES ('H7413', 'English', 'The tables are grouped in sections. Use the action column to remove orphaned entries from the connection. '); +INSERT INTO txt VALUES ('H7414', 'German', 'Email-Protokoll: Zeigt alle protokollierten Benachrichtigungs-Emails mit Zeitstempel, Benachrichtigungstyp, Empfängern und Betreff. +'); +INSERT INTO txt VALUES ('H7414', 'English', 'Email log: Shows all logged notification emails with timestamp, notification type, recipients and subject. +'); + INSERT INTO txt VALUES ('H7421', 'German', 'Diese Seite zeigt alle angeforderten Schnittstellen mit beantragender und angefragter Applikation, Ticket, Status und Erstellungsdatum. Zusätzlich zeigt die Spalte "Verwendet in Verbindungs-IDs", in welchen bestehenden Verbindungen die Schnittstelle bereits referenziert wird. Über die Aktionsspalte können Schnittstellendetails und das zugehörige Ticket angezeigt werden. diff --git a/roles/database/files/upgrade/9.5.4.sql b/roles/database/files/upgrade/9.5.4.sql new file mode 100644 index 0000000000..47d8956e95 --- /dev/null +++ b/roles/database/files/upgrade/9.5.4.sql @@ -0,0 +1,419 @@ +-- migrate interface request notification text from legacy config to notification rows +WITH request_config AS +( + SELECT + MAX(CASE WHEN config_key = 'modReqEmailReceiver' THEN BTRIM(COALESCE(config_value, '')) END) AS recipients, + MAX(CASE WHEN config_key = 'modReqEmailOtherAddresses' THEN COALESCE(config_value, '') END) AS other_addresses, + MAX(CASE WHEN config_key = 'modReqEmailRequesterInCc' THEN LOWER(BTRIM(COALESCE(config_value, ''))) END) AS requester_in_cc, + MAX(CASE WHEN config_key = 'modReqEmailSubject' THEN COALESCE(config_value, '') END) AS subject, + MAX(CASE WHEN config_key = 'modReqEmailBody' THEN COALESCE(config_value, '') END) AS body, + MAX(CASE WHEN config_key = 'modUnansweredReqEmailBody' THEN COALESCE(config_value, '') END) AS reminder_body + FROM config + WHERE config_user = 0 + AND config_key IN ('modReqEmailReceiver', 'modReqEmailOtherAddresses', 'modReqEmailRequesterInCc', 'modReqEmailSubject', 'modReqEmailBody', 'modUnansweredReqEmailBody') +), +request_recipient AS +( + SELECT + request_config.*, + CASE + WHEN recipients = 'OwnerMainResponsible' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":false,"owner_responsible_type_ids":[1]}' + WHEN recipients = 'OwnerGroupOnly' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":false,"owner_responsible_type_ids":[2]}' + WHEN recipients = 'FallbackToMainResponsibleIfOwnerGroupEmpty' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":true,"owner_responsible_type_ids":[2,1]}' + WHEN recipients = 'AllOwnerResponsibles' THEN + json_build_object( + 'none', false, + 'other_addresses', false, + 'other_address_list', '[]'::json, + 'requester', false, + 'ensure_at_least_one_notification', false, + 'owner_responsible_type_ids', COALESCE(( + SELECT json_agg(id ORDER BY sort_order DESC) + FROM owner_responsible_type + WHERE active = true + ), '[]'::json) + )::text + WHEN recipients = 'OtherAddresses' OR (recipients = '' AND other_addresses <> '') THEN + json_build_object( + 'none', false, + 'other_addresses', true, + 'other_address_list', COALESCE(( + SELECT json_agg(btrim(address)) + FROM regexp_split_to_table(other_addresses, '[,;|]') AS address + WHERE btrim(address) <> '' + ), '[]'::json), + 'requester', false, + 'ensure_at_least_one_notification', false, + 'owner_responsible_type_ids', '[]'::json + )::text + WHEN recipients LIKE '{%' AND other_addresses <> '' AND recipients NOT LIKE '%"other_address_list"%' + THEN jsonb_set(recipients::jsonb, '{other_address_list}', COALESCE(( + SELECT jsonb_agg(btrim(address)) + FROM regexp_split_to_table(other_addresses, '[,;|]') AS address + WHERE btrim(address) <> '' + ), '[]'::jsonb), true)::text + WHEN recipients LIKE '{%' THEN recipients + ELSE '' + END AS recipient_selection + FROM request_config +), +initial_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'InterfaceRequest' + AND (deadline = 'None' OR deadline IS NULL) +), +reminder_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'InterfaceRequest' + AND deadline = 'RequestDate' +), +insert_initial_notification AS +( + INSERT INTO notification + ( + notification_client, + name, + channel, + recipient_to, + email_address_to, + recipient_cc, + email_address_cc, + recipient_bcc, + email_address_bcc, + email_subject, + email_body, + layout, + deadline, + interval_before_deadline, + offset_before_deadline, + repeat_interval_after_deadline, + initial_offset_after_deadline, + repeat_offset_after_deadline, + repetitions_after_deadline + ) + SELECT + 'InterfaceRequest', + 'Interface requested', + 'Email', + CASE + WHEN recipient_selection ~ '"requester"[[:space:]]*:[[:space:]]*true' + OR recipient_selection ~ '"owner_responsible_type_ids"[[:space:]]*:[[:space:]]*\[[[:space:]]*[0-9]' THEN 'ConfiguredResponsibles' + WHEN recipient_selection ~ '"other_addresses"[[:space:]]*:[[:space:]]*true' + AND recipient_selection ~ '"other_address_list"[[:space:]]*:[[:space:]]*\[[[:space:]]*"' THEN 'OtherAddresses' + ELSE 'None' + END, + CASE + WHEN recipient_selection = '' THEN '' + ELSE recipient_selection + END, + CASE WHEN requester_in_cc = 'true' THEN 'Requester' ELSE 'None' END, + '', + 'None', + '', + CASE + WHEN LENGTH(subject) = 0 THEN 'Interface requested' + ELSE subject + END, + body, + 'SimpleText', + 'None', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM request_recipient + CROSS JOIN initial_notification_seed + WHERE notification_count = 0 + AND (COALESCE(recipients, '') <> '' OR COALESCE(other_addresses, '') <> '') + RETURNING 1 +), +update_initial_bodies AS +( + UPDATE notification n + SET email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN request_config.body + ELSE n.email_body + END + FROM request_config + CROSS JOIN initial_notification_seed + WHERE n.notification_client = 'InterfaceRequest' + AND (n.deadline = 'None' OR n.deadline IS NULL) + AND initial_notification_seed.notification_count > 0 + RETURNING 1 +), +update_reminder_bodies AS +( + UPDATE notification n + SET email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN request_config.reminder_body + ELSE n.email_body + END + FROM request_config + CROSS JOIN reminder_notification_seed + WHERE n.notification_client = 'InterfaceRequest' + AND n.deadline = 'RequestDate' + AND reminder_notification_seed.notification_count > 0 + RETURNING 1 +) +SELECT 1; + +-- migrate rule-by-rule recertification delivery to notifications +WITH recert_config AS +( + SELECT + MAX(CASE WHEN config_key = 'recCheckEmailSubject' THEN COALESCE(config_value, '') END) AS subject + FROM config + WHERE config_user = 0 + AND config_key = 'recCheckEmailSubject' +), +recert_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'RuleRecertification' +), +insert_rule_recertification_notification AS +( + INSERT INTO notification + ( + notification_client, + name, + channel, + recipient_to, + email_address_to, + recipient_cc, + email_address_cc, + recipient_bcc, + email_address_bcc, + email_subject, + email_body, + layout, + deadline, + interval_before_deadline, + offset_before_deadline, + repeat_interval_after_deadline, + initial_offset_after_deadline, + repeat_offset_after_deadline, + repetitions_after_deadline + ) + SELECT + 'RuleRecertification', + 'Rule recertification', + 'Email', + 'AllOwnerResponsibles', + '', + 'None', + '', + 'None', + '', + CASE + WHEN COALESCE(recert_config.subject, '') = '' THEN 'Rule recertification @@APPNAME@@' + ELSE recert_config.subject || ' @@APPNAME@@' + END, + '@@CONTENT@@', + 'SimpleText', + 'None', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM recert_config + CROSS JOIN recert_notification_seed + WHERE notification_count = 0 + RETURNING 1 +) +SELECT 1; + +ALTER TABLE notification + ADD COLUMN IF NOT EXISTS logging Varchar NOT NULL DEFAULT 'send_only'; + +ALTER TABLE notification + ADD COLUMN IF NOT EXISTS active Boolean NOT NULL DEFAULT TRUE; + +UPDATE notification +SET logging = 'send_only' +WHERE NULLIF(logging, '') IS NULL; + +CREATE TABLE IF NOT EXISTS notification_log +( + id SERIAL PRIMARY KEY, + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + notification_id INTEGER NOT NULL, + notification_type Varchar NOT NULL, + "to" Varchar NOT NULL DEFAULT '', + cc Varchar NOT NULL DEFAULT '', + bcc Varchar NOT NULL DEFAULT '', + subject Varchar NOT NULL DEFAULT '', + deadline_type Varchar NOT NULL DEFAULT 'None', + deadline TIMESTAMP WITH TIME ZONE, + status Varchar NOT NULL DEFAULT 'Pending', + error Varchar NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS notification_log_timestamp_id_idx + ON notification_log ("timestamp" DESC, id DESC); + +WITH decomm_config AS +( + SELECT + MAX(CASE WHEN config_key = 'modDecommEmailReceiver' THEN BTRIM(COALESCE(config_value, '')) END) AS recipients, + MAX(CASE WHEN config_key = 'modDecommEmailOtherAddresses' THEN COALESCE(config_value, '') END) AS other_addresses, + MAX(CASE WHEN config_key = 'modDecommEmailSubject' THEN COALESCE(config_value, '') END) AS subject, + MAX(CASE WHEN config_key = 'modDecommEmailBody' THEN COALESCE(config_value, '') END) AS body + FROM config + WHERE config_user = 0 + AND config_key IN ('modDecommEmailReceiver', 'modDecommEmailOtherAddresses', 'modDecommEmailSubject', 'modDecommEmailBody') +), +decomm_recipient AS +( + SELECT + decomm_config.*, + CASE + WHEN recipients = 'OwnerMainResponsible' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":false,"owner_responsible_type_ids":[1]}' + WHEN recipients = 'OwnerGroupOnly' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":false,"owner_responsible_type_ids":[2]}' + WHEN recipients = 'FallbackToMainResponsibleIfOwnerGroupEmpty' THEN + '{"none":false,"other_addresses":false,"other_address_list":[],"requester":false,"ensure_at_least_one_notification":true,"owner_responsible_type_ids":[2,1]}' + WHEN recipients = 'AllOwnerResponsibles' THEN + json_build_object( + 'none', false, + 'other_addresses', false, + 'other_address_list', '[]'::json, + 'requester', false, + 'ensure_at_least_one_notification', false, + 'owner_responsible_type_ids', COALESCE(( + SELECT json_agg(id ORDER BY sort_order DESC) + FROM owner_responsible_type + WHERE active = true + ), '[]'::json) + )::text + WHEN recipients = 'OtherAddresses' OR (recipients = '' AND other_addresses <> '') THEN + json_build_object( + 'none', false, + 'other_addresses', true, + 'other_address_list', COALESCE(( + SELECT json_agg(btrim(address)) + FROM regexp_split_to_table(other_addresses, '[,;|]') AS address + WHERE btrim(address) <> '' + ), '[]'::json), + 'requester', false, + 'ensure_at_least_one_notification', false, + 'owner_responsible_type_ids', '[]'::json + )::text + WHEN recipients LIKE '{%' AND other_addresses <> '' AND recipients NOT LIKE '%"other_address_list"%' + THEN jsonb_set(recipients::jsonb, '{other_address_list}', COALESCE(( + SELECT jsonb_agg(btrim(address)) + FROM regexp_split_to_table(other_addresses, '[,;|]') AS address + WHERE btrim(address) <> '' + ), '[]'::jsonb), true)::text + WHEN recipients LIKE '{%' THEN recipients + ELSE '' + END AS recipient_selection + FROM decomm_config +), +decomm_notification_seed AS +( + SELECT COUNT(*) AS notification_count + FROM notification + WHERE notification_client = 'InterfaceDecomm' + AND (deadline = 'None' OR deadline IS NULL) +), +insert_decomm_notification AS +( + INSERT INTO notification + ( + notification_client, + name, + channel, + recipient_to, + email_address_to, + recipient_cc, + email_address_cc, + recipient_bcc, + email_address_bcc, + email_subject, + email_body, + layout, + deadline, + interval_before_deadline, + offset_before_deadline, + repeat_interval_after_deadline, + initial_offset_after_deadline, + repeat_offset_after_deadline, + repetitions_after_deadline + ) + SELECT + 'InterfaceDecomm', + 'Interface decommissioned', + 'Email', + CASE + WHEN recipient_selection ~ '"requester"[[:space:]]*:[[:space:]]*true' + OR recipient_selection ~ '"owner_responsible_type_ids"[[:space:]]*:[[:space:]]*\[[[:space:]]*[0-9]' THEN 'ConfiguredResponsibles' + WHEN recipient_selection ~ '"other_addresses"[[:space:]]*:[[:space:]]*true' + AND recipient_selection ~ '"other_address_list"[[:space:]]*:[[:space:]]*\[[[:space:]]*"' THEN 'OtherAddresses' + ELSE 'None' + END, + CASE + WHEN recipient_selection = '' THEN '' + ELSE recipient_selection + END, + 'None', + '', + 'None', + '', + CASE + WHEN LENGTH(subject) = 0 THEN 'Interface decommissioned' + ELSE subject + END, + body, + 'SimpleText', + 'None', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM decomm_recipient + CROSS JOIN decomm_notification_seed + WHERE notification_count = 0 + AND ( + recipient_selection ~ '"owner_responsible_type_ids"[[:space:]]*:[[:space:]]*\[[[:space:]]*[0-9]' + OR recipient_selection ~ '"other_address_list"[[:space:]]*:[[:space:]]*\[[[:space:]]*"' + OR LENGTH(subject) > 0 + OR LENGTH(body) > 0 + ) + RETURNING 1 +), +update_decomm_subject_bodies AS +( + UPDATE notification n + SET + email_subject = CASE + WHEN COALESCE(n.email_subject, '') = '' THEN CASE WHEN LENGTH(decomm_config.subject) = 0 THEN 'Interface decommissioned' ELSE decomm_config.subject END + ELSE n.email_subject + END, + email_body = CASE + WHEN COALESCE(n.email_body, '') = '' THEN decomm_config.body + ELSE n.email_body + END + FROM decomm_config + CROSS JOIN decomm_notification_seed + WHERE n.notification_client = 'InterfaceDecomm' + AND (n.deadline = 'None' OR n.deadline IS NULL) + AND decomm_notification_seed.notification_count > 0 + RETURNING 1 +) +SELECT 1; diff --git a/roles/lib/files/FWO.Api.Client/Queries/ModellingQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/ModellingQueries.cs index 15b7719fe6..c5ea8f2ff5 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/ModellingQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/ModellingQueries.cs @@ -35,6 +35,7 @@ public class ModellingQueries : Queries public static readonly string getPublishedInterfaces; public static readonly string getRequestedInterfaces; public static readonly string getConnectionById; + public static readonly string getConnectionForNotification; public static readonly string getConnections; public static readonly string getConnectionsResolved; public static readonly string getConnectionsByTicketId; @@ -167,6 +168,7 @@ static ModellingQueries() getPublishedInterfaces = connectionDetailsFragment + GetQueryText("modelling/getPublishedInterfaces.graphql"); getRequestedInterfaces = connectionDetailsFragment + GetQueryText("modelling/getRequestedInterfaces.graphql"); getConnectionById = connectionDetailsFragment + GetQueryText("modelling/getConnectionById.graphql"); + getConnectionForNotification = GetQueryText("modelling/getConnectionForNotification.graphql"); getConnections = connectionDetailsFragment + GetQueryText("modelling/getConnections.graphql"); getConnectionsResolved = connectionResolvedDetailsFragment + GetQueryText("modelling/getConnectionsResolved.graphql"); getConnectionsByTicketId = connectionDetailsFragment + GetQueryText("modelling/getConnectionsByTicketId.graphql"); diff --git a/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs index 23d2ad3990..2c9418cc22 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/MonitorQueries.cs @@ -9,6 +9,7 @@ public class MonitorQueries : Queries public static readonly string addUiLogEntry; public static readonly string getUiLogEntrys; public static readonly string getAllUiLogEntrys; + public static readonly string getNotificationLogEntrys; public static readonly string getImportLogEntrys; public static readonly string addAlert; public static readonly string getOpenAlerts; @@ -38,6 +39,7 @@ static MonitorQueries() addUiLogEntry = GetQueryText("monitor/addUiLogEntry.graphql"); getUiLogEntrys = GetQueryText("monitor/getUiLogEntrys.graphql"); getAllUiLogEntrys = GetQueryText("monitor/getAllUiLogEntrys.graphql"); + getNotificationLogEntrys = GetQueryText("monitor/getNotificationLogEntrys.graphql"); getImportLogEntrys = GetQueryText("monitor/getImportLogEntrys.graphql"); diff --git a/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs index 2cfac0ee49..baca6090cc 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/NotificationQueries.cs @@ -5,9 +5,15 @@ namespace FWO.Api.Client.Queries public class NotificationQueries : Queries { public static readonly string getNotifications; + public static readonly string getNotificationById; public static readonly string addNotification; public static readonly string updateNotification; public static readonly string updateNotificationsLastSent; + public static readonly string insertNotificationLog; + public static readonly string updateNotificationLog; + public static readonly string refreshNotificationLog; + public static readonly string getNoRecipientNotificationLogs; + public static readonly string getNoRecipientNotificationLogsWithoutDeadline; public static readonly string deleteNotification; @@ -17,9 +23,15 @@ static NotificationQueries() try { getNotifications = File.ReadAllText(QueryPath + "notification/getNotifications.graphql"); + getNotificationById = File.ReadAllText(QueryPath + "notification/getNotificationById.graphql"); addNotification = File.ReadAllText(QueryPath + "notification/addNotification.graphql"); updateNotification = File.ReadAllText(QueryPath + "notification/updateNotification.graphql"); updateNotificationsLastSent = File.ReadAllText(QueryPath + "notification/updateNotificationsLastSent.graphql"); + insertNotificationLog = File.ReadAllText(QueryPath + "notification_log/insertNotificationLog.graphql"); + updateNotificationLog = File.ReadAllText(QueryPath + "notification_log/updateNotificationLog.graphql"); + refreshNotificationLog = File.ReadAllText(QueryPath + "notification_log/refreshNotificationLog.graphql"); + getNoRecipientNotificationLogs = File.ReadAllText(QueryPath + "notification_log/getNoRecipientNotificationLogs.graphql"); + getNoRecipientNotificationLogsWithoutDeadline = File.ReadAllText(QueryPath + "notification_log/getNoRecipientNotificationLogsWithoutDeadline.graphql"); deleteNotification = File.ReadAllText(QueryPath + "notification/deleteNotification.graphql"); } catch (Exception exception) diff --git a/roles/lib/files/FWO.Api.Client/Queries/OwnerQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/OwnerQueries.cs index 2345f803ce..d51c919e51 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/OwnerQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/OwnerQueries.cs @@ -8,6 +8,7 @@ public class OwnerQueries : Queries public static readonly string ownerDetailsWithLifecycleStateFragment; public static readonly string getOwnerById; + public static readonly string getOwnerForNotification; public static readonly string getOwners; public static readonly string getOwnersFiltered; public static readonly string getApplicationIdentifiers; @@ -70,6 +71,7 @@ static OwnerQueries() ownerDetailsWithLifecycleStateFragment = ownerDetailsFragment + GetQueryText("owner/fragments/ownerDetailsWithLifecycleState.graphql"); getOwnerById = GetQueryText("owner/getOwnerById.graphql"); + getOwnerForNotification = GetQueryText("owner/getOwnerForNotification.graphql"); getOwners = ownerDetailsFragment + GetQueryText("owner/getOwners.graphql"); getOwnersFiltered = ownerDetailsWithLifecycleStateFragment + GetQueryText("owner/getOwnersFiltered.graphql"); getApplicationIdentifiers = GetQueryText("owner/getApplicationIdentifiers.graphql"); diff --git a/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs b/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs index 68ca529be7..c85c74124a 100644 --- a/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs +++ b/roles/lib/files/FWO.Api.Client/Queries/RequestQueries.cs @@ -18,6 +18,7 @@ public class RequestQueries : Queries public static readonly string getFullTicketsPaged; public static readonly string getOwnerTicketIds; public static readonly string getTicketById; + public static readonly string getTicketRequesterId; public static readonly string getAuditProofCriticalChangesForTicket; public static readonly string getTicketIdIfExists; public static readonly string getTicketsByParameters; @@ -108,6 +109,7 @@ static RequestQueries() getTicketsByParameters = ticketDetailsReqTaskOverviewFragment + GetQueryText("request/getTicketsByParameters.graphql"); getRequestTasksByIds = reqTaskDetailsFragment + GetQueryText("request/getRequestTasksByIds.graphql"); getTicketById = ticketDetailsFragment + GetQueryText("request/getTicketById.graphql"); + getTicketRequesterId = GetQueryText("request/getTicketRequesterId.graphql"); getAuditProofCriticalChangesForTicket = GetQueryText("request/getAuditProofCriticalChangesForTicket.graphql"); getTicketIdIfExists = GetQueryText("request/getTicketIdIfExists.graphql"); newTicket = GetQueryText("request/newTicket.graphql"); diff --git a/roles/lib/files/FWO.Config.Api/Config.cs b/roles/lib/files/FWO.Config.Api/Config.cs index 8e9334f70e..b3624a24a7 100644 --- a/roles/lib/files/FWO.Config.Api/Config.cs +++ b/roles/lib/files/FWO.Config.Api/Config.cs @@ -27,6 +27,13 @@ public abstract class Config : ConfigData, IDisposable protected readonly SemaphoreSlim semaphoreSlim = new(1, 1); + private static readonly HashSet ignoredLegacyConfigKeys = + [ + "manageOwnerLdapGroups", + "ownerLdapId", + "ownerLdapGroupNames" + ]; + // To detect redundant dispose calls private bool _isDisposed; protected bool IsDisposed => _isDisposed; @@ -127,7 +134,7 @@ protected void Update(ConfigItem[] configItems) ApplyConfigValue(property, resolvedKey, configItem, remainingConfigItemNames); } } - foreach (var name in remainingConfigItemNames.Where(n => !n.Contains("StateMatrix"))) // StateMatrix ConfigItems are handled separately + foreach (var name in remainingConfigItemNames.Where(n => !n.Contains("StateMatrix") && !ignoredLegacyConfigKeys.Contains(n))) // StateMatrix and retired settings are handled separately { Log.WriteDebug($"Load {(UserId == 0 ? "Global " : "")}Config Items", $"Config item with key \"{name}\" could not be found. {(UserId == 0 ? "" : "User might not have customized the setting. ")}Using default value."); } diff --git a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs index 416a201d06..3633b975f4 100644 --- a/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs +++ b/roles/lib/files/FWO.Config.Api/Data/ConfigData.cs @@ -170,6 +170,7 @@ public class ConfigData : ICloneable public string RecCheckParams { get; set; } = System.Text.Json.JsonSerializer.Serialize(new RecertCheckParams()); [JsonProperty("recCheckEmailSubject"), JsonPropertyName("recCheckEmailSubject")] + [Obsolete("Migrated to notification subjects in version 9.5.3.")] public string RecCheckEmailSubject { get; set; } = ""; [JsonProperty("recCheckEmailUpcomingText"), JsonPropertyName("recCheckEmailUpcomingText")] @@ -415,21 +416,26 @@ public class ConfigData : ICloneable [JsonProperty("flowZoneGroupNamePatterns"), JsonPropertyName("flowZoneGroupNamePatterns")] public string FlowZoneGroupNamePatterns { get; set; } = "[]"; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailReceiver"), JsonPropertyName("modReqEmailReceiver")] public string ModReqEmailReceiver { get; set; } = nameof(EmailRecipientOption.None); + [Obsolete("Use notification recipient selection for NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailRequesterInCc"), JsonPropertyName("modReqEmailRequesterInCc")] public bool ModReqEmailRequesterInCc { get; set; } = true; [JsonProperty("modReqEmailOtherAddresses"), JsonPropertyName("modReqEmailOtherAddresses")] public string ModReqEmailOtherAddresses { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailSubject"), JsonPropertyName("modReqEmailSubject")] public string ModReqEmailSubject { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modReqEmailBody"), JsonPropertyName("modReqEmailBody")] public string ModReqEmailBody { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceRequest instead.")] [JsonProperty("modUnansweredReqEmailBody"), JsonPropertyName("modUnansweredReqEmailBody")] public string ModUnansweredReqEmailBody { get; set; } = ""; @@ -448,15 +454,19 @@ public class ConfigData : ICloneable [JsonProperty("modReqTaskTitle"), JsonPropertyName("modReqTaskTitle")] public string ModReqTaskTitle { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceDecomm instead.")] [JsonProperty("modDecommEmailReceiver"), JsonPropertyName("modDecommEmailReceiver")] public string ModDecommEmailReceiver { get; set; } = nameof(EmailRecipientOption.None); + [Obsolete("Use notification entries with NotificationClient.InterfaceDecomm instead.")] [JsonProperty("modDecommEmailOtherAddresses"), JsonPropertyName("modDecommEmailOtherAddresses")] public string ModDecommEmailOtherAddresses { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceDecomm instead.")] [JsonProperty("modDecommEmailSubject"), JsonPropertyName("modDecommEmailSubject")] public string ModDecommEmailSubject { get; set; } = ""; + [Obsolete("Use notification entries with NotificationClient.InterfaceDecomm instead.")] [JsonProperty("modDecommEmailBody"), JsonPropertyName("modDecommEmailBody")] public string ModDecommEmailBody { get; set; } = ""; diff --git a/roles/lib/files/FWO.Data/EmailRecipientSelection.cs b/roles/lib/files/FWO.Data/EmailRecipientSelection.cs index 08490c4850..67711782b1 100644 --- a/roles/lib/files/FWO.Data/EmailRecipientSelection.cs +++ b/roles/lib/files/FWO.Data/EmailRecipientSelection.cs @@ -19,6 +19,9 @@ public class EmailRecipientSelection [JsonPropertyName("other_address_list")] public List OtherAddressList { get; set; } = []; + [JsonPropertyName("requester")] + public bool Requester { get; set; } = false; + [JsonPropertyName("ensure_at_least_one_notification")] public bool EnsureAtLeastOneNotification { get; set; } = false; @@ -60,7 +63,7 @@ public string ToConfigValue(IEnumerable? activeOwnerResponsibleTypeIds = nu /// public bool HasAnyRecipientOption() { - return OtherAddresses || OwnerResponsibleTypeIds.Count > 0; + return Requester || OtherAddresses || OwnerResponsibleTypeIds.Count > 0; } /// @@ -104,6 +107,7 @@ private EmailRecipientSelection Sanitize(IEnumerable? activeOwnerResponsibl .Select(address => address.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(), + Requester = Requester, EnsureAtLeastOneNotification = EnsureAtLeastOneNotification, OwnerResponsibleTypeIds = OwnerResponsibleTypeIds .Where(id => id > 0) @@ -130,7 +134,7 @@ private EmailRecipientSelection Sanitize(IEnumerable? activeOwnerResponsibl sanitized.OtherAddresses = false; } - sanitized.None = !sanitized.OtherAddresses && sanitized.OwnerResponsibleTypeIds.Count == 0; + sanitized.None = !sanitized.Requester && !sanitized.OtherAddresses && sanitized.OwnerResponsibleTypeIds.Count == 0; if (sanitized.None) { sanitized.EnsureAtLeastOneNotification = false; diff --git a/roles/lib/files/FWO.Data/FwoNotification.cs b/roles/lib/files/FWO.Data/FwoNotification.cs index fdb54a7e93..f78a690592 100644 --- a/roles/lib/files/FWO.Data/FwoNotification.cs +++ b/roles/lib/files/FWO.Data/FwoNotification.cs @@ -13,7 +13,9 @@ public enum NotificationClient RuleTimer = 5, AppDecomm = 6, Report = 7, - WfAction = 8 + WfAction = 8, + InterfaceDecomm = 9, + RuleRecertification = 10 } public static class NotificationClientGroups @@ -28,7 +30,9 @@ public static bool IsModellingRecipientClient(this NotificationClient client) return client is NotificationClient.Recertification or NotificationClient.InterfaceRequest or NotificationClient.RuleTimer - or NotificationClient.AppDecomm; + or NotificationClient.AppDecomm + or NotificationClient.InterfaceDecomm + or NotificationClient.RuleRecertification; } /// @@ -47,6 +51,21 @@ public enum NotificationChannel Email = 1 } + /// + /// Result of processing a notification delivery request. + /// + public enum NotificationDeliveryResult + { + /// Email was sent successfully. + Delivered, + /// Sending was intentionally skipped by the logging mode. + Suppressed, + /// No recipient was resolved. + NoRecipients, + /// Sending was attempted but failed. + Failed + } + public enum NotificationDeadline { None = 0, @@ -80,6 +99,25 @@ NotificationDeadline.RequestDate or } } + public static class NotificationLoggingMode + { + public const string SendOnly = "send_only"; + public const string SendAndLog = "send_and_log"; + public const string LogOnly = "log_only"; + + public static List OfferedOptions() => [SendOnly, SendAndLog, LogOnly]; + + public static bool ShouldLog(string? loggingMode) + { + return loggingMode is SendAndLog or LogOnly; + } + + public static bool ShouldSend(string? loggingMode) + { + return loggingMode != LogOnly; + } + } + public class FwoNotification { public FwoNotification() @@ -114,6 +152,8 @@ public FwoNotification(FwoNotification notification) RepeatOffsetAfterDeadline = notification.RepeatOffsetAfterDeadline; RepetitionsAfterDeadline = notification.RepetitionsAfterDeadline; LastSent = notification.LastSent; + Logging = notification.Logging; + Active = notification.Active; } [JsonProperty("id"), JsonPropertyName("id")] @@ -201,16 +241,24 @@ public EmailRecipientOption RecipientBcc [JsonProperty("last_sent"), JsonPropertyName("last_sent")] public DateTime? LastSent { get; set; } + [JsonProperty("logging"), JsonPropertyName("logging")] + public string Logging { get; set; } = NotificationLoggingMode.SendOnly; + + [JsonProperty("active"), JsonPropertyName("active")] + public bool Active { get; set; } = true; + public static List OfferedDeadlineOptions(NotificationClient client) { return client switch { NotificationClient.Recertification => [NotificationDeadline.RecertDate], + NotificationClient.RuleRecertification => [NotificationDeadline.None], NotificationClient.ImportChange => [NotificationDeadline.None], NotificationClient.RuleTimer => [NotificationDeadline.RuleExpiry], - NotificationClient.InterfaceRequest => [NotificationDeadline.RequestDate], + NotificationClient.InterfaceRequest => [NotificationDeadline.None, NotificationDeadline.RequestDate], NotificationClient.AppDecomm => [NotificationDeadline.None, NotificationDeadline.DecommissionDate], + NotificationClient.InterfaceDecomm => [NotificationDeadline.None], NotificationClient.WfAction => [NotificationDeadline.None], _ => Enum.GetValues(typeof(NotificationDeadline)).Cast().ToList() }; diff --git a/roles/lib/files/FWO.Data/Middleware/InterfaceDecommissionNotificationParameters.cs b/roles/lib/files/FWO.Data/Middleware/InterfaceDecommissionNotificationParameters.cs new file mode 100644 index 0000000000..1e9ce03747 --- /dev/null +++ b/roles/lib/files/FWO.Data/Middleware/InterfaceDecommissionNotificationParameters.cs @@ -0,0 +1,23 @@ +namespace FWO.Data.Middleware +{ + /// + /// Identifies an interface decommission notification and its optional replacement. + /// + public class InterfaceDecommissionNotificationParameters + { + /// + /// Database ID of the decommissioned modelling connection. + /// + public int ConnectionId { get; set; } + + /// + /// Database ID of the proposed replacement connection, if any. + /// + public int? ReplacementConnectionId { get; set; } + + /// + /// Reason supplied by the user for the decommission. + /// + public string Reason { get; set; } = ""; + } +} diff --git a/roles/lib/files/FWO.Data/Middleware/InterfaceRequestNotificationParameters.cs b/roles/lib/files/FWO.Data/Middleware/InterfaceRequestNotificationParameters.cs new file mode 100644 index 0000000000..b0fbfa15bb --- /dev/null +++ b/roles/lib/files/FWO.Data/Middleware/InterfaceRequestNotificationParameters.cs @@ -0,0 +1,13 @@ +namespace FWO.Data.Middleware +{ + /// + /// Identifies an already-created interface request whose notification should be processed. + /// + public class InterfaceRequestNotificationParameters + { + /// + /// Database ID of the requested modelling connection. + /// + public int ConnectionId { get; set; } + } +} diff --git a/roles/lib/files/FWO.Data/Middleware/WorkflowActionParameters.cs b/roles/lib/files/FWO.Data/Middleware/WorkflowActionParameters.cs index 32b7d489b8..a06a53bf0a 100644 --- a/roles/lib/files/FWO.Data/Middleware/WorkflowActionParameters.cs +++ b/roles/lib/files/FWO.Data/Middleware/WorkflowActionParameters.cs @@ -2,17 +2,61 @@ namespace FWO.Data.Middleware { + /// + /// Parameters passed from the UI workflow handler to the middleware workflow handler. + /// public class WorkflowActionParameters { + /// + /// Workflow scope of the stateful object. + /// public string Scope { get; set; } = WfObjectScopes.None.ToString(); + + /// + /// Identifier of the workflow action to execute. + /// public int ActionId { get; set; } + + /// + /// Identifier of the stateful workflow object. + /// public long ObjectId { get; set; } + + /// + /// Identifier of the related workflow ticket. + /// public long TicketId { get; set; } + + /// + /// State before the workflow transition. + /// public int OldStateId { get; set; } + + /// + /// State after the workflow transition. + /// public int NewStateId { get; set; } + + /// + /// Indicates that the state transition happened while creating the object. + /// public bool StateChangedByCreation { get; set; } + + /// + /// Current workflow phase. + /// public string Phase { get; set; } = WorkflowPhases.request.ToString(); + + /// + /// Workflow action execution mode. + /// public string ExecutionMode { get; set; } = ""; + + /// + /// Caller-provided notification placeholder values. + /// + public NotificationPlaceholderData? NotificationPlaceholders { get; set; } + /// /// Id of the workflow email bundle this action belongs to, empty when the action is not bundled. /// diff --git a/roles/lib/files/FWO.Data/NotificationLogEntry.cs b/roles/lib/files/FWO.Data/NotificationLogEntry.cs new file mode 100644 index 0000000000..f8d0c64106 --- /dev/null +++ b/roles/lib/files/FWO.Data/NotificationLogEntry.cs @@ -0,0 +1,70 @@ +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace FWO.Data +{ + public enum NotificationLogStatus + { + Pending, + Sent, + Failed, + Suppressed + } + + /// + /// Shared data for notification log read and insert entries. + /// + public abstract class NotificationLogData + { + [JsonProperty("timestamp"), JsonPropertyName("timestamp")] + public DateTimeOffset Timestamp { get; set; } + + [JsonProperty("notification_id"), JsonPropertyName("notification_id")] + public int NotificationId { get; set; } + + [JsonProperty("notification_type"), JsonPropertyName("notification_type")] + public string NotificationType { get; set; } = ""; + + [JsonProperty("to"), JsonPropertyName("to")] + public string To { get; set; } = ""; + + [JsonProperty("cc"), JsonPropertyName("cc")] + public string Cc { get; set; } = ""; + + [JsonProperty("bcc"), JsonPropertyName("bcc")] + public string Bcc { get; set; } = ""; + + [JsonProperty("subject"), JsonPropertyName("subject")] + public string Subject { get; set; } = ""; + + [JsonProperty("deadline_type"), JsonPropertyName("deadline_type")] + [Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public NotificationDeadline DeadlineType { get; set; } = NotificationDeadline.None; + + [JsonProperty("deadline"), JsonPropertyName("deadline")] + public DateTimeOffset? Deadline { get; set; } + + [JsonProperty("status"), JsonPropertyName("status")] + [Newtonsoft.Json.JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public NotificationLogStatus Status { get; set; } = NotificationLogStatus.Pending; + + [JsonProperty("error"), JsonPropertyName("error")] + public string Error { get; set; } = ""; + } + + /// + /// Log entry for a notification send attempt. + /// + public class NotificationLogEntry : NotificationLogData + { + [JsonProperty("id"), JsonPropertyName("id")] + public int Id { get; set; } + } + + /// + /// Notification log data sent to the insert mutation. + /// + public class NotificationLogInsertEntry : NotificationLogData + { + } +} diff --git a/roles/lib/files/FWO.Data/NotificationPlaceholderData.cs b/roles/lib/files/FWO.Data/NotificationPlaceholderData.cs new file mode 100644 index 0000000000..1fb290ec7d --- /dev/null +++ b/roles/lib/files/FWO.Data/NotificationPlaceholderData.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace FWO.Data +{ + /// + /// Transient values supplied by a workflow caller for notification placeholder replacement. + /// + public class NotificationPlaceholderData + { + [JsonProperty("requesting_app_name"), JsonPropertyName("requesting_app_name")] + public string RequestingAppName { get; set; } = ""; + + [JsonProperty("requesting_app_id"), JsonPropertyName("requesting_app_id")] + public string RequestingAppId { get; set; } = ""; + + [JsonProperty("interface_name"), JsonPropertyName("interface_name")] + public string InterfaceName { get; set; } = ""; + + [JsonProperty("interface_link_text"), JsonPropertyName("interface_link_text")] + public string InterfaceLinkText { get; set; } = ""; + + [JsonProperty("interface_link_name"), JsonPropertyName("interface_link_name")] + public string InterfaceLinkName { get; set; } = ""; + + [JsonProperty("interface_link_url"), JsonPropertyName("interface_link_url")] + public string InterfaceLinkUrl { get; set; } = ""; + + [JsonProperty("new_interface_name"), JsonPropertyName("new_interface_name")] + public string NewInterfaceName { get; set; } = ""; + + [JsonProperty("new_interface_link_text"), JsonPropertyName("new_interface_link_text")] + public string NewInterfaceLinkText { get; set; } = ""; + + [JsonProperty("new_interface_link_name"), JsonPropertyName("new_interface_link_name")] + public string NewInterfaceLinkName { get; set; } = ""; + + [JsonProperty("new_interface_link_url"), JsonPropertyName("new_interface_link_url")] + public string NewInterfaceLinkUrl { get; set; } = ""; + + [JsonProperty("reason"), JsonPropertyName("reason")] + public string Reason { get; set; } = ""; + + [JsonProperty("content"), JsonPropertyName("content")] + public string Content { get; set; } = ""; + + [JsonProperty("user_name"), JsonPropertyName("user_name")] + public string UserName { get; set; } = ""; + + [JsonProperty("requester_name"), JsonPropertyName("requester_name")] + public string RequesterName { get; set; } = ""; + + [JsonProperty("request_date"), JsonPropertyName("request_date")] + public string RequestDate { get; set; } = ""; + } +} diff --git a/roles/lib/files/FWO.Data/Workflow/WfStatefulObject.cs b/roles/lib/files/FWO.Data/Workflow/WfStatefulObject.cs index 2af4a657a2..11e798fab2 100644 --- a/roles/lib/files/FWO.Data/Workflow/WfStatefulObject.cs +++ b/roles/lib/files/FWO.Data/Workflow/WfStatefulObject.cs @@ -40,7 +40,6 @@ public int StateId [JsonProperty("assigned_group"), JsonPropertyName("assigned_group")] public string? AssignedGroup { get; set; } - // need private declarations, else we get problems with request_reqtask_arr_rel_insert_input in newTicket private int stateId; private int oldStateId; diff --git a/roles/lib/files/FWO.Mail/MailerMailKit.cs b/roles/lib/files/FWO.Mail/MailerMailKit.cs index 20d63bcb4b..2bdd2169f6 100644 --- a/roles/lib/files/FWO.Mail/MailerMailKit.cs +++ b/roles/lib/files/FWO.Mail/MailerMailKit.cs @@ -48,15 +48,12 @@ public static async Task SendAsync( CancellationToken ct = default ) { + MailData pendingMail = CopyMailData(mailData); + using var smtp = new SmtpClient(); + smtp.Timeout = 5000; + try { - var mail = new MimeMessage(); - AddRecipients(emailConn, mailData, mail); - await AddContent(mailData, mail, mailFormatHtml); - - using var smtp = new SmtpClient(); - smtp.Timeout = 5000; - switch (emailConn.Encryption) { case EmailEncryptionMethod.None: @@ -90,10 +87,37 @@ await smtp.ConnectAsync( { await smtp.AuthenticateAsync(emailConn.User, AesEnc.TryDecrypt(emailConn.Password ?? "", true), ct); } - await smtp.SendAsync(mail, ct); - await smtp.DisconnectAsync(true, ct); - return true; + while (HasRecipients(pendingMail)) + { + var mail = new MimeMessage(); + AddRecipients(emailConn, pendingMail, mail); + await AddContent(pendingMail, mail, mailFormatHtml); + + try + { + await smtp.SendAsync(mail, ct); + await smtp.DisconnectAsync(true, ct); + return true; + } + catch (SmtpCommandException exception) when (exception.Mailbox != null) + { + string rejectedAddress = exception.Mailbox.Address; + if (!RemoveRecipient(pendingMail, rejectedAddress)) + { + throw; + } + + Log.WriteWarning( + "Send Email", + $"SMTP rejected recipient '{rejectedAddress}'. Retrying with the remaining recipients." + ); + } + } + + await smtp.DisconnectAsync(true, ct); + Log.WriteWarning("Send Email", "SMTP rejected all recipients. No email was sent."); + return false; } catch (Exception exception) { @@ -102,6 +126,60 @@ await smtp.ConnectAsync( } } + private static MailData CopyMailData(MailData source) + { + List toRecipients = [.. source.To]; + List bccRecipients = [.. source.Bcc]; + List ccRecipients = [.. source.Cc]; + + return new MailData(toRecipients, source.Subject) + { + Bcc = bccRecipients, + Cc = ccRecipients, + From = source.From, + DisplayName = source.DisplayName, + ReplyTo = source.ReplyTo, + ReplyToName = source.ReplyToName, + Body = source.Body, + Attachments = source.Attachments + }; + } + + private static bool HasRecipients(MailData mailData) + { + return mailData.To.Any(IsAddressPresent) + || mailData.Cc.Any(IsAddressPresent) + || mailData.Bcc.Any(IsAddressPresent); + } + + private static bool IsAddressPresent(string address) + { + return !string.IsNullOrWhiteSpace(address); + } + + private static bool RemoveRecipient(MailData mailData, string rejectedAddress) + { + return RemoveRecipient(mailData.To, rejectedAddress) + || RemoveRecipient(mailData.Cc, rejectedAddress) + || RemoveRecipient(mailData.Bcc, rejectedAddress); + } + + private static bool RemoveRecipient(List recipients, string rejectedAddress) + { + int index = recipients.FindIndex(address => string.Equals( + address.Trim(), + rejectedAddress, + StringComparison.OrdinalIgnoreCase + )); + if (index < 0) + { + return false; + } + + recipients.RemoveAt(index); + return true; + } + private static void AddRecipients(EmailConnection emailConn, MailData mailData, MimeMessage mail) { string senderString = ""; diff --git a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs index 863efbce54..566c07bba4 100644 --- a/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs +++ b/roles/lib/files/FWO.Middleware.Client/MiddlewareClient.cs @@ -1,4 +1,5 @@ using FWO.Api.Client; +using FWO.Data; using FWO.Data.Middleware; using RestSharp; @@ -276,6 +277,38 @@ public async Task> ExecuteWorkflowActions(Wor return await restClient.ExecuteAsync(request); } + /// + /// Processes the configured immediate notification for an interface request. + /// + /// Database ID of the requested modelling connection. + /// The REST response containing the result of processing the configured notifications. + public virtual async Task> SendInterfaceRequestNotification(int connectionId) + { + RestRequest request = new("Notification/interface-request", Method.Post); + request.AddJsonBody(new InterfaceRequestNotificationParameters { ConnectionId = connectionId }); + return await restClient.ExecuteAsync(request); + } + + /// + /// Processes configured immediate notifications for applications using a decommissioned interface. + /// + /// Database ID of the decommissioned modelling connection. + /// Optional replacement connection ID. + /// User-supplied decommission reason. + /// The REST response containing the processing result. + public virtual async Task> SendInterfaceDecommissionNotification( + int connectionId, int? replacementConnectionId, string reason) + { + RestRequest request = new("Notification/interface-decommission", Method.Post); + request.AddJsonBody(new InterfaceDecommissionNotificationParameters + { + ConnectionId = connectionId, + ReplacementConnectionId = replacementConnectionId, + Reason = reason + }); + return await restClient.ExecuteAsync(request); + } + public async Task> GetReport(ReportGetParameters parameters) { RestRequest request = new("Report", Method.Post); diff --git a/roles/lib/files/FWO.Recert/RecertRefresh.cs b/roles/lib/files/FWO.Recert/RecertRefresh.cs index a9fd3452e9..5fec912059 100644 --- a/roles/lib/files/FWO.Recert/RecertRefresh.cs +++ b/roles/lib/files/FWO.Recert/RecertRefresh.cs @@ -22,7 +22,8 @@ public static async Task RecalcRecerts(ApiConnection apiConnection) OwnerRefresh? refreshResult = (await apiConnection.SendQueryAsync>(RecertQueries.refreshViewRuleWithOwner)).FirstOrDefault(); if (refreshResult == null || refreshResult.GetStatus() != "Materialized view refreshed successfully") { - Log.WriteError("Refresh materialized view view_rule_with_owner", "refresh failed"); + string status = refreshResult?.GetStatus() ?? "No refresh result returned"; + Log.WriteError("Refresh materialized view view_rule_with_owner", $"refresh failed: {status}"); return true; } watch.Stop(); @@ -33,8 +34,9 @@ public static async Task RecalcRecerts(ApiConnection apiConnection) await RecalcRecertsOfOwner(owner, managements, apiConnection); } } - catch (Exception) + catch (Exception exception) { + Log.WriteError("Refresh recertification data", "Recertification refresh failed unexpectedly.", exception); return true; } return false; diff --git a/roles/lib/files/FWO.Services/EmailNotification/EmailActionParams.cs b/roles/lib/files/FWO.Services/EmailNotification/EmailActionParams.cs index 5d1bd99f3a..978ba3d2ac 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/EmailActionParams.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/EmailActionParams.cs @@ -46,7 +46,12 @@ public enum WorkflowEmailDeliveryResult /// /// Sending was attempted and did not succeed. /// - Failed = 2 + Failed = 2, + + /// + /// Sending was intentionally skipped by the notification logging mode. + /// + Suppressed = 3 } public class EmailActionParams diff --git a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs index e98aec23c0..aed2872d9a 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/EmailHelper.cs @@ -33,6 +33,7 @@ public class EmailHelper private string? ScopedUserEmailTo; private string? ScopedUserEmailCc; private string? ScopedUserEmailBcc; + private string? ScopedUserName; public EmailHelper(ApiConnection apiConnection, MiddlewareClient? middlewareClient, UserConfig userConfig, Action displayMessageInUi, @@ -48,7 +49,8 @@ public EmailHelper(ApiConnection apiConnection, MiddlewareClient? middlewareClie } public virtual async Task Init(string? scopedUserTo = null, string? scopedUserCc = null, string? scopedUserBcc = null, - string? scopedUserEmailTo = null, string? scopedUserEmailCc = null, string? scopedUserEmailBcc = null) + string? scopedUserEmailTo = null, string? scopedUserEmailCc = null, string? scopedUserEmailBcc = null, + string? scopedUserName = null) { if (!useInMwServer && middlewareClient != null) { @@ -72,27 +74,69 @@ public virtual async Task Init(string? scopedUserTo = null, string? scopedUserCc ScopedUserEmailTo = scopedUserEmailTo; ScopedUserEmailCc = scopedUserEmailCc; ScopedUserEmailBcc = scopedUserEmailBcc; + ScopedUserName = scopedUserName; } - public virtual async Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, EmailRecipientOption recOpt, bool reqInCc = false) + /// + /// Sends a notification email using the notification recipient fields. + /// + /// Notification template and recipient configuration. + /// Owner context used to resolve configured responsibles. + /// Rendered notification subject. + /// Rendered notification body. + /// True when an email was sent; otherwise false. + public virtual async Task SendEmailToNotificationRecipients(FwoNotification notification, FwoOwner? owner, string subject, string body) { - List? requester = reqInCc ? new() { GetEmailAddress(userConfig.User.Dn) } : null; - return await SendEmail(await GetRecipients(recOpt, null, owner, null, null), subject, body, requester); + return await SendEmailToNotificationRecipientsWithResult(notification, owner, subject, body) == NotificationDeliveryResult.Delivered; } - public virtual async Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, string recipientConfig, bool reqInCc = false, List? otherAddresses = null) - { - List? requester = reqInCc ? new() { GetEmailAddress(userConfig.User.Dn) } : null; - List recipients = await GetRecipients(recipientConfig, owner, otherAddresses); - return await SendEmail(recipients, subject, body, requester); + /// + /// Sends a notification email and reports whether it was delivered, suppressed, or could not be sent. + /// + /// Notification template and recipient configuration. + /// Owner context used to resolve configured responsibles. + /// Rendered notification subject. + /// Rendered notification body. + /// The explicit notification delivery outcome. + public virtual async Task SendEmailToNotificationRecipientsWithResult(FwoNotification notification, FwoOwner? owner, string subject, string body) + { + List tos = await GetNotificationRecipients(notification.RecipientTo, notification.EmailAddressTo, owner); + List? ccs = notification.RecipientCc == EmailRecipientOption.None + ? null + : await GetNotificationRecipients(notification.RecipientCc, notification.EmailAddressCc, owner); + List? bccs = notification.RecipientBcc == EmailRecipientOption.None + ? null + : await GetNotificationRecipients(notification.RecipientBcc, notification.EmailAddressBcc, owner); + + int logId = await LogNotificationIfConfigured(notification, tos, ccs, bccs, subject); + + if (!NotificationLoggingMode.ShouldSend(notification.Logging)) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Suppressed); + return NotificationDeliveryResult.Suppressed; + } + + try + { + bool sent = await SendEmail(tos, subject, body, ccs, bccs, notification.Layout == NotificationLayout.HtmlInBody); + await CompleteNotificationLog(logId, sent ? NotificationLogStatus.Sent : NotificationLogStatus.Failed, + sent ? "" : "SMTP delivery failed or no To recipients resolved."); + return sent ? NotificationDeliveryResult.Delivered : NotificationDeliveryResult.Failed; + } + catch (Exception exception) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Failed, exception.Message); + throw; + } } /// /// Sends an immediate workflow action email using notification recipient fields. /// - /// Whether the email was delivered, had no recipients, or failed to send + /// Whether the workflow email was delivered, had no recipients, or failed. public async Task SendWorkflowActionEmail(FwoNotification notification, WfStatefulObject statefulObject, FwoOwner? owner, string? userGrpDn = null, - WorkflowEmailContent? workflowContent = null, WfStatefulObject? placeholderObject = null) + WorkflowEmailContent? workflowContent = null, WfStatefulObject? placeholderObject = null, + NotificationPlaceholderData? placeholderData = null) { List tos = await GetWorkflowActionRecipients(notification.RecipientTo, notification.EmailAddressTo, statefulObject, owner, ScopedUserTo, ScopedUserEmailTo, userGrpDn); List? ccs = notification.RecipientCc == EmailRecipientOption.None @@ -102,11 +146,67 @@ public async Task SendWorkflowActionEmail(FwoNotifi ? null : await GetWorkflowActionRecipients(notification.RecipientBcc, notification.EmailAddressBcc, statefulObject, owner, ScopedUserBcc, ScopedUserEmailBcc, userGrpDn); WfStatefulObject placeholderContext = placeholderObject ?? statefulObject; - string subject = NotificationPlaceholderResolver.ReplaceWorkflowPlaceholders(notification.EmailSubject, placeholderContext, owner); - string body = NotificationPlaceholderResolver.ReplaceWorkflowPlaceholders(NotificationEmailLayoutHelper.BuildBody(notification, workflowContent), placeholderContext, owner); + string subject = NotificationPlaceholderResolver.ReplaceWorkflowPlaceholders(notification.EmailSubject, placeholderContext, owner, placeholderData); + string body = NotificationPlaceholderResolver.ReplaceWorkflowPlaceholders(BuildWorkflowActionBody(notification, workflowContent, placeholderData), placeholderContext, owner, + placeholderData, renderHtmlLinks: notification.Layout == NotificationLayout.HtmlInBody); FormFile? attachment = await NotificationEmailLayoutHelper.BuildAttachment(notification.Layout, workflowContent, subject); - return await SendEmailWithResult(tos, subject, body, ccs, bccs, - notification.Layout == NotificationLayout.HtmlInBody, attachment); + int logId = await LogNotificationIfConfigured(notification, tos, ccs, bccs, subject); + if (!NotificationLoggingMode.ShouldSend(notification.Logging)) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Suppressed); + return WorkflowEmailDeliveryResult.Suppressed; + } + try + { + WorkflowEmailDeliveryResult deliveryResult = await SendEmailWithResult(tos, subject, body, ccs, bccs, + notification.Layout == NotificationLayout.HtmlInBody, attachment); + bool sent = deliveryResult == WorkflowEmailDeliveryResult.Delivered; + await CompleteNotificationLog(logId, sent ? NotificationLogStatus.Sent : NotificationLogStatus.Failed, + sent ? "" : "SMTP delivery failed or no To recipients resolved."); + return deliveryResult; + } + catch (Exception exception) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Failed, exception.Message); + throw; + } + } + + private static string BuildWorkflowActionBody(FwoNotification notification, WorkflowEmailContent? workflowContent, + NotificationPlaceholderData? placeholderData) + { + if (!string.IsNullOrWhiteSpace(placeholderData?.Content)) + { + return NotificationEmailLayoutHelper.BuildBody(notification, placeholderData.Content); + } + + return NotificationEmailLayoutHelper.BuildBody(notification, workflowContent); + } + + private async Task LogNotificationIfConfigured(FwoNotification notification, List tos, List? ccs, + List? bccs, string subject) + { + if (!NotificationLoggingMode.ShouldLog(notification.Logging)) + { + return 0; + } + + List loggedTos = [.. tos]; + List? loggedCcs = ccs == null ? null : [.. ccs]; + List? loggedBccs = bccs == null ? null : [.. bccs]; + ApplyDummyRecipientOverride(ref loggedTos, ref loggedCcs, ref loggedBccs); + NotificationLogInsertEntry entry = NotificationLogHelper.CreateEntry(notification, loggedTos, loggedCcs, loggedBccs, subject); + return await NotificationLogHelper.InsertAsync(apiConnection, entry); + } + + private async Task CompleteNotificationLog(int logId, NotificationLogStatus status, string error = "") + { + if (logId == 0) + { + return; + } + + await NotificationLogHelper.UpdateAsync(apiConnection, logId, status, error); } private async Task> GetWorkflowActionRecipients( @@ -136,7 +236,7 @@ private static WfStatefulObject AssignedGroupRecipientContext(EmailRecipientOpti return new WfStatefulObject(statefulObject) { AssignedGroup = assignedGroupDn }; } - private async Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, + protected virtual async Task SendEmail(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, FormFile? attachment = null) { return await SendEmailWithResult(tos, subject, body, ccs, bccs, mailFormatHtml, attachment) == WorkflowEmailDeliveryResult.Delivered; @@ -147,7 +247,7 @@ private async Task SendEmail(List tos, string subject, string body /// to be surfaced, while an email with no resolvable recipient is a property of the configuration. /// /// Whether the email was delivered, had no recipients, or failed to send - private async Task SendEmailWithResult(List tos, string subject, string body, List? ccs = null, List? bccs = null, + protected virtual async Task SendEmailWithResult(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, FormFile? attachment = null) { EmailConnection emailConnection = new(userConfig.EmailServerAddress, userConfig.EmailPort, @@ -199,6 +299,7 @@ private Dictionary>>> BuildRecipien string? scopedUserEmail) { Func>> scopedUserHandler = () => CollectEmailAddressesFromScopedUser(scopedUser, scopedUserEmail); + Func>> requesterHandler = () => CollectEmailAddressesFromScopedUser(scopedUser, scopedUserEmail, ScopedUserName); return new Dictionary>>> { { EmailRecipientOption.CurrentHandler, () => CollectEmailAddressesFromUser(statefulObject?.CurrentHandler) }, @@ -208,7 +309,7 @@ private Dictionary>>> BuildRecipien { EmailRecipientOption.AllOwnerResponsibles, () => CollectEmailAddressesFromDns(owner?.GetAllOwnerResponsibles()) }, { EmailRecipientOption.OwnerGroupOnly, () => CollectOwnerAddressesByType(owner, GlobalConst.kOwnerResponsibleTypeSupporting) }, { EmailRecipientOption.ConfiguredResponsibles, () => Task.FromResult(new List()) }, - { EmailRecipientOption.Requester, scopedUserHandler }, + { EmailRecipientOption.Requester, requesterHandler }, { EmailRecipientOption.Approver, scopedUserHandler }, { EmailRecipientOption.LastCommenter, scopedUserHandler }, { EmailRecipientOption.FallbackToMainResponsibleIfOwnerGroupEmpty, () => GetOwnerGroupOrMainResponsibleRecipients(owner) }, @@ -244,7 +345,8 @@ private async Task> GetOwnerGroupOrMainResponsibleRecipients(FwoOwn return mainResponsibleAddresses; } - public async Task> GetRecipients(EmailRecipientSelection selection, FwoOwner? owner, List? otherAddresses) + public async Task> GetRecipients(EmailRecipientSelection selection, FwoOwner? owner, List? otherAddresses, + UiUser? requester = null) { if (!selection.HasAnyRecipientOption()) { @@ -257,6 +359,7 @@ public async Task> GetRecipients(EmailRecipientSelection selection, HashSet recipients = new(StringComparer.OrdinalIgnoreCase); AddOtherAddresses(selection, otherAddresses, recipients); + await AddRequesterRecipients(selection, recipients, requester); if (owner != null) { await AddOwnerTypeRecipients(owner, selection.OwnerResponsibleTypeIds.Distinct(), recipients); @@ -266,10 +369,29 @@ public async Task> GetRecipients(EmailRecipientSelection selection, return recipients.ToList(); } - public async Task> GetRecipients(string recipientConfig, FwoOwner? owner, List? otherAddresses) + public async Task> GetRecipients(string recipientConfig, FwoOwner? owner, List? otherAddresses, + UiUser? requester = null) { EmailRecipientSelection selection = EmailRecipientSelection.Parse(recipientConfig, GetActiveOwnerResponsibleTypeIds()); - return await GetRecipients(selection, owner, otherAddresses); + return await GetRecipients(selection, owner, otherAddresses, requester); + } + + private async Task> GetNotificationRecipients(EmailRecipientOption recipientOption, string addressList, FwoOwner? owner) + { + if (recipientOption == EmailRecipientOption.ConfiguredResponsibles) + { + return await GetRecipients(addressList, owner, null); + } + if (recipientOption == EmailRecipientOption.OtherAddresses && LooksLikeRecipientSelectionJson(addressList)) + { + return await GetRecipients(addressList, null, null); + } + return await GetRecipients(recipientOption, null, owner, null, SplitAddresses(addressList)); + } + + private static bool LooksLikeRecipientSelectionJson(string? recipientValue) + { + return recipientValue?.TrimStart().StartsWith('{') == true; } private static void AddOtherAddresses(EmailRecipientSelection selection, List? otherAddresses, HashSet recipients) @@ -281,6 +403,19 @@ private static void AddOtherAddresses(EmailRecipientSelection selection, List recipients, UiUser? requester) + { + if (!selection.Requester) + { + return; + } + + List requesterRecipients = requester == null + ? await CollectEmailAddressesFromUser(userConfig.User) + : await CollectEmailAddressesFromUser(requester); + AddAddresses(recipients, requesterRecipients); + } + private async Task AddOwnerTypeRecipients(FwoOwner owner, IEnumerable responsibleTypeIds, HashSet recipients) { foreach (int responsibleTypeId in responsibleTypeIds) @@ -402,7 +537,7 @@ private async Task> CollectEmailAddressesFromUserOrGroup(string? dn return await CollectEmailAddressesFromDns(dn == null ? null : [dn]); } - private async Task> CollectEmailAddressesFromScopedUser(string? dn, string? email) + private async Task> CollectEmailAddressesFromScopedUser(string? dn, string? email, string? userName = null) { if (userConfig.UseDummyEmailAddress) { @@ -412,12 +547,24 @@ private async Task> CollectEmailAddressesFromScopedUser(string? dn, { return [email]; } - return await CollectEmailAddressesFromUserOrGroup(dn); + if (!string.IsNullOrWhiteSpace(dn)) + { + return await CollectEmailAddressesFromUserOrGroup(dn); + } + UiUser? cachedUser = uiUsers.FirstOrDefault(user => + !string.IsNullOrWhiteSpace(userName) + && string.Equals(user.Name, userName, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(user.Email)); + if (cachedUser != null) + { + return [cachedUser.Email!]; + } + return await CollectEmailAddressesFromUserOrGroup(null); } private async Task> CollectEmailAddressesFromUser(UiUser? user) { - if (user == null || string.IsNullOrWhiteSpace(user.Dn)) + if (user == null) { return []; } @@ -429,6 +576,10 @@ private async Task> CollectEmailAddressesFromUser(UiUser? user) { return [user.Email]; } + if (string.IsNullOrWhiteSpace(user.Dn)) + { + return []; + } return await CollectEmailAddressesFromUserOrGroup(user.Dn); } diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs index c45263a043..fd9c735358 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationEmailLayoutHelper.cs @@ -98,7 +98,7 @@ private static string ResolveContent(NotificationLayout layout, string? content) : content; } - private static async Task ToPdf(string html) + internal static async Task ToPdf(string html) { OperatingSystem os = Environment.OSVersion; string path = os.Platform == PlatformID.Unix ? GlobalConst.ChromeBinPathLinux : ""; diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationLastSentHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationLastSentHelper.cs new file mode 100644 index 0000000000..fd469640fd --- /dev/null +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationLastSentHelper.cs @@ -0,0 +1,53 @@ +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Data; +using FWO.Logging; + +namespace FWO.Services +{ + /// + /// Updates the last-sent timestamp for notifications delivered by different send paths. + /// + public static class NotificationLastSentHelper + { + private const string LogMessageTitle = "Notifications"; + + /// + /// Updates last-sent timestamps for the supplied notification IDs. + /// + /// API connection used to persist the timestamps. + /// Notification IDs for which processing was attempted. + /// The number of affected notification rows. + public static async Task UpdateAsync(ApiConnection apiConnection, IEnumerable notificationIds) + { + List distinctNotificationIds = [.. notificationIds.Where(id => id > 0).Distinct()]; + if (distinctNotificationIds.Count == 0) + { + Log.WriteDebug(LogMessageTitle, "No processed notification IDs are pending for last_sent update."); + return 0; + } + + try + { + int affectedRows = (await apiConnection.SendQueryAsync(NotificationQueries.updateNotificationsLastSent, + new { ids = distinctNotificationIds, lastSent = DateTime.Now })).AffectedRows; + if (affectedRows == distinctNotificationIds.Count) + { + Log.WriteDebug(LogMessageTitle, + $"Updated last_sent for notification(s): {string.Join(", ", distinctNotificationIds)}."); + } + else + { + Log.WriteWarning(LogMessageTitle, + $"Updated last_sent for {affectedRows} of {distinctNotificationIds.Count} notification(s)."); + } + return affectedRows; + } + catch (Exception exception) + { + Log.WriteWarning(LogMessageTitle, $"Could not update last_sent for notification(s): {exception.Message}"); + return 0; + } + } + } +} diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationLogHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationLogHelper.cs new file mode 100644 index 0000000000..375ec5292a --- /dev/null +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationLogHelper.cs @@ -0,0 +1,79 @@ +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Data; + +namespace FWO.Services +{ + /// + /// Persists notification delivery data for the email log. + /// + public static class NotificationLogHelper + { + /// + /// Inserts one notification delivery log entry. + /// + /// API connection used to persist the entry. + /// Notification configuration being delivered. + /// Resolved To recipients. + /// Resolved Cc recipients. + /// Resolved Bcc recipients. + /// Rendered subject. + /// Resolved deadline timestamp. + public static async Task InsertAsync(ApiConnection apiConnection, FwoNotification notification, + IEnumerable tos, IEnumerable? ccs, IEnumerable? bccs, string subject, + DateTimeOffset? deadline = null) + { + NotificationLogInsertEntry entry = CreateEntry(notification, tos, ccs, bccs, subject, deadline); + return await InsertAsync(apiConnection, entry); + } + + /// + /// Creates a notification log entry from rendered notification data. + /// + public static NotificationLogInsertEntry CreateEntry(FwoNotification notification, + IEnumerable tos, IEnumerable? ccs, IEnumerable? bccs, string subject, + DateTimeOffset? deadline = null) + { + return new NotificationLogInsertEntry + { + Timestamp = DateTimeOffset.UtcNow, + NotificationId = notification.Id, + NotificationType = notification.NotificationClient.ToString(), + To = string.Join(", ", tos), + Cc = string.Join(", ", ccs ?? []), + Bcc = string.Join(", ", bccs ?? []), + Subject = subject, + DeadlineType = notification.Deadline, + Deadline = deadline + }; + } + + /// + /// Inserts a prepared notification log entry. + /// + public static async Task InsertAsync(ApiConnection apiConnection, NotificationLogInsertEntry entry) + { + + ReturnIdWrapper result = await apiConnection.SendQueryAsync(NotificationQueries.insertNotificationLog, + new { entries = new List { entry } }); + return result.ReturnIds?.FirstOrDefault()?.Id is long id ? (int)id : 0; + } + + public static async Task UpdateAsync(ApiConnection apiConnection, int logId, NotificationLogStatus status, string error = "") + { + await apiConnection.SendQueryAsync(NotificationQueries.updateNotificationLog, + new { id = logId, status = status.ToString(), error }); + } + + /// + /// Refreshes the timestamp of an existing notification log entry. + /// + /// API connection used to persist the refresh. + /// Identifier of the existing log entry. + public static async Task RefreshAsync(ApiConnection apiConnection, int logId) + { + await apiConnection.SendQueryAsync(NotificationQueries.refreshNotificationLog, + new { id = logId, timestamp = DateTimeOffset.UtcNow }); + } + } +} diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs index 0e38de5cb5..5e324725d2 100644 --- a/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationPlaceholderResolver.cs @@ -1,6 +1,7 @@ using FWO.Basics; using FWO.Data; using FWO.Data.Workflow; +using System.Net; namespace FWO.Services { @@ -9,6 +10,29 @@ namespace FWO.Services /// public static class NotificationPlaceholderResolver { + /// + /// Carries the values required to resolve notification placeholders. + /// Callers populate the data they have; the resolver decides how to render it. + /// + public sealed record NotificationPlaceholderValues + { + public FwoOwner Application { get; init; } = new(); + public FwoOwner? RequestingOwner { get; init; } + public UiUser? Requester { get; init; } + public string InterfaceName { get; init; } = ""; + public string InterfaceLinkText { get; init; } = ""; + public string InterfaceLinkName { get; init; } = ""; + public string InterfaceLinkUrl { get; init; } = ""; + public string NewInterfaceName { get; init; } = ""; + public string NewInterfaceLinkText { get; init; } = ""; + public string NewInterfaceLinkName { get; init; } = ""; + public string NewInterfaceLinkUrl { get; init; } = ""; + public string Reason { get; init; } = ""; + public string UserName { get; init; } = ""; + public string RequesterName { get; init; } = ""; + public string RequestDate { get; init; } = ""; + } + /// /// Replaces owner and time interval placeholders. /// @@ -23,10 +47,38 @@ public static string ReplaceOwnerPlaceholders(string text, FwoOwner? owner, stri /// /// Replaces workflow placeholders. /// - public static string ReplaceWorkflowPlaceholders(string text, WfStatefulObject statefulObject, FwoOwner? owner) + public static string ReplaceWorkflowPlaceholders(string text, WfStatefulObject statefulObject, FwoOwner? owner, + NotificationPlaceholderData? placeholderData = null, bool renderHtmlLinks = false) { - return ReplaceOwnerPlaceholders(text, owner ?? GetWorkflowOwner(statefulObject)) - .Replace(Placeholder.REQUESTER, GetRequesterName(statefulObject)); + NotificationPlaceholderValues values = new() + { + Application = owner ?? GetWorkflowOwner(statefulObject) ?? new(), + RequesterName = GetRequesterName(statefulObject), + Reason = GetWorkflowReason(statefulObject), + RequestDate = GetWorkflowRequestDate(statefulObject) + }; + values = ApplyWorkflowPlaceholderData(values, placeholderData); + return ReplaceNotificationPlaceholders(text, values, renderHtmlLinks); + } + + /// + /// Replaces notification placeholders. + /// + public static string ReplaceNotificationPlaceholders(string text, NotificationPlaceholderValues values, bool renderHtmlLinks = false) + { + return text + .Replace(Placeholder.APPNAME, values.Application.Name ?? "") + .Replace(Placeholder.APPID, values.Application.ExtAppId ?? "") + .Replace(Placeholder.REQUESTING_APPNAME, values.RequestingOwner?.Name ?? "") + .Replace(Placeholder.REQUESTING_APPID, values.RequestingOwner?.ExtAppId ?? "") + .Replace(Placeholder.REQUESTER, FirstNonEmpty(values.RequesterName, values.RequestingOwner?.Name)) + .Replace(Placeholder.REQUESTDATE, values.RequestDate) + .Replace(Placeholder.INTERFACE_NAME, values.InterfaceName) + .Replace(Placeholder.INTERFACE_LINK, RenderLink(values.InterfaceLinkUrl, values.InterfaceLinkText, values.InterfaceLinkName, renderHtmlLinks)) + .Replace(Placeholder.NEW_INTERFACE_NAME, values.NewInterfaceName) + .Replace(Placeholder.NEW_INTERFACE_LINK, RenderLink(values.NewInterfaceLinkUrl, values.NewInterfaceLinkText, values.NewInterfaceLinkName, renderHtmlLinks)) + .Replace(Placeholder.REASON, values.Reason) + .Replace(Placeholder.USER_NAME, values.UserName); } private static FwoOwner? GetWorkflowOwner(WfStatefulObject statefulObject) @@ -48,9 +100,84 @@ private static string GetRequesterName(WfStatefulObject statefulObject) return ""; } + private static string GetWorkflowReason(WfStatefulObject statefulObject) + { + return statefulObject switch + { + WfTicket ticket => ticket.Reason ?? "", + WfReqTask reqTask => reqTask.Reason ?? "", + WfImplTask implTask => implTask.Comments.LastOrDefault()?.Comment.CommentText ?? "", + _ => statefulObject.OptComment() ?? "" + }; + } + + private static string GetWorkflowRequestDate(WfStatefulObject statefulObject) + { + return statefulObject is WfTicket ticket && ticket.CreationDate != default + ? ticket.CreationDate.ToString("dd.MM.yyyy") + : ""; + } + + private static NotificationPlaceholderValues ApplyWorkflowPlaceholderData(NotificationPlaceholderValues values, NotificationPlaceholderData? data) + { + if (data == null) + { + return values; + } + + return values with + { + RequestingOwner = !string.IsNullOrWhiteSpace(data.RequestingAppName) || !string.IsNullOrWhiteSpace(data.RequestingAppId) + ? new FwoOwner { Name = data.RequestingAppName, ExtAppId = data.RequestingAppId } + : values.RequestingOwner, + InterfaceName = FirstNonEmpty(data.InterfaceName, values.InterfaceName), + InterfaceLinkText = FirstNonEmpty(data.InterfaceLinkText, values.InterfaceLinkText), + InterfaceLinkName = FirstNonEmpty(data.InterfaceLinkName, values.InterfaceLinkName), + InterfaceLinkUrl = FirstNonEmpty(data.InterfaceLinkUrl, values.InterfaceLinkUrl), + NewInterfaceName = FirstNonEmpty(data.NewInterfaceName, values.NewInterfaceName), + NewInterfaceLinkText = FirstNonEmpty(data.NewInterfaceLinkText, values.NewInterfaceLinkText), + NewInterfaceLinkName = FirstNonEmpty(data.NewInterfaceLinkName, values.NewInterfaceLinkName), + NewInterfaceLinkUrl = FirstNonEmpty(data.NewInterfaceLinkUrl, values.NewInterfaceLinkUrl), + Reason = FirstNonEmpty(data.Reason, values.Reason), + UserName = FirstNonEmpty(data.UserName, values.UserName), + RequesterName = FirstNonEmpty(data.RequesterName, values.RequesterName), + RequestDate = FirstNonEmpty(data.RequestDate, values.RequestDate) + }; + } + private static string FirstNonEmpty(params string?[] values) { return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? ""; } + + private static string RenderLink(string url, string text, string linkName, bool renderHtmlLinks) + { + if (string.IsNullOrWhiteSpace(url)) + { + return ""; + } + + if (!renderHtmlLinks) + { + return url; + } + + string displayText = string.IsNullOrWhiteSpace(text) ? linkName : text; + if (string.IsNullOrWhiteSpace(displayText)) + { + return url; + } + + string encodedUrl = WebUtility.HtmlEncode(url); + string encodedDisplayText = WebUtility.HtmlEncode(displayText); + string encodedLinkName = WebUtility.HtmlEncode(linkName); + + if (string.IsNullOrWhiteSpace(linkName)) + { + return $"{encodedDisplayText}"; + } + + return $"{encodedDisplayText}: {encodedLinkName}"; + } } } diff --git a/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs b/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs new file mode 100644 index 0000000000..cd99fa0787 --- /dev/null +++ b/roles/lib/files/FWO.Services/EmailNotification/NotificationScheduleHelper.cs @@ -0,0 +1,126 @@ +using FWO.Basics; +using FWO.Data; + +namespace FWO.Services +{ + /// + /// Evaluates notification schedules against the current date. + /// + public static class NotificationScheduleHelper + { + /// + /// Returns whether the notification is due for sending at the current time. + /// + /// Owner context used for owner-based deadlines. + /// External deadline date, for example a request creation time. + /// Notification to evaluate. + /// True when the notification should be sent now. + public static bool IsNotificationDue(FwoOwner? owner, DateTime? extDeadline, FwoNotification notification) + { + if (!notification.Active) + { + return false; + } + + if (notification.Deadline == NotificationDeadline.None) + { + return true; + } + + if (notification.Deadline == NotificationDeadline.RequestDate + && extDeadline?.Date == DateTime.Now.Date + && notification.OffsetBeforeDeadline == 0) + { + return notification.LastSent == null || notification.LastSent.Value.Date < DateTime.Now.Date; + } + + DateTime deadline = GetDeadlineDate(notification.Deadline, owner, extDeadline); + return deadline.Date >= DateTime.Now.Date + ? IsNotificationDueBeforeDeadline(deadline, notification) + : IsNotificationDueAfterDeadline(deadline, notification); + } + + private static bool IsTimeToSend(DateTime? lastSent, DateTime notifDate) + { + return (lastSent == null || ((DateTime)lastSent).Date < notifDate.Date) && notifDate.Date <= DateTime.Now.Date; + } + + private static bool TryGetConfiguredInterval(SchedulerInterval? interval, out SchedulerInterval configuredInterval) + { + if (interval != null) + { + configuredInterval = (SchedulerInterval)interval; + return true; + } + + // Before- and after-deadline schedules are optional. An unset interval + // disables only the corresponding phase; it is not an error. + configuredInterval = default; + return false; + } + + private static bool IsNotificationDueBeforeDeadline(DateTime deadline, FwoNotification notification) + { + if (!TryGetConfiguredInterval(notification.IntervalBeforeDeadline, out SchedulerInterval intervalBeforeDeadline)) + { + return false; + } + + DateTime notifDate = ApplyIntervalOffset(deadline, intervalBeforeDeadline, -(int)(notification.OffsetBeforeDeadline ?? 0)); + return IsTimeToSend(notification.LastSent, notifDate); + } + + private static bool IsNotificationDueAfterDeadline(DateTime deadline, FwoNotification notification) + { + if (!TryGetConfiguredInterval(notification.RepeatIntervalAfterDeadline, out SchedulerInterval repeatIntervalAfterDeadline)) + { + return false; + } + + DateTime nextNotifDate = ApplyIntervalOffset(deadline.Date, repeatIntervalAfterDeadline, (int)(notification.InitialOffsetAfterDeadline ?? 0)); + DateTime currentNotifDate = nextNotifDate; + int counter = -1; + while (nextNotifDate <= DateTime.Now.Date && counter++ <= notification.RepetitionsAfterDeadline) + { + currentNotifDate = nextNotifDate; + nextNotifDate = ApplyIntervalOffset(nextNotifDate, repeatIntervalAfterDeadline, (int)(notification.RepeatOffsetAfterDeadline ?? 0)); + } + + return counter <= notification.RepetitionsAfterDeadline && IsTimeToSend(notification.LastSent, currentNotifDate); + } + + private static DateTime GetDeadlineDate(NotificationDeadline deadline, FwoOwner? owner, DateTime? extDeadline) + { + if (deadline == NotificationDeadline.RecertDate && owner?.NextRecertDate != null) + { + return (DateTime)owner.NextRecertDate; + } + else if (deadline == NotificationDeadline.RequestDate && extDeadline != null) + { + return (DateTime)extDeadline; + } + else if (deadline == NotificationDeadline.RuleExpiry && extDeadline != null) + { + return (DateTime)extDeadline; + } + else if (deadline == NotificationDeadline.DecommissionDate && owner?.DecommDate != null) + { + return (DateTime)owner.DecommDate; + } + return DateTime.Now; + } + + private static DateTime ApplyIntervalOffset(DateTime dateTime, SchedulerInterval interval, long value) + { + return interval switch + { + SchedulerInterval.Minutes => dateTime.AddMinutes(value), + SchedulerInterval.Hours => dateTime.AddHours(value), + SchedulerInterval.Days => dateTime.AddDays(value), + SchedulerInterval.Weeks => dateTime.AddDays(value * GlobalConst.kDaysPerWeek), + SchedulerInterval.Months => dateTime.AddMonths((int)value), + _ => throw new NotSupportedException("Time interval is not supported.") + }; + } + } +} diff --git a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandler.cs b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandler.cs index 61c815aaa4..5ecbabb95f 100644 --- a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandler.cs +++ b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandler.cs @@ -168,7 +168,7 @@ public async Task RefreshObjects() RefreshSelectableNwObjects(); } - private async Task RefreshActConn() + public async Task RefreshActConn() { try { diff --git a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs index a5313d3fbc..b3b2f00c81 100644 --- a/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs +++ b/roles/lib/files/FWO.Services/Modelling/ModellingConnectionHandlerInterfHandling.cs @@ -91,51 +91,42 @@ public async Task DecommissionInterface(string reason, bool proposeAlternative, await Save(true, true); await RemoveFromAllSelections(); - List appsToNotify = []; - if (!string.IsNullOrWhiteSpace(userConfig.ModDecommEmailReceiver) - && !userConfig.ModDecommEmailReceiver.Equals(nameof(EmailRecipientOption.None), StringComparison.Ordinal)) + List appsToNotify = UsingConnections + .Where(c => c.AppId != null && c.AppId != ActConn.AppId) + .Select(c => AllApps.FirstOrDefault(app => app.Id == c.AppId) ?? c.App) + .Distinct() + .ToList(); + if (appsToNotify.Count > 0) { - appsToNotify = UsingConnections.Where(c => c.AppId != null && c.AppId != ActConn.AppId).Select(c => c.App).Distinct().ToList(); - await NotifyUsers(appsToNotify, reason, proposedInterface, middlewareClient); + await NotifyUsers(reason, proposedInterface, middlewareClient); } await AddPermittedOwnersIfMissing(proposedInterface, appsToNotify); await AddToSelections(proposeAlternative, proposedInterface, appsToNotify); } - protected virtual async Task NotifyUsers(List appsToNotify, string reason, ModellingConnection? proposedInterface, MiddlewareClient middlewareClient) + protected virtual async Task NotifyUsers(string reason, ModellingConnection? proposedInterface, MiddlewareClient middlewareClient) { try { - EmailHelper emailHelper = CreateEmailHelper(middlewareClient); - await emailHelper.Init(); - - string subject = userConfig.ModDecommEmailSubject.Replace(Placeholder.INTERFACE_NAME, ActConn.Name); - - int successCount = 0; - int failCount = 0; - List legacyOtherAddresses = EmailHelper.SplitAddresses(userConfig.ModDecommEmailOtherAddresses); - foreach (var app in appsToNotify) + RestSharp.RestResponse response = await middlewareClient.SendInterfaceDecommissionNotification( + ActConn.Id, proposedInterface?.Id, reason); + if (!response.IsSuccessful) { - if (await emailHelper.SendEmailToOwnerResponsibles(app, subject, ConstructBody(app, reason, proposedInterface), - userConfig.ModDecommEmailReceiver, false, legacyOtherAddresses)) - { - successCount++; - } - else - { - failCount++; - } - } - if (successCount > 0) - { - string msgText = userConfig.GetText("U9033").Replace(Placeholder.OK_NUMBER, successCount.ToString()); - DisplayMessageInUi(null, userConfig.GetText("send_email"), msgText, false); + throw new InvalidOperationException("Middleware interface decommission notification failed."); } - if (failCount > 0) + + switch (response.Data) { - string msgText = userConfig.GetText("E9019").Replace(Placeholder.FAIL_NUMBER, failCount.ToString()); - DisplayMessageInUi(null, userConfig.GetText("send_email"), msgText, true); + case NotificationDeliveryResult.Delivered: + string successText = userConfig.GetText("U9044"); + DisplayMessageInUi(null, userConfig.GetText("send_email"), successText, false); + break; + case NotificationDeliveryResult.Failed: + case NotificationDeliveryResult.NoRecipients: + string failureText = userConfig.GetText("E9031"); + DisplayMessageInUi(null, userConfig.GetText("send_email"), failureText, true); + break; } } catch (Exception exception) @@ -144,25 +135,6 @@ protected virtual async Task NotifyUsers(List appsToNotify, string rea } } - protected virtual EmailHelper CreateEmailHelper(MiddlewareClient middlewareClient) - { - return new EmailHelper(apiConnection, middlewareClient, userConfig, DisplayMessageInUi); - } - - private string ConstructBody(FwoOwner app, string reason, ModellingConnection? proposedInterface) - { - string interfaceUrl = $"{userConfig.UiHostName}/{PageName.Modelling}/{proposedInterface?.App.ExtAppId}/{proposedInterface?.Id}"; - string interfacelink = $"{userConfig.GetText("interface")}: {proposedInterface?.Name}
    "; - string body = userConfig.ModDecommEmailBody - .Replace(Placeholder.INTERFACE_NAME, $"{ActConn.Name}") - .Replace(Placeholder.NEW_INTERFACE_NAME, $"{proposedInterface?.Name}") - .Replace(Placeholder.NEW_INTERFACE_LINK, $"{interfacelink}") - .Replace(Placeholder.REASON, $"{reason}") - .Replace(Placeholder.USER_NAME, $"{userConfig.User.Name}"); - string connList = string.Join("
    ", UsingConnections.Where(c => c.AppId != null && c.AppId == app.Id).Select(a => a.Name)); - return $"{body}
    {connList}"; - } - private async Task AddToSelections(bool proposeAlternative, ModellingConnection? proposedInterface, List appsToNotify) { if (!proposeAlternative || proposedInterface == null) @@ -539,6 +511,11 @@ private async Task AddToPreselectedList(ModellingConnection? requestedInterface) { if (requestedInterface != null) { + if (PreselectedInterfaces.Any(selected => selected.Id == requestedInterface.Id)) + { + return; + } + var Variables = new { appId = requestedInterface.AppId, diff --git a/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs b/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7271a539c7 --- /dev/null +++ b/roles/lib/files/FWO.Services/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FWO.Test")] diff --git a/roles/lib/files/FWO.Services/Workflow/ActionHandler.cs b/roles/lib/files/FWO.Services/Workflow/ActionHandler.cs index 1adf451b00..1291fdc58a 100644 --- a/roles/lib/files/FWO.Services/Workflow/ActionHandler.cs +++ b/roles/lib/files/FWO.Services/Workflow/ActionHandler.cs @@ -85,7 +85,8 @@ public List GetOfferedActions(WfStatefulObject statefulObject, Wf return offeredActions; } - public async Task DoStateChangeActions(WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null) + public async Task DoStateChangeActions(WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null, + NotificationPlaceholderData? placeholderData = null) { if (!statefulObject.StateChanged()) { @@ -97,7 +98,7 @@ public async Task DoStateChangeActions(WfStatefulObject statefulObject, WfObject { try { - await ExecuteInMiddleware(BuildWorkflowActionParameters(statefulObject, scope, ticketId)); + await ExecuteInMiddleware(BuildWorkflowActionParameters(statefulObject, scope, ticketId, placeholderData: placeholderData)); } finally { @@ -115,8 +116,9 @@ public async Task DoStateChangeActions(WfStatefulObject statefulObject, WfObject List onLeaveActions = StateActionsForEvent(statefulObject, scope, StateActionEvents.OnLeave, false); statefulObject.ResetStateChanged(); - await PerformStateActions(onSetActions, StateActionEvents.OnSet, statefulObject, scope, owner, ticketId, userGrpDn); - await PerformStateActions(onLeaveActions, StateActionEvents.OnLeave, statefulObject, scope, owner, ticketId, userGrpDn); + StateActionExecutionContext context = new(owner, ticketId, userGrpDn, placeholderData); + await PerformStateActions(onSetActions, StateActionEvents.OnSet, statefulObject, scope, context); + await PerformStateActions(onLeaveActions, StateActionEvents.OnLeave, statefulObject, scope, context); } /// @@ -198,8 +200,11 @@ private List StateActionsForEvent(WfStatefulObject statefulObject return [.. GetRelevantActions(statefulObject, scope, currentState).Where(action => action.Event == actionEvent.ToString())]; } + private sealed record StateActionExecutionContext(FwoOwner? Owner, long? TicketId, string? UserGrpDn, + NotificationPlaceholderData? PlaceholderData); + private async Task PerformStateActions(List actions, StateActionEvents actionEvent, WfStatefulObject statefulObject, - WfObjectScopes scope, FwoOwner? owner, long? ticketId, string? userGrpDn) + WfObjectScopes scope, StateActionExecutionContext context) { foreach (var action in actions.Where(IsActionInCurrentPhase)) { @@ -207,7 +212,7 @@ private async Task PerformStateActions(List actions, StateActionE Log.WriteDebug("DoStateChangeActions", $"Perform {actionEvent} action '{action.Name}' ({action.ActionType}) for {scope} state {stateText}."); try { - await PerformAction(action, statefulObject, scope, owner, ticketId, userGrpDn); + await PerformAction(action, statefulObject, scope, context.Owner, context.TicketId, context.UserGrpDn, context.PlaceholderData); } catch (Exception exc) { @@ -254,12 +259,12 @@ private static bool IsSamePersistedAction(WfStateAction firstAction, WfStateActi } public async Task PerformAction(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, - FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null) + FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null, NotificationPlaceholderData? placeholderData = null) { if (scope != WfObjectScopes.None && !useInMwServer && wfHandler.MiddlewareClient != null && !WfStateAction.IsReadonlyType(action.ActionType)) { Log.WriteDebug("PerformAction", $"Delegating action '{action.Name}' ({action.ActionType}) to middleware."); - await ExecuteInMiddleware(BuildWorkflowActionParameters(statefulObject, scope, ticketId, action.Id)); + await ExecuteInMiddleware(BuildWorkflowActionParameters(statefulObject, scope, ticketId, action.Id, placeholderData)); return; } @@ -287,7 +292,7 @@ public async Task PerformAction(WfStateAction action, WfStatefulObject statefulO await CallExternal(action); break; case nameof(StateActionTypes.SendEmail): - await SendEmail(action, statefulObject, scope, owner, userGrpDn); + await SendEmail(action, statefulObject, scope, owner, userGrpDn, placeholderData); break; case nameof(StateActionTypes.CreateFlow): await CreateFlow(action, statefulObject, scope, owner, ticketId); @@ -322,7 +327,7 @@ public async Task PerformAction(WfStateAction action, WfStatefulObject statefulO } public async Task PerformActionById(int actionId, WfStatefulObject statefulObject, WfObjectScopes scope, - FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null) + FwoOwner? owner = null, long? ticketId = null, string? userGrpDn = null, NotificationPlaceholderData? placeholderData = null) { WfStateAction? action = GetOfferedActions(statefulObject, scope, wfHandler.Phase).FirstOrDefault(action => action.Id == actionId); if (action == null) @@ -330,11 +335,12 @@ public async Task PerformActionById(int actionId, WfStatefulObject statefu Log.WriteError("Workflow Actions", $"Action id {actionId} is not offered for {scope} in state {statefulObject.StateId} and phase {wfHandler.Phase}."); return false; } - await PerformAction(action, statefulObject, scope, owner, ticketId, userGrpDn); + await PerformAction(action, statefulObject, scope, owner, ticketId, userGrpDn, placeholderData); return true; } - private WorkflowActionParameters BuildWorkflowActionParameters(WfStatefulObject statefulObject, WfObjectScopes scope, long? ticketId, int actionId = 0) + private WorkflowActionParameters BuildWorkflowActionParameters(WfStatefulObject statefulObject, WfObjectScopes scope, long? ticketId, int actionId = 0, + NotificationPlaceholderData? placeholderData = null) { return new() { @@ -347,6 +353,7 @@ private WorkflowActionParameters BuildWorkflowActionParameters(WfStatefulObject StateChangedByCreation = statefulObject.StateChangedByCreation(), Phase = wfHandler.Phase.ToString(), ExecutionMode = wfHandler.userConfig.ExecutionMode, + NotificationPlaceholders = placeholderData, EmailBundleId = wfHandler.WorkflowEmailBundleId ?? "" }; } diff --git a/roles/lib/files/FWO.Services/Workflow/ActionHandlerSendEmail.cs b/roles/lib/files/FWO.Services/Workflow/ActionHandlerSendEmail.cs index b687f36e27..4b128c341e 100644 --- a/roles/lib/files/FWO.Services/Workflow/ActionHandlerSendEmail.cs +++ b/roles/lib/files/FWO.Services/Workflow/ActionHandlerSendEmail.cs @@ -10,18 +10,12 @@ namespace FWO.Services.Workflow public partial class ActionHandler { /// - /// Performs a send email state action, discarding the delivery outcome. This is the entry point of - /// the ordinary state action dispatch, where a delivery problem has already been reported to the - /// user and no caller can act on it. Use where the outcome matters. + /// Performs a send email state action, discarding the delivery outcome. /// - /// State action to execute - /// Object the action was triggered for - /// Scope the action was triggered in - /// Owner the recipients are resolved for, if any - /// User group DN the action was triggered for, if any - public async Task SendEmail(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner, string? userGrpDn = null) + public async Task SendEmail(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner, string? userGrpDn = null, + NotificationPlaceholderData? placeholderData = null) { - await TrySendEmail(action, statefulObject, scope, owner, userGrpDn); + await TrySendEmail(action, statefulObject, scope, owner, userGrpDn, placeholderData); } /// @@ -34,8 +28,10 @@ public async Task SendEmail(WfStateAction action, WfStatefulObject statefulObjec /// Scope the action was triggered in /// Owner the recipients are resolved for, if any /// User group DN the action was triggered for, if any - /// false if a send was attempted and failed, or the action threw; true otherwise - public virtual async Task TrySendEmail(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner, string? userGrpDn = null) + /// Caller-provided values for notification placeholders + /// false if the action failed or a send was attempted and failed; true otherwise + public virtual async Task TrySendEmail(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, FwoOwner? owner, string? userGrpDn = null, + NotificationPlaceholderData? placeholderData = null) { Log.WriteDebug("SendEmail", "Perform Action"); EmailActionParams? emailActionParams = null; @@ -48,7 +44,7 @@ public virtual async Task TrySendEmail(WfStateAction action, WfStatefulObj return true; } - return await SendActionNotifications(emailActionParams, statefulObject, scope, owner, userGrpDn); + return await SendActionNotifications(emailActionParams, statefulObject, scope, owner, userGrpDn, placeholderData); } catch (Exception exc) { @@ -66,7 +62,7 @@ public virtual async Task TrySendEmail(WfStateAction action, WfStatefulObj /// /// false if at least one send was attempted and failed private async Task SendActionNotifications(EmailActionParams emailActionParams, WfStatefulObject statefulObject, - WfObjectScopes scope, FwoOwner? owner, string? userGrpDn) + WfObjectScopes scope, FwoOwner? owner, string? userGrpDn, NotificationPlaceholderData? placeholderData) { List actionNotifications = await ResolveActionNotifications(emailActionParams); int sentEmailCount = 0; @@ -77,17 +73,22 @@ private async Task SendActionNotifications(EmailActionParams emailActionPa await SetScope(statefulObject, scope, actionNotification); WorkflowEmailContent? workflowContent = await CreateWorkflowEmailContent(emailActionParams, statefulObject, scope); EmailHelper emailHelper = new(apiConnection, wfHandler.MiddlewareClient, wfHandler.userConfig, wfHandler.DisplayMessage, UserGroups, useInMwServer, workflowRecipientResolver); - await emailHelper.Init(ScopedUserTo, ScopedUserCc, ScopedUserBcc, ScopedUserEmailTo, ScopedUserEmailCc, ScopedUserEmailBcc); + await emailHelper.Init(ScopedUserTo, ScopedUserCc, ScopedUserBcc, ScopedUserEmailTo, ScopedUserEmailCc, ScopedUserEmailBcc, + wfHandler.ActTicket.Requester?.Name); WfStatefulObject placeholderObject = WorkflowPlaceholderObject(statefulObject); - WorkflowEmailDeliveryResult deliveryResult = await emailHelper.SendWorkflowActionEmail(actionNotification, statefulObject, owner, userGrpDn, workflowContent, placeholderObject); + WorkflowEmailDeliveryResult deliveryResult = await emailHelper.SendWorkflowActionEmail(actionNotification, statefulObject, owner, userGrpDn, workflowContent, placeholderObject, + placeholderData); if (deliveryResult == WorkflowEmailDeliveryResult.Failed) { ++failedEmailCount; } - else if (deliveryResult == WorkflowEmailDeliveryResult.Delivered) + else if (deliveryResult is WorkflowEmailDeliveryResult.Delivered or WorkflowEmailDeliveryResult.Suppressed) { - ++sentEmailCount; AddSentNotificationId(sentNotificationIds, actionNotification); + if (deliveryResult == WorkflowEmailDeliveryResult.Delivered) + { + ++sentEmailCount; + } } } await UpdateSentNotificationTimestamps(sentNotificationIds); @@ -139,25 +140,7 @@ private void DisplaySentEmailConfirmation(EmailActionParams emailActionParams, i private async Task UpdateSentNotificationTimestamps(List notificationIds) { - List distinctNotificationIds = [.. notificationIds.Where(id => id > 0).Distinct()]; - if (distinctNotificationIds.Count == 0) - { - return; - } - - try - { - int affectedRows = (await apiConnection.SendQueryAsync(NotificationQueries.updateNotificationsLastSent, - new { ids = distinctNotificationIds, lastSent = DateTime.Now })).AffectedRows; - if (affectedRows != distinctNotificationIds.Count) - { - Log.WriteWarning("SendEmail", $"Updated last_sent for {affectedRows} of {distinctNotificationIds.Count} workflow action notification(s)."); - } - } - catch (Exception exc) - { - Log.WriteWarning("SendEmail", $"Could not update last_sent for workflow action notification(s): {exc.Message}"); - } + await NotificationLastSentHelper.UpdateAsync(apiConnection, notificationIds); } private async Task> ResolveActionNotifications(EmailActionParams emailActionParams) @@ -167,7 +150,7 @@ private async Task> ResolveActionNotifications(EmailAction { List notifications = await apiConnection.SendQueryAsync>(NotificationQueries.getNotifications, new { client = NotificationClient.WfAction.ToString() }); - List actionNotifications = [.. notifications.Where(n => notificationIds.Contains(n.Id))]; + List actionNotifications = [.. notifications.Where(n => n.Active && notificationIds.Contains(n.Id))]; List missingNotificationIds = [.. notificationIds.Except(actionNotifications.Select(n => n.Id))]; if (missingNotificationIds.Count > 0) { diff --git a/roles/lib/files/FWO.Services/Workflow/StateMatrix.cs b/roles/lib/files/FWO.Services/Workflow/StateMatrix.cs index 223a3b90e0..3162b90b63 100644 --- a/roles/lib/files/FWO.Services/Workflow/StateMatrix.cs +++ b/roles/lib/files/FWO.Services/Workflow/StateMatrix.cs @@ -70,12 +70,17 @@ public async Task Init(WorkflowPhases phase, ApiConnection apiConnection, List p.Value == true).Key].LowestEndState; + WorkflowPhases lastActivePhase = PhaseActive + .Where(entry => entry.Value) + .Select(entry => entry.Key) + .DefaultIfEmpty(phase) + .Max(); + MinTicketCompleted = glbStateMatrix.GlobalMatrix[lastActivePhase].LowestEndState; } public bool getNextActivePhase(ref WorkflowPhases phase) { - foreach (var tmpPhase in PhaseActive) + foreach (var tmpPhase in PhaseActive.OrderBy(entry => entry.Key)) { if (tmpPhase.Key > phase && tmpPhase.Value) { diff --git a/roles/lib/files/FWO.Services/Workflow/TicketCreator.cs b/roles/lib/files/FWO.Services/Workflow/TicketCreator.cs index ac3c270d29..ad3f28bd7c 100644 --- a/roles/lib/files/FWO.Services/Workflow/TicketCreator.cs +++ b/roles/lib/files/FWO.Services/Workflow/TicketCreator.cs @@ -156,15 +156,17 @@ public async Task SetInterfaceId(long ticketId, long connId) /// /// Promotes the first new-interface implementation task of the given ticket. /// - public async Task PromoteNewInterfaceImplTask(long ticketId, ExtStates extState, string comment = "") + public async Task PromoteNewInterfaceImplTask(long ticketId, ExtStates extState, string comment = "", + NotificationPlaceholderData? placeholderData = null) { - return await PromoteNewInterfaceImplTasks(ticketId, extState, null, comment) > 0; + return await PromoteNewInterfaceImplTasks(ticketId, extState, null, comment, placeholderData) > 0; } /// /// Promotes selected new-interface implementation tasks of the given ticket. /// - public async Task PromoteNewInterfaceImplTasks(long ticketId, ExtStates extState, IEnumerable? requestTaskIds, string comment = "") + public async Task PromoteNewInterfaceImplTasks(long ticketId, ExtStates extState, IEnumerable? requestTaskIds, string comment = "", + NotificationPlaceholderData? placeholderData = null) { ExtStateHandler extStateHandler = new(apiConnection); await wfHandler.Init(); @@ -183,7 +185,7 @@ public async Task PromoteNewInterfaceImplTasks(long ticketId, ExtStates ext { await wfHandler.ConfAddCommentToImplTask(comment); } - await wfHandler.PromoteImplTask(new() { StateId = (int)newState }); + await wfHandler.PromoteImplTask(new() { StateId = (int)newState }, placeholderData); promotedCount++; } return promotedCount; diff --git a/roles/lib/files/FWO.Services/Workflow/WfDbAccess.cs b/roles/lib/files/FWO.Services/Workflow/WfDbAccess.cs index 770b48feee..be28b0986c 100644 --- a/roles/lib/files/FWO.Services/Workflow/WfDbAccess.cs +++ b/roles/lib/files/FWO.Services/Workflow/WfDbAccess.cs @@ -415,7 +415,8 @@ private async Task RemoveOwnerInDb(long reqTaskId, long ownerId) // State changes - public async Task UpdateTicketStateInDb(WfTicket ticket, bool triggerActions = true, WfTicket? previousTicket = null) + public async Task UpdateTicketStateInDb(WfTicket ticket, bool triggerActions = true, WfTicket? previousTicket = null, + NotificationPlaceholderData? placeholderData = null) { WfTicket? storedTicket = previousTicket ?? await LoadPreviousTicket(ticket.Id); try @@ -442,7 +443,7 @@ await LogWorkflowChange(new(ticket.Id, ModellingTypes.ChangeType.Update, ChangeH } if (triggerActions) { - await ActionHandler.DoStateChangeActions(ticket, WfObjectScopes.Ticket, null, ticket.Id, GetRequesterDn(ticket)); + await ActionHandler.DoStateChangeActions(ticket, WfObjectScopes.Ticket, null, ticket.Id, GetRequesterDn(ticket), placeholderData); } } } @@ -452,7 +453,8 @@ await LogWorkflowChange(new(ticket.Id, ModellingTypes.ChangeType.Update, ChangeH } } - public async Task UpdateReqTaskStateInDb(WfReqTask reqtask, bool triggerActions = true, WfTicket? previousTicket = null) + public async Task UpdateReqTaskStateInDb(WfReqTask reqtask, bool triggerActions = true, WfTicket? previousTicket = null, + NotificationPlaceholderData? placeholderData = null) { WfTicket? storedTicket = previousTicket ?? await LoadPreviousTicket(reqtask.TicketId); WfReqTask? previousTask = storedTicket?.Tasks.FirstOrDefault(task => task.Id == reqtask.Id); @@ -482,7 +484,8 @@ await LogWorkflowChange(new(reqtask.TicketId, ModellingTypes.ChangeType.Update, } if (triggerActions) { - await ActionHandler.DoStateChangeActions(reqtask, WfObjectScopes.RequestTask, reqtask.Owners.Count > 0 ? reqtask.Owners.First().Owner : null, reqtask.TicketId); + await ActionHandler.DoStateChangeActions(reqtask, WfObjectScopes.RequestTask, reqtask.Owners.Count > 0 ? reqtask.Owners.First().Owner : null, reqtask.TicketId, + placeholderData: placeholderData); } } } @@ -497,7 +500,8 @@ await LogWorkflowChange(new(reqtask.TicketId, ModellingTypes.ChangeType.Update, return !string.IsNullOrWhiteSpace(ticket.Requester?.Dn) ? ticket.Requester.Dn : ticket.RequesterDn; } - public async Task UpdateImplTaskStateInDb(WfImplTask impltask, bool triggerActions = true, WfTicket? previousTicket = null) + public async Task UpdateImplTaskStateInDb(WfImplTask impltask, bool triggerActions = true, WfTicket? previousTicket = null, + NotificationPlaceholderData? placeholderData = null) { WfTicket? storedTicket = previousTicket ?? await LoadPreviousTicket(impltask.TicketId); WfImplTask? previousTask = storedTicket?.Tasks @@ -529,7 +533,7 @@ await LogWorkflowChange(new(impltask.TicketId, ModellingTypes.ChangeType.Update, } if (triggerActions) { - await ActionHandler.DoStateChangeActions(impltask, WfObjectScopes.ImplementationTask); + await ActionHandler.DoStateChangeActions(impltask, WfObjectScopes.ImplementationTask, placeholderData: placeholderData); } } } diff --git a/roles/lib/files/FWO.Services/Workflow/WfDbAccessChangeHistory.cs b/roles/lib/files/FWO.Services/Workflow/WfDbAccessChangeHistory.cs index ed61cd558c..1119132db1 100644 --- a/roles/lib/files/FWO.Services/Workflow/WfDbAccessChangeHistory.cs +++ b/roles/lib/files/FWO.Services/Workflow/WfDbAccessChangeHistory.cs @@ -55,32 +55,51 @@ private async Task LogWorkflowChange(WorkflowChangeTarget target, string changeT return; } - var variables = new - { - appId = (int?)null, - module = GlobalConst.kModuleWorkflow, - ticketId = target.TicketId, - changeType = (int)target.ChangeType, - objectType = (int)target.ObjectType, - objectId = target.ObjectId, - changeText, - changer = string.IsNullOrWhiteSpace(UserConfig.User.Name) ? Roles.MiddlewareServer : UserConfig.User.Name, - changerId = ChangerId > 0 ? ChangerId : null, - changeSource = GlobalConst.kModuleWorkflow, - workflowPhase = (int)WorkflowPhase, - oldData = oldValue, - newData = newValue, - auditProofCritical = contentChange && IsUiContext && requester != null && requester.DbId != UserConfig.UserId - }; try { - // In the UI the connection runs with a user role, whose changer_id insert preset fills the - // column from the session and is therefore not part of that role's insert input. Only the - // middleware-server role writes changer_id itself, so only that path may name it in the - // mutation. changerId stays in the variables of both: a variable value the operation does - // not declare is not coerced and is ignored. - await ApiConnection.SendQueryAsync( - IsUiContext ? ModellingQueries.addHistoryEntry : ModellingQueries.addHistoryEntryAsService, variables); + string changer = string.IsNullOrWhiteSpace(UserConfig.User.Name) ? Roles.MiddlewareServer : UserConfig.User.Name; + bool auditProofCritical = contentChange && IsUiContext && requester != null && requester.DbId != UserConfig.UserId; + if (IsUiContext) + { + var variables = new + { + appId = (int?)null, + module = GlobalConst.kModuleWorkflow, + ticketId = target.TicketId, + changeType = (int)target.ChangeType, + objectType = (int)target.ObjectType, + objectId = target.ObjectId, + changeText, + changer, + changeSource = GlobalConst.kModuleWorkflow, + workflowPhase = (int)WorkflowPhase, + oldData = oldValue, + newData = newValue, + auditProofCritical + }; + await ApiConnection.SendQueryAsync(ModellingQueries.addHistoryEntry, variables); + } + else + { + var variables = new + { + appId = (int?)null, + module = GlobalConst.kModuleWorkflow, + ticketId = target.TicketId, + changeType = (int)target.ChangeType, + objectType = (int)target.ObjectType, + objectId = target.ObjectId, + changeText, + changer, + changerId = ChangerId > 0 ? ChangerId : null, + changeSource = GlobalConst.kModuleWorkflow, + workflowPhase = (int)WorkflowPhase, + oldData = oldValue, + newData = newValue, + auditProofCritical + }; + await ApiConnection.SendQueryAsync(ModellingQueries.addHistoryEntryAsService, variables); + } } catch (Exception exception) { diff --git a/roles/lib/files/FWO.Services/Workflow/WfHandlerImplTasks.cs b/roles/lib/files/FWO.Services/Workflow/WfHandlerImplTasks.cs index da903fba35..5cf43035ae 100644 --- a/roles/lib/files/FWO.Services/Workflow/WfHandlerImplTasks.cs +++ b/roles/lib/files/FWO.Services/Workflow/WfHandlerImplTasks.cs @@ -346,7 +346,10 @@ private async Task> RequestTasksForInitialImplCreation(List LoadReqTaskDetailsForImplCreation(WfReqTask reqTask) { - if (reqTask.Elements.Count > 0 || reqTask.Id <= 0 || reqTask.TicketId <= 0 || dbAcc == null) + // Only access tasks need their elements reloaded. Generic tasks, including new-interface tasks, + // intentionally have no request elements and must keep the active ticket instance unchanged. + if (reqTask.TaskType != WfTaskType.access.ToString() + || reqTask.Elements.Count > 0 || reqTask.Id <= 0 || reqTask.TicketId <= 0 || dbAcc == null) { return reqTask; } diff --git a/roles/lib/files/FWO.Services/Workflow/WfHandlerStateChanges.cs b/roles/lib/files/FWO.Services/Workflow/WfHandlerStateChanges.cs index be1e04c30f..0e2dc839b1 100644 --- a/roles/lib/files/FWO.Services/Workflow/WfHandlerStateChanges.cs +++ b/roles/lib/files/FWO.Services/Workflow/WfHandlerStateChanges.cs @@ -1,4 +1,5 @@ using FWO.Basics; +using FWO.Data; using FWO.Data.Workflow; using FWO.Logging; @@ -156,7 +157,7 @@ public async Task PromoteReqTask(WfStatefulObject reqTask, bool setStartedHandle } } - public async Task PromoteImplTask(WfStatefulObject implTask) + public async Task PromoteImplTask(WfStatefulObject implTask, NotificationPlaceholderData? placeholderData = null) { try { @@ -166,10 +167,10 @@ public async Task PromoteImplTask(WfStatefulObject implTask) { ActImplTask.Stop = DateTime.Now; } - await UpdateActImplTaskState(); + await UpdateActImplTaskState(placeholderData: placeholderData); ResetImplTaskList(); - await UpdateReqTaskStatesFromActImplTask(); - await UpdateActTicketStateFromReqTasks(); + await UpdateReqTaskStatesFromActImplTask(placeholderData: placeholderData); + await UpdateActTicketStateFromReqTasks(placeholderData: placeholderData); DisplayPromoteImplTaskMode = false; } catch (Exception exception) @@ -364,7 +365,8 @@ private static void AuditUnexpectedStateTransition(WfStatefulObject statefulObje // synchronization between the different objects - private async Task UpdateActTicketState(bool triggerActions = true, bool syncImplementationTasks = true) + private async Task UpdateActTicketState(bool triggerActions = true, bool syncImplementationTasks = true, + NotificationPlaceholderData? placeholderData = null) { if (ActTicket.StateId >= MasterStateMatrix.MinTicketCompleted) { @@ -377,7 +379,7 @@ private async Task UpdateActTicketState(bool triggerActions = true, bool syncImp if (dbAcc != null) { AuditUnexpectedStateTransition(ActTicket, WfObjectScopes.Ticket, MasterStateMatrix); - await dbAcc.UpdateTicketStateInDb(ActTicket, triggerActions); + await dbAcc.UpdateTicketStateInDb(ActTicket, triggerActions, placeholderData: placeholderData); } int idx = TicketList.FindIndex(x => x.Id == ActTicket.Id); if (idx >= 0) @@ -395,7 +397,8 @@ private void SyncActTicketFromReqTask(WfReqTask reqTask) } } - private async Task UpdateActTicketStateFromReqTasks(bool triggerActions = true, bool syncImplementationTasks = true) + private async Task UpdateActTicketStateFromReqTasks(bool triggerActions = true, bool syncImplementationTasks = true, + NotificationPlaceholderData? placeholderData = null) { if (ActTicket.Tasks.Count > 0) { @@ -408,7 +411,7 @@ private async Task UpdateActTicketStateFromReqTasks(bool triggerActions = true, Log.WriteDebug("UpdateActTicketStateFromReqTasks", $"Ticket {ActTicket.Id}: derived state {derivedState} from request task states {string.Join(", ", taskStates)}."); ActTicket.StateId = derivedState; } - await UpdateActTicketState(triggerActions, syncImplementationTasks); + await UpdateActTicketState(triggerActions, syncImplementationTasks, placeholderData); } public async Task UpdateActReqTaskState(bool triggerActions = true) @@ -477,7 +480,8 @@ private async Task UpdateReqTaskAndApprovalStatesFromTicket(WfReqTask reqTask, S } } - private async Task UpdateReqTaskStateFromImplTasks(WfReqTask reqTask, bool triggerActions = true, WfTicket? storedTicket = null) + private async Task UpdateReqTaskStateFromImplTasks(WfReqTask reqTask, bool triggerActions = true, WfTicket? storedTicket = null, + NotificationPlaceholderData? placeholderData = null) { if (reqTask.ImplementationTasks.Count > 0) { @@ -491,16 +495,17 @@ private async Task UpdateReqTaskStateFromImplTasks(WfReqTask reqTask, bool trigg if (dbAcc != null) { AuditUnexpectedStateTransition(reqTask, WfObjectScopes.RequestTask, stateMatrixDict.Matrices[reqTask.TaskType]); - await dbAcc.UpdateReqTaskStateInDb(reqTask, triggerActions, storedTicket); + await dbAcc.UpdateReqTaskStateInDb(reqTask, triggerActions, storedTicket, placeholderData); } SyncActTicketFromReqTask(reqTask); } - private async Task UpdateReqTaskStatesFromActImplTask(bool triggerActions = true) + private async Task UpdateReqTaskStatesFromActImplTask(bool triggerActions = true, + NotificationPlaceholderData? placeholderData = null) { SyncReqTaskStopTime(ActReqTask); WfTicket? storedTicket = dbAcc != null ? await dbAcc.LoadPreviousTicket(ActReqTask.TicketId) : null; - await UpdateReqTaskStateFromImplTasks(ActReqTask, triggerActions, storedTicket); + await UpdateReqTaskStateFromImplTasks(ActReqTask, triggerActions, storedTicket, placeholderData); List bundledTasks = [.. GetBundledRequestTasks(ActReqTask).Where(task => task.Id != ActReqTask.Id)]; foreach (WfReqTask bundledTask in bundledTasks) @@ -513,7 +518,7 @@ private async Task UpdateReqTaskStatesFromActImplTask(bool triggerActions = true if (dbAcc != null) { AuditUnexpectedStateTransition(bundledTask, WfObjectScopes.RequestTask, stateMatrixDict.Matrices[bundledTask.TaskType]); - await dbAcc.UpdateReqTaskStateInDb(bundledTask, triggerActions, storedTicket); + await dbAcc.UpdateReqTaskStateInDb(bundledTask, triggerActions, storedTicket, placeholderData); } SyncActTicketFromReqTask(bundledTask); } @@ -532,12 +537,12 @@ private List GetBundledRequestTasks(WfReqTask reqTask) : [.. ActTicket.Tasks.Where(task => task.GetAddInfoValue(AdditionalInfoKeys.FlowBundleId) == bundleId)]; } - private async Task UpdateActImplTaskState(bool triggerActions = true) + private async Task UpdateActImplTaskState(bool triggerActions = true, NotificationPlaceholderData? placeholderData = null) { if (dbAcc != null) { AuditUnexpectedStateTransition(ActImplTask, WfObjectScopes.ImplementationTask, ActStateMatrix); - await dbAcc.UpdateImplTaskStateInDb(ActImplTask, triggerActions); + await dbAcc.UpdateImplTaskStateInDb(ActImplTask, triggerActions, placeholderData: placeholderData); } int index = ActReqTask.ImplementationTasks.FindIndex(x => x.Id == ActImplTask.Id); if (index >= 0) diff --git a/roles/middleware/files/FWO.Middleware.Server/AppDataImport.cs b/roles/middleware/files/FWO.Middleware.Server/AppDataImport.cs index a8450d0b2a..b59f8a273a 100644 --- a/roles/middleware/files/FWO.Middleware.Server/AppDataImport.cs +++ b/roles/middleware/files/FWO.Middleware.Server/AppDataImport.cs @@ -483,7 +483,7 @@ protected virtual async Task LoadHasImmediateAppDecommNotification() List notifications = await apiConnection.SendQueryAsync>( NotificationQueries.getNotifications, new { client = NotificationClient.AppDecomm.ToString() }); - return notifications.Any(notification => notification.Deadline == NotificationDeadline.None); + return notifications.Any(notification => notification.Active && notification.Deadline == NotificationDeadline.None); } private static bool LooksLikeDistinguishedName(string identifier) diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/NotificationController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/NotificationController.cs new file mode 100644 index 0000000000..b0dc04e52d --- /dev/null +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/NotificationController.cs @@ -0,0 +1,421 @@ +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Basics; +using FWO.Config.Api; +using FWO.Data; +using FWO.Data.Middleware; +using FWO.Data.Modelling; +using FWO.Data.Workflow; +using FWO.Logging; +using FWO.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace FWO.Middleware.Server.Controllers; + +/// +/// Performs rendered notification email delivery in the trusted middleware process. +/// +[Authorize] +[ApiController] +[Route("api/notification")] +public class NotificationController(ApiConnection apiConnection, GlobalConfig globalConfig) : ControllerBase +{ + /// + /// Processes immediate notifications for applications using a decommissioned interface. + /// Notification data and recipients are resolved from persisted middleware data. + /// + [HttpPost("interface-decommission")] + [Authorize(Roles = $"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Modeller}")] + [ProducesResponseType(typeof(NotificationDeliveryResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)] + public async Task> SendInterfaceDecommission( + [FromBody] InterfaceDecommissionNotificationParameters parameters) + { + try + { + if (parameters is null || parameters.ConnectionId <= 0 || string.IsNullOrWhiteSpace(parameters.Reason)) + { + return BadRequest("A connection ID and decommission reason are required."); + } + + ModellingConnection? connection = await LoadConnection(parameters.ConnectionId); + if (connection is null || !CanProcessModellingNotification(connection)) + { + return Forbid(); + } + if (!connection.IsInterface || !connection.Removed || connection.AppId is not > 0) + { + return BadRequest("The decommissioned interface could not be found."); + } + + ModellingConnection? replacement = null; + if (parameters.ReplacementConnectionId is > 0) + { + replacement = await LoadConnection(parameters.ReplacementConnectionId.Value); + if (!IsEligibleReplacement(connection, replacement)) + { + return BadRequest("The replacement interface is not eligible for decommissioning."); + } + } + List usingConnections = await apiConnection.SendQueryAsync>( + ModellingQueries.getInterfaceUsers, new { id = connection.Id }); + List usingOwners = await LoadUsingOwners(usingConnections, connection.AppId); + + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceDecomm, globalConfig, apiConnection); + List notifications = notificationService.Notifications + .Where(notification => notification.Deadline == NotificationDeadline.None) + .ToList(); + NotificationDeliveryResult result = await SendInterfaceDecommissionNotifications(notificationService, notifications, + connection, replacement, usingConnections, usingOwners, parameters.Reason); + await notificationService.UpdateNotificationsLastSent(); + return Ok(result); + } + catch (Exception exception) + { + Log.WriteError("Interface Decommission Notification", "Could not process interface decommission notification.", exception); + return StatusCode(StatusCodes.Status500InternalServerError, "Internal server error"); + } + } + + /// + /// Processes the configured immediate notification for an existing interface request. + /// All message data is resolved from the persisted connection and ticket in middleware. + /// + [HttpPost("interface-request")] + [Authorize(Roles = $"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Modeller}")] + [ProducesResponseType(typeof(NotificationDeliveryResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)] + public async Task> SendInterfaceRequest( + [FromBody] InterfaceRequestNotificationParameters parameters) + { + try + { + if (parameters is null || parameters.ConnectionId <= 0) + { + return BadRequest("Connection ID must be greater than zero."); + } + + ModellingConnection? connection = await LoadConnection(parameters.ConnectionId); + if (connection is null || !await CanProcessInterfaceRequest(connection)) + { + return Forbid(); + } + if (!connection.IsInterface || !connection.IsRequested || connection.Removed + || connection.GetBoolProperty(ConState.Rejected.ToString()) || connection.TicketId is not > 0 + || connection.ProposedAppId is not > 0) + { + return BadRequest("The requested interface could not be found."); + } + + WfTicket? ticket = await apiConnection.SendQueryAsync(RequestQueries.getTicketById, + new { id = connection.TicketId.Value }); + if (!CanProcessModellingNotification(connection, ticket, allowRequestCreator: true)) + { + return Forbid(); + } + + WfReqTask? requestTask = ticket?.Tasks.FirstOrDefault(task => task.TaskType == WfTaskType.new_interface.ToString()); + FwoOwner owner = await LoadOwner(connection.ProposedAppId, includeResponsibles: true) ?? connection.ProposedApp; + FwoOwner? requestingOwner = await LoadOwner(requestTask?.GetAddInfoIntValue(AdditionalInfoKeys.ReqOwner)); + UiUser? requester = CreateRequesterContext(ticket); + NotificationPlaceholderResolver.NotificationPlaceholderValues placeholderValues = + BuildInterfaceRequestPlaceholderValues(connection, ticket, requestTask, owner, requestingOwner, requester); + + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, globalConfig, apiConnection); + List notifications = notificationService.Notifications + .Where(notification => notification.Deadline == NotificationDeadline.None + && (notification.OwnerId == null || notification.OwnerId == owner.Id)) + .ToList(); + if (notifications.Count == 0) + { + return Ok(NotificationDeliveryResult.Suppressed); + } + + NotificationDeliveryResult result = await SendNotifications(notificationService, notifications, owner, placeholderValues); + await notificationService.UpdateNotificationsLastSent(); + return Ok(result); + } + catch (Exception exception) + { + Log.WriteError("Interface Request Notification", "Could not process interface request notification.", exception); + return StatusCode(StatusCodes.Status500InternalServerError, "Internal server error"); + } + } + + private async Task LoadConnection(int connectionId) + { + List connections = await apiConnection.SendQueryAsync>( + ModellingQueries.getConnectionForNotification, new { id = connectionId }); + return connections.SingleOrDefault(); + } + + private async Task CanProcessInterfaceRequest(ModellingConnection connection) + { + if (CanProcessModellingNotification(connection)) + { + return true; + } + + if (!connection.IsRequested || connection.TicketId is not > 0) + { + return false; + } + + WfTicket? ticket = await apiConnection.SendQueryAsync(RequestQueries.getTicketRequesterId, + new { id = connection.TicketId.Value }); + return CanProcessModellingNotification(connection, ticket, allowRequestCreator: true); + } + + private static bool IsEligibleReplacement(ModellingConnection connection, ModellingConnection? replacement) + { + if (replacement == null || replacement.Id == connection.Id) + { + return false; + } + + if (!replacement.IsInterface || !replacement.IsPublished || replacement.Removed) + { + return false; + } + + if (replacement.GetBoolProperty(ConState.Decommissioned.ToString()) + || replacement.GetBoolProperty(ConState.Rejected.ToString())) + { + return false; + } + + return !string.Equals(replacement.InterfacePermission, InterfacePermissions.Private.ToString(), + StringComparison.OrdinalIgnoreCase); + } + + private async Task LoadOwner(int? ownerId, bool includeResponsibles = false) + { + if (ownerId is not > 0) + { + return null; + } + + string query = OwnerQueries.getOwnerById; + if (includeResponsibles) + { + query = OwnerQueries.getOwnerForNotification; + } + + return await apiConnection.SendQueryAsync(query, new { id = ownerId.Value }); + } + + private async Task> LoadUsingOwners(List usingConnections, int? decommissionedOwnerId) + { + List owners = []; + foreach (ModellingConnection usingConnection in usingConnections + .Where(usingConnection => usingConnection.AppId is > 0 && usingConnection.AppId != decommissionedOwnerId) + .DistinctBy(usingConnection => usingConnection.AppId)) + { + FwoOwner? owner = await LoadOwner(usingConnection.AppId, includeResponsibles: true); + FwoOwner resolvedOwner = usingConnection.App; + if (owner is { Id: > 0 }) + { + resolvedOwner = owner; + } + if (resolvedOwner.Id > 0) + { + owners.Add(resolvedOwner); + } + } + return owners; + } + + private bool CanProcessModellingNotification(ModellingConnection connection, WfTicket? ticket = null, + bool allowRequestCreator = false) + { + ClaimsPrincipal caller = ControllerContext.HttpContext.User; + if (caller.IsInRole(Roles.Admin) || caller.IsInRole(Roles.FwAdmin)) + { + return true; + } + + int? targetOwnerId = connection.IsRequested ? connection.ProposedAppId : connection.AppId; + if (!caller.IsInRole(Roles.Modeller) || targetOwnerId is not > 0) + { + return false; + } + + string callerName = caller.FindFirstValue("unique_name") ?? caller.Identity?.Name ?? ""; + int callerId = JwtClaimParser.ExtractIntClaimValues(caller.Claims, "x-hasura-user-id").FirstOrDefault(); + if (allowRequestCreator && callerId > 0 && ticket?.Requester?.DbId == callerId) + { + return true; + } + + List editableOwnerIds = JwtClaimParser.ExtractIntClaimValues(caller.Claims, "x-hasura-editable-owners"); + bool isEditableOwner = editableOwnerIds.Contains(targetOwnerId.Value); + if (!isEditableOwner) + { + Log.WriteWarning("Interface Request Notification", + $"Notification scope denied. Caller='{callerName}', callerId={callerId}, connectionId={connection.Id}, " + + $"connectionCreator='{connection.Creator}', ticketRequester='{ticket?.Requester?.Name}', " + + $"ticketRequesterId={ticket?.Requester?.DbId}, targetOwnerId={targetOwnerId.Value}, " + + $"editableOwnerIds='{string.Join(",", editableOwnerIds)}'."); + } + return isEditableOwner; + } + + + private static UiUser? CreateRequesterContext(WfTicket? ticket) + { + if (ticket?.Requester != null) + { + string requesterDn = ticket.Requester.Dn ?? ""; + if (string.IsNullOrWhiteSpace(requesterDn)) + { + requesterDn = ticket.RequesterDn ?? ""; + } + + UiUser requester = new(ticket.Requester) { Dn = requesterDn }; + return requester; + } + + if (string.IsNullOrWhiteSpace(ticket?.RequesterDn)) + { + return null; + } + + return new UiUser { Dn = ticket.RequesterDn }; + } + + private NotificationPlaceholderResolver.NotificationPlaceholderValues BuildInterfaceRequestPlaceholderValues( + ModellingConnection connection, WfTicket? ticket, WfReqTask? requestTask, FwoOwner owner, FwoOwner? requestingOwner, + UiUser? requester) + { + string interfaceName = requestTask?.Title ?? connection.Name ?? globalConfig.GetText("interface"); + string interfaceUrl = $"{globalConfig.UiHostName}/{PageName.Modelling}/{owner.ExtAppId}/{connection.Id}"; + string requesterName = ticket?.Requester?.Name ?? ticket?.RequesterDn ?? ""; + return new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = owner, + RequestingOwner = requestingOwner, + Requester = requester, + InterfaceName = interfaceName, + InterfaceLinkText = globalConfig.GetText("request_interface"), + InterfaceLinkName = interfaceName, + InterfaceLinkUrl = interfaceUrl, + NewInterfaceName = interfaceName, + NewInterfaceLinkText = globalConfig.GetText("request_interface"), + NewInterfaceLinkName = interfaceName, + NewInterfaceLinkUrl = interfaceUrl, + Reason = requestTask?.Reason ?? ticket?.Reason ?? connection.Reason ?? "", + UserName = requesterName, + RequesterName = requesterName, + RequestDate = ticket?.CreationDate.ToString("dd.MM.yyyy") ?? "" + }; + } + + private static async Task SendNotifications(NotificationService service, + List notifications, FwoOwner owner, + NotificationPlaceholderResolver.NotificationPlaceholderValues placeholderValues) + { + if (notifications.Count == 0) + { + return NotificationDeliveryResult.Suppressed; + } + + bool delivered = false; + bool failed = false; + bool noRecipients = false; + foreach (FwoNotification notification in notifications) + { + NotificationDeliveryResult result = await service.SendNotificationWithResult(notification, owner, + placeholderValues: placeholderValues); + delivered |= result == NotificationDeliveryResult.Delivered; + failed |= result == NotificationDeliveryResult.Failed; + noRecipients |= result == NotificationDeliveryResult.NoRecipients; + } + + return CombineDeliveryResults(delivered, failed, noRecipients); + } + + private async Task SendInterfaceDecommissionNotifications(NotificationService service, + List notifications, ModellingConnection connection, ModellingConnection? replacement, + List usingConnections, List usingOwners, string reason) + { + if (notifications.Count == 0 || usingOwners.Count == 0) + { + return NotificationDeliveryResult.Suppressed; + } + + bool delivered = false; + bool failed = false; + bool noRecipients = false; + FwoOwner interfaceOwner = connection.App; + foreach (FwoOwner owner in usingOwners) + { + foreach (FwoNotification notification in notifications.Where(notification => notification.OwnerId == null || notification.OwnerId == owner.Id)) + { + string separator = notification.Layout == NotificationLayout.HtmlInBody ? "
    " : Environment.NewLine; + string connectionList = string.Join(separator, usingConnections.Where(usingConnection => usingConnection.AppId == owner.Id) + .Select(usingConnection => usingConnection.Name)); + NotificationPlaceholderResolver.NotificationPlaceholderValues values = BuildInterfaceDecommissionPlaceholderValues( + connection, replacement, interfaceOwner, reason); + NotificationDeliveryResult result = await service.SendNotificationWithResult(notification, owner, connectionList, + placeholderValues: values); + delivered |= result == NotificationDeliveryResult.Delivered; + failed |= result == NotificationDeliveryResult.Failed; + noRecipients |= result == NotificationDeliveryResult.NoRecipients; + } + } + + return CombineDeliveryResults(delivered, failed, noRecipients); + } + + private static NotificationDeliveryResult CombineDeliveryResults(bool delivered, bool failed, bool noRecipients) + { + if (failed) + { + return NotificationDeliveryResult.Failed; + } + + if (noRecipients) + { + return NotificationDeliveryResult.NoRecipients; + } + + if (delivered) + { + return NotificationDeliveryResult.Delivered; + } + + return NotificationDeliveryResult.Suppressed; + } + + private NotificationPlaceholderResolver.NotificationPlaceholderValues BuildInterfaceDecommissionPlaceholderValues( + ModellingConnection connection, ModellingConnection? replacement, FwoOwner interfaceOwner, string reason) + { + string replacementUrl = replacement?.App is not null && replacement.Id > 0 + ? $"{globalConfig.UiHostName}/{PageName.Modelling}/{replacement.App.ExtAppId}/{replacement.Id}" + : ""; + return new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = interfaceOwner, + InterfaceName = connection.Name ?? "", + NewInterfaceName = replacement?.Name ?? "", + InterfaceLinkText = globalConfig.GetText("interface"), + InterfaceLinkName = connection.Name ?? "", + InterfaceLinkUrl = $"{globalConfig.UiHostName}/{PageName.Modelling}/{interfaceOwner.ExtAppId}/{connection.Id}", + NewInterfaceLinkText = globalConfig.GetText("interface"), + NewInterfaceLinkName = replacement?.Name ?? "", + NewInterfaceLinkUrl = replacementUrl, + Reason = reason, + UserName = ControllerContext.HttpContext.User.Identity?.Name ?? "" + }; + } + +} diff --git a/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs b/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs index e7cdfbe6ff..7c6eebfa37 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Controllers/WorkflowController.cs @@ -421,11 +421,12 @@ private static async Task ExecuteResolvedAction(WfHandler wfHandler, Workf { if (parameters.ActionId > 0) { - return await wfHandler.ActionHandler!.PerformActionById(parameters.ActionId, statefulObject, scope, owner, actionTicketId, userGrpDn); + return await wfHandler.ActionHandler!.PerformActionById(parameters.ActionId, statefulObject, scope, owner, actionTicketId, userGrpDn, + parameters.NotificationPlaceholders); } MarkStateChanged(statefulObject, parameters.OldStateId, parameters.NewStateId); - await wfHandler.ActionHandler!.DoStateChangeActions(statefulObject, scope, owner, actionTicketId, userGrpDn); + await wfHandler.ActionHandler!.DoStateChangeActions(statefulObject, scope, owner, actionTicketId, userGrpDn, parameters.NotificationPlaceholders); return true; } diff --git a/roles/middleware/files/FWO.Middleware.Server/ExternalRequestHandlerInternalWork.cs b/roles/middleware/files/FWO.Middleware.Server/ExternalRequestHandlerInternalWork.cs index cfc046df9e..d76f367337 100644 --- a/roles/middleware/files/FWO.Middleware.Server/ExternalRequestHandlerInternalWork.cs +++ b/roles/middleware/files/FWO.Middleware.Server/ExternalRequestHandlerInternalWork.cs @@ -230,7 +230,7 @@ private async Task TrySendPendingInternalWorkEmailsIndividually(long ticke try { - if (wfHandler.ActionHandler == null && !await wfHandler.Init()) + if (wfHandler.ActionHandler == null && !await wfHandler.InitForActionExecution()) { return false; } diff --git a/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs b/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs index ae5ab07648..724dd9eccb 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Jobs/DailyCheckJob.cs @@ -251,7 +251,8 @@ private async Task CheckUnansweredInterfaceRequests() connectedLdaps, OwnerGroups); - foreach (var notification in notificationService.Notifications) + foreach (var notification in notificationService.Notifications + .Where(notification => notification.Deadline == NotificationDeadline.RequestDate)) { SchedulerInterval repeatInterval = notification.RepeatIntervalAfterDeadline ?? SchedulerInterval.Days; int cutOffPeriod = GetInterfaceRequestCutOffPeriod(notification, repeatInterval); @@ -276,7 +277,13 @@ private async Task CheckUnansweredInterfaceRequests() continue; } - int sentForTicket = await notificationService.SendNotification(notification, owner, await PrepareBody(ticket, owner)); + NotificationPlaceholderResolver.NotificationPlaceholderValues placeholderValues = + await BuildRequestPlaceholderValues(ticket, owner); + int sentForTicket = await notificationService.SendNotification( + notification, + owner, + resolvedDeadline: ticket.CreationDate, + placeholderValues: placeholderValues); emailsSent += sentForTicket; if (sentForTicket == 0) { @@ -322,19 +329,28 @@ private static long GetMaximumCutOffPeriod(SchedulerInterval interval) }; } - private async Task PrepareBody(WfTicket ticket, FwoOwner owner) + private async Task BuildRequestPlaceholderValues(WfTicket ticket, FwoOwner owner) { WfReqTask? reqTask = ticket.Tasks.FirstOrDefault(r => r.TaskType == WfTaskType.new_interface.ToString()); FwoOwner? requestingOwner = await GetRequestingOwner(reqTask?.GetAddInfoIntValue(AdditionalInfoKeys.ReqOwner)); - - return globalConfig.ModUnansweredReqEmailBody - .Replace(Placeholder.REQUESTER, ticket.Requester?.Name) - .Replace(Placeholder.REQUESTDATE, ticket.CreationDate.ToString("dd.MM.yyyy")) - .Replace(Placeholder.REQUESTING_APPNAME, requestingOwner?.Name) - .Replace(Placeholder.REQUESTING_APPID, requestingOwner?.ExtAppId) - .Replace(Placeholder.APPNAME, owner.Name) - .Replace(Placeholder.APPID, owner.ExtAppId) - .Replace(Placeholder.INTERFACE_LINK, ConstructLink(owner, reqTask)); + FwoOwner effectiveRequestingOwner = requestingOwner ?? new FwoOwner(); + string interfaceName = reqTask?.Title ?? globalConfig.GetText("interface"); + string interfaceUrl = ConstructLink(owner, reqTask); + return new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = owner, + RequestingOwner = effectiveRequestingOwner, + InterfaceName = interfaceName, + InterfaceLinkText = globalConfig.GetText("request_interface"), + InterfaceLinkUrl = interfaceUrl, + NewInterfaceName = interfaceName, + NewInterfaceLinkText = globalConfig.GetText("request_interface"), + NewInterfaceLinkUrl = interfaceUrl, + Reason = reqTask?.Reason ?? ticket.Reason ?? "", + UserName = ticket.Requester?.Name ?? "", + RequesterName = ticket.Requester?.Name ?? ticket.RequesterDn ?? "", + RequestDate = ticket.CreationDate.ToString("dd.MM.yyyy") + }; } private async Task GetRequestingOwner(int? ownerId) @@ -357,8 +373,7 @@ private async Task PrepareBody(WfTicket ticket, FwoOwner owner) private string ConstructLink(FwoOwner owner, WfReqTask? reqTask) { int? connId = reqTask?.GetAddInfoIntValue(AdditionalInfoKeys.ConnId); - string interfaceUrl = $"{globalConfig.UiHostName}/{PageName.Modelling}/{owner.ExtAppId}/{connId}"; - return $"{reqTask?.Title ?? globalConfig.GetText("interface")}"; + return $"{globalConfig.UiHostName}/{PageName.Modelling}/{owner.ExtAppId}/{connId}"; } } } diff --git a/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs b/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs index c537862acc..63286539ca 100644 --- a/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs +++ b/roles/middleware/files/FWO.Middleware.Server/NotificationService.cs @@ -17,6 +17,7 @@ namespace FWO.Middleware.Server ///
    public class NotificationService { + private const string kNoRecipientFailureMessage = "No recipients resolved."; /// /// Notifications for current NotificationClient /// @@ -33,7 +34,7 @@ public class NotificationService { ApiConnection = apiConnection; GlobalConfig = globalConfig; - Notifications = notifications; + Notifications = notifications.Where(notification => notification.Active).ToList(); OwnerGroups = ownerGroups; WorkflowRecipientResolver = workflowRecipientResolver; } @@ -117,20 +118,47 @@ public async Task SendNotificationsIfDue(FwoOwner? owner, DateTime? extDead /// Text for notification (e.g. email body). /// Optional report to be sent as attachment. /// Optional resolved time interval text for placeholder replacement. + /// Resolved deadline timestamp used for notification logging. + /// Optional caller-provided values for notification placeholder replacement. /// number of emails sent - public async Task SendNotification(FwoNotification notification, FwoOwner? owner, string? content = null, ReportBase? report = null, string timeIntervalText = "") + public async Task SendNotification(FwoNotification notification, FwoOwner? owner, string? content = null, ReportBase? report = null, + string timeIntervalText = "", DateTime? resolvedDeadline = null, + NotificationPlaceholderResolver.NotificationPlaceholderValues? placeholderValues = null) { - // Later: Handle other channels here when implemented - bool sent = await SendEmail(notification, content, owner, report, timeIntervalText); - if (!sent) + return await SendNotificationWithResult(notification, owner, content, report, timeIntervalText, + resolvedDeadline, placeholderValues) == NotificationDeliveryResult.Delivered ? 1 : 0; + } + + /// + /// Sends one active notification and preserves the complete delivery outcome. + /// + /// Notification to send. + /// Owner for whom the notification is sent. + /// Optional caller content for the notification body. + /// Optional report attachment. + /// Optional interval text for placeholders. + /// Resolved deadline used for logging. + /// Optional caller context for placeholders. + /// The explicit processing result. + public async Task SendNotificationWithResult(FwoNotification notification, FwoOwner? owner, + string? content = null, ReportBase? report = null, string timeIntervalText = "", DateTime? resolvedDeadline = null, + NotificationPlaceholderResolver.NotificationPlaceholderValues? placeholderValues = null) + { + if (!notification.Active) { - return 0; + return NotificationDeliveryResult.Suppressed; } - if (!CheckedNotificationIds.Contains(notification.Id)) + + // Later: Handle other channels here when implemented. + NotificationDeliveryResult deliveryResult = await SendEmail(notification, content, owner, report, timeIntervalText, + resolvedDeadline, placeholderValues); + if (deliveryResult is NotificationDeliveryResult.Delivered + or NotificationDeliveryResult.Suppressed + or NotificationDeliveryResult.NoRecipients) { - CheckedNotificationIds.Add(notification.Id); + AddCheckedNotificationId(notification.Id); } - return 1; + return deliveryResult; } /// @@ -145,30 +173,45 @@ public async Task SendNotification(FwoNotification notification, FwoOwner? public async Task SendBundledNotifications(List notifications, FwoOwner? owner, string? content = null, ReportBase? report = null, string timeIntervalText = "") { int emailsSent = 0; - foreach (IGrouping notificationGroup in notifications.GroupBy(GetBundleGroupKey)) + foreach (IGrouping notificationGroup in notifications.Where(notification => notification.Active).GroupBy(GetBundleGroupKey)) { List groupedNotifications = [.. notificationGroup]; - if (groupedNotifications.Count == 1 || groupedNotifications[0].BundleType == null) - { - emailsSent += await SendNotification(groupedNotifications[0], owner, content, report, timeIntervalText); - continue; - } + emailsSent += await SendNotificationGroup(groupedNotifications, owner, content, report, timeIntervalText); + } + return emailsSent; + } + + private async Task SendNotificationGroup(List notifications, FwoOwner? owner, string? content, + ReportBase? report, string timeIntervalText) + { + if (notifications.Count == 1 || notifications[0].BundleType == null) + { + return await SendNotification(notifications[0], owner, content, report, timeIntervalText); + } - bool sent = await SendBundledEmail(groupedNotifications, content, owner, report, timeIntervalText); - if (!sent) + NotificationDeliveryResult result = await SendBundledEmail(notifications, content, owner, report, timeIntervalText); + TrackBundledNotificationResult(notifications, result); + return result == NotificationDeliveryResult.Delivered ? 1 : 0; + } + + private void TrackBundledNotificationResult(List notifications, NotificationDeliveryResult result) + { + if (result == NotificationDeliveryResult.NoRecipients) + { + foreach (FwoNotification notification in notifications) { - continue; + AddCheckedNotificationId(notification.Id); } - foreach (FwoNotification notification in groupedNotifications) + return; + } + + if (result == NotificationDeliveryResult.Delivered) + { + foreach (FwoNotification notification in notifications.Where(notification => NotificationLoggingMode.ShouldSend(notification.Logging))) { - if (!CheckedNotificationIds.Contains(notification.Id)) - { - CheckedNotificationIds.Add(notification.Id); - } + AddCheckedNotificationId(notification.Id); } - emailsSent++; } - return emailsSent; } /// @@ -185,7 +228,7 @@ public async Task SendNotificationIfDue(FwoNotification notification, FwoOw { if (IsNotificationDue(owner, extDeadline, notification)) { - return await SendNotification(notification, owner, content, report, timeIntervalText); + return await SendNotification(notification, owner, content, report, timeIntervalText, extDeadline); } return 0; } @@ -196,7 +239,7 @@ public async Task SendNotificationIfDue(FwoNotification notification, FwoOw /// public async Task UpdateNotificationsLastSent() { - int updatedNotifications = (await ApiConnection.SendQueryAsync(NotificationQueries.updateNotificationsLastSent, new { ids = CheckedNotificationIds, lastSent = DateTime.Now })).AffectedRows; + int updatedNotifications = await NotificationLastSentHelper.UpdateAsync(ApiConnection, CheckedNotificationIds); CheckedNotificationIds = []; return updatedNotifications; } @@ -210,96 +253,7 @@ public async Task UpdateNotificationsLastSent() /// True if the notification should be sent now; otherwise false. public static bool IsNotificationDue(FwoOwner? owner, DateTime? extDeadline, FwoNotification notification) { - if (notification.Deadline == NotificationDeadline.None) - { - return true; - } - - DateTime deadline = GetDeadlineDate(notification.Deadline, owner, extDeadline); - - return deadline >= DateTime.Now - ? IsNotificationDueBeforeDeadline(deadline, notification) - : IsNotificationDueAfterDeadline(deadline, notification); - } - - private static bool IsTimeToSend(DateTime? lastSent, DateTime notifDate) - { - return (lastSent == null || ((DateTime)lastSent).Date < notifDate.Date) && notifDate.Date <= DateTime.Now.Date; - } - - private static bool TryGetConfiguredInterval(SchedulerInterval? interval, string propertyName, out SchedulerInterval configuredInterval) - { - if (interval != null) - { - configuredInterval = (SchedulerInterval)interval; - return true; - } - - Log.WriteWarning("Notifications", $"Notification interval '{propertyName}' is not configured. Skipping due evaluation."); - configuredInterval = default; - return false; - } - - private static bool IsNotificationDueBeforeDeadline(DateTime deadline, FwoNotification notification) - { - if (!TryGetConfiguredInterval(notification.IntervalBeforeDeadline, nameof(notification.IntervalBeforeDeadline), out SchedulerInterval intervalBeforeDeadline)) - { - return false; - } - - DateTime notifDate = ApplyIntervalOffset(deadline, intervalBeforeDeadline, -notification.OffsetBeforeDeadline ?? 0); - return IsTimeToSend(notification.LastSent, notifDate); - } - - private static bool IsNotificationDueAfterDeadline(DateTime deadline, FwoNotification notification) - { - if (!TryGetConfiguredInterval(notification.RepeatIntervalAfterDeadline, nameof(notification.RepeatIntervalAfterDeadline), out SchedulerInterval repeatIntervalAfterDeadline)) - { - return false; - } - - DateTime nextNotifDate = ApplyIntervalOffset(deadline.Date, repeatIntervalAfterDeadline, notification.InitialOffsetAfterDeadline ?? 0); - DateTime currentNotifDate = nextNotifDate; - int counter = -1; - while (nextNotifDate <= DateTime.Now.Date && counter++ <= notification.RepetitionsAfterDeadline) - { - currentNotifDate = nextNotifDate; - nextNotifDate = ApplyIntervalOffset(nextNotifDate, repeatIntervalAfterDeadline, notification.RepeatOffsetAfterDeadline ?? 0); - } - - return counter <= notification.RepetitionsAfterDeadline && IsTimeToSend(notification.LastSent, currentNotifDate); - } - - private static DateTime ApplyIntervalOffset(DateTime value, SchedulerInterval interval, int offset) - { - return interval switch - { - SchedulerInterval.Days => value.AddDays(offset), - SchedulerInterval.Weeks => value.AddDays(offset * GlobalConst.kDaysPerWeek), - SchedulerInterval.Months => value.AddMonths(offset), - _ => throw new NotSupportedException("Time interval is not supported.") - }; - } - - private static DateTime GetDeadlineDate(NotificationDeadline deadline, FwoOwner? owner, DateTime? extDeadline) - { - if (deadline == NotificationDeadline.RecertDate && owner?.NextRecertDate != null) - { - return (DateTime)owner.NextRecertDate; - } - else if (deadline == NotificationDeadline.RequestDate && extDeadline != null) - { - return (DateTime)extDeadline; - } - else if (deadline == NotificationDeadline.RuleExpiry && extDeadline != null) - { - return (DateTime)extDeadline; - } - else if (deadline == NotificationDeadline.DecommissionDate && owner?.DecommDate != null) - { - return (DateTime)owner.DecommDate; - } - return DateTime.Now; + return NotificationScheduleHelper.IsNotificationDue(owner, extDeadline, notification); } /// @@ -386,22 +340,109 @@ private static async Task> LoadOwnerGroups(List? connected /// Owner context used for placeholder replacement. /// Optional report attachment. /// Optional resolved time interval text. - /// True when an email was sent; otherwise false. - private async Task SendEmail(FwoNotification notification, string? content, FwoOwner? owner, ReportBase? report = null, string timeIntervalText = "") + /// Resolved deadline timestamp used for notification logging. + /// Optional caller-provided values for notification placeholder replacement. + /// The delivery outcome of the notification. + private async Task SendEmail(FwoNotification notification, string? content, FwoOwner? owner, ReportBase? report = null, + string timeIntervalText = "", DateTime? resolvedDeadline = null, + NotificationPlaceholderResolver.NotificationPlaceholderValues? placeholderValues = null) { - MailData? mail = await PrepareEmail(notification, content, owner, report, timeIntervalText); + MailData? mail = await PrepareEmail(notification, content, owner, report, timeIntervalText, placeholderValues); if (mail.To.Count == 0 && mail.Cc.Count == 0 && mail.Bcc.Count == 0) { Log.WriteWarning("Notifications", $"No recipients resolved for notification client {notification.NotificationClient} while preparing notification {notification.Id}. Skipping send."); - return false; + await LogNoRecipientFailureIfConfigured(notification, mail.Subject, resolvedDeadline); + return NotificationDeliveryResult.NoRecipients; + } + + int logId = 0; + if (NotificationLoggingMode.ShouldLog(notification.Logging)) + { + DateTimeOffset? deadline = resolvedDeadline.HasValue ? new DateTimeOffset(resolvedDeadline.Value) : null; + logId = await NotificationLogHelper.InsertAsync(ApiConnection, notification, mail.To, mail.Cc, mail.Bcc, mail.Subject, deadline); + } + + if (!NotificationLoggingMode.ShouldSend(notification.Logging)) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Suppressed); + return NotificationDeliveryResult.Suppressed; + } + + try + { + string decryptedSecret = AesEnc.TryDecrypt(GlobalConfig.EmailPassword, false, "NotificationService", "Could not decrypt mailserver password."); + EmailConnection emailConnection = new(GlobalConfig.EmailServerAddress, GlobalConfig.EmailPort, + GlobalConfig.EmailTls, GlobalConfig.EmailUser, decryptedSecret, GlobalConfig.EmailSenderAddress); + + bool sent = await MailKitMailer.SendAsync(mail, emailConnection, notification.Layout == NotificationLayout.HtmlInBody, new()); + await CompleteNotificationLog(logId, sent ? NotificationLogStatus.Sent : NotificationLogStatus.Failed, + sent ? "" : "SMTP delivery failed."); + return sent ? NotificationDeliveryResult.Delivered : NotificationDeliveryResult.Failed; + } + catch (Exception exception) + { + await CompleteNotificationLog(logId, NotificationLogStatus.Failed, exception.Message); + throw; + } + } + + private async Task CompleteNotificationLog(int logId, NotificationLogStatus status, string error = "") + { + if (logId > 0) + { + await NotificationLogHelper.UpdateAsync(ApiConnection, logId, status, error); + } + } + + private async Task LogNoRecipientFailureIfConfigured(FwoNotification notification, string subject, DateTime? deadline) + { + if (!NotificationLoggingMode.ShouldLog(notification.Logging) + || await RefreshMatchingNoRecipientFailure(notification, subject, deadline)) + { + return; } - string decryptedSecret = AesEnc.TryDecrypt(GlobalConfig.EmailPassword, false, "NotificationService", "Could not decrypt mailserver password."); - EmailConnection emailConnection = new(GlobalConfig.EmailServerAddress, GlobalConfig.EmailPort, - GlobalConfig.EmailTls, GlobalConfig.EmailUser, decryptedSecret, GlobalConfig.EmailSenderAddress); + DateTimeOffset? resolvedDeadline = deadline.HasValue ? new DateTimeOffset(deadline.Value) : null; + int logId = await NotificationLogHelper.InsertAsync(ApiConnection, notification, [], [], [], subject, resolvedDeadline); + await CompleteNotificationLog(logId, NotificationLogStatus.Failed, kNoRecipientFailureMessage); + } - await MailKitMailer.SendAsync(mail, emailConnection, notification.Layout == NotificationLayout.HtmlInBody, new()); + private async Task RefreshMatchingNoRecipientFailure(FwoNotification notification, string subject, DateTime? deadline) + { + DateTimeOffset? resolvedDeadline = deadline.HasValue ? new DateTimeOffset(deadline.Value) : null; + string query = resolvedDeadline.HasValue + ? NotificationQueries.getNoRecipientNotificationLogs + : NotificationQueries.getNoRecipientNotificationLogsWithoutDeadline; + object variables = resolvedDeadline.HasValue + ? new + { + notificationId = notification.Id, + status = NotificationLogStatus.Failed.ToString(), + error = kNoRecipientFailureMessage, + subject, + deadlineType = notification.Deadline.ToString(), + deadline = resolvedDeadline.Value + } + : new + { + notificationId = notification.Id, + status = NotificationLogStatus.Failed.ToString(), + error = kNoRecipientFailureMessage, + subject, + deadlineType = notification.Deadline.ToString() + }; + List entries = await ApiConnection.SendQueryAsync>( + query, variables); + NotificationLogEntry? matchingEntry = entries.FirstOrDefault(entry => entry.Subject == subject + && entry.DeadlineType == notification.Deadline + && entry.Deadline == resolvedDeadline); + if (matchingEntry == null) + { + return false; + } + + await NotificationLogHelper.RefreshAsync(ApiConnection, matchingEntry.Id); return true; } @@ -413,39 +454,182 @@ private async Task SendEmail(FwoNotification notification, string? content /// Owner context used for placeholder replacement. /// Optional report attachment. /// Optional resolved time interval text. - /// True when the email was sent; otherwise false. - private async Task SendBundledEmail(List notifications, string? content, FwoOwner? owner, ReportBase? report = null, string timeIntervalText = "") + /// The delivery outcome of the bundled notification. + private async Task SendBundledEmail(List notifications, string? content, FwoOwner? owner, ReportBase? report = null, string timeIntervalText = "") { - MailData mail = await PrepareBundledEmail(notifications, content, owner, report, timeIntervalText); - if (mail.To.Count == 0 && mail.Cc.Count == 0 && mail.Bcc.Count == 0) + List sendableNotifications = [.. notifications.Where(notification => NotificationLoggingMode.ShouldSend(notification.Logging))]; + List suppressedNotifications = [.. notifications.Where(notification => !NotificationLoggingMode.ShouldSend(notification.Logging))]; + PreparedBundleMails preparedMails = await PrepareBundleMails( + sendableNotifications, suppressedNotifications, content, owner, report, timeIntervalText); + List suppressedLogIds = await LogSuppressedBundle(suppressedNotifications, preparedMails.SuppressedMail, + preparedMails.HasSuppressedRecipients); + await CompleteNotificationLogs(suppressedLogIds, NotificationLogStatus.Suppressed); + + if (await HandleNoRecipientBundle(notifications, sendableNotifications, suppressedNotifications, + preparedMails)) { - FwoNotification baseNotification = notifications.First(); - Log.WriteWarning("Notifications", - $"No recipients resolved for notification client {baseNotification.NotificationClient} while preparing bundled notification {baseNotification.Id}. Skipping send."); - return false; + return NotificationDeliveryResult.NoRecipients; } - string decryptedSecret = AesEnc.TryDecrypt(GlobalConfig.EmailPassword, false, "NotificationService", "Could not decrypt mailserver password."); - EmailConnection emailConnection = new(GlobalConfig.EmailServerAddress, GlobalConfig.EmailPort, - GlobalConfig.EmailTls, GlobalConfig.EmailUser, decryptedSecret, GlobalConfig.EmailSenderAddress); + if (sendableNotifications.Count == 0) + { + return NotificationDeliveryResult.Suppressed; + } + + return await SendPreparedBundle(sendableNotifications, preparedMails.SendableMail!); + } + + private async Task PrepareBundleMails( + List sendableNotifications, List suppressedNotifications, + string? content, FwoOwner? owner, ReportBase? report, string timeIntervalText) + { + MailData? suppressedMail = suppressedNotifications.Count > 0 + ? await PrepareBundledEmail(suppressedNotifications, content, owner, report, timeIntervalText) + : null; + MailData? sendableMail = sendableNotifications.Count > 0 + ? await PrepareBundledEmail(sendableNotifications, content, owner, report, timeIntervalText) + : null; + return new PreparedBundleMails(suppressedMail, sendableMail); + } + + private async Task> LogSuppressedBundle(List notifications, MailData? mail, bool hasRecipients) + { + if (!hasRecipients) + { + return []; + } + + foreach (FwoNotification notification in notifications) + { + AddCheckedNotificationId(notification.Id); + } + return await LogBundledNotifications(notifications, mail!); + } + + private async Task HandleNoRecipientBundle(List allNotifications, + List sendableNotifications, List suppressedNotifications, + PreparedBundleMails preparedMails) + { + bool noSendableRecipients = sendableNotifications.Count > 0 && !preparedMails.HasSendableRecipients; + bool noSuppressedRecipients = suppressedNotifications.Count > 0 && !preparedMails.HasSuppressedRecipients; + if (noSuppressedRecipients) + { + await LogNoRecipientFailures(suppressedNotifications, preparedMails.SuppressedMail!.Subject); + } + + if (!noSendableRecipients) + { + return false; + } - await MailKitMailer.SendAsync(mail, emailConnection, false, new()); + await LogNoRecipientFailures(sendableNotifications, preparedMails.SendableMail!.Subject); + FwoNotification baseNotification = allNotifications.First(); + Log.WriteWarning("Notifications", + $"No recipients resolved for notification client {baseNotification.NotificationClient} while preparing bundled notification {baseNotification.Id}. Skipping send."); return true; } - private async Task PrepareEmail(FwoNotification notification, string? content, FwoOwner? owner, ReportBase? report = null, string timeIntervalText = "") + private async Task LogNoRecipientFailures(List notifications, string subject) + { + foreach (FwoNotification notification in notifications) + { + AddCheckedNotificationId(notification.Id); + await LogNoRecipientFailureIfConfigured(notification, subject, null); + } + } + + private async Task SendPreparedBundle(List notifications, MailData mail) + { + List sendableLogIds = await LogBundledNotifications(notifications, mail); + + try + { + string decryptedSecret = AesEnc.TryDecrypt(GlobalConfig.EmailPassword, false, "NotificationService", "Could not decrypt mailserver password."); + EmailConnection emailConnection = new(GlobalConfig.EmailServerAddress, GlobalConfig.EmailPort, + GlobalConfig.EmailTls, GlobalConfig.EmailUser, decryptedSecret, GlobalConfig.EmailSenderAddress); + + bool sent = await MailKitMailer.SendAsync(mail, emailConnection, false, new()); + await CompleteNotificationLogs(sendableLogIds, sent ? NotificationLogStatus.Sent : NotificationLogStatus.Failed, + sent ? "" : "SMTP delivery failed."); + return sent ? NotificationDeliveryResult.Delivered : NotificationDeliveryResult.Failed; + } + catch (Exception exception) + { + await CompleteNotificationLogs(sendableLogIds, NotificationLogStatus.Failed, exception.Message); + throw; + } + } + + private sealed class PreparedBundleMails + { + public PreparedBundleMails(MailData? suppressedMail, MailData? sendableMail) + { + SuppressedMail = suppressedMail; + SendableMail = sendableMail; + } + + public MailData? SuppressedMail { get; } + public MailData? SendableMail { get; } + public bool HasSuppressedRecipients => SuppressedMail is { } mail && HasRecipients(mail); + public bool HasSendableRecipients => SendableMail is { } mail && HasRecipients(mail); + } + + private static bool HasRecipients(MailData mail) + { + return mail.To.Count > 0 || mail.Cc.Count > 0 || mail.Bcc.Count > 0; + } + + private void AddCheckedNotificationId(int notificationId) + { + if (!CheckedNotificationIds.Contains(notificationId)) + { + CheckedNotificationIds.Add(notificationId); + } + } + + private async Task> LogBundledNotifications(List notifications, MailData mail) + { + List logIds = []; + foreach (FwoNotification notification in notifications.Where(notification => NotificationLoggingMode.ShouldLog(notification.Logging))) + { + int logId = await NotificationLogHelper.InsertAsync(ApiConnection, notification, mail.To, mail.Cc, mail.Bcc, mail.Subject); + if (logId > 0) + { + logIds.Add(logId); + } + } + return logIds; + } + + private async Task CompleteNotificationLogs(List logIds, NotificationLogStatus status, string error = "") + { + foreach (int logId in logIds) + { + await NotificationLogHelper.UpdateAsync(ApiConnection, logId, status, error); + } + } + + private async Task PrepareEmail(FwoNotification notification, string? content, FwoOwner? owner, ReportBase? report = null, + string timeIntervalText = "", NotificationPlaceholderResolver.NotificationPlaceholderValues? placeholderValues = null) { string subject = NotificationPlaceholderResolver.ReplaceOwnerPlaceholders(notification.EmailSubject ?? "", owner, timeIntervalText); string body = NotificationPlaceholderResolver.ReplaceOwnerPlaceholders(NotificationEmailLayoutHelper.BuildBody(notification, content), owner, timeIntervalText); + if (placeholderValues != null) + { + subject = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(subject, placeholderValues); + body = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders(body, placeholderValues, + renderHtmlLinks: notification.Layout == NotificationLayout.HtmlInBody); + } FormFile? attachment = report != null ? await BuildAttachment(notification, report, subject) : null; EmailHelper? emailHelper = GlobalConfig.UseDummyEmailAddress ? null : await CreateEmailHelper(); if (report != null && notification.Layout == NotificationLayout.HtmlInBody) { body += report.ExportToHtmlBody(); } - List tos = emailHelper == null ? await CollectRecipients(notification, owner) : await CollectRecipients(notification, owner, emailHelper); - List bccs = emailHelper == null ? await CollectRecipients(notification, owner, false, true) : await CollectRecipients(notification, owner, emailHelper, false, true); - List ccs = emailHelper == null ? await CollectRecipients(notification, owner, true) : await CollectRecipients(notification, owner, emailHelper, true); + UiUser? requester = placeholderValues?.Requester; + List tos = emailHelper == null ? await CollectRecipients(notification, owner, requester) : await CollectRecipients(notification, owner, emailHelper, requester); + List bccs = emailHelper == null ? await CollectRecipients(notification, owner, requester, false, true) : await CollectRecipients(notification, owner, emailHelper, requester, false, true); + List ccs = emailHelper == null ? await CollectRecipients(notification, owner, requester, true) : await CollectRecipients(notification, owner, emailHelper, requester, true); MailData mailData = new(tos, subject) { Body = body, @@ -530,17 +714,19 @@ private async Task CreateEmailHelper() /// /// Notification to inspect. /// Owner context used for recipient resolution. + /// Optional persisted requester used for requester recipient resolution. /// Whether to resolve the Cc recipient set. /// Whether to resolve the Bcc recipient set. /// Resolved email addresses. - private async Task> CollectRecipients(FwoNotification notification, FwoOwner? owner, bool cc = false, bool bcc = false) + private async Task> CollectRecipients(FwoNotification notification, FwoOwner? owner, UiUser? requester, + bool cc = false, bool bcc = false) { if (GlobalConfig.UseDummyEmailAddress) { return [GlobalConfig.DummyEmailAddress]; } EmailHelper emailHelper = await CreateEmailHelper(); - return await CollectRecipients(notification, owner, emailHelper, cc, bcc); + return await CollectRecipients(notification, owner, emailHelper, requester, cc, bcc); } /// @@ -549,10 +735,12 @@ private async Task> CollectRecipients(FwoNotification notification, /// Notification to inspect. /// Owner context used for recipient resolution. /// Preinitialized email helper. + /// Optional persisted requester used for requester recipient resolution. /// Whether to resolve the Cc recipient set. /// Whether to resolve the Bcc recipient set. /// Resolved email addresses. - private static async Task> CollectRecipients(FwoNotification notification, FwoOwner? owner, EmailHelper emailHelper, bool cc = false, bool bcc = false) + private static async Task> CollectRecipients(FwoNotification notification, FwoOwner? owner, EmailHelper emailHelper, + UiUser? requester, bool cc = false, bool bcc = false) { EmailRecipientOption recipientOption = notification.RecipientTo; string? addressList = notification.EmailAddressTo; @@ -570,7 +758,7 @@ private static async Task> CollectRecipients(FwoNotification notifi List addresses = EmailHelper.SplitAddresses(addressList); if (recipientOption == EmailRecipientOption.ConfiguredResponsibles) { - List recipients = await emailHelper.GetRecipients(addressList ?? "", owner, null); + List recipients = await emailHelper.GetRecipients(addressList ?? "", owner, null, requester); if (recipients.Count == 0) { Log.WriteWarning("Notifications", $"No recipients resolved for configured responsibles while preparing notification client {notification.NotificationClient}."); @@ -579,14 +767,15 @@ private static async Task> CollectRecipients(FwoNotification notifi } if (recipientOption == EmailRecipientOption.OtherAddresses && LooksLikeRecipientSelectionJson(addressList)) { - List recipients = await emailHelper.GetRecipients(addressList ?? "", null, null); + List recipients = await emailHelper.GetRecipients(addressList ?? "", null, null, requester); if (recipients.Count == 0) { Log.WriteWarning("Notifications", $"No recipients resolved for other addresses while preparing notification client {notification.NotificationClient}."); } return recipients; } - List resolvedRecipients = await emailHelper.GetRecipients(recipientOption, null, owner, null, addresses); + List resolvedRecipients = await emailHelper.GetRecipients(recipientOption, null, owner, + requester?.Dn, addresses, requester?.Email); if (resolvedRecipients.Count == 0 && recipientOption != EmailRecipientOption.None) { Log.WriteWarning("Notifications", $"No recipients resolved for notification client {notification.NotificationClient} using option {recipientOption}."); diff --git a/roles/middleware/files/FWO.Middleware.Server/Program.cs b/roles/middleware/files/FWO.Middleware.Server/Program.cs index f164bd495e..e74b07f7d8 100644 --- a/roles/middleware/files/FWO.Middleware.Server/Program.cs +++ b/roles/middleware/files/FWO.Middleware.Server/Program.cs @@ -80,6 +80,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(tokenLifetimeProvider); builder.Services.AddSingleton(internalApiTokenService); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Register config listeners as singletons (activated at startup) diff --git a/roles/middleware/files/FWO.Middleware.Server/RecertCheck.cs b/roles/middleware/files/FWO.Middleware.Server/RecertCheck.cs index a6653f2283..aeac4dfaa2 100644 --- a/roles/middleware/files/FWO.Middleware.Server/RecertCheck.cs +++ b/roles/middleware/files/FWO.Middleware.Server/RecertCheck.cs @@ -7,9 +7,7 @@ using FWO.Data; using FWO.Data.Middleware; using FWO.Data.Report; -using FWO.Encryption; using FWO.Logging; -using FWO.Mail; using FWO.Middleware.Server.Services; using FWO.Report; using FWO.Services; @@ -27,8 +25,6 @@ public class RecertCheck private readonly ApiConnection apiConnectionMiddlewareServer; private readonly GlobalConfig globalConfig; private readonly TokenLifetimeProvider tokenLifetimeProvider; - private List connectedLdaps = []; - private List uiUsers = []; private RecertCheckParams? globCheckParams; private List owners = []; private const string LogMessageTitle = "Recertification Check"; @@ -54,16 +50,22 @@ public async Task CheckRecertifications() await InitEnv(); if (globalConfig.RecertificationMode == RecertificationMode.RuleByRule) { - string decryptedSecret = AesEnc.TryDecrypt(globalConfig.EmailPassword, false, LogMessageTitle, "Could not decrypt mailserver password."); - EmailConnection emailConnection = new(globalConfig.EmailServerAddress, globalConfig.EmailPort, - globalConfig.EmailTls, globalConfig.EmailUser, decryptedSecret, globalConfig.EmailSenderAddress); + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.RuleRecertification, + globalConfig, + apiConnectionMiddlewareServer); JwtWriter jwtWriter = new(ConfigFile.JwtPrivateKey); ApiConnection apiConnectionReporter = new GraphQlApiConnection(ConfigFile.ApiServerUri ?? throw new ArgumentException("Missing api server url on startup."), jwtWriter.CreateJWTReporterViewall(tokenLifetimeProvider.GetInternalServiceTokenLifetime())); foreach (FwoOwner owner in owners) { - emailsSent += await CheckRuleByRule(owner, apiConnectionReporter, emailConnection); - await SetOwnerLastCheck(owner); + int ownerEmailsSent = await CheckRuleByRule(owner, apiConnectionReporter, notificationService); + emailsSent += ownerEmailsSent; + if (ownerEmailsSent > 0) + { + await SetOwnerLastCheck(owner); + } } + await notificationService.UpdateNotificationsLastSent(); } else { @@ -71,10 +73,22 @@ public async Task CheckRecertifications() NotificationClient.Recertification, globalConfig, apiConnectionMiddlewareServer); + using UserConfig reportUserConfig = UserConfig.ForGlobalSettings( + globalConfig, + apiConnectionMiddlewareServer, + globalConfig.DefaultLanguage); foreach (FwoOwner? owner in owners.Where(o => IsRecertCheckTime(o))) { - emailsSent += await notificationService.SendNotificationsIfDue(owner, null, PrepareOwnerBody(owner), await PrepareOwnerReport(owner)); - await SetOwnerLastCheck(owner); + int ownerEmailsSent = await notificationService.SendNotificationsIfDue( + owner, + owner.NextRecertDate, + PrepareOwnerBody(owner), + await PrepareOwnerReport(owner, reportUserConfig)); + emailsSent += ownerEmailsSent; + if (ownerEmailsSent > 0) + { + await SetOwnerLastCheck(owner); + } } await notificationService.UpdateNotificationsLastSent(); } @@ -89,8 +103,6 @@ public async Task CheckRecertifications() private async Task InitEnv() { globCheckParams = System.Text.Json.JsonSerializer.Deserialize(globalConfig.RecCheckParams); - connectedLdaps = apiConnectionMiddlewareServer.SendQueryAsync>(AuthQueries.getLdapConnections).Result; - uiUsers = await apiConnectionMiddlewareServer.SendQueryAsync>(AuthQueries.getUsers); owners = await apiConnectionMiddlewareServer.SendQueryAsync>(OwnerQueries.getOwners); } @@ -164,7 +176,7 @@ private static DateTime CalcForMonths(DateTime lastCheck, RecertCheckParams chec return nextCheck; } - private async Task CheckRuleByRule(FwoOwner owner, ApiConnection apiConnection, EmailConnection emailConnection) + private async Task CheckRuleByRule(FwoOwner owner, ApiConnection apiConnection, NotificationService notificationService) { List openRecerts = await GenerateRulesRecertificationReport(apiConnection, owner); List upcomingRecerts = []; @@ -180,12 +192,18 @@ private async Task CheckRuleByRule(FwoOwner owner, ApiConnection apiConnect overdueRecerts.Add(rule); } } - if (upcomingRecerts.Count > 0 || overdueRecerts.Count > 0) + if (upcomingRecerts.Count == 0 && overdueRecerts.Count == 0) + { + return 0; + } + + string body = PrepareRulesBody(upcomingRecerts, overdueRecerts, owner.Name); + int emailsSent = 0; + foreach (FwoNotification notification in notificationService.Notifications.Where(n => n.OwnerId == null || n.OwnerId == owner.Id)) { - await MailKitMailer.SendAsync(await PrepareRulesEmail(owner, upcomingRecerts, overdueRecerts), emailConnection, false, new()); - return 1; + emailsSent += await notificationService.SendNotification(notification, owner, body); } - return 0; + return emailsSent; } private async Task> GenerateRulesRecertificationReport(ApiConnection apiConnection, FwoOwner owner) @@ -230,12 +248,6 @@ private async Task> GenerateRulesRecertificationReport(ApiConnection return rules; } - private async Task PrepareRulesEmail(FwoOwner owner, List upcomingRecerts, List overdueRecerts) - { - string subject = globalConfig.RecCheckEmailSubject + " " + owner.Name; - return new MailData(await CollectEmailAddresses(owner), subject) { Body = PrepareRulesBody(upcomingRecerts, overdueRecerts, owner.Name) }; - } - private string PrepareRulesBody(List upcomingRecerts, List overdueRecerts, string ownerName) { StringBuilder body = new(); @@ -266,94 +278,25 @@ private static string PrepareLine(Rule rule) + rule.DeviceName + ": " + rule.Name + ":" + rule.Uid + "\r\n\r\n"; // link ? } - private async Task> CollectEmailAddresses(FwoOwner owner) - { - if (globalConfig.UseDummyEmailAddress) - { - return [globalConfig.DummyEmailAddress]; - } - List tos = []; - List userDns = await ResolveOwnerUserDns(owner); - foreach (var userDn in userDns) - { - UiUser? uiuser = uiUsers.FirstOrDefault(x => DistName.DnEquals(x.Dn, userDn)); - if (uiuser != null && uiuser.Email != null && uiuser.Email != "") - { - tos.Add(uiuser.Email); - } - } - return tos; - } - - private async Task> ResolveOwnerUserDns(FwoOwner owner) - { - List ownerDns = owner.GetAllOwnerResponsibles(); - if (ownerDns.Count == 0) - { - return []; - } - - HashSet resolvedUsers = new(DistName.DnComparer); - object resolvedLock = new(); - List ldapRequests = []; - - foreach (Ldap currentLdap in connectedLdaps.Where(ldap => ldap.HasGroupHandling())) - { - ldapRequests.Add(Task.Run(async () => - { - List currentResolved = await currentLdap.ResolveUsersFromDns(ownerDns); - if (currentResolved.Count == 0) - { - return; - } - - lock (resolvedLock) - { - foreach (string dn in currentResolved) - { - resolvedUsers.Add(dn); - } - } - })); - } - - await Task.WhenAll(ldapRequests); - - foreach (string dn in ownerDns) - { - if (string.IsNullOrWhiteSpace(dn)) - { - continue; - } - bool isGroupDn = connectedLdaps.Any(ldap => ldap.HasGroupHandling() - && !string.IsNullOrWhiteSpace(ldap.GroupSearchPath) - && dn.EndsWith(ldap.GroupSearchPath, StringComparison.OrdinalIgnoreCase)); - if (!isGroupDn) - { - resolvedUsers.Add(dn); - } - } - - return resolvedUsers.ToList(); - } - private string PrepareOwnerBody(FwoOwner owner) { string msgText = owner.NextRecertDate >= DateTime.Today ? globalConfig.RecCheckEmailUpcomingText : globalConfig.RecCheckEmailOverdueText; return msgText.Replace(Placeholder.APPNAME, owner.Name); } - private async Task PrepareOwnerReport(FwoOwner owner) + private async Task PrepareOwnerReport(FwoOwner owner, UserConfig reportUserConfig) { ReportParams reportParams = new((int)ReportType.OwnerRecertification, new()) { ModellingFilter = new() { - SelectedOwner = owner + SelectedOwner = owner, + // The scheduled check uses LastRecertCheck for due evaluation. Do not + // apply the interactive report's next_recert_date filter as well. + ShowAllOwners = true } }; - using UserConfig userConfig = UserConfig.ForGlobalSettings(globalConfig, apiConnectionMiddlewareServer, globalConfig.DefaultLanguage); - return await ReportGenerator.GenerateFromTemplate(new ReportTemplate("", reportParams), apiConnectionMiddlewareServer, userConfig, DefaultInit.DoNothing); + return await ReportGenerator.GenerateFromTemplate(new ReportTemplate("", reportParams), apiConnectionMiddlewareServer, reportUserConfig, DefaultInit.DoNothing); } private async Task SetOwnerLastCheck(FwoOwner owner) diff --git a/roles/middleware/files/FWO.Middleware.Server/Services/NotificationEmailSender.cs b/roles/middleware/files/FWO.Middleware.Server/Services/NotificationEmailSender.cs new file mode 100644 index 0000000000..258ce2ac7f --- /dev/null +++ b/roles/middleware/files/FWO.Middleware.Server/Services/NotificationEmailSender.cs @@ -0,0 +1,30 @@ +using FWO.Mail; + +namespace FWO.Middleware.Server.Services; + +/// +/// Sends a prepared notification email. +/// +public interface INotificationEmailSender +{ + /// + /// Sends the prepared email using the supplied connection settings. + /// + /// Prepared email including recipients, body, and attachments. + /// SMTP connection settings. + /// Whether the body should be sent as HTML. + /// True when the mail server accepts the email; otherwise false. + Task SendAsync(MailData mail, EmailConnection connection, bool html); +} + +/// +/// Production notification sender backed by MailKit. +/// +public sealed class NotificationEmailSender : INotificationEmailSender +{ + /// + public Task SendAsync(MailData mail, EmailConnection connection, bool html) + { + return MailKitMailer.SendAsync(mail, connection, html, new()); + } +} diff --git a/roles/tests-unit/files/FWO.Test/ActionHandlerTest.cs b/roles/tests-unit/files/FWO.Test/ActionHandlerTest.cs index ea3130c1ff..8be9e029b6 100644 --- a/roles/tests-unit/files/FWO.Test/ActionHandlerTest.cs +++ b/roles/tests-unit/files/FWO.Test/ActionHandlerTest.cs @@ -127,6 +127,17 @@ public override Task SendQueryAsync(string query, object? variables = null UpdatedNotificationLastSentIds = GetVariable>(variables, "ids"); return Task.FromResult((T)(object)new ReturnId { AffectedRows = UpdateNotificationsLastSentAffectedRows }); } + if (query == NotificationQueries.insertNotificationLog) + { + return Task.FromResult((T)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = 1 }] + }); + } + if (query == NotificationQueries.updateNotificationLog) + { + return Task.FromResult((T)(object)new ReturnId { AffectedRows = 1 }); + } if (query == OwnerQueries.getOwnerResponsibleTypes) { return Task.FromResult((T)(object)new List()); @@ -1012,7 +1023,6 @@ public async Task SendEmail_DisplaysError_WhenReferencedNotificationIsMissingAnd }; await handler.SendEmail(action, new WfTicket(), WfObjectScopes.Ticket, null); - Assert.Multiple(() => { Assert.That(messages, Has.Count.EqualTo(1)); @@ -1022,6 +1032,54 @@ public async Task SendEmail_DisplaysError_WhenReferencedNotificationIsMissingAnd }); } + [Test] + public async Task SendEmail_LogOnlyWritesLogAndUpdatesTimestampWithoutConfirmingSend() + { + ActionHandlerTestApiConn apiConn = new() + { + Notifications = new List + { + new FwoNotification + { + Id = 42, + NotificationClient = NotificationClient.WfAction, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "recipient@example.test", + EmailSubject = "Interface requested", + EmailBody = "The interface was requested.", + Logging = NotificationLoggingMode.LogOnly + } + }, + UpdateNotificationsLastSentAffectedRows = 1 + }; + List<(Exception? Exception, string Title, string Message, bool ErrorFlag)> messages = new(); + WfHandler wfHandler = new(new SimulatedUserConfig(), apiConn, WorkflowPhases.request, null, + displayMessage: (exception, title, message, errorFlag) => messages.Add((exception, title, message, errorFlag))); + ActionHandler handler = new(apiConn, wfHandler, useInMwServer: true); + WfStateAction action = new() + { + ExternalParams = JsonSerializer.Serialize(new EmailActionParams + { + NotificationIds = new List { 42 }, + ConfirmSentMail = true, + AttachedContent = EmailAttachedContent.RequestedConnections + }) + }; + + await handler.SendEmail(action, new WfTicket(), WfObjectScopes.Ticket, null); + List expectedUpdatedNotificationIds = [42]; + + Assert.Multiple(() => + { + Assert.That(apiConn.Queries.Count(query => query == NotificationQueries.getNotifications), Is.EqualTo(1)); + Assert.That(apiConn.Queries.Count(query => query == NotificationQueries.insertNotificationLog), Is.EqualTo(1)); + Assert.That(apiConn.Queries.Count(query => query == StmQueries.getIpProtocols), Is.EqualTo(1)); + Assert.That(apiConn.UpdatedNotificationLastSentIds, Is.EqualTo(expectedUpdatedNotificationIds)); + Assert.That(apiConn.Queries.Count(query => query == NotificationQueries.updateNotificationLog), Is.EqualTo(1)); + Assert.That(messages, Is.Empty); + }); + } + [Test] public void DisplaySentEmailConfirmation_SuppressesZeroSentEmails() { @@ -1333,7 +1391,7 @@ public void BuildWorkflowActionParameters_IncludesCreationStateChangeFlag() ticket.MarkCreatedStateChanged(1); WorkflowActionParameters parameters = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [ticket, WfObjectScopes.Ticket, null, 0])!; + .Invoke(handler, [ticket, WfObjectScopes.Ticket, null, 0, null])!; Assert.Multiple(() => { @@ -1343,6 +1401,20 @@ public void BuildWorkflowActionParameters_IncludesCreationStateChangeFlag() }); } + [Test] + public void BuildWorkflowActionParameters_PropagatesNotificationPlaceholderData() + { + ActionHandler handler = new(new ActionHandlerTestApiConn(), new WfHandler()); + WfImplTask implTask = new() { Id = 12, TicketId = 42, StateId = 5 }; + NotificationPlaceholderData placeholderData = new() { Content = "Rejected because the interface is obsolete." }; + + WorkflowActionParameters parameters = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") + .Invoke(handler, [implTask, WfObjectScopes.ImplementationTask, null, 66, placeholderData])!; + + Assert.That(parameters.NotificationPlaceholders, Is.SameAs(placeholderData)); + Assert.That(parameters.NotificationPlaceholders!.Content, Is.EqualTo("Rejected because the interface is obsolete.")); + } + [Test] public void BuildWorkflowActionParameters_IncludesWorkflowEmailBundleId() { @@ -1351,7 +1423,7 @@ public void BuildWorkflowActionParameters_IncludesWorkflowEmailBundleId() ActionHandler handler = new(new ActionHandlerTestApiConn(), wfHandler); WorkflowActionParameters parameters = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [new WfTicket { Id = 42 }, WfObjectScopes.Ticket, null, 0])!; + .Invoke(handler, [new WfTicket { Id = 42 }, WfObjectScopes.Ticket, null, 0, null])!; Assert.Multiple(() => { @@ -1371,13 +1443,13 @@ public void BuildWorkflowActionParameters_UsesScopeSpecificIds() WfApproval approval = new() { Id = 13, StateId = 4 }; WorkflowActionParameters reqParams = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [reqTask, WfObjectScopes.RequestTask, null, 5])!; + .Invoke(handler, [reqTask, WfObjectScopes.RequestTask, null, 5, null])!; WorkflowActionParameters implParams = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [implTask, WfObjectScopes.ImplementationTask, null, 6])!; + .Invoke(handler, [implTask, WfObjectScopes.ImplementationTask, null, 6, null])!; WorkflowActionParameters approvalParams = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [approval, WfObjectScopes.Approval, null, 7])!; + .Invoke(handler, [approval, WfObjectScopes.Approval, null, 7, null])!; WorkflowActionParameters explicitTicketParams = (WorkflowActionParameters)GetPrivateMethod("BuildWorkflowActionParameters") - .Invoke(handler, [reqTask, WfObjectScopes.RequestTask, 999L, 8])!; + .Invoke(handler, [reqTask, WfObjectScopes.RequestTask, 999L, 8, null])!; Assert.Multiple(() => { @@ -1818,7 +1890,7 @@ private sealed class DeliveryControlledActionHandler(ApiConnection apiConnection public List AttemptedTaskIds { get; } = []; public override Task TrySendEmail(WfStateAction action, WfStatefulObject statefulObject, WfObjectScopes scope, - FwoOwner? owner, string? userGrpDn = null) + FwoOwner? owner, string? userGrpDn = null, NotificationPlaceholderData? placeholderData = null) { long taskId = statefulObject is WfReqTask reqTask ? reqTask.Id : 0; AttemptedTaskIds.Add(taskId); diff --git a/roles/tests-unit/files/FWO.Test/ApiPermissionAlignmentTest.cs b/roles/tests-unit/files/FWO.Test/ApiPermissionAlignmentTest.cs index 8db5503073..157e41aab3 100644 --- a/roles/tests-unit/files/FWO.Test/ApiPermissionAlignmentTest.cs +++ b/roles/tests-unit/files/FWO.Test/ApiPermissionAlignmentTest.cs @@ -193,6 +193,26 @@ public void ApiCalls_ReturnOnlyColumnsTheirRolesMaySelect() "A role may run these mutations but cannot select the columns they return:"); } + /// + /// Notification log writes are restricted to trusted application roles because the table has no + /// ownership column with which a modeller write could be scoped. + /// + [Test] + public void NotificationLog_WritesAreRestrictedToTrustedRoles() + { + TableMetadata notificationLog = ReadMetadata()["notification_log"]; + + Assert.Multiple(() => + { + Assert.That(notificationLog.RolesByOperation["insert"], + Is.EquivalentTo(new List { "fw-admin", "middleware-server" })); + Assert.That(notificationLog.RolesByOperation["update"], + Is.EquivalentTo(new List { "fw-admin", "middleware-server" })); + Assert.That(notificationLog.RolesByOperation["insert"], Does.Not.Contain("modeller")); + Assert.That(notificationLog.RolesByOperation["update"], Does.Not.Contain("modeller")); + }); + } + /// /// A query filtering on columns no role at all may select cannot be issued by anybody, so it is /// broken however it is called. Which role issues a query is recorded nowhere, which is why only diff --git a/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs b/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs index c3df7e8a58..4938f5cdc5 100644 --- a/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs +++ b/roles/tests-unit/files/FWO.Test/DailyCheckJobTest.cs @@ -9,6 +9,7 @@ using FWO.Data.Report; using FWO.Data.Workflow; using FWO.Middleware.Server.Jobs; +using FWO.Services; using FWO.Services.Workflow; using NUnit.Framework; using FWO.Test.Helpers; @@ -56,8 +57,6 @@ internal class DailyCheckJobTest private static readonly int[] ExpectedSuccessLogSeverities = [1]; private static readonly int[] ExpectedNoAlertLogSeverities = [0]; - private static readonly long[] ExpectedUpdatedNotificationIds = [11L]; - [Test] public void LoadEnabledModules_ReturnsAllModules_WhenConfigIsBlank() { @@ -210,18 +209,10 @@ public async Task CheckRecerts_DoesNothingWhenDisabled() } [Test] - public async Task CheckRecerts_RunsRecertCheckAndWritesLogWhenEnabled() + public async Task CheckRecerts_SkipsInactiveOwnersWithoutSendingOrUpdatingNotifications() { RecordingRecertCheckApiConnection apiConnection = new() { - Ldaps = - [ - CreateInternalTestLdap() - ], - Users = - [ - new UiUser { Dn = "cn=user,dc=test", Email = "user@example.test" } - ], Owners = [ new FwoOwner { Id = 1, Name = "Owner A", RecertActive = false } @@ -253,11 +244,9 @@ public async Task CheckRecerts_RunsRecertCheckAndWritesLogWhenEnabled() Assert.Multiple(() => { - Assert.That(apiConnection.CountQuery(AuthQueries.getLdapConnections), Is.EqualTo(1)); - Assert.That(apiConnection.CountQuery(AuthQueries.getUsers), Is.EqualTo(1)); Assert.That(apiConnection.CountQuery(OwnerQueries.getOwners), Is.EqualTo(1)); Assert.That(apiConnection.CountQuery(NotificationQueries.getNotifications), Is.EqualTo(1)); - Assert.That(apiConnection.CountQuery(NotificationQueries.updateNotificationsLastSent), Is.EqualTo(1)); + Assert.That(apiConnection.CountQuery(NotificationQueries.updateNotificationsLastSent), Is.Zero); Assert.That(apiConnection.CountQuery(MonitorQueries.addLogEntry), Is.EqualTo(1)); }); } @@ -408,8 +397,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningAndSkipsTicketWith SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = true, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -437,7 +425,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningAndSkipsTicketWith [Test] [NonParallelizable] - public async Task CheckUnansweredInterfaceRequests_SendsDueNotificationForOwnedTicket() + public async Task CheckUnansweredInterfaceRequests_UpdatesLastSentForLogOnlyNotification() { DailyCheckInterfaceRequestsApiConnection apiConnection = new() { @@ -457,8 +445,7 @@ public async Task CheckUnansweredInterfaceRequests_SendsDueNotificationForOwnedT SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = true, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -470,13 +457,66 @@ public async Task CheckUnansweredInterfaceRequests_SendsDueNotificationForOwnedT { await (Task)(checkUnansweredInterfaceRequests.Invoke(dailyCheckJob, null) ?? throw new InvalidOperationException("CheckUnansweredInterfaceRequests returned null task.")); + List expectedUpdatedNotificationIds = [11]; Assert.Multiple(() => { Assert.That(apiConnection.LdapQueryCount, Is.EqualTo(1)); Assert.That(apiConnection.NotificationLoadCount, Is.EqualTo(1)); Assert.That(apiConnection.OpenTicketQueryCount, Is.EqualTo(1)); - Assert.That(apiConnection.UpdatedNotificationIds, Is.EqualTo(ExpectedUpdatedNotificationIds)); + Assert.That(apiConnection.UpdatedNotificationIds, Is.EqualTo(expectedUpdatedNotificationIds)); + }); + } + finally + { + GlobalStateMatrix.Factory = previousFactory; + } + } + + [Test] + [NonParallelizable] + public async Task CheckUnansweredInterfaceRequests_IgnoresImmediateRequestNotification() + { + FwoNotification immediateNotification = CreateInterfaceRequestNotification(10); + immediateNotification.Deadline = NotificationDeadline.None; + DailyCheckInterfaceRequestsApiConnection apiConnection = new() + { + LdapConnections = + [ + CreateInternalTestLdap() + ], + Notifications = + [ + immediateNotification, + CreateInterfaceRequestNotification(11) + ], + OpenTickets = + [ + CreateInterfaceRequestTicket(501, new FwoOwner { Id = 7, Name = "Owner A", ExtAppId = "APP-7" }, DateTime.Now.AddDays(-1)) + ] + }; + SimulatedGlobalConfig globalConfig = new() + { + UseDummyEmailAddress = true, + DummyEmailAddress = "dummy@example.test" + }; + DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); + MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("CheckUnansweredInterfaceRequests method not found."); + Func previousFactory = GlobalStateMatrix.Factory; + GlobalStateMatrix.Factory = () => new TestGlobalStateMatrix(); + + try + { + await (Task)(checkUnansweredInterfaceRequests.Invoke(dailyCheckJob, null) + ?? throw new InvalidOperationException("CheckUnansweredInterfaceRequests returned null task.")); + List expectedUpdatedNotificationIds = [11]; + + Assert.Multiple(() => + { + Assert.That(apiConnection.NotificationLoadCount, Is.EqualTo(1)); + Assert.That(apiConnection.OpenTicketQueryCount, Is.EqualTo(1)); + Assert.That(apiConnection.UpdatedNotificationIds, Is.EqualTo(expectedUpdatedNotificationIds)); }); } finally @@ -507,8 +547,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningWhenRecipientsCann SimulatedGlobalConfig globalConfig = new() { UseDummyEmailAddress = false, - DummyEmailAddress = "dummy@example.test", - ModUnansweredReqEmailBody = "body" + DummyEmailAddress = "dummy@example.test" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); MethodInfo checkUnansweredInterfaceRequests = typeof(DailyCheckJob).GetMethod("CheckUnansweredInterfaceRequests", BindingFlags.Instance | BindingFlags.NonPublic) @@ -523,6 +562,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningWhenRecipientsCann await (Task)(checkUnansweredInterfaceRequests.Invoke(dailyCheckJob, null) ?? throw new InvalidOperationException("CheckUnansweredInterfaceRequests returned null task.")); }); + List expectedUpdatedNotificationIds = [11]; Assert.Multiple(() => { @@ -532,7 +572,7 @@ public async Task CheckUnansweredInterfaceRequests_LogsWarningWhenRecipientsCann Assert.That(output, Does.Contain("Unanswered Interface Requests Check: Sent 0 emails.")); Assert.That(apiConnection.NotificationLoadCount, Is.EqualTo(1)); Assert.That(apiConnection.OpenTicketQueryCount, Is.EqualTo(1)); - Assert.That(apiConnection.UpdatedNotificationIds, Is.Empty); + Assert.That(apiConnection.UpdatedNotificationIds, Is.EqualTo(expectedUpdatedNotificationIds)); }); } finally @@ -601,7 +641,7 @@ public void ConstructLink_ReturnsExpectedModellingUrl() string link = (string)(constructLink.Invoke(dailyCheckJob, [owner, reqTask]) ?? throw new InvalidOperationException("ConstructLink returned null.")); - Assert.That(link, Is.EqualTo($"Interface Request")); + Assert.That(link, Is.EqualTo($"https://fwo.example/{PageName.Modelling}/APP-42/123")); } [Test] @@ -619,11 +659,11 @@ public void ConstructLink_UsesLocalizedFallbackTitle_WhenTaskTitleIsMissing() string link = (string)(constructLink.Invoke(dailyCheckJob, [owner, null]) ?? throw new InvalidOperationException("ConstructLink returned null.")); - Assert.That(link, Is.EqualTo($"Interface")); + Assert.That(link, Is.EqualTo($"https://fwo.example/{PageName.Modelling}/APP-42/")); } [Test] - public async Task PrepareBody_ReplacesAllKnownPlaceholders() + public async Task BuildRequestPlaceholderValues_ReplacesAllKnownPlaceholders() { OwnerLookupApiConnection apiConnection = new() { @@ -632,11 +672,11 @@ public async Task PrepareBody_ReplacesAllKnownPlaceholders() SimulatedGlobalConfig globalConfig = new() { UiHostName = "https://fwo.example", - ModUnansweredReqEmailBody = string.Join("|", ExpectedModUnansweredReqEmailBodyPlaceholders) + ModReqInterfaceName = "Interface" }; DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); - MethodInfo prepareBody = typeof(DailyCheckJob).GetMethod("PrepareBody", BindingFlags.Instance | BindingFlags.NonPublic) - ?? throw new InvalidOperationException("PrepareBody method not found."); + MethodInfo buildPlaceholderValues = typeof(DailyCheckJob).GetMethod("BuildRequestPlaceholderValues", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues method not found."); WfReqTask reqTask = new() { Title = "Interface Request", @@ -652,12 +692,58 @@ public async Task PrepareBody_ReplacesAllKnownPlaceholders() }; FwoOwner owner = new() { Name = "Owner A", ExtAppId = "APP-42" }; - Task task = (Task)(prepareBody.Invoke(dailyCheckJob, [ticket, owner]) - ?? throw new InvalidOperationException("PrepareBody returned null task.")); - string body = await task; + NotificationPlaceholderResolver.NotificationPlaceholderValues values = (NotificationPlaceholderResolver.NotificationPlaceholderValues) + (await (Task)(buildPlaceholderValues.Invoke(dailyCheckJob, [ticket, owner]) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues returned null task."))); + string body = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + string.Join("|", ExpectedModUnansweredReqEmailBodyPlaceholders), values, renderHtmlLinks: true); + + Assert.That(body, Is.EqualTo( + $"Requester A|02.01.2025|Requesting App|REQ-7|Owner A|APP-42|Request Interface")); + } + + [Test] + public async Task BuildRequestPlaceholderValues_UsesFallbackValuesWhenRequestContextIsIncomplete() + { + OwnerLookupApiConnection apiConnection = new(); + SimulatedGlobalConfig globalConfig = new() + { + UiHostName = "https://fwo.example", + ModReqInterfaceName = "Interface" + }; + DailyCheckJob dailyCheckJob = new(apiConnection, globalConfig); + MethodInfo buildPlaceholderValues = typeof(DailyCheckJob).GetMethod("BuildRequestPlaceholderValues", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues method not found."); + WfReqTask reqTask = new() + { + Title = "Interface Request", + TaskType = WfTaskType.new_interface.ToString() + }; + reqTask.SetAddInfo(AdditionalInfoKeys.ConnId, "123"); + WfTicket ticket = new() + { + CreationDate = new DateTime(2025, 1, 2), + Requester = new UiUser { Name = "Requester A", Dn = "cn=requester,dc=test" }, + Tasks = new List { reqTask } + }; + FwoOwner owner = new() { Name = "Owner A", ExtAppId = "APP-42" }; + NotificationPlaceholderResolver.NotificationPlaceholderValues values = (NotificationPlaceholderResolver.NotificationPlaceholderValues) + (await (Task)(buildPlaceholderValues.Invoke(dailyCheckJob, [ticket, owner]) + ?? throw new InvalidOperationException("BuildRequestPlaceholderValues returned null task."))); + string body = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + string.Join("|", new List + { + Placeholder.REQUESTER, + Placeholder.INTERFACE_NAME, + Placeholder.REQUESTING_APPNAME, + Placeholder.REQUESTING_APPID, + Placeholder.USER_NAME, + Placeholder.REQUESTDATE, + Placeholder.INTERFACE_LINK + }), values, renderHtmlLinks: true); Assert.That(body, Is.EqualTo( - $"Requester A|02.01.2025|Requesting App|REQ-7|Owner A|APP-42|Interface Request")); + $"Requester A|Interface Request|||Requester A|02.01.2025|Request Interface")); } [Test] @@ -726,12 +812,13 @@ private static HashSet InvokeLoadEnabledModules(SimulatedGloba ?? throw new InvalidOperationException("LoadEnabledModules returned null.")); } - private static FwoNotification CreateInterfaceRequestNotification(int id) + private static FwoNotification CreateInterfaceRequestNotification(int id, string logging = NotificationLoggingMode.LogOnly) { return new FwoNotification { Id = id, NotificationClient = NotificationClient.InterfaceRequest, + Logging = logging, RecipientTo = EmailRecipientOption.OtherAddresses, EmailAddressTo = "notify@example.test", EmailSubject = "subject", @@ -879,6 +966,19 @@ public override Task SendQueryAsync(string return Task.FromResult((QueryResponseType)(object)OpenTickets); } + if (query == NotificationQueries.insertNotificationLog && typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = 1 }] + }); + } + + if (query == NotificationQueries.updateNotificationLog && typeof(QueryResponseType) == typeof(ReturnId)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + if (query == NotificationQueries.updateNotificationsLastSent && typeof(QueryResponseType) == typeof(ReturnId)) { object vars = variables ?? throw new InvalidOperationException("Update notification variables missing."); diff --git a/roles/tests-unit/files/FWO.Test/EmailHelperDeliveryResultTest.cs b/roles/tests-unit/files/FWO.Test/EmailHelperDeliveryResultTest.cs index 9468d01c09..7b48d7b155 100644 --- a/roles/tests-unit/files/FWO.Test/EmailHelperDeliveryResultTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailHelperDeliveryResultTest.cs @@ -59,13 +59,16 @@ public async Task SendEmailWithResultTreatsBlankAddressesAsNoRecipients() } [Test] - public async Task SendEmailToOwnerResponsiblesStillReportsFalseWhenNothingCouldBeSent() + public async Task SendEmailToNotificationRecipientsReportsFalseWhenNothingCouldBeSent() { - // The boolean overload is what the non-workflow callers use. Only a delivered email may come - // back as true, so an unresolvable recipient list has to stay false. + // Only a delivered email may come back as true, so an unresolvable recipient list has to stay false. EmailHelper helper = CreateEmailHelper(); - bool sent = await helper.SendEmailToOwnerResponsibles(new FwoOwner(), "subject", "body", EmailRecipientOption.None); + bool sent = await helper.SendEmailToNotificationRecipients( + new FwoNotification { RecipientTo = EmailRecipientOption.None }, + new FwoOwner(), + "subject", + "body"); Assert.That(sent, Is.False); } diff --git a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs index 6fc4fd263f..b76ece8649 100644 --- a/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailHelperRecipientTest.cs @@ -3,11 +3,14 @@ using FWO.Basics; using FWO.Config.Api; using FWO.Data; +using FWO.Data.Middleware; using FWO.Data.Workflow; using FWO.Middleware.Server; +using FWO.Middleware.Client; using FWO.Services; using Microsoft.AspNetCore.Http; using NUnit.Framework; +using System.Net; using System.IO; using System.Reflection; using FWO.Test.Helpers; @@ -43,7 +46,8 @@ public class EmailHelperRecipientTest private static readonly string[] kResolverDns = ["cn=existing,dc=test", "cn=fresh,dc=test"]; private static readonly string[] kOwnerGroupDns = ["cn=network-team,dc=test", "cn=external,dc=test"]; private static readonly string[] kResolvedRecipients = ["new@example.test", "fresh@example.test"]; - private static readonly Type[] kCollectRecipientsParameterTypes = [typeof(FwoNotification), typeof(FwoOwner), typeof(bool), typeof(bool)]; + private static readonly Type[] kCollectRecipientsParameterTypes = + [typeof(FwoNotification), typeof(FwoOwner), typeof(UiUser), typeof(bool), typeof(bool)]; private static EmailHelper CreateEmailHelper(List? ownerGroups = null, bool useDummyEmailAddress = true, IWorkflowRecipientResolver? recipientResolver = null) @@ -74,6 +78,375 @@ public async Task GetRecipientsReturnsDummyForFallbackSelection() Assert.That(recipients, Is.EqualTo(kDummyRecipients)); } + [Test] + public async Task SendEmailToNotificationRecipients_UsesNotificationRecipientsAndLayout() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=main,dc=test", Email = "main@example.test" }, + new() { Dn = "cn=cc,dc=test", Email = "cc@example.test" }, + new() { Dn = "cn=bcc,dc=test", Email = "bcc@example.test" } + }); + + FwoOwner owner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=main,dc=test"); + FwoNotification notification = new() + { + Layout = NotificationLayout.HtmlInBody, + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + RecipientCc = EmailRecipientOption.OtherAddresses, + EmailAddressCc = "cc@example.test", + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = "Subject", + EmailBody = "Body", + Deadline = NotificationDeadline.RequestDate + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, owner, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.CapturedTo, Is.EqualTo(new List { "main@example.test" })); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "cc@example.test" })); + Assert.That(helper.CapturedBcc, Is.EqualTo(new List { "bcc@example.test" })); + Assert.That(helper.CapturedSubject, Is.EqualTo("Rendered subject")); + Assert.That(helper.CapturedBody, Is.EqualTo("Rendered body")); + Assert.That(helper.CapturedMailFormatHtml, Is.True); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_ResolvesRequesterCcFromNotificationSelection() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + userConfig.User.Email = "requester@example.test"; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=main,dc=test", Email = "main@example.test" }, + new() { Dn = "cn=cc,dc=test", Email = "cc@example.test" }, + }); + + FwoOwner owner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=main,dc=test"); + EmailRecipientSelection ccSelection = new() + { + Requester = true, + OtherAddresses = true, + OtherAddressList = new List { "cc@example.test" } + }; + FwoNotification notification = new() + { + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + RecipientCc = EmailRecipientOption.ConfiguredResponsibles, + EmailAddressCc = ccSelection.ToConfigValue(), + EmailSubject = "Subject", + EmailBody = "Body" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, owner, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "cc@example.test", "requester@example.test" })); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_SendAndLog_WritesNotificationLogAndSendsEmail() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection); + + FwoNotification notification = new() + { + Id = 41, + NotificationClient = NotificationClient.InterfaceRequest, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test", + RecipientCc = EmailRecipientOption.OtherAddresses, + EmailAddressCc = "cc@example.test", + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = "Subject", + EmailBody = "Body", + Deadline = NotificationDeadline.RequestDate + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, null, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.True); + Assert.That(helper.SendEmailCallCount, Is.EqualTo(1)); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.InsertCalls[0].NotificationId, Is.EqualTo(41)); + Assert.That(apiConnection.InsertCalls[0].NotificationType, Is.EqualTo(NotificationClient.InterfaceRequest.ToString())); + Assert.That(apiConnection.InsertCalls[0].To, Is.EqualTo("to@example.test")); + Assert.That(apiConnection.InsertCalls[0].Cc, Is.EqualTo("cc@example.test")); + Assert.That(apiConnection.InsertCalls[0].Bcc, Is.EqualTo("bcc@example.test")); + Assert.That(apiConnection.InsertCalls[0].Subject, Is.EqualTo("Rendered subject")); + Assert.That(apiConnection.InsertCalls[0].DeadlineType, Is.EqualTo(NotificationDeadline.RequestDate)); + Assert.That(apiConnection.InsertCalls[0].Deadline, Is.Null); + Assert.That(apiConnection.InsertCalls[0].Timestamp, Is.GreaterThan(DateTimeOffset.UtcNow.AddMinutes(-1))); + Assert.That(apiConnection.UpdateCalls, Is.EqualTo(new List<(int Id, string Status, string Error)> + { + (1, NotificationLogStatus.Sent.ToString(), "") + })); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_LogOnly_WritesNotificationLogWithoutSendingEmail() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection); + + FwoNotification notification = new() + { + Id = 42, + NotificationClient = NotificationClient.AppDecomm, + Logging = NotificationLoggingMode.LogOnly, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test", + EmailSubject = "Subject", + EmailBody = "Body" + }; + + NotificationDeliveryResult deliveryResult = await helper.SendEmailToNotificationRecipientsWithResult(notification, null, "Rendered subject", "Rendered body"); + + Assert.Multiple(() => + { + Assert.That(deliveryResult, Is.EqualTo(NotificationDeliveryResult.Suppressed)); + Assert.That(helper.SendEmailCallCount, Is.Zero); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.InsertCalls[0].NotificationId, Is.EqualTo(42)); + Assert.That(apiConnection.InsertCalls[0].Subject, Is.EqualTo("Rendered subject")); + Assert.That(apiConnection.UpdateCalls, Is.EqualTo(new List<(int Id, string Status, string Error)> + { + (1, NotificationLogStatus.Suppressed.ToString(), "") + })); + }); + } + + [Test] + public async Task SendEmailToNotificationRecipients_MarksLogFailed_WhenMailerReturnsFalse() + { + SimulatedUserConfig userConfig = new() { UseDummyEmailAddress = false }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection) { SendResult = false }; + FwoNotification notification = new() + { + Id = 43, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test" + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, null, "Subject", "Body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.False); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.UpdateCalls, Is.EqualTo(new List<(int Id, string Status, string Error)> + { + (1, NotificationLogStatus.Failed.ToString(), "SMTP delivery failed or no To recipients resolved.") + })); + }); + } + + [Test] + public void SendEmailToNotificationRecipients_MarksLogFailed_WhenMailerThrows() + { + SimulatedUserConfig userConfig = new() { UseDummyEmailAddress = false }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection) + { + SendException = new InvalidOperationException("SMTP unavailable") + }; + FwoNotification notification = new() + { + Id = 44, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.test" + }; + + Assert.ThrowsAsync(async () => + await helper.SendEmailToNotificationRecipients(notification, null, "Subject", "Body")); + + Assert.That(apiConnection.UpdateCalls, Is.EqualTo(new List<(int Id, string Status, string Error)> + { + (1, NotificationLogStatus.Failed.ToString(), "SMTP unavailable") + })); + } + + [Test] + public async Task SendEmailToNotificationRecipients_MarksLogFailed_WhenNoRecipientsResolve() + { + SimulatedUserConfig userConfig = new() { UseDummyEmailAddress = false }; + RecordingNotificationLogApiConnection apiConnection = new(); + CapturingEmailHelper helper = new(userConfig, apiConnection); + FwoNotification notification = new() + { + Id = 45, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.None + }; + + bool sent = await helper.SendEmailToNotificationRecipients(notification, null, "Subject", "Body"); + + Assert.Multiple(() => + { + Assert.That(sent, Is.False); + Assert.That(helper.SendEmailCallCount, Is.EqualTo(1)); + Assert.That(apiConnection.InsertCalls, Has.Count.EqualTo(1)); + Assert.That(apiConnection.UpdateCalls, Is.EqualTo(new List<(int Id, string Status, string Error)> + { + (1, NotificationLogStatus.Failed.ToString(), "SMTP delivery failed or no To recipients resolved.") + })); + }); + } + + [Test] + public async Task SendWorkflowActionEmail_UsesWorkflowRecipientsAndPlaceholderObject() + { + SimulatedUserConfig userConfig = new() + { + UseDummyEmailAddress = false + }; + CapturingEmailHelper helper = new(userConfig); + SetPrivateField(helper, "ownerGroups", new List + { + new() + { + Dn = "cn=workflow-group,dc=test", + Users = + [ + new UiUser { Dn = "cn=group-user,dc=test" } + ] + } + }); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" }, + new() { Dn = "cn=group-user,dc=test", Email = "group@example.test" } + }); + SetPrivateField(helper, "ScopedUserTo", "cn=requester,dc=test"); + SetPrivateField(helper, "ScopedUserEmailTo", "requester@example.test"); + + FwoOwner owner = new() + { + Name = "Owner A", + ExtAppId = "APP-42" + }; + WfTicket placeholderTicket = new() + { + Requester = new UiUser + { + Name = "Requester A", + Dn = "cn=requester,dc=test" + }, + RequesterDn = "cn=requester,dc=test" + }; + FwoNotification notification = new() + { + Layout = NotificationLayout.HtmlAsAttachment, + RecipientTo = EmailRecipientOption.Requester, + RecipientCc = EmailRecipientOption.AssignedGroup, + RecipientBcc = EmailRecipientOption.OtherAddresses, + EmailAddressBcc = "bcc@example.test", + EmailSubject = $"{Placeholder.REQUESTER}|{Placeholder.APPNAME}|{Placeholder.APPID}|{Placeholder.REQUESTING_APPNAME}|{Placeholder.REQUESTING_APPID}", + EmailBody = $"Body:{Placeholder.REQUESTER}|{Placeholder.APPNAME}|{Placeholder.APPID}|{Placeholder.REQUESTING_APPNAME}|{Placeholder.REQUESTING_APPID}" + }; + WorkflowEmailContent workflowContent = new() + { + Html = "

    Workflow content

    ", + PlainText = "Workflow content", + Json = "{}", + Csv = "workflow" + }; + + WorkflowEmailDeliveryResult deliveryResult = await helper.SendWorkflowActionEmail( + notification, + new WfStatefulObject(), + owner, + "cn=workflow-group,dc=test", + workflowContent, + placeholderTicket, + new NotificationPlaceholderData + { + RequestingAppName = "Requesting App", + RequestingAppId = "REQ-APP" + }); + + Assert.Multiple(() => + { + Assert.That(deliveryResult, Is.EqualTo(WorkflowEmailDeliveryResult.Delivered)); + Assert.That(helper.CapturedTo, Is.EqualTo(new List { "requester@example.test" })); + Assert.That(helper.CapturedCc, Is.EqualTo(new List { "group@example.test" })); + Assert.That(helper.CapturedBcc, Is.EqualTo(new List { "bcc@example.test" })); + Assert.That(helper.CapturedSubject, Is.EqualTo("Requester A|Owner A|APP-42|Requesting App|REQ-APP")); + Assert.That(helper.CapturedBody, Is.EqualTo("Body:Requester A|Owner A|APP-42|Requesting App|REQ-APP")); + Assert.That(helper.CapturedMailFormatHtml, Is.False); + Assert.That(helper.CapturedAttachment, Is.Not.Null); + Assert.That(helper.CapturedAttachment!.ContentType, Is.EqualTo("application/html")); + Assert.That(helper.CapturedAttachment.FileName, Does.EndWith(".html")); + }); + } + + [Test] + public async Task SendWorkflowActionEmail_UsesCallerContentForContentPlaceholder() + { + CapturingEmailHelper helper = new(new SimulatedUserConfig()); + FwoNotification notification = new() + { + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "owner@example.test", + EmailBody = $"Reject reason: {Placeholder.CONTENT}" + }; + WorkflowEmailContent workflowContent = new() + { + PlainText = "workflow content", + Html = "

    workflow content

    " + }; + NotificationPlaceholderData placeholderData = new() { Content = "not approved" }; + + await helper.SendWorkflowActionEmail(notification, new WfStatefulObject(), null, + workflowContent: workflowContent, placeholderData: placeholderData); + + Assert.That(helper.CapturedBody, Is.EqualTo("Reject reason: not approved")); + } + [Test] public async Task GetRecipientsReturnsDummyForOtherAddressesOption() { @@ -296,7 +669,7 @@ public async Task CollectRecipientsLogsWarningWhenNoRecipientsResolve() null) ?? throw new MissingMethodException(typeof(NotificationService).FullName, "CollectRecipients"); - object?[] args = new object?[] { notification, null, false, false }; + object?[] args = new object?[] { notification, null, null, false, false }; Task> task = (Task>)method.Invoke(notificationService, args)!; List recipients = await task; @@ -753,15 +1126,32 @@ public async Task CollectEmailAddressesFromScopedUserReturnsDummyAndEmailFallbac new() { Dn = "cn=scoped,dc=test", Email = "scoped@example.test" } }); - List dummyRecipients = await InvokePrivateAsync>(dummyHelper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", "scoped@example.test" }); - List explicitRecipients = await InvokePrivateAsync>(helper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", "scoped@example.test" }); - List fallbackRecipients = await InvokePrivateAsync>(helper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", null }); + List dummyRecipients = await InvokePrivateAsync>(dummyHelper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", "scoped@example.test", null }); + List explicitRecipients = await InvokePrivateAsync>(helper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", "scoped@example.test", null }); + List fallbackRecipients = await InvokePrivateAsync>(helper, "CollectEmailAddressesFromScopedUser", new object?[] { "cn=scoped,dc=test", null, null }); Assert.That(dummyRecipients, Is.EqualTo(kDummyRecipients)); Assert.That(explicitRecipients, Is.EqualTo(kScopedRecipients)); Assert.That(fallbackRecipients, Is.EqualTo(kScopedRecipients)); } + [Test] + public async Task CollectEmailAddressesFromScopedUserPrefersDnOverDuplicateUsername() + { + EmailHelper helper = CreateEmailHelper(useDummyEmailAddress: false); + SetPrivateField(helper, "uiUsers", new List + { + new() { Dn = "uid=requester,ou=tenant-a,dc=test", Name = "requester", Email = "tenant-a@example.test" }, + new() { Dn = "uid=requester,ou=tenant-b,dc=test", Name = "requester", Email = "tenant-b@example.test" } + }); + + List recipients = await InvokePrivateAsync>(helper, "CollectEmailAddressesFromScopedUser", + new object?[] { "uid=requester,ou=tenant-b,dc=test", null, "requester" }); + List expectedRecipients = ["tenant-b@example.test"]; + + Assert.That(recipients, Is.EqualTo(expectedRecipients)); + } + [Test] public async Task CollectEmailAddressesFromUserReturnsEmptyForMissingDn() { @@ -994,5 +1384,97 @@ public override Task SendQueryAsync(string } } + private sealed class CapturingEmailHelper : EmailHelper + { + public bool SendResult { get; set; } = true; + public Exception? SendException { get; set; } + public int SendEmailCallCount { get; private set; } + public List CapturedTo { get; private set; } = []; + public List? CapturedCc { get; private set; } + public List? CapturedBcc { get; private set; } + public string CapturedSubject { get; private set; } = ""; + public string CapturedBody { get; private set; } = ""; + public bool CapturedMailFormatHtml { get; private set; } + public FormFile? CapturedAttachment { get; private set; } + + public CapturingEmailHelper(UserConfig userConfig, ApiConnection? apiConnection = null) + : base(apiConnection ?? new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) + { + } + + protected override Task SendEmailWithResult(List tos, string subject, string body, List? ccs = null, List? bccs = null, bool mailFormatHtml = true, Microsoft.AspNetCore.Http.FormFile? attachment = null) + { + SendEmailCallCount++; + CapturedTo = [.. tos]; + CapturedCc = ccs == null ? null : [.. ccs]; + CapturedBcc = bccs == null ? null : [.. bccs]; + CapturedSubject = subject; + CapturedBody = body; + CapturedMailFormatHtml = mailFormatHtml; + CapturedAttachment = attachment; + if (SendException != null) + { + throw SendException; + } + return Task.FromResult(SendResult && tos.Any(recipient => recipient != "") + ? WorkflowEmailDeliveryResult.Delivered + : WorkflowEmailDeliveryResult.Failed); + } + } + + private sealed class RecordingNotificationLogApiConnection : SimulatedApiConnection + { + public List InsertCalls { get; } = []; + public List<(int Id, string Status, string Error)> UpdateCalls { get; } = []; + + public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, FWO.Api.Client.QueryChunkingOptions? chunkingOptions = null) + { + if (query == NotificationQueries.insertNotificationLog && typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + if (variables != null) + { + PropertyInfo? entriesProperty = variables.GetType().GetProperty("entries"); + if (entriesProperty?.GetValue(variables) is IEnumerable entries) + { + InsertCalls.AddRange(entries.Select(entry => new NotificationLogEntry + { + Timestamp = entry.Timestamp, + NotificationId = entry.NotificationId, + NotificationType = entry.NotificationType, + To = entry.To, + Cc = entry.Cc, + Bcc = entry.Bcc, + Subject = entry.Subject, + DeadlineType = entry.DeadlineType, + Deadline = entry.Deadline, + Status = entry.Status, + Error = entry.Error + })); + } + } + + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = 1 }] + }); + } + + if (query == NotificationQueries.updateNotificationLog && typeof(QueryResponseType) == typeof(ReturnId)) + { + if (variables != null) + { + int id = (int)(variables.GetType().GetProperty("id")?.GetValue(variables) ?? 0); + string status = (string)(variables.GetType().GetProperty("status")?.GetValue(variables) ?? ""); + string error = (string)(variables.GetType().GetProperty("error")?.GetValue(variables) ?? ""); + UpdateCalls.Add((id, status, error)); + } + + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + + throw new NotImplementedException(); + } + } + } } diff --git a/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs b/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs index 3fab5ea4c1..76a6682928 100644 --- a/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs +++ b/roles/tests-unit/files/FWO.Test/EmailNotificationContentTest.cs @@ -6,6 +6,7 @@ using FWO.Services.Modelling; using Microsoft.AspNetCore.Http; using NUnit.Framework; +using System.Text; namespace FWO.Test { @@ -336,6 +337,57 @@ public void ReplaceWorkflowPlaceholdersUsesRequesterDnWhenRequesterNameIsMissing Assert.That(text, Is.EqualTo("Application/APP-2/cn=requester,dc=test")); } + [Test] + public void ReplaceWorkflowPlaceholdersAppliesCallerProvidedContext() + { + WfTicket ticket = new() + { + Requester = new() { Name = "Requester" }, + Reason = "Original reason", + CreationDate = new DateTime(2025, 1, 2) + }; + NotificationPlaceholderData placeholderData = new() + { + RequestingAppName = "Requesting App", + RequestingAppId = "REQ-APP", + InterfaceName = "old-interface", + InterfaceLinkText = "Interface", + InterfaceLinkName = "old-interface", + InterfaceLinkUrl = "https://example.test/old", + NewInterfaceName = "new-interface", + NewInterfaceLinkText = "New interface", + NewInterfaceLinkName = "new-interface", + NewInterfaceLinkUrl = "https://example.test/new", + Reason = "Rejected reason", + UserName = "Rejecter", + RequestDate = "03.01.2025" + }; + + string text = NotificationPlaceholderResolver.ReplaceWorkflowPlaceholders( + string.Join("|", new List + { + Placeholder.APPNAME, + Placeholder.APPID, + Placeholder.REQUESTER, + Placeholder.REQUESTING_APPNAME, + Placeholder.REQUESTING_APPID, + Placeholder.INTERFACE_NAME, + Placeholder.INTERFACE_LINK, + Placeholder.NEW_INTERFACE_NAME, + Placeholder.NEW_INTERFACE_LINK, + Placeholder.REASON, + Placeholder.USER_NAME, + Placeholder.REQUESTDATE + }), + ticket, + new FwoOwner { Name = "Owner", ExtAppId = "APP-1" }, + placeholderData, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo( + "Owner|APP-1|Requester|Requesting App|REQ-APP|old-interface|Interface: old-interface|new-interface|New interface: new-interface|Rejected reason|Rejecter|03.01.2025")); + } + [Test] public void ReplaceOwnerPlaceholdersReplacesAppAndTimeInterval() { @@ -351,6 +403,126 @@ public void ReplaceOwnerPlaceholdersReplacesAppAndTimeInterval() Assert.That(text, Is.EqualTo("Application/APP-3/14 days")); } + [Test] + public void ReplaceNotificationPlaceholdersReplacesRequestContext() + { + FwoOwner selectedOwner = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + FwoOwner requestingOwner = new() + { + Name = "Requester", + ExtAppId = "APP-2" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.APPNAME}/{Placeholder.APPID}/{Placeholder.REQUESTING_APPNAME}/{Placeholder.REQUESTING_APPID}/{Placeholder.REQUESTER}/{Placeholder.INTERFACE_NAME}/{Placeholder.INTERFACE_LINK}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = selectedOwner, + RequestingOwner = requestingOwner, + InterfaceName = "if-test", + InterfaceLinkText = "Interface Request", + InterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-1/99", + NewInterfaceName = "if-test", + NewInterfaceLinkText = "Interface Request", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-1/99", + Reason = "Need access", + UserName = "Tester" + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo( + "Selected/APP-1/Requester/APP-2/Requester/if-test/" + + "Interface Request" + + "/if-test/" + + "Interface Request" + + "/Need access/Tester")); + } + + [Test] + public void ReplaceDecommissionPlaceholdersReplacesDecommissionContext() + { + FwoOwner application = new() + { + Name = "Application", + ExtAppId = "APP-9" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.APPNAME}/{Placeholder.APPID}/{Placeholder.INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceName = "if-old", + NewInterfaceName = "if-new", + NewInterfaceLinkText = "Interface", + NewInterfaceLinkName = "if-new", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-9/100", + Reason = "Planned decommission", + UserName = "Tester" + }); + + Assert.That(text, Is.EqualTo("Application/APP-9/if-old/if-new/https://ui.example.test/networkmodelling/APP-9/100/Planned decommission/Tester")); + } + + [Test] + public void ReplaceDecommissionBodyPlaceholdersRendersFormattedContext() + { + FwoOwner application = new() + { + Name = "Application", + ExtAppId = "APP-9" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + $"{Placeholder.INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_NAME}/{Placeholder.NEW_INTERFACE_LINK}/{Placeholder.REASON}/{Placeholder.USER_NAME}", + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceName = "if-old", + NewInterfaceName = "if-new", + NewInterfaceLinkText = "Interface", + NewInterfaceLinkName = "if-new", + NewInterfaceLinkUrl = "https://ui.example.test/networkmodelling/APP-9/100", + Reason = "Planned decommission", + UserName = "Tester" + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo("if-old/if-new/Interface: if-new/Planned decommission/Tester")); + } + + [Test] + public void NotificationScheduleHelper_ConsidersRequestNotificationsDueImmediately() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now, notification), Is.True); + } + + [Test] + public void NotificationScheduleHelper_SkipsDelayedRequestReminders() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + RepeatIntervalAfterDeadline = SchedulerInterval.Days, + InitialOffsetAfterDeadline = 2, + RepeatOffsetAfterDeadline = 1, + RepetitionsAfterDeadline = 2 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now.AddDays(-1), notification), Is.False); + } + [Test] public void NotificationRequestBuilderKeepsNetworkIpsWhenTicketIsSerialized() { diff --git a/roles/tests-unit/files/FWO.Test/ExternalRequestHandlerInternalWorkTest.cs b/roles/tests-unit/files/FWO.Test/ExternalRequestHandlerInternalWorkTest.cs index e491418a5c..796842004e 100644 --- a/roles/tests-unit/files/FWO.Test/ExternalRequestHandlerInternalWorkTest.cs +++ b/roles/tests-unit/files/FWO.Test/ExternalRequestHandlerInternalWorkTest.cs @@ -20,7 +20,7 @@ internal class ExternalRequestHandlerInternalWorkTest { private const long kTicketId = 123; - private static readonly SimulatedUserConfig kUserConfig = new(); + private static readonly SimulatedUserConfig kUserConfig = new() { ReqPriorities = "[]" }; /// /// Builds a request task, optionally marked as internal work the way the promotion does it. diff --git a/roles/tests-unit/files/FWO.Test/FilterTest.cs b/roles/tests-unit/files/FWO.Test/FilterTest.cs index 2a96749a28..f45be51791 100644 --- a/roles/tests-unit/files/FWO.Test/FilterTest.cs +++ b/roles/tests-unit/files/FWO.Test/FilterTest.cs @@ -4,6 +4,7 @@ using NUnit.Framework; using NUnit.Framework.Legacy; using FWO.Basics; +using FWO.Data; using FWO.Data.Report; using FWO.Data.Workflow; using System.Reflection; @@ -525,6 +526,23 @@ public void OwnerRecertificationFilterWithZeroDisplayPeriodKeepsOnlyDueActiveOwn Assert.That((DateTime)query.QueryVariables["refDate"], Is.EqualTo(DateTime.Now).Within(TimeSpan.FromSeconds(5))); } + [Test] + [Parallelizable] + public void OwnerRecertificationFilterWithShowAllOwnersSkipsNextRecertificationDate() + { + ReportTemplate t = new(); + t.ReportParams.ReportType = (int)ReportType.OwnerRecertification; + t.ReportParams.ModellingFilter.SelectedOwners.Add(new FwoOwner { Id = 1 }); + t.ReportParams.ModellingFilter.ShowAllOwners = true; + + DynGraphqlQuery query = Compiler.Compile(t); + + StringAssert.Contains("id: { _in: $selectedOwners }", query.OwnerWhereStatement); + StringAssert.Contains("recert_active: { _eq: true }", query.OwnerWhereStatement); + StringAssert.DoesNotContain("next_recert_date: { _lte: $refDate }", query.OwnerWhereStatement); + Assert.That(query.QueryVariables.ContainsKey("refDate"), Is.False); + } + [Test] [Parallelizable] public void OwnersFilterIncludesStateAndCriticality() diff --git a/roles/tests-unit/files/FWO.Test/GraphQlApiConnectionClientCertificateTest.cs b/roles/tests-unit/files/FWO.Test/GraphQlApiConnectionClientCertificateTest.cs index ccb82f8fd6..a96605443e 100644 --- a/roles/tests-unit/files/FWO.Test/GraphQlApiConnectionClientCertificateTest.cs +++ b/roles/tests-unit/files/FWO.Test/GraphQlApiConnectionClientCertificateTest.cs @@ -405,7 +405,9 @@ private static void CreateApiServerCertificate() CertificateRequest serverRequest = new("CN=fwo-api-server-test", serverKey, HashAlgorithmName.SHA256); serverRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); serverRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); - apiServerCertificate = serverRequest.Create(certificateAuthority, now.AddDays(-1), now.AddDays(1), RandomNumberGenerator.GetBytes(16)); + DateTimeOffset serverNotBefore = DateTimeOffset.UtcNow.AddMinutes(-1); + DateTimeOffset serverNotAfter = certificateAuthority.NotAfter.ToUniversalTime().AddMinutes(-1); + apiServerCertificate = serverRequest.Create(certificateAuthority, serverNotBefore, serverNotAfter, RandomNumberGenerator.GetBytes(16)); } /// diff --git a/roles/tests-unit/files/FWO.Test/MailerMailKitTest.cs b/roles/tests-unit/files/FWO.Test/MailerMailKitTest.cs new file mode 100644 index 0000000000..b6af519215 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/MailerMailKitTest.cs @@ -0,0 +1,336 @@ +using FWO.Mail; +using Microsoft.AspNetCore.Http; +using MimeKit; +using NUnit.Framework; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; + +namespace FWO.Test +{ + [TestFixture] + internal class MailerMailKitTest + { + [Test] + public void MailData_InitializesRecipientsAndSubject() + { + List recipients = new() { "to@example.test" }; + + MailData mail = new(recipients, "Subject") + { + Body = "Body" + }; + + Assert.Multiple(() => + { + Assert.That(mail.To, Is.SameAs(recipients)); + Assert.That(mail.Cc, Is.Empty); + Assert.That(mail.Bcc, Is.Empty); + Assert.That(mail.Subject, Is.EqualTo("Subject")); + Assert.That(mail.Body, Is.EqualTo("Body")); + }); + } + + [Test] + public void CopyMailData_CopiesCollectionsWithoutSharingThem() + { + MailData source = new(new List { "to@example.test" }, "Subject") + { + Cc = new List { "cc@example.test" }, + Bcc = new List { "bcc@example.test" }, + From = "from@example.test", + DisplayName = "Sender", + ReplyTo = "reply@example.test", + ReplyToName = "Reply", + Body = "Body" + }; + + MailData copy = Invoke("CopyMailData", source); + + Assert.Multiple(() => + { + Assert.That(copy.To, Is.EqualTo(source.To)); + Assert.That(copy.Cc, Is.EqualTo(source.Cc)); + Assert.That(copy.Bcc, Is.EqualTo(source.Bcc)); + Assert.That(copy.To, Is.Not.SameAs(source.To)); + Assert.That(copy.Cc, Is.Not.SameAs(source.Cc)); + Assert.That(copy.Bcc, Is.Not.SameAs(source.Bcc)); + Assert.That(copy.From, Is.EqualTo(source.From)); + Assert.That(copy.DisplayName, Is.EqualTo(source.DisplayName)); + Assert.That(copy.ReplyTo, Is.EqualTo(source.ReplyTo)); + Assert.That(copy.ReplyToName, Is.EqualTo(source.ReplyToName)); + Assert.That(copy.Body, Is.EqualTo(source.Body)); + }); + } + + [TestCase(false)] + [TestCase(true)] + public void HasRecipients_RecognizesPresentAddresses(bool present) + { + MailData mail = new(new List { present ? "to@example.test" : " " }, "Subject") + { + Cc = new List { present ? "cc@example.test" : " " } + }; + + bool result = Invoke("HasRecipients", mail); + + Assert.That(result, Is.EqualTo(present)); + } + + [Test] + public void RemoveRecipient_RemovesMatchingAddressFromAnyRecipientList() + { + MailData mail = new(new List { "to@example.test" }, "Subject") + { + Cc = new List { " CC@example.test " }, + Bcc = new List { "bcc@example.test" } + }; + + bool removed = Invoke("RemoveRecipient", mail, "cc@example.test"); + + Assert.Multiple(() => + { + Assert.That(removed, Is.True); + Assert.That(mail.Cc, Is.Empty); + Assert.That(mail.To, Is.EqualTo(new List { "to@example.test" })); + Assert.That(mail.Bcc, Is.EqualTo(new List { "bcc@example.test" })); + }); + } + + [Test] + public void RemoveRecipient_ReturnsFalseWhenAddressIsUnknown() + { + MailData mail = new(new List { "to@example.test" }, "Subject"); + + bool removed = Invoke("RemoveRecipient", mail, "missing@example.test"); + + Assert.That(removed, Is.False); + } + + [Test] + public void RemoveRecipient_SearchesToAndBccAfterEarlierLists() + { + MailData mail = new(new List { "to@example.test" }, "Subject") + { + Cc = new List { "cc@example.test" }, + Bcc = new List { "bcc@example.test" } + }; + + bool removedTo = Invoke("RemoveRecipient", mail, "to@example.test"); + bool removedBcc = Invoke("RemoveRecipient", mail, "bcc@example.test"); + + Assert.Multiple(() => + { + Assert.That(removedTo, Is.True); + Assert.That(removedBcc, Is.True); + Assert.That(mail.To, Is.Empty); + Assert.That(mail.Bcc, Is.Empty); + }); + } + + [Test] + public void AddRecipients_AddsSenderRecipientsAndReplyTo() + { + MailData mail = new(new List { "to@example.test", " " }, "Subject") + { + Cc = new List { "cc@example.test" }, + Bcc = new List { " bcc@example.test " }, + ReplyTo = "reply@example.test", + ReplyToName = "Reply" + }; + EmailConnection connection = new() { SenderEmailAddress = "sender@example.test" }; + MimeMessage message = new(); + + Invoke("AddRecipients", connection, mail, message); + + Assert.Multiple(() => + { + Assert.That(message.From.Mailboxes.Single().Address, Is.EqualTo("sender@example.test")); + Assert.That(message.Sender?.Address, Is.EqualTo("sender@example.test")); + Assert.That(message.To.Mailboxes.Select(x => x.Address), Is.EqualTo(new List { "to@example.test" })); + Assert.That(message.Cc.Mailboxes.Single().Address, Is.EqualTo("cc@example.test")); + Assert.That(message.Bcc.Mailboxes.Single().Address, Is.EqualTo("bcc@example.test")); + Assert.That(message.ReplyTo.Mailboxes.Single().Address, Is.EqualTo("reply@example.test")); + Assert.That(message.ReplyTo.Mailboxes.Single().Name, Is.EqualTo("Reply")); + }); + } + + [Test] + public async Task AddContent_CreatesPlainTextBodyAndAttachments() + { + FormFile attachment = CreateFile("attachment.txt", "text/plain", "attachment"); + MailData mail = new(new List(), "Subject") + { + Body = "Plain body", + Attachments = new FormFileCollection { attachment } + }; + MimeMessage message = new(); + + await InvokeAsync("AddContent", mail, message, false); + + MimePart attachmentPart = (MimePart)message.Attachments.Single(); + Assert.Multiple(() => + { + Assert.That(message.Subject, Is.EqualTo("Subject")); + Assert.That(message.TextBody, Is.EqualTo("Plain body")); + Assert.That(attachmentPart.FileName, Is.EqualTo("attachment.txt")); + Assert.That(attachmentPart.ContentType.MimeType, Is.EqualTo("text/plain")); + }); + } + + [Test] + public async Task AddContent_SkipsEmptyAttachments() + { + FormFile attachment = CreateFile("empty.txt", "text/plain", ""); + MailData mail = new(new List(), "Subject") + { + Body = "Plain body", + Attachments = new FormFileCollection { attachment } + }; + MimeMessage message = new(); + + await InvokeAsync("AddContent", mail, message, false); + + Assert.That(message.Attachments, Is.Empty); + } + + [Test] + public async Task AddContent_CreatesHtmlBodyWithoutAttachments() + { + MailData mail = new(new List(), "Subject") { Body = "

    HTML body

    " }; + MimeMessage message = new(); + + await InvokeAsync("AddContent", mail, message, true); + + Assert.Multiple(() => + { + Assert.That(message.HtmlBody, Is.EqualTo("

    HTML body

    ")); + Assert.That(message.TextBody, Is.Null); + }); + } + + [TestCase(EmailEncryptionMethod.None)] + [TestCase(EmailEncryptionMethod.StartTls)] + [TestCase(EmailEncryptionMethod.Tls)] + public async Task SendAsync_ReturnsFalseWhenConnectionFails(EmailEncryptionMethod encryption) + { + MailData mail = new(new List { "to@example.test" }, "Subject"); + EmailConnection connection = new("127.0.0.1", 1, encryption, "", "", "sender@example.test"); + + bool result = await MailKitMailer.SendAsync(mail, connection); + + Assert.That(result, Is.False); + } + + [Test] + public async Task SendAsync_RemovesRejectedRecipientAndRetriesRemainingRecipients() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + Task server = RunSmtpServer(listener); + MailData mail = new(new List { "bad@example.test", "good@example.test" }, "Subject") + { + Body = "Body" + }; + EmailConnection connection = new("127.0.0.1", port, EmailEncryptionMethod.None, "", "", "sender@example.test"); + + bool result = await MailKitMailer.SendAsync(mail, connection); + await server; + + Assert.Multiple(() => + { + Assert.That(result, Is.True); + Assert.That(mail.To, Is.EqualTo(new List { "bad@example.test", "good@example.test" })); + }); + } + + private static async Task RunSmtpServer(TcpListener listener) + { + using TcpClient client = await listener.AcceptTcpClientAsync(); + await using NetworkStream stream = client.GetStream(); + using StreamReader reader = new(stream, Encoding.ASCII, leaveOpen: true); + using StreamWriter writer = new(stream, Encoding.ASCII, leaveOpen: true) { NewLine = "\r\n", AutoFlush = true }; + await writer.WriteLineAsync("220 localhost"); + + int rejectedRecipients = 0; + string? line; + while ((line = await reader.ReadLineAsync()) != null) + { + string command = line.Split(' ', 2)[0].ToUpperInvariant(); + switch (command) + { + case "EHLO": + case "HELO": + await writer.WriteLineAsync("250-localhost"); + await writer.WriteLineAsync("250 OK"); + break; + case "MAIL": + await writer.WriteLineAsync("250 OK"); + break; + case "RCPT": + if (rejectedRecipients++ == 0) + { + await writer.WriteLineAsync("550 mailbox unavailable"); + } + else + { + await writer.WriteLineAsync("250 OK"); + } + break; + case "DATA": + await writer.WriteLineAsync("354 End data with ."); + while ((line = await reader.ReadLineAsync()) != null && line != ".") + { + } + await writer.WriteLineAsync("250 OK"); + break; + case "QUIT": + await writer.WriteLineAsync("221 Bye"); + return; + default: + await writer.WriteLineAsync("250 OK"); + break; + } + } + } + + private static FormFile CreateFile(string fileName, string contentType, string content) + { + byte[] bytes = Encoding.UTF8.GetBytes(content); + MemoryStream stream = new(bytes); + return new FormFile(stream, 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + private static MethodInfo GetPrivateMethod(string name, object?[] arguments) + { + return typeof(MailKitMailer).GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name == name + && method.GetParameters().Length == arguments.Length + && method.GetParameters().Select((parameter, index) => parameter.ParameterType.IsInstanceOfType(arguments[index])).All(result => result)) + ?? throw new MissingMethodException(typeof(MailKitMailer).FullName, name); + } + + private static T Invoke(string name, params object?[] arguments) + { + return (T)(GetPrivateMethod(name, arguments).Invoke(null, arguments) + ?? throw new InvalidOperationException($"Private method '{name}' returned null.")); + } + + private static void Invoke(string name, params object?[] arguments) + { + GetPrivateMethod(name, arguments).Invoke(null, arguments); + } + + private static async Task InvokeAsync(string name, params object?[] arguments) + { + await (Task)(GetPrivateMethod(name, arguments).Invoke(null, arguments) + ?? throw new InvalidOperationException($"Private method '{name}' returned null.")); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs index 61929ea037..f3e533c25c 100644 --- a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs +++ b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerDecommissionTest.cs @@ -10,6 +10,7 @@ using FWO.Services.Modelling; using FWO.Middleware.Client; using FWO.Api.Client.Queries; +using RestSharp; using System; using System.Collections.Generic; using System.Linq; @@ -21,15 +22,15 @@ namespace FWO.Test [Parallelizable] internal class ModellingConnectionHandlerDecommissionTest { + private static readonly Func kInsertedConnectionReturnIds = + connectionId => [new ReturnId { InsertedId = connectionId }]; + private static readonly ReturnId[] kHistoryReturnIds = [new ReturnId { AffectedRows = 1 }]; + [Test] public async Task DecommissionInterface_NotifiesAndAddsPermissionsAndSelections() { DecommissionTestApiConn apiConnection = new(); SimulatedUserConfig userConfig = new(); - userConfig.ModDecommEmailReceiver = nameof(EmailRecipientOption.OwnerMainResponsible); - userConfig.ModDecommEmailOtherAddresses = "extra1@example.test;extra2@example.test"; - userConfig.ModDecommEmailSubject = $"Subject {Placeholder.INTERFACE_NAME}"; - userConfig.ModDecommEmailBody = $"Body {Placeholder.INTERFACE_NAME} {Placeholder.NEW_INTERFACE_NAME} {Placeholder.NEW_INTERFACE_LINK} {Placeholder.REASON} {Placeholder.USER_NAME}"; userConfig.UiHostName = "https://ui.example.test"; userConfig.User.Name = "Tester"; @@ -50,105 +51,126 @@ public async Task DecommissionInterface_NotifiesAndAddsPermissionsAndSelections( App = new FwoOwner { Id = 4, Name = "Owner4", ExtAppId = "APP4" } }; - TestEmailHelper emailHelper = new(userConfig); - List interfaceUsers = - [ + FwoNotification notification = new() + { + Id = 10, + NotificationClient = NotificationClient.InterfaceDecomm, + Deadline = NotificationDeadline.None, + Layout = NotificationLayout.HtmlInBody, + RecipientTo = EmailRecipientOption.OwnerMainResponsible, + EmailSubject = $"Subject {Placeholder.INTERFACE_NAME}", + EmailBody = $"Body {Placeholder.INTERFACE_NAME} {Placeholder.NEW_INTERFACE_NAME} {Placeholder.NEW_INTERFACE_LINK} {Placeholder.REASON} {Placeholder.USER_NAME}" + }; + apiConnection.Notifications = new List { notification }; + + List interfaceUsers = new() + { new ModellingConnection { Id = 20, AppId = 2, App = new FwoOwner { Id = 2, Name = "Owner2" }, Name = "Conn2" }, new ModellingConnection { Id = 21, AppId = 3, App = new FwoOwner { Id = 3, Name = "Owner3" }, Name = "Conn3" }, new ModellingConnection { Id = 22, AppId = 1, App = owner, Name = "ConnOwn" } - ]; + }; apiConnection.InterfaceUsers = interfaceUsers; apiConnection.ConnectionById = interfaceConn; - DecommissionTestHandler handler = new(apiConnection, userConfig, owner, [interfaceConn], interfaceConn, addMode: false, + ModellingConnectionHandler handler = new(apiConnection, userConfig, owner, new List { interfaceConn }, interfaceConn, addMode: false, readOnly: false, DefaultInit.DoNothing, DefaultInit.DoNothing, isOwner: true) { UsingConnections = interfaceUsers, - ActConnNeedsRefresh = false, - EmailHelperOverride = emailHelper + ActConnNeedsRefresh = false }; - MiddlewareClient middlewareClient = new("http://localhost/"); + RecordingNotificationMiddlewareClient middlewareClient = new(); await handler.DecommissionInterface("Planned", true, proposedInterface, middlewareClient); - ClassicAssert.IsTrue(emailHelper.InitCalled); - ClassicAssert.AreEqual(2, emailHelper.SentEmails.Count); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Owner.Id != owner.Id)); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Subject == $"Subject {interfaceConn.Name}")); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{interfaceConn.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{proposedInterface.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{userConfig.User.Name}"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"Planned"))); - ClassicAssert.IsTrue(emailHelper.SentEmails.All(email => email.Body.Contains($"{userConfig.UiHostName}/{PageName.Modelling}/{proposedInterface.App.ExtAppId}/{proposedInterface.Id}"))); - ClassicAssert.IsTrue(emailHelper.OtherAddressCalls.All(addresses => - addresses.SequenceEqual(new[] { "extra1@example.test", "extra2@example.test" }))); - - CollectionAssert.AreEquivalent(new[] { 2, 3 }, apiConnection.AddedPermittedOwnerAppIds); - CollectionAssert.AreEquivalent(new[] { 2, 3 }, apiConnection.AddedSelectedConnectionAppIds); + ClassicAssert.AreEqual(interfaceConn.Id, middlewareClient.ConnectionId); + ClassicAssert.AreEqual(proposedInterface.Id, middlewareClient.ReplacementConnectionId); + ClassicAssert.AreEqual("Planned", middlewareClient.Reason); + + CollectionAssert.AreEquivalent(new List { 2, 3 }, apiConnection.AddedPermittedOwnerAppIds); + CollectionAssert.AreEquivalent(new List { 2, 3 }, apiConnection.AddedSelectedConnectionAppIds); ClassicAssert.IsTrue(apiConnection.AddedSelectedConnections.All(c => c.ConnectionId == proposedInterface.Id)); ClassicAssert.IsTrue(apiConnection.RemovedSelectedConnections.Contains(interfaceConn.Id)); } - private sealed class DecommissionTestHandler : ModellingConnectionHandler + [Test] + public async Task NotifyUsers_FailureDoesNotClaimExactRecipientCount() { - public TestEmailHelper? EmailHelperOverride { get; set; } - - public DecommissionTestHandler(ApiConnection apiConnection, SimulatedUserConfig userConfig, FwoOwner application, - List connections, ModellingConnection conn, bool addMode, bool readOnly, - Action displayMessageInUi, Func refreshParent, bool isOwner) - : base(apiConnection, userConfig, application, connections, conn, addMode, readOnly, displayMessageInUi, refreshParent, isOwner) + SimulatedUserConfig userConfig = new(); + List<(string Title, string Message, bool IsError)> messages = []; + userConfig.UiHostName = "https://ui.example.test"; + FwoOwner owner = new() { Id = 1, Name = "Owner1", ExtAppId = "APP1" }; + ModellingConnection connection = new() { Id = 10, AppId = owner.Id, App = owner, IsInterface = true }; + List connections = [connection]; + TestableModellingConnectionHandler handler = new( + new DecommissionTestApiConn(), userConfig, owner, connections, connection, + (exception, title, message, isError) => messages.Add((title, message, isError))); + RecordingNotificationMiddlewareClient middlewareClient = new() { - } + DeliveryResult = NotificationDeliveryResult.Failed + }; - protected override EmailHelper CreateEmailHelper(MiddlewareClient middlewareClient) - { - return EmailHelperOverride ?? base.CreateEmailHelper(middlewareClient); - } + await handler.NotifyUsersForTest("reason", null, middlewareClient); + + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].IsError, Is.True); + Assert.That(messages[0].Message, Is.EqualTo(userConfig.GetText("E9031"))); + Assert.That(messages[0].Message, Does.Not.Contain("2")); } - private sealed class TestEmailHelper : EmailHelper + private sealed class TestableModellingConnectionHandler : ModellingConnectionHandler { - public bool InitCalled { get; private set; } - public List<(FwoOwner Owner, string Subject, string Body, EmailRecipientOption Recipient)> SentEmails { get; } = []; - public List> OtherAddressCalls { get; } = []; - - public TestEmailHelper(UserConfig userConfig) - : base(new SimulatedApiConnection(), null, userConfig, DefaultInit.DoNothing) + public TestableModellingConnectionHandler(DecommissionTestApiConn apiConnection, SimulatedUserConfig userConfig, + FwoOwner application, List connections, ModellingConnection activeConnection, + Action displayMessage) + : base(apiConnection, userConfig, application, connections, activeConnection, false, false, + displayMessage, DefaultInit.DoNothing, true) { } - public override Task Init(string? scopedUserTo = null, string? scopedUserCc = null, string? scopedUserBcc = null, - string? scopedUserEmailTo = null, string? scopedUserEmailCc = null, string? scopedUserEmailBcc = null) + public Task NotifyUsersForTest(string reason, ModellingConnection? proposedInterface, + MiddlewareClient middlewareClient) { - InitCalled = true; - return Task.CompletedTask; + return NotifyUsers(reason, proposedInterface, middlewareClient); } + } - public override Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, EmailRecipientOption recOpt, bool reqInCc = false) - { - SentEmails.Add((owner, subject, body, recOpt)); - return Task.FromResult(true); - } + private sealed class RecordingNotificationMiddlewareClient : MiddlewareClient + { + public NotificationDeliveryResult DeliveryResult { get; init; } = NotificationDeliveryResult.Delivered; + public int ConnectionId { get; private set; } + public int? ReplacementConnectionId { get; private set; } + public string Reason { get; private set; } = ""; - public override Task SendEmailToOwnerResponsibles(FwoOwner owner, string subject, string body, string recipientConfig, bool reqInCc = false, List? otherAddresses = null) + public RecordingNotificationMiddlewareClient() : base("http://localhost/") + { } + + public override Task> SendInterfaceDecommissionNotification( + int connectionId, int? replacementConnectionId, string reason) { - EmailRecipientSelection parsedSelection = EmailRecipientSelection.Parse(recipientConfig); - EmailRecipientOption recipient = parsedSelection.None ? EmailRecipientOption.None : EmailRecipientOption.OwnerMainResponsible; - SentEmails.Add((owner, subject, body, recipient)); - OtherAddressCalls.Add(otherAddresses ?? []); - return Task.FromResult(true); + ConnectionId = connectionId; + ReplacementConnectionId = replacementConnectionId; + Reason = reason; + return Task.FromResult(new RestResponse(new RestRequest()) + { + StatusCode = System.Net.HttpStatusCode.OK, + ResponseStatus = ResponseStatus.Completed, + IsSuccessStatusCode = true, + Data = DeliveryResult + }); } } private sealed class DecommissionTestApiConn : SimulatedApiConnection { - public List AddedPermittedOwnerAppIds { get; } = []; - public List AddedSelectedConnectionAppIds { get; } = []; - public List<(int AppId, int ConnectionId)> AddedSelectedConnections { get; } = []; - public List RemovedSelectedConnections { get; } = []; - public List InterfaceUsers { get; set; } = []; - public List PermittedOwners { get; set; } = []; + public List AddedPermittedOwnerAppIds { get; } = new(); + public List AddedSelectedConnectionAppIds { get; } = new(); + public List<(int AppId, int ConnectionId)> AddedSelectedConnections { get; } = new(); + public List RemovedSelectedConnections { get; } = new(); + public List UpdatedNotificationIds { get; } = new(); + public List InterfaceUsers { get; set; } = new(); + public List PermittedOwners { get; set; } = new(); + public List Notifications { get; set; } = new(); public ModellingConnection? ConnectionById { get; set; } public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, FWO.Api.Client.QueryChunkingOptions? chunkingOptions = null) @@ -165,7 +187,7 @@ public override Task SendQueryAsync(string int connId = GetIntVariable(variables, "connectionId"); AddedSelectedConnectionAppIds.Add(appId); AddedSelectedConnections.Add((appId, connId)); - ReturnIdWrapper wrapper = new() { ReturnIds = [new ReturnId { InsertedId = connId }] }; + ReturnIdWrapper wrapper = new() { ReturnIds = kInsertedConnectionReturnIds(connId) }; return Task.FromResult((QueryResponseType)(object)wrapper); } if (query == ModellingQueries.removeSelectedConnection) @@ -178,9 +200,18 @@ public override Task SendQueryAsync(string { return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); } + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + return Task.FromResult((QueryResponseType)(object)Notifications); + } + if (query == NotificationQueries.updateNotificationsLastSent) + { + UpdatedNotificationIds.AddRange(GetIntListVariable(variables, "ids")); + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = UpdatedNotificationIds.Count }); + } if (query == ModellingQueries.addHistoryEntry) { - ReturnIdWrapper wrapper = new() { ReturnIds = [new ReturnId { AffectedRows = 1 }] }; + ReturnIdWrapper wrapper = new() { ReturnIds = kHistoryReturnIds }; return Task.FromResult((QueryResponseType)(object)wrapper); } if (responseType == typeof(List) && query == ModellingQueries.getSelectedConnections) @@ -241,6 +272,17 @@ private static int GetIntVariable(object? variables, string name) object? value = variables.GetType().GetProperties().FirstOrDefault(p => p.Name == name)?.GetValue(variables); return value == null ? 0 : Convert.ToInt32(value); } + + private static List GetIntListVariable(object? variables, string name) + { + System.Reflection.PropertyInfo? property = variables?.GetType().GetProperties().FirstOrDefault(p => p.Name == name); + if (property?.GetValue(variables) is IEnumerable values) + { + return [.. values]; + } + + return []; + } } } } diff --git a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerInterfHandlingTest.cs b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerInterfHandlingTest.cs index 91ba214906..cc2d1da01f 100644 --- a/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerInterfHandlingTest.cs +++ b/roles/tests-unit/files/FWO.Test/ModellingConnectionHandlerInterfHandlingTest.cs @@ -1,11 +1,13 @@ using NUnit.Framework; using NUnit.Framework.Legacy; +using FWO.Api.Client.Queries; using FWO.Data; using FWO.Data.Modelling; using FWO.Services; using FWO.Services.Modelling; using FWO.Basics; using System.Collections.Generic; +using System.Reflection; namespace FWO.Test { @@ -324,6 +326,83 @@ public async Task RequestReplaceInterface_MismatchShowsErrorAndDoesNotEnableRepl ClassicAssert.IsNull(handler.IntConnHandler); } + [Test] + public async Task ReplaceLinks_DoesNothingWhenInterfaceIsNotUsed() + { + ModellingConnectionHandler handler = CreateHandler(new ModellingConnection { Id = 22 }); + + await InvokeAsync(handler, "ReplaceLinks"); + + Assert.That(handler.UsingConnections, Is.Empty); + } + + [Test] + public async Task ReplaceInterface_DoesNothingWhenReplacementHandlerIsMissing() + { + ModellingConnectionHandler handler = CreateHandler(new ModellingConnection { Id = 30 }); + + await handler.ReplaceInterface(null!, null!); + + Assert.That(handler.ReplaceMode, Is.False); + } + + [Test] + public async Task DeleteRequestedInterface_ReturnsFalseWhenInterfaceIsUsed() + { + ModellingConnectionHandler handler = CreateHandler(new ModellingConnection { Id = 23 }); + handler.UsingConnections.Add(new ModellingConnection { Id = 24 }); + + bool deleted = await InvokeAsync(handler, "DeleteRequestedInterface"); + + Assert.That(deleted, Is.False); + } + + [Test] + public async Task UpdateTicket_DoesNothingWhenTicketIsMissing() + { + ModellingConnectionHandler handler = CreateHandler(new ModellingConnection { Id = 25, TicketId = null }); + + await InvokeAsync(handler, "UpdateTicket", null, null, null); + + Assert.That(handler.ActConn.TicketId, Is.Null); + } + + [Test] + public void RequestRemovePreselectedInterface_PreparesConfirmation() + { + ModellingConnectionHandler handler = CreateHandler(new ModellingConnection { Id = 26 }); + ModellingConnection selected = new() { Id = 27, Name = "SelectedInterface" }; + + handler.RequestRemovePreselectedInterface(selected); + + Assert.Multiple(() => + { + Assert.That(handler.RemovePreselectedInterfaceMode, Is.True); + Assert.That(handler.Message, Does.Contain("SelectedInterface")); + }); + } + + [Test] + public async Task RemovePreselectedInterface_RemovesSelectionAfterSuccessfulDelete() + { + ModellingConnection selected = new() { Id = 28, Name = "SelectedInterface" }; + ModellingConnectionHandler handler = new ModellingConnectionHandler( + new PreselectedInterfaceApiConn(), userConfig, Application, [new ModellingConnection { Id = 29 }], + new ModellingConnection { Id = 29 }, false, false, DisplayMessageInUi, DefaultInit.DoNothing, true) + { + PreselectedInterfaces = [selected] + }; + handler.RequestRemovePreselectedInterface(selected); + + await handler.RemovePreselectedInterface(); + + Assert.Multiple(() => + { + Assert.That(handler.PreselectedInterfaces, Is.Empty); + Assert.That(handler.RemovePreselectedInterfaceMode, Is.False); + }); + } + [Test] public void DisplayInterface_UsesOwnerForDisplayName() { @@ -474,5 +553,37 @@ private static ModellingServiceGroupWrapper WrapServiceGroup(int id, string name { return new ModellingServiceGroupWrapper { Content = new ModellingServiceGroup { Id = id, Name = name } }; } + + private static MethodInfo GetPrivateMethod(string name) + { + return typeof(ModellingConnectionHandler).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new MissingMethodException(typeof(ModellingConnectionHandler).FullName, name); + } + + private static async Task InvokeAsync(ModellingConnectionHandler handler, string name, params object?[] arguments) + { + await (Task)(GetPrivateMethod(name).Invoke(handler, arguments) + ?? throw new InvalidOperationException($"Private method '{name}' returned null.")); + } + + private static async Task InvokeAsync(ModellingConnectionHandler handler, string name, params object?[] arguments) + { + return (T)(await (Task)(GetPrivateMethod(name).Invoke(handler, arguments) + ?? throw new InvalidOperationException($"Private method '{name}' returned null.")))!; + } + + private sealed class PreselectedInterfaceApiConn : SimulatedApiConnection + { + public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, + FWO.Api.Client.QueryChunkingOptions? chunkingOptions = null) + { + if (typeof(T) == typeof(ReturnId) && query == ModellingQueries.removeSelectedConnectionFromApp) + { + return Task.FromResult((T)(object)new ReturnId { AffectedRows = 1 }); + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } } } diff --git a/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs b/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs index 10ce45441a..492ac175e2 100644 --- a/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs +++ b/roles/tests-unit/files/FWO.Test/ModellingEmailRecipientSelectionTest.cs @@ -10,19 +10,19 @@ public class EmailRecipientSelectionTest [Test] public void ParseLegacyOwnerGroupOnlyMapsToSupportingResponsible() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OwnerGroupOnly), [1, 2, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OwnerGroupOnly), new List { 1, 2, 3 }); Assert.That(selection.None, Is.False); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { GlobalConst.kOwnerResponsibleTypeSupporting })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { GlobalConst.kOwnerResponsibleTypeSupporting })); } [Test] public void ParseLegacyAllOwnerResponsiblesUsesActiveResponsibleTypes() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.AllOwnerResponsibles), [1, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.AllOwnerResponsibles), new List { 1, 3 }); Assert.That(selection.None, Is.False); - Assert.That(selection.OwnerResponsibleTypeIds.OrderBy(id => id), Is.EqualTo(new[] { 1, 3 })); + Assert.That(selection.OwnerResponsibleTypeIds.OrderBy(id => id), Is.EqualTo(new List { 1, 3 })); } [Test] @@ -38,11 +38,11 @@ public void ParseLegacyAllOwnerResponsiblesReturnsEmptyWhenNoResponsibleTypesAre public void ParseJsonDerivesNoneFromEffectiveSelections() { string rawConfig = "{\"none\":true,\"other_addresses\":true,\"owner_responsible_type_ids\":[1,2]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2, 3]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2, 3 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { 1, 2 })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 1, 2 })); } [Test] @@ -52,10 +52,10 @@ public void ToConfigValueWithoutRecipientsStoresNone() { None = false, OtherAddresses = false, - OwnerResponsibleTypeIds = [] + OwnerResponsibleTypeIds = new List() }; - Assert.That(selection.ToConfigValue([1, 2]), Is.EqualTo(nameof(EmailRecipientOption.None))); + Assert.That(selection.ToConfigValue(new List { 1, 2 }), Is.EqualTo(nameof(EmailRecipientOption.None))); } [Test] @@ -65,10 +65,10 @@ public void ToConfigValueWithEmptyOtherAddressListStoresNone() { None = false, OtherAddresses = true, - OtherAddressList = [] + OtherAddressList = new List() }; - Assert.That(selection.ToConfigValue([1, 2]), Is.EqualTo(nameof(EmailRecipientOption.None))); + Assert.That(selection.ToConfigValue(new List { 1, 2 }), Is.EqualTo(nameof(EmailRecipientOption.None))); } [Test] @@ -76,7 +76,7 @@ public void ParseJsonWithEmptyOtherAddressListClearsOtherAddresses() { string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[],\"owner_responsible_type_ids\":[]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2 }); Assert.That(selection.None, Is.True); Assert.That(selection.OtherAddresses, Is.False); @@ -85,7 +85,7 @@ public void ParseJsonWithEmptyOtherAddressListClearsOtherAddresses() [Test] public void ParseLegacyOtherAddressesKeepsSelectionForLegacyAddressMerge() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OtherAddresses), [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.OtherAddresses), new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); @@ -96,11 +96,39 @@ public void ParseJsonKeepsSanitizedOtherAddressList() { string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[\" a@test \",\"A@test\",\"b@test\"],\"owner_responsible_type_ids\":[]}"; - EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.OtherAddressList, Is.EqualTo(new[] { "a@test", "b@test" })); + Assert.That(selection.OtherAddressList, Is.EqualTo(new List { "a@test", "b@test" })); + } + + [Test] + public void ParseJsonKeepsRequesterSelection() + { + string rawConfig = "{\"none\":false,\"other_addresses\":true,\"other_address_list\":[\"cc@example.test\"],\"requester\":true,\"owner_responsible_type_ids\":[]}"; + List activeResponsibleTypeIds = new List { 1, 2 }; + + EmailRecipientSelection selection = EmailRecipientSelection.Parse(rawConfig, activeResponsibleTypeIds); + + Assert.That(selection.None, Is.False); + Assert.That(selection.Requester, Is.True); + Assert.That(selection.OtherAddresses, Is.True); + } + + [Test] + public void ToConfigValueKeepsRequesterSelection() + { + EmailRecipientSelection selection = new() + { + None = false, + Requester = true + }; + List activeResponsibleTypeIds = new List { 1, 2 }; + + string configValue = selection.ToConfigValue(activeResponsibleTypeIds); + + Assert.That(configValue, Does.Contain("\"requester\":true")); } [Test] @@ -109,29 +137,29 @@ public void GetOwnerResponsibleTypeFallbackOrderUsesHighestSortOrderFirst() EmailRecipientSelection selection = new() { None = false, - OwnerResponsibleTypeIds = [1, 2, 3] + OwnerResponsibleTypeIds = new List { 1, 2, 3 } }; - List ownerResponsibleTypes = - [ + List ownerResponsibleTypes = new List + { new OwnerResponsibleType { Id = 1, Active = true, SortOrder = 10 }, new OwnerResponsibleType { Id = 2, Active = true, SortOrder = 50 }, new OwnerResponsibleType { Id = 3, Active = false, SortOrder = 100 } - ]; + }; List fallbackOrder = selection.GetOwnerResponsibleTypeFallbackOrder(ownerResponsibleTypes).ToList(); - Assert.That(fallbackOrder, Is.EqualTo(new[] { 2, 1 })); + Assert.That(fallbackOrder, Is.EqualTo(new List { 2, 1 })); } [Test] public void ParseLegacyFallbackOptionEnablesEnsureAtLeastOneNotification() { - EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.FallbackToMainResponsibleIfOwnerGroupEmpty), [1, 2]); + EmailRecipientSelection selection = EmailRecipientSelection.Parse(nameof(EmailRecipientOption.FallbackToMainResponsibleIfOwnerGroupEmpty), new List { 1, 2 }); Assert.That(selection.None, Is.False); Assert.That(selection.EnsureAtLeastOneNotification, Is.True); - Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new[] { 2, 1 })); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 2, 1 })); } [Test] @@ -139,7 +167,7 @@ public void ParseInvalidJsonFallsBackToLegacyValue() { EmailRecipientSelection selection = EmailRecipientSelection.Parse( "{invalid-json", - [GlobalConst.kOwnerResponsibleTypeMain, GlobalConst.kOwnerResponsibleTypeSupporting]); + new List { GlobalConst.kOwnerResponsibleTypeMain, GlobalConst.kOwnerResponsibleTypeSupporting }); Assert.That(selection.None, Is.True); Assert.That(selection.OtherAddresses, Is.False); diff --git a/roles/tests-unit/files/FWO.Test/NotificationControllerTest.cs b/roles/tests-unit/files/FWO.Test/NotificationControllerTest.cs new file mode 100644 index 0000000000..45b9520263 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationControllerTest.cs @@ -0,0 +1,554 @@ +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Basics; +using FWO.Config.Api; +using FWO.Data; +using FWO.Data.Middleware; +using FWO.Data.Modelling; +using FWO.Data.Workflow; +using FWO.Middleware.Server; +using FWO.Middleware.Server.Controllers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using NUnit.Framework; +using System.Reflection; +using System.Security.Claims; + +namespace FWO.Test; + +[TestFixture] +internal class NotificationControllerTest +{ + [Test] + public void InterfaceRequestEndpoint_AllowsBusinessRoles() + { + Assert.That(GetRoles(nameof(NotificationController.SendInterfaceRequest)), + Is.EqualTo($"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Modeller}")); + } + + [Test] + public void InterfaceDecommissionEndpoint_AllowsBusinessRoles() + { + Assert.That(GetRoles(nameof(NotificationController.SendInterfaceDecommission)), + Is.EqualTo($"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Modeller}")); + } + + [Test] + public async Task SendInterfaceRequest_RejectsInvalidConnectionId() + { + NotificationController controller = CreateController(new ControllerApiConnection(), new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 0 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_RejectsNonRequestedConnection() + { + ControllerApiConnection apiConnection = new() + { + Connection = new ModellingConnection { Id = 10, IsInterface = true, TicketId = 20 } + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_RejectsRejectedConnection() + { + ModellingConnection connection = RequestedConnection(); + connection.AddProperty(ConState.Rejected.ToString()); + ControllerApiConnection apiConnection = new() + { + Connection = connection, + Ticket = new WfTicket() + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_RejectsModellerOutsideOwnerScope() + { + ControllerApiConnection apiConnection = new() + { + Connection = RequestedConnection(), + Ticket = new WfTicket() + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig(), Roles.Modeller, + "{ 7 }"); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_RejectsUnauthorizedCallerBeforeLifecycleValidation() + { + ModellingConnection connection = RequestedConnection(); + connection.AddProperty(ConState.Rejected.ToString()); + ControllerApiConnection apiConnection = new() + { + Connection = connection, + Ticket = new WfTicket() + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig(), Roles.Modeller, + "{ 7 }"); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_AllowsTheRequestCreatorWithoutTargetOwnerEditRights() + { + ModellingConnection connection = RequestedConnection(); + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = connection, + Ticket = new WfTicket { Requester = new UiUser { DbId = 4, Name = "modeller" } } + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig(), Roles.Modeller); + List claims = + [ + new Claim(ClaimTypes.Role, Roles.Modeller), + new Claim("x-hasura-user-id", "4") + ]; + controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.Suppressed)); + } + + [Test] + public async Task SendInterfaceRequest_RejectsEmptyCallerNameWhenStoredNamesAreEmpty() + { + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = RequestedConnection(), + Ticket = new WfTicket { Requester = new UiUser { Name = "" } } + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig(), Roles.Modeller); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_RejectsSameUsernameWithoutMatchingUserId() + { + ModellingConnection connection = RequestedConnection(); + connection.Creator = "modeller"; + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = connection, + Ticket = new WfTicket { Requester = new UiUser { Name = "modeller" } } + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig(), Roles.Modeller); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceRequest_ReturnsSuppressedWhenNoNotificationIsConfigured() + { + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = RequestedConnection(), + Ticket = new WfTicket() + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.Suppressed)); + } + + [Test] + public async Task SendInterfaceRequest_ResolvesTicketAndRequestOwner() + { + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Owner = new FwoOwner { Id = 9, Name = "Request Owner" }, + Connection = RequestedConnection(), + Ticket = new WfTicket + { + CreationDate = new DateTime(2026, 1, 2), + RequesterDn = "cn=requester", + Tasks = + [ + new WfReqTask + { + TaskType = WfTaskType.new_interface.ToString(), + Title = "Requested interface", + Reason = "Request reason", + AdditionalInfo = "{\"ReqOwner\":\"9\"}" + } + ] + } + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.Suppressed)); + Assert.That(apiConnection.OwnerQueryCount, Is.EqualTo(2)); + } + + [Test] + public async Task SendInterfaceRequest_ReturnsNoRecipientsAndLogsFailure() + { + ControllerApiConnection apiConnection = ConfiguredNoRecipientConnection(); + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceRequest( + new InterfaceRequestNotificationParameters { ConnectionId = 10 }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(apiConnection.InsertCount, Is.EqualTo(1)); + Assert.That(apiConnection.LastSentUpdateCount, Is.EqualTo(1)); + } + + [Test] + public async Task SendInterfaceDecommission_RejectsMissingReason() + { + NotificationController controller = CreateController(new ControllerApiConnection(), new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters { ConnectionId = 10 }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceDecommission_RejectsActiveConnection() + { + ControllerApiConnection apiConnection = new() + { + Connection = new ModellingConnection { Id = 10, IsInterface = true, AppId = 8 } + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters { ConnectionId = 10, Reason = "reason" }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceDecommission_RejectsModellerOutsideOwnerScope() + { + ControllerApiConnection apiConnection = new() { Connection = DecommissionedConnection() }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig(), Roles.Modeller, + "{ 7 }"); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters { ConnectionId = 10, Reason = "reason" }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task SendInterfaceDecommission_ReturnsSuppressedWhenNoUsersOrNotificationsExist() + { + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = DecommissionedConnection() + }; + NotificationController controller = CreateController(apiConnection, new SimulatedGlobalConfig()); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters { ConnectionId = 10, Reason = "reason" }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.Suppressed)); + } + + [Test] + public async Task SendInterfaceDecommission_ReturnsNoRecipientsAndLogsFailure() + { + ControllerApiConnection apiConnection = ConfiguredNoRecipientConnection(); + apiConnection.Connection = DecommissionedConnection(); + apiConnection.InterfaceUsers = [new ModellingConnection + { + Id = 11, + AppId = 9, + App = new FwoOwner { Id = 9, ExtAppId = "APP-9" }, + Name = "Using interface" + }]; + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters { ConnectionId = 10, Reason = "reason" }); + + Assert.That(GetResult(result), Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(apiConnection.InsertCount, Is.EqualTo(1)); + Assert.That(apiConnection.LastSentUpdateCount, Is.EqualTo(1)); + } + + [TestCase(10, false, true, "Public", false, null)] + [TestCase(11, false, true, "Public", false, null)] + [TestCase(11, true, false, "Public", false, null)] + [TestCase(11, true, true, "Private", false, null)] + [TestCase(11, true, true, "Public", true, "Decommissioned")] + [TestCase(11, true, true, "Public", false, "Rejected")] + public async Task SendInterfaceDecommission_RejectsIneligibleReplacement( + int replacementId, bool isInterface, bool isPublished, string interfacePermission, bool removed, + string? lifecycleProperty) + { + ControllerApiConnection apiConnection = new() + { + NotificationExists = false, + Connection = DecommissionedConnection(), + ReplacementConnection = new ModellingConnection + { + Id = replacementId, + IsInterface = isInterface, + IsPublished = isPublished, + Removed = removed, + Properties = lifecycleProperty == null ? "" : $"{{\"{lifecycleProperty}\":\"\"}}", + InterfacePermission = interfacePermission + } + }; + NotificationController controller = CreateController(apiConnection, new GlobalConfig()); + + ActionResult result = await controller.SendInterfaceDecommission( + new InterfaceDecommissionNotificationParameters + { + ConnectionId = 10, + ReplacementConnectionId = replacementId, + Reason = "reason" + }); + + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public void CombineDeliveryResults_PrioritizesPartialFailureOverDelivery() + { + MethodInfo method = typeof(NotificationController).GetMethod( + "CombineDeliveryResults", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException(typeof(NotificationController).FullName, "CombineDeliveryResults"); + object?[] arguments = [true, true, true]; + + NotificationDeliveryResult result = (NotificationDeliveryResult)method.Invoke(null, arguments)!; + + Assert.That(result, Is.EqualTo(NotificationDeliveryResult.Failed)); + } + + private static string? GetRoles(string methodName) + { + MethodInfo method = typeof(NotificationController).GetMethod(methodName)!; + return method.GetCustomAttribute()?.Roles; + } + + private static NotificationDeliveryResult GetResult(ActionResult result) + { + return (NotificationDeliveryResult)((OkObjectResult)result.Result!).Value!; + } + + private static NotificationController CreateController(ControllerApiConnection apiConnection, GlobalConfig globalConfig, + string role = Roles.Admin, string? editableOwners = null) + { + List claims = [new Claim(ClaimTypes.Role, role)]; + if (editableOwners != null) + { + claims.Add(new Claim("x-hasura-editable-owners", editableOwners)); + } + return new NotificationController(apiConnection, globalConfig) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test")) + } + } + }; + } + + private static ModellingConnection RequestedConnection() + { + return new ModellingConnection + { + Id = 10, + ProposedAppId = 8, + ProposedApp = new FwoOwner { Id = 8, ExtAppId = "APP-8" }, + IsInterface = true, + IsRequested = true, + TicketId = 20, + Name = "Requested interface" + }; + } + + private static ModellingConnection DecommissionedConnection() + { + return new ModellingConnection + { + Id = 10, + AppId = 8, + App = new FwoOwner { Id = 8, ExtAppId = "APP-8" }, + IsInterface = true, + Removed = true, + Name = "Old interface" + }; + } + + private static ControllerApiConnection ConfiguredNoRecipientConnection() + { + return new ControllerApiConnection + { + InsertedId = 9, + Logging = NotificationLoggingMode.SendAndLog, + RecipientTo = EmailRecipientOption.None, + Connection = RequestedConnection(), + Ticket = new WfTicket() + }; + } + + private sealed class ControllerApiConnection : SimulatedApiConnection + { + public int InsertedId { get; init; } + public string Logging { get; init; } = NotificationLoggingMode.SendOnly; + public bool Active { get; init; } = true; + public bool NotificationExists { get; init; } = true; + public NotificationClient NotificationClient { get; init; } = NotificationClient.InterfaceRequest; + public NotificationDeadline Deadline { get; init; } = NotificationDeadline.None; + public EmailRecipientOption RecipientTo { get; init; } = EmailRecipientOption.OtherAddresses; + public ModellingConnection? Connection { get; set; } + public ModellingConnection? ReplacementConnection { get; init; } + public List InterfaceUsers { get; set; } = []; + public WfTicket? Ticket { get; init; } + public FwoOwner? Owner { get; init; } + public int OwnerQueryCount { get; private set; } + public int InsertCount { get; private set; } + public int UpdateCount { get; private set; } + public int LastSentUpdateCount { get; private set; } + + public override Task SendQueryAsync(string query, object? variables = null, + string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + if (typeof(QueryResponseType) == typeof(List) && query == ModellingQueries.getConnectionForNotification) + { + int requestedId = variables?.GetType().GetProperty("id")?.GetValue(variables) is int id ? id : 0; + ModellingConnection? selectedConnection = requestedId == ReplacementConnection?.Id + ? ReplacementConnection + : Connection; + List result = selectedConnection == null ? [] : [selectedConnection]; + return Task.FromResult((QueryResponseType)(object)result); + } + if (typeof(QueryResponseType) == typeof(List) && query == ModellingQueries.getInterfaceUsers) + { + return Task.FromResult((QueryResponseType)(object)InterfaceUsers); + } + if (typeof(QueryResponseType) == typeof(WfTicket) && query == RequestQueries.getTicketById) + { + return Task.FromResult((QueryResponseType)(object)(Ticket ?? new WfTicket())); + } + if (typeof(QueryResponseType) == typeof(WfTicket) && query == RequestQueries.getTicketRequesterId) + { + return Task.FromResult((QueryResponseType)(object)new WfTicket + { + Requester = Ticket?.Requester + }); + } + if (typeof(QueryResponseType) == typeof(FwoOwner) + && (query == OwnerQueries.getOwnerById || query == OwnerQueries.getOwnerForNotification)) + { + OwnerQueryCount++; + return Task.FromResult((QueryResponseType)(object)(Owner ?? new FwoOwner())); + } + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getLdapConnections) + { + return Task.FromResult((QueryResponseType)(object)new List()); + } + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return Task.FromResult((QueryResponseType)(object)new List()); + } + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return Task.FromResult((QueryResponseType)(object)new List()); + } + if (typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + InsertCount++; + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = InsertedId }] + }); + } + if (typeof(QueryResponseType) == typeof(ReturnId) + && query == NotificationQueries.updateNotificationsLastSent) + { + LastSentUpdateCount++; + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + if (typeof(QueryResponseType) == typeof(ReturnId)) + { + UpdateCount++; + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + if (typeof(QueryResponseType) == typeof(List)) + { + if (!NotificationExists) + { + return Task.FromResult((QueryResponseType)(object)new List()); + } + string response = JsonConvert.SerializeObject(new + { + id = 4, + notification_client = NotificationClient.ToString(), + deadline = Deadline.ToString(), + recipient_to = RecipientTo.ToString(), + email_address_to = "", + recipient_cc = EmailRecipientOption.None.ToString(), + email_address_cc = "", + recipient_bcc = EmailRecipientOption.None.ToString(), + email_address_bcc = "", + logging = Logging, + active = Active + }); + List notifications = JsonConvert.DeserializeObject>($"[{response}]")!; + return Task.FromResult((QueryResponseType)(object)notifications); + } + if (typeof(QueryResponseType) == typeof(List) + && (query == NotificationQueries.getNoRecipientNotificationLogs + || query == NotificationQueries.getNoRecipientNotificationLogsWithoutDeadline)) + { + return Task.FromResult((QueryResponseType)(object)new List()); + } + throw new InvalidOperationException($"Unexpected query: {query}"); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationLastSentHelperTest.cs b/roles/tests-unit/files/FWO.Test/NotificationLastSentHelperTest.cs new file mode 100644 index 0000000000..0aa5d260bc --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationLastSentHelperTest.cs @@ -0,0 +1,86 @@ +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Data; +using FWO.Services; +using NUnit.Framework; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationLastSentHelperTest + { + [Test] + public async Task UpdateAsync_SkipsEmptyAndInvalidIds() + { + RecordingApiConnection apiConnection = new(); + + int affectedRows = await NotificationLastSentHelper.UpdateAsync(apiConnection, new List { 0, -1 }); + + Assert.Multiple(() => + { + Assert.That(affectedRows, Is.Zero); + Assert.That(apiConnection.UpdatedIds, Is.Empty); + }); + } + + [Test] + public async Task UpdateAsync_DeduplicatesPositiveIds() + { + RecordingApiConnection apiConnection = new() { AffectedRows = 2 }; + + int affectedRows = await NotificationLastSentHelper.UpdateAsync(apiConnection, new List { 7, 0, 7, -1, 9 }); + + Assert.Multiple(() => + { + Assert.That(affectedRows, Is.EqualTo(2)); + Assert.That(apiConnection.UpdatedIds, Is.EqualTo(new List { 7, 9 })); + }); + } + + [Test] + public async Task UpdateAsync_ReturnsAffectedRowsForPartialUpdate() + { + RecordingApiConnection apiConnection = new() { AffectedRows = 1 }; + + int affectedRows = await NotificationLastSentHelper.UpdateAsync(apiConnection, new List { 7, 9 }); + + Assert.That(affectedRows, Is.EqualTo(1)); + } + + [Test] + public async Task UpdateAsync_ReturnsZeroWhenMutationFails() + { + RecordingApiConnection apiConnection = new() { ThrowOnUpdate = true }; + + int affectedRows = await NotificationLastSentHelper.UpdateAsync(apiConnection, new List { 7 }); + + Assert.That(affectedRows, Is.Zero); + } + + private sealed class RecordingApiConnection : SimulatedApiConnection + { + public List UpdatedIds { get; private set; } = []; + public int AffectedRows { get; init; } + public bool ThrowOnUpdate { get; init; } + + public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, + QueryChunkingOptions? chunkingOptions = null) + { + Assert.That(query, Is.EqualTo(NotificationQueries.updateNotificationsLastSent)); + UpdatedIds = GetIds(variables); + if (ThrowOnUpdate) + { + throw new InvalidOperationException("update failed"); + } + + return Task.FromResult((T)(object)new ReturnId { AffectedRows = AffectedRows }); + } + + private static List GetIds(object? variables) + { + object? value = variables?.GetType().GetProperties().FirstOrDefault(property => property.Name == "ids")?.GetValue(variables); + return value is IEnumerable ids ? [.. ids] : []; + } + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationLogEntryTest.cs b/roles/tests-unit/files/FWO.Test/NotificationLogEntryTest.cs new file mode 100644 index 0000000000..809ced326c --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationLogEntryTest.cs @@ -0,0 +1,46 @@ +using FWO.Data; +using Newtonsoft.Json; +using NUnit.Framework; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationLogEntryTest + { + [TestCase("None", NotificationDeadline.None)] + [TestCase("RecertDate", NotificationDeadline.RecertDate)] + [TestCase("RequestDate", NotificationDeadline.RequestDate)] + [TestCase("RuleExpiry", NotificationDeadline.RuleExpiry)] + [TestCase("DecommissionDate", NotificationDeadline.DecommissionDate)] + public void DeserializeDeadlineType_ReadsDatabaseEnumValue(string value, NotificationDeadline expected) + { + NotificationLogEntry? entry = JsonConvert.DeserializeObject( + $"{{\"deadline_type\":\"{value}\"}}"); + + Assert.That(entry, Is.Not.Null); + Assert.That(entry!.DeadlineType, Is.EqualTo(expected)); + } + + [Test] + public void SerializeDeadlineType_WritesDatabaseEnumValue() + { + string json = JsonConvert.SerializeObject(new NotificationLogEntry + { + DeadlineType = NotificationDeadline.RequestDate + }); + + Assert.That(json, Does.Contain("\"deadline_type\":\"RequestDate\"")); + } + + [Test] + public void SerializeStatus_WritesEnumName() + { + string json = JsonConvert.SerializeObject(new NotificationLogEntry + { + Status = NotificationLogStatus.Failed + }); + + Assert.That(json, Does.Contain("\"status\":\"Failed\"")); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs b/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs index 2ad5d9c24d..d4581acfe6 100644 --- a/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs +++ b/roles/tests-unit/files/FWO.Test/NotificationMutationHelperTest.cs @@ -24,9 +24,11 @@ public async Task AddAsync_PersistsNotificationAndReturnsAssignedId() Assert.That(persistedNotification.NotificationClient, Is.EqualTo(NotificationClient.Report)); Assert.That(persistedNotification.Name, Is.EqualTo(notification.Name)); Assert.That(persistedNotification.EmailAddressTo, Is.EqualTo(notification.EmailAddressTo)); + Assert.That(persistedNotification.Logging, Is.EqualTo(notification.Logging)); Assert.That(apiConnection.AddCalls[0].Client, Is.EqualTo(NotificationClient.Report.ToString())); Assert.That(apiConnection.AddCalls[0].Name, Is.EqualTo(notification.Name)); Assert.That(apiConnection.AddCalls[0].BundleId, Is.EqualTo(notification.BundleId)); + Assert.That(apiConnection.AddCalls[0].Logging, Is.EqualTo(notification.Logging)); }); } @@ -45,9 +47,11 @@ public async Task UpdateAsync_PersistsNotificationAndReturnsUpdatedCopy() Assert.That(apiConnection.UpdateCalls, Has.Count.EqualTo(1)); Assert.That(persistedNotification.Id, Is.EqualTo(17)); Assert.That(persistedNotification.EmailSubject, Is.EqualTo("Updated Subject")); + Assert.That(persistedNotification.Logging, Is.EqualTo(notification.Logging)); Assert.That(apiConnection.UpdateCalls[0].Id, Is.EqualTo(17)); Assert.That(apiConnection.UpdateCalls[0].Subject, Is.EqualTo("Updated Subject")); Assert.That(apiConnection.UpdateCalls[0].Layout, Is.EqualTo(notification.Layout.ToString())); + Assert.That(apiConnection.UpdateCalls[0].Logging, Is.EqualTo(notification.Logging)); }); } @@ -78,7 +82,8 @@ private static FwoNotification NewNotification() BundleType = BundleType.Attachments, BundleId = "bundle-1", Layout = NotificationLayout.PdfAsAttachment, - Deadline = NotificationDeadline.None + Deadline = NotificationDeadline.None, + Logging = NotificationLoggingMode.SendOnly }; } @@ -139,6 +144,7 @@ private sealed class NotificationMutationCall internal string Subject { get; init; } = ""; internal string Layout { get; init; } = ""; internal string? BundleId { get; init; } + internal string Logging { get; init; } = ""; internal static NotificationMutationCall From(object? variables) { @@ -149,7 +155,8 @@ internal static NotificationMutationCall From(object? variables) Name = ReadString(variables, "name"), Subject = ReadString(variables, "subject"), Layout = ReadString(variables, "layout"), - BundleId = ReadNullableString(variables, "bundleId") + BundleId = ReadNullableString(variables, "bundleId"), + Logging = ReadString(variables, "logging") }; } diff --git a/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs b/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs new file mode 100644 index 0000000000..7118aece77 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationPlaceholderResolverTest.cs @@ -0,0 +1,75 @@ +using FWO.Data; +using FWO.Services; +using NUnit.Framework; +using static FWO.Basics.Placeholder; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationPlaceholderResolverTest + { + [Test] + public void ReplaceNotificationPlaceholders_UsesRequestingOwnerWhenRequesterNameIsMissing() + { + FwoOwner application = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + FwoOwner requestingOwner = new() + { + Name = "Requester", + ExtAppId = "APP-2" + }; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + REQUESTER, + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + RequestingOwner = requestingOwner + }); + + Assert.That(text, Is.EqualTo("Requester")); + } + + [Test] + public void ReplaceNotificationPlaceholders_RendersHtmlLinkWithoutLinkName() + { + FwoOwner application = new() + { + Name = "Selected", + ExtAppId = "APP-1" + }; + string interfaceUrl = "https://ui.example.test/networkmodelling/APP-1/99"; + + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + INTERFACE_LINK, + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + Application = application, + InterfaceLinkText = "Interface Request", + InterfaceLinkUrl = interfaceUrl + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo($"Interface Request")); + } + + [Test] + public void ReplaceNotificationPlaceholders_EncodesHtmlLinkAttributesAndText() + { + string text = NotificationPlaceholderResolver.ReplaceNotificationPlaceholders( + INTERFACE_LINK, + new NotificationPlaceholderResolver.NotificationPlaceholderValues + { + InterfaceLinkText = "Interface", + InterfaceLinkName = "x\">", + InterfaceLinkUrl = "https://example.test/interface?id=\"&mode=full" + }, + renderHtmlLinks: true); + + Assert.That(text, Is.EqualTo("Interface: x"><script>alert(1)</script>")); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs b/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs new file mode 100644 index 0000000000..8646bfe2e4 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/NotificationScheduleHelperTest.cs @@ -0,0 +1,59 @@ +using FWO.Data; +using FWO.Services; +using NUnit.Framework; + +namespace FWO.Test +{ + [TestFixture] + internal class NotificationScheduleHelperTest + { + [Test] + public void IsNotificationDue_ReturnsTrueForNoneDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.None + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), null, notification), Is.True); + } + + [Test] + public void IsNotificationDue_ReturnsFalseForInactiveNotification() + { + FwoNotification notification = new() + { + Active = false, + Deadline = NotificationDeadline.None + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), null, notification), Is.False); + } + + [Test] + public void IsNotificationDue_ReturnsTrueForImmediateRequestDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now, notification), Is.True); + } + + [Test] + public void IsNotificationDue_ReturnsFalseForFutureRequestDeadline() + { + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RequestDate, + IntervalBeforeDeadline = SchedulerInterval.Days, + OffsetBeforeDeadline = 0 + }; + + Assert.That(NotificationScheduleHelper.IsNotificationDue(new FwoOwner(), DateTime.Now.AddDays(1), notification), Is.False); + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/NotificationTest.cs b/roles/tests-unit/files/FWO.Test/NotificationTest.cs index 5cf5e1eed6..85a439ce15 100644 --- a/roles/tests-unit/files/FWO.Test/NotificationTest.cs +++ b/roles/tests-unit/files/FWO.Test/NotificationTest.cs @@ -29,7 +29,18 @@ internal class NotificationTest private static readonly string[] kJsonRecipients = ["json@example.test"]; private static readonly string[] kMainRecipients = ["main@example.test"]; private static readonly NotificationDeadline[] kNoneDeadline = [NotificationDeadline.None]; - private static readonly Type[] kCollectRecipientsParameterTypes = [typeof(FwoNotification), typeof(FwoOwner), typeof(bool), typeof(bool)]; + private static readonly NotificationDeadline[] kInterfaceRequestDeadlines = [NotificationDeadline.None, NotificationDeadline.RequestDate]; + private static readonly Type[] kCollectRecipientsParameterTypes = + [typeof(FwoNotification), typeof(FwoOwner), typeof(UiUser), typeof(bool), typeof(bool)]; + + [SetUp] + public void ResetApiConnectionState() + { + apiConnection.UpdatedNotificationIds.Clear(); + apiConnection.NotificationLogEntries.Clear(); + apiConnection.NotificationLogUpdates.Clear(); + apiConnection.RefreshedNotificationLogIds.Clear(); + } [Test] public async Task TestInterfaceRequestNotification() @@ -37,14 +48,13 @@ public async Task TestInterfaceRequestNotification() List ownerGroups = []; NotificationService notificationService = await NotificationService.CreateAsync(NotificationClient.InterfaceRequest, globalConfig, apiConnection, ownerGroups); FwoOwner owner = new(); - int emailsSent = await notificationService.SendNotificationsIfDue(owner, DateTime.Now.AddDays(-8), EmailText); - ClassicAssert.AreEqual(2, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); ClassicAssert.AreEqual(2, await notificationService.UpdateNotificationsLastSent()); notificationService.Notifications[0].LastSent = DateTime.Now.AddDays(-1); emailsSent = await notificationService.SendNotificationsIfDue(owner, DateTime.Now.AddDays(-8), EmailText); - ClassicAssert.AreEqual(1, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); ClassicAssert.AreEqual(1, await notificationService.UpdateNotificationsLastSent()); notificationService.Notifications[1].LastSent = DateTime.Now.AddDays(-8); @@ -54,11 +64,65 @@ public async Task TestInterfaceRequestNotification() notificationService.Notifications[1].InitialOffsetAfterDeadline = 7; emailsSent = await notificationService.SendNotificationsIfDue(owner, DateTime.Now.AddDays(-15), EmailText); - ClassicAssert.AreEqual(1, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); notificationService.Notifications[1].InitialOffsetAfterDeadline = -7; emailsSent = await notificationService.SendNotificationsIfDue(owner, DateTime.Now.AddDays(-1), EmailText); - ClassicAssert.AreEqual(1, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); + } + + [Test] + public async Task SendNotification_SkipsInactiveNotification() + { + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, globalConfig, apiConnection, []); + FwoNotification notification = notificationService.Notifications[0]; + notification.Active = false; + + int emailsSent = await notificationService.SendNotification(notification, new FwoOwner(), EmailText); + + Assert.That(emailsSent, Is.Zero); + } + + [Test] + public async Task UpdateNotificationsLastSent_MixedResultsUpdatesOnlyDeliveredNotification() + { + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, globalConfig, apiConnection, []); + FwoNotification deliveredNotification = notificationService.Notifications[0]; + FwoNotification failedNotification = notificationService.Notifications[1]; + NotificationDeliveryResult deliveredResult = NotificationDeliveryResult.Delivered; + NotificationDeliveryResult failedResult = NotificationDeliveryResult.Failed; + MethodInfo addCheckedNotificationId = typeof(NotificationService).GetMethod( + "AddCheckedNotificationId", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(NotificationService).FullName, "AddCheckedNotificationId"); + object?[] addIdArguments = [deliveredNotification.Id]; + addCheckedNotificationId.Invoke(notificationService, addIdArguments); + int updatedNotifications = await notificationService.UpdateNotificationsLastSent(); + + List expectedNotificationIds = [deliveredNotification.Id]; + Assert.Multiple(() => + { + Assert.That(deliveredResult, Is.EqualTo(NotificationDeliveryResult.Delivered)); + Assert.That(failedResult, Is.EqualTo(NotificationDeliveryResult.Failed)); + Assert.That(updatedNotifications, Is.EqualTo(1)); + Assert.That(apiConnection.UpdatedNotificationIds, Is.EqualTo(expectedNotificationIds)); + Assert.That(apiConnection.UpdatedNotificationIds, Does.Not.Contain(failedNotification.Id)); + }); + } + + [Test] + public async Task SendBundledNotifications_SkipsInactiveNotifications() + { + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, globalConfig, apiConnection, []); + FwoNotification notification = notificationService.Notifications[0]; + notification.Active = false; + + int emailsSent = await notificationService.SendBundledNotifications( + [notification], new FwoOwner(), EmailText); + + Assert.That(emailsSent, Is.Zero); } [Test] @@ -69,7 +133,7 @@ public async Task TestRecertNotification() FwoOwner owner = new() { NextRecertDate = DateTime.Now.AddDays(21) }; int emailsSent = await notificationService.SendNotificationsIfDue(owner, null, EmailText, new ReportRecertEvent(new(""), UserConfig.ForTextOnly(globalConfig), Basics.ReportType.RecertificationEvent) { }); - ClassicAssert.AreEqual(1, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); notificationService.Notifications[0].LastSent = DateTime.Now; emailsSent = await notificationService.SendNotificationsIfDue(owner, null, EmailText); @@ -78,7 +142,7 @@ public async Task TestRecertNotification() notificationService.Notifications[0].LastSent = DateTime.Now.AddDays(-7); owner.NextRecertDate = DateTime.Now.AddDays(-7); emailsSent = await notificationService.SendNotificationsIfDue(owner, null, EmailText); - ClassicAssert.AreEqual(1, emailsSent); + ClassicAssert.AreEqual(0, emailsSent); notificationService.Notifications[0].LastSent = DateTime.Now.AddDays(-7); owner.NextRecertDate = DateTime.Now.AddDays(-14); @@ -208,7 +272,8 @@ public async Task SendNotification_UsesNotificationBodyWhenContentIsNull() MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + object?[] prepareEmailArguments = [notification, null, owner, null, "", null]; + Task task = (Task)(prepareEmail?.Invoke(notificationService, prepareEmailArguments) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -228,7 +293,7 @@ public async Task SendNotification_PrepareEmail_HandlesNullSubjectAndBody() MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -247,7 +312,7 @@ public async Task SendNotification_PrepareEmail_UsesContentPlaceholder_WhenPrese MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, "\r\n\r\nMgmt A: 5 changes", owner, null, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, "\r\n\r\nMgmt A: 5 changes", owner, null, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -268,7 +333,7 @@ public async Task SendNotification_PrepareEmail_AppendsHtmlReportBody_WhenLayout MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, report, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, report, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -292,7 +357,7 @@ public async Task SendNotification_PrepareEmail_IncludesBccRecipients() MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -320,7 +385,7 @@ public async Task SendNotification_PrepareEmail_IncludesCcRecipients() MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -349,7 +414,7 @@ public async Task SendNotification_PrepareEmail_AllowsNullBccFields() MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, "", null]) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -380,7 +445,8 @@ public async Task SendNotification_PrepareEmail_UsesToAddresses_WhenCcAndBccAreN MethodInfo? prepareEmail = typeof(NotificationService).GetMethod("PrepareEmail", BindingFlags.Instance | BindingFlags.NonPublic); ClassicAssert.IsNotNull(prepareEmail); - Task task = (Task)(prepareEmail?.Invoke(notificationService, [notification, null, owner, null, ""]) + object?[] prepareEmailArguments = [notification, null, owner, null, "", null]; + Task task = (Task)(prepareEmail?.Invoke(notificationService, prepareEmailArguments) ?? throw new InvalidOperationException("PrepareEmail returned null task.")); FWO.Mail.MailData mailData = await task; @@ -429,22 +495,26 @@ public async Task SendBundledNotifications_PrepareBundledEmail_AddsAllBundleAtta public async Task SendBundledNotifications_GroupsBundleAndTracksAllNotificationIds() { SimulatedGlobalConfig localConfig = new() { UseDummyEmailAddress = true, DummyEmailAddress = "x@y.de" }; + NotificationTestApiConn bundledApiConnection = new(); NotificationService notificationService = await NotificationService.CreateAsync( NotificationClient.InterfaceRequest, localConfig, - new NotificationTestApiConn(), + bundledApiConnection, new List()); FwoOwner owner = new() { Name = "Owner", ExtAppId = "1" }; FwoNotification bundledA = notificationService.Notifications[0]; bundledA.BundleType = BundleType.Attachments; bundledA.BundleId = "bundle-1"; + bundledA.Logging = NotificationLoggingMode.LogOnly; FwoNotification bundledB = notificationService.Notifications[1]; bundledB.BundleType = BundleType.Attachments; bundledB.BundleId = "bundle-1"; + bundledB.Logging = NotificationLoggingMode.LogOnly; FwoNotification standalone = new() { Id = 99, + Logging = NotificationLoggingMode.LogOnly, RecipientTo = EmailRecipientOption.OtherAddresses, EmailAddressTo = "single@example.test", EmailSubject = "single subject", @@ -454,11 +524,135 @@ public async Task SendBundledNotifications_GroupsBundleAndTracksAllNotificationI List bundledNotifications = new() { bundledA, bundledB, standalone }; int emailsSent = await notificationService.SendBundledNotifications(bundledNotifications, owner, "body"); int updatedNotifications = await notificationService.UpdateNotificationsLastSent(); + List expectedUpdatedNotificationIds = [bundledA.Id, bundledB.Id, standalone.Id]; Assert.Multiple(() => { - Assert.That(emailsSent, Is.EqualTo(2)); - Assert.That(updatedNotifications, Is.EqualTo(3)); + Assert.That(emailsSent, Is.Zero); + Assert.That(updatedNotifications, Is.EqualTo(expectedUpdatedNotificationIds.Count)); + Assert.That(bundledApiConnection.UpdatedNotificationIds, Is.EqualTo(expectedUpdatedNotificationIds)); + }); + } + + [Test] + public async Task SendBundledNotifications_MixedLoggingUsesOnlySendableRecipients() + { + apiConnection.NotificationLogUpdates.Clear(); + SimulatedGlobalConfig localConfig = new() { UseDummyEmailAddress = false }; + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, + localConfig, + apiConnection, + new List()); + FwoNotification sendable = notificationService.Notifications[0]; + sendable.BundleType = BundleType.Attachments; + sendable.BundleId = "mixed-bundle"; + sendable.Logging = NotificationLoggingMode.SendAndLog; + sendable.RecipientTo = EmailRecipientOption.None; + FwoNotification suppressed = notificationService.Notifications[1]; + suppressed.BundleType = BundleType.Attachments; + suppressed.BundleId = "mixed-bundle"; + suppressed.Logging = NotificationLoggingMode.LogOnly; + + MethodInfo bundledEmailMethod = typeof(NotificationService).GetMethod("SendBundledEmail", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException(typeof(NotificationService).FullName, "SendBundledEmail"); + object?[] bundledEmailArguments = + [ + new List { sendable, suppressed }, + "body", + new FwoOwner(), + null, + "" + ]; + Task bundledEmailTask = (Task)(bundledEmailMethod.Invoke( + notificationService, bundledEmailArguments) + ?? throw new InvalidOperationException("SendBundledEmail returned null task.")); + NotificationDeliveryResult result = await bundledEmailTask; + + List expectedStatuses = + [ + NotificationLogStatus.Suppressed, + NotificationLogStatus.Failed + ]; + Assert.That(result, Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(apiConnection.NotificationLogUpdates.Select(update => update.Status), + Is.EqualTo(expectedStatuses)); + } + + [Test] + public async Task SendNotification_NoRecipientsLogsFailureOnceAndAdvancesLastSent() + { + apiConnection.NotificationLogUpdates.Clear(); + SimulatedGlobalConfig localConfig = new() { UseDummyEmailAddress = false }; + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, localConfig, apiConnection, []); + FwoNotification notification = notificationService.Notifications[0]; + notification.Logging = NotificationLoggingMode.SendAndLog; + notification.RecipientTo = EmailRecipientOption.None; + + NotificationDeliveryResult result = await notificationService.SendNotificationWithResult( + notification, new FwoOwner(), "body"); + int updatedNotifications = await notificationService.UpdateNotificationsLastSent(); + NotificationDeliveryResult repeatedResult = await notificationService.SendNotificationWithResult( + notification, new FwoOwner(), "body"); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(repeatedResult, Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(updatedNotifications, Is.EqualTo(1)); + Assert.That(apiConnection.NotificationLogUpdates, Has.Count.EqualTo(1)); + Assert.That(apiConnection.RefreshedNotificationLogIds, Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task SendNotification_NoRecipientsMatchesExactNonNullDeadline() + { + SimulatedGlobalConfig localConfig = new() { UseDummyEmailAddress = false }; + NotificationService notificationService = await NotificationService.CreateAsync( + NotificationClient.InterfaceRequest, localConfig, apiConnection, []); + FwoNotification notification = notificationService.Notifications[0]; + notification.Logging = NotificationLoggingMode.SendAndLog; + notification.RecipientTo = EmailRecipientOption.None; + DateTime targetDeadline = DateTime.Now.AddDays(3); + DateTimeOffset targetDeadlineOffset = new(targetDeadline); + List existingEntries = + [ + new NotificationLogEntry + { + Id = 40, + NotificationId = notification.Id, + Subject = notification.EmailSubject, + DeadlineType = notification.Deadline, + Deadline = targetDeadlineOffset, + Timestamp = DateTimeOffset.UtcNow.AddMinutes(-2), + Status = NotificationLogStatus.Failed, + Error = "No recipients resolved." + }, + new NotificationLogEntry + { + Id = 41, + NotificationId = notification.Id, + Subject = notification.EmailSubject, + DeadlineType = notification.Deadline, + Deadline = targetDeadlineOffset.AddDays(1), + Timestamp = DateTimeOffset.UtcNow, + Status = NotificationLogStatus.Failed, + Error = "No recipients resolved." + } + ]; + apiConnection.NotificationLogEntries.AddRange(existingEntries); + + NotificationDeliveryResult result = await notificationService.SendNotificationWithResult( + notification, new FwoOwner(), "body", resolvedDeadline: targetDeadline); + List expectedRefreshedLogIds = [40]; + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(NotificationDeliveryResult.NoRecipients)); + Assert.That(apiConnection.RefreshedNotificationLogIds, Is.EqualTo(expectedRefreshedLogIds)); + Assert.That(apiConnection.NotificationLogEntries, Has.Count.EqualTo(2)); }); } @@ -568,10 +762,10 @@ public async Task CollectRecipientsSupportsJsonOtherAddressesAndConfiguredRespon MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] jsonArgs = [jsonNotification, owner, false, false]; + object?[] jsonArgs = [jsonNotification, owner, null, false, false]; Task> jsonTask = (Task>)(collectRecipients?.Invoke(notificationService, jsonArgs) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); - object?[] configuredArgs = [configuredNotification, owner, false, false]; + object?[] configuredArgs = [configuredNotification, owner, null, false, false]; Task> configuredTask = (Task>)(collectRecipients?.Invoke(notificationService, configuredArgs) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); @@ -590,8 +784,9 @@ public async Task CollectRecipientsSupportsJsonOtherAddressesAndConfiguredRespon [Test] public async Task CollectRecipientsReturnsDummyRecipientsWhenDummyEmailIsEnabled() { + SimulatedGlobalConfig localConfig = new() { UseDummyEmailAddress = true, DummyEmailAddress = "x@y.de" }; List ownerGroups = []; - NotificationService notificationService = await NotificationService.CreateAsync(NotificationClient.InterfaceRequest, globalConfig, apiConnection, ownerGroups); + NotificationService notificationService = await NotificationService.CreateAsync(NotificationClient.InterfaceRequest, localConfig, apiConnection, ownerGroups); FwoNotification notification = new() { NotificationClient = NotificationClient.InterfaceRequest, @@ -603,7 +798,7 @@ public async Task CollectRecipientsReturnsDummyRecipientsWhenDummyEmailIsEnabled MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] args = [notification, owner, false, false]; + object?[] args = [notification, owner, null, false, false]; Task> task = (Task>)(collectRecipients?.Invoke(notificationService, args) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); List recipients = await task; @@ -630,7 +825,7 @@ public async Task CollectRecipientsLogsWarningForConfiguredResponsiblesWhenNoRec MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] args = [notification, owner, false, false]; + object?[] args = [notification, owner, null, false, false]; Task> task = (Task>)(collectRecipients?.Invoke(notificationService, args) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); List recipients = await task; @@ -659,7 +854,7 @@ public async Task CollectRecipientsLogsWarningForJsonOtherAddressesWhenNoRecipie MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] args = [notification, null, false, false]; + object?[] args = [notification, null, null, false, false]; Task> task = (Task>)(collectRecipients?.Invoke(notificationService, args) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); List recipients = await task; @@ -689,7 +884,7 @@ public async Task CollectRecipientsLogsWarningForGenericRecipientOptionWhenNoRec MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] args = [notification, null, false, false]; + object?[] args = [notification, null, null, false, false]; Task> task = (Task>)(collectRecipients?.Invoke(notificationService, args) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); List recipients = await task; @@ -719,7 +914,7 @@ public async Task CollectRecipientsDoesNotWarnForNoneRecipientOption() MethodInfo? collectRecipients = GetCollectRecipientsMethod(); ClassicAssert.IsNotNull(collectRecipients); - object?[] args = [notification, null, false, false]; + object?[] args = [notification, null, null, false, false]; Task> task = (Task>)(collectRecipients?.Invoke(notificationService, args) ?? throw new InvalidOperationException("CollectRecipients returned null task.")); List recipients = await task; @@ -785,6 +980,18 @@ public void OfferedDeadlineOptions_ReturnsOnlyNone_ForWfAction() CollectionAssert.AreEqual(kNoneDeadline, FwoNotification.OfferedDeadlineOptions(NotificationClient.WfAction)); } + [Test] + public void OfferedDeadlineOptions_ReturnsNoneAndRequestDate_ForInterfaceRequest() + { + CollectionAssert.AreEqual(kInterfaceRequestDeadlines, FwoNotification.OfferedDeadlineOptions(NotificationClient.InterfaceRequest)); + } + + [Test] + public void OfferedDeadlineOptions_ReturnsOnlyNone_ForInterfaceDecomm() + { + CollectionAssert.AreEqual(kNoneDeadline, FwoNotification.OfferedDeadlineOptions(NotificationClient.InterfaceDecomm)); + } + [Test] public void NotificationClientGroups_ClassifiesWfActionAsWorkflowRecipientClient() { diff --git a/roles/tests-unit/files/FWO.Test/NotificationTestApiConn.cs b/roles/tests-unit/files/FWO.Test/NotificationTestApiConn.cs index 2edde43d10..87b8855c7e 100644 --- a/roles/tests-unit/files/FWO.Test/NotificationTestApiConn.cs +++ b/roles/tests-unit/files/FWO.Test/NotificationTestApiConn.cs @@ -2,11 +2,17 @@ using FWO.Api.Client.Queries; using FWO.Data; using FWO.Services; +using System.Reflection; namespace FWO.Test { internal class NotificationTestApiConn : SimulatedApiConnection { + public List<(int Id, NotificationLogStatus Status, string Error)> NotificationLogUpdates { get; } = []; + public List NotificationLogEntries { get; } = []; + public List UpdatedNotificationIds { get; } = []; + public List RefreshedNotificationLogIds { get; } = []; + readonly FwoNotification NotifReq1 = new() { Id = 1, @@ -93,6 +99,31 @@ public override async Task SendQueryAsync( } if (responseType == typeof(ReturnId)) { + if (query == NotificationQueries.updateNotificationsLastSent) + { + PropertyInfo? idsProperty = variables?.GetType().GetProperty("ids"); + if (idsProperty?.GetValue(variables) is IEnumerable ids) + { + UpdatedNotificationIds.AddRange(ids); + } + } + + if (query == NotificationQueries.updateNotificationLog) + { + int id = GetVariable(variables, "id"); + NotificationLogStatus status = Enum.Parse(GetVariable(variables, "status")); + string error = GetVariable(variables, "error"); + NotificationLogUpdates.Add((id, status, error)); + NotificationLogEntry? entry = NotificationLogEntries.FirstOrDefault(logEntry => logEntry.Id == id); + if (entry != null) + { + entry.Status = status; + entry.Error = error; + } + GraphQLResponse updateResponse = new() { Data = new ReturnId() { AffectedRows = 1 } }; + return updateResponse.Data; + } + int notifCount = 0; var idsProp = variables?.GetType().GetProperty("ids"); if (idsProp != null) @@ -106,6 +137,72 @@ public override async Task SendQueryAsync( GraphQLResponse response = new() { Data = new ReturnId() { AffectedRows = notifCount } }; return response.Data; } + if (responseType == typeof(ReturnIdWrapper) && query == NotificationQueries.insertNotificationLog) + { + if (variables?.GetType().GetProperty("entries")?.GetValue(variables) + is IEnumerable entries) + { + foreach (NotificationLogInsertEntry entry in entries) + { + NotificationLogEntries.Add(new NotificationLogEntry + { + Id = NotificationLogEntries.Count + 1, + NotificationId = entry.NotificationId, + Subject = entry.Subject, + DeadlineType = entry.DeadlineType, + Deadline = entry.Deadline, + Status = NotificationLogStatus.Pending + }); + } + } + GraphQLResponse response = new() + { + Data = new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = NotificationLogEntries.LastOrDefault()?.Id ?? 1 }] + } + }; + return response.Data; + } + if (responseType == typeof(ReturnIdWrapper) && query == NotificationQueries.refreshNotificationLog) + { + int id = GetVariable(variables, "id"); + NotificationLogEntry? entry = NotificationLogEntries.FirstOrDefault(logEntry => logEntry.Id == id); + if (entry != null) + { + entry.Timestamp = DateTimeOffset.UtcNow; + } + RefreshedNotificationLogIds.Add(id); + GraphQLResponse response = new() + { + Data = new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = id }] + } + }; + return response.Data; + } + if (responseType == typeof(List) + && (query == NotificationQueries.getNoRecipientNotificationLogs + || query == NotificationQueries.getNoRecipientNotificationLogsWithoutDeadline)) + { + int notificationId = GetVariable(variables, "notificationId"); + string subject = GetVariable(variables, "subject"); + string deadlineType = GetVariable(variables, "deadlineType"); + bool withoutDeadline = query == NotificationQueries.getNoRecipientNotificationLogsWithoutDeadline; + DateTimeOffset? deadline = withoutDeadline ? null : GetVariable(variables, "deadline"); + return (QueryResponseType)(object)NotificationLogEntries + .Where(entry => entry.NotificationId == notificationId + && entry.Status == NotificationLogStatus.Failed + && entry.Error == "No recipients resolved." + && entry.Subject == subject + && entry.DeadlineType.ToString() == deadlineType + && entry.Deadline == deadline) + .OrderByDescending(entry => entry.Timestamp) + .ThenByDescending(entry => entry.Id) + .Take(1) + .ToList(); + } if (responseType == typeof(List) && query == AuthQueries.getUserEmails) { GraphQLResponse response = new() { Data = new List() }; @@ -120,6 +217,12 @@ public override async Task SendQueryAsync( throw new NotImplementedException(); } + private static T GetVariable(object? variables, string name) + { + return (T)(variables?.GetType().GetProperty(name)?.GetValue(variables) + ?? throw new InvalidOperationException($"Missing notification-log variable '{name}'.")); + } + private static FwoNotification CloneNotification(FwoNotification notification) { return new FwoNotification @@ -149,7 +252,8 @@ private static FwoNotification CloneNotification(FwoNotification notification) InitialOffsetAfterDeadline = notification.InitialOffsetAfterDeadline, RepeatOffsetAfterDeadline = notification.RepeatOffsetAfterDeadline, RepetitionsAfterDeadline = notification.RepetitionsAfterDeadline, - LastSent = notification.LastSent + LastSent = notification.LastSent, + Logging = NotificationLoggingMode.LogOnly }; } } diff --git a/roles/tests-unit/files/FWO.Test/RecertCheckTest.cs b/roles/tests-unit/files/FWO.Test/RecertCheckTest.cs index cd0d734835..57a349c9f7 100644 --- a/roles/tests-unit/files/FWO.Test/RecertCheckTest.cs +++ b/roles/tests-unit/files/FWO.Test/RecertCheckTest.cs @@ -6,12 +6,11 @@ using FWO.Config.Api; using FWO.Config.Api.Data; using FWO.Data; -using FWO.Data.Middleware; using FWO.Data.Report; using FWO.Data.Workflow; -using FWO.Mail; using FWO.Middleware.Server; using FWO.Middleware.Server.Services; +using FWO.Report; using NUnit.Framework; namespace FWO.Test @@ -21,13 +20,8 @@ internal class RecertCheckTest { private static readonly string kUpcomingText = "upcoming for " + Placeholder.APPNAME; private static readonly string kOverdueText = "overdue for " + Placeholder.APPNAME; - private static readonly string[] kDummyEmailAddresses = ["dummy@example.test"]; - private static readonly string[] kResolvedEmailAddresses = ["alpha@example.test", "beta@example.test"]; - private static readonly string[] kResolvedOwnerDns = ["cn=alpha,dc=test", "cn=beta,dc=test"]; private static readonly string[] kExpectedQueries = [ - AuthQueries.getLdapConnections, - AuthQueries.getUsers, OwnerQueries.getOwners ]; @@ -36,11 +30,6 @@ public async Task CheckRecertifications_ReturnsZeroWhenInternalGroupsAreMissing( { RecertCheckApiConnection apiConnection = new() { - Ldaps = [], - Users = - [ - new UiUser { Dn = "cn=user,dc=test", Email = "user@example.test" } - ], Owners = [ new FwoOwner { Id = 1, Name = "Owner A", RecertActive = true } @@ -58,11 +47,11 @@ public async Task CheckRecertifications_ReturnsZeroWhenInternalGroupsAreMissing( } [Test] - public async Task CheckRecertifications_ReturnsZeroWhenInitEnvThrows() + public async Task CheckRecertifications_ReturnsZeroWhenOwnerLoadingThrows() { RecertCheckApiConnection apiConnection = new() { - ThrowOnLdapConnections = true + ThrowOnOwners = true }; RecertCheck recertCheck = CreateRecertCheck(apiConnection, CreateGlobalConfig()); @@ -72,23 +61,60 @@ public async Task CheckRecertifications_ReturnsZeroWhenInitEnvThrows() { Assert.That(emailsSent, Is.Zero); Assert.That(apiConnection.Queries, Has.Count.EqualTo(1)); - Assert.That(apiConnection.Queries[0], Is.EqualTo(AuthQueries.getLdapConnections)); + Assert.That(apiConnection.Queries[0], Is.EqualTo(OwnerQueries.getOwners)); }); } [Test] - public async Task InitEnv_LoadsGlobalParamsAndQueryData() + public async Task CheckRecertifications_DoesNotCountButMarksLogOnlyNotificationAsProcessed() { RecertCheckApiConnection apiConnection = new() { - Ldaps = + Owners = [ - new Ldap() + new FwoOwner + { + Id = 1, + Name = "Owner A", + RecertActive = true, + LastRecertCheck = DateTime.Today.AddDays(-8), + NextRecertDate = DateTime.Today.AddDays(-1) + } ], - Users = + Notifications = [ - new UiUser { Dn = "cn=user,dc=test", Email = "user@example.test" } - ], + new FwoNotification + { + Id = 7, + NotificationClient = NotificationClient.Recertification, + Deadline = NotificationDeadline.None, + Logging = NotificationLoggingMode.LogOnly, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "owner@example.test", + EmailSubject = "recertification", + EmailBody = "body" + } + ] + }; + + RecertCheck recertCheck = CreateRecertCheck(apiConnection, CreateGlobalConfig()); + + int emailsSent = await recertCheck.CheckRecertifications(); + + Assert.Multiple(() => + { + Assert.That(emailsSent, Is.Zero); + Assert.That(apiConnection.LastUpdatedNotificationIdCount, Is.EqualTo(1)); + Assert.That(apiConnection.UpdatedOwnerIds, Is.Empty); + Assert.That(apiConnection.Queries, Does.Contain(NotificationQueries.getNotifications)); + }); + } + + [Test] + public async Task InitEnv_LoadsGlobalParamsAndQueryData() + { + RecertCheckApiConnection apiConnection = new() + { Owners = [ new FwoOwner { Id = 7, Name = "Owner B" } @@ -100,7 +126,6 @@ public async Task InitEnv_LoadsGlobalParamsAndQueryData() await InvokePrivateTask(recertCheck, "InitEnv"); RecertCheckParams? loadedParams = GetPrivateField(recertCheck, "globCheckParams"); - List uiUsers = GetPrivateField>(recertCheck, "uiUsers"); List owners = GetPrivateField>(recertCheck, "owners"); Assert.Multiple(() => @@ -108,7 +133,6 @@ public async Task InitEnv_LoadsGlobalParamsAndQueryData() Assert.That(loadedParams, Is.Not.Null); Assert.That(loadedParams!.RecertCheckInterval, Is.EqualTo(SchedulerInterval.Days)); Assert.That(loadedParams.RecertCheckOffset, Is.EqualTo(7)); - Assert.That(uiUsers, Has.Count.EqualTo(1)); Assert.That(owners, Has.Count.EqualTo(1)); }); } @@ -266,81 +290,6 @@ public void IsRecertCheckTime_ThrowsForUnsupportedInterval() Assert.That(exception.InnerException, Is.TypeOf()); } - [Test] - public async Task CollectEmailAddresses_UsesDummyAddress() - { - SimulatedGlobalConfig globalConfig = CreateGlobalConfig(); - globalConfig.UseDummyEmailAddress = true; - globalConfig.DummyEmailAddress = "dummy@example.test"; - RecertCheck recertCheck = CreateRecertCheck(new RecertCheckApiConnection(), globalConfig); - - List result = await InvokePrivateAsync>(recertCheck, "CollectEmailAddresses", new FwoOwner()); - - Assert.That(result, Is.EquivalentTo(kDummyEmailAddresses)); - } - - [Test] - public async Task CollectEmailAddresses_ResolvesConfiguredEmails() - { - RecertCheckApiConnection apiConnection = new(); - SimulatedGlobalConfig globalConfig = CreateGlobalConfig(); - globalConfig.UseDummyEmailAddress = false; - RecertCheck recertCheck = CreateRecertCheck(apiConnection, globalConfig); - SetPrivateField(recertCheck, "connectedLdaps", new List - { - new Ldap - { - UserSearchPath = "", - GroupSearchPath = "" - } - }); - SetPrivateField(recertCheck, "uiUsers", new List - { - new() { Dn = "cn=alpha,dc=test", Email = "alpha@example.test" }, - new() { Dn = "cn=beta,dc=test", Email = "beta@example.test" } - }); - - FwoOwner owner = new(); - owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=alpha,dc=test"); - owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeSupporting, "cn=beta,dc=test"); - - List result = await InvokePrivateAsync>(recertCheck, "CollectEmailAddresses", owner); - - Assert.That(result, Is.EquivalentTo(kResolvedEmailAddresses)); - } - - [Test] - public async Task ResolveOwnerUserDns_ReturnsDirectDnsWhenNoGroupHandlingIsConfigured() - { - RecertCheck recertCheck = CreateRecertCheck(new RecertCheckApiConnection(), CreateGlobalConfig()); - SetPrivateField(recertCheck, "connectedLdaps", new List - { - new Ldap - { - UserSearchPath = "", - GroupSearchPath = "" - } - }); - - FwoOwner owner = new(); - owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeMain, "cn=alpha,dc=test"); - owner.AddOwnerResponsible(GlobalConst.kOwnerResponsibleTypeSupporting, "cn=beta,dc=test"); - - List result = await InvokePrivateAsync>(recertCheck, "ResolveOwnerUserDns", owner); - - Assert.That(result, Is.EquivalentTo(kResolvedOwnerDns)); - } - - [Test] - public async Task ResolveOwnerUserDns_ReturnsEmptyWhenNoResponsiblesExist() - { - RecertCheck recertCheck = CreateRecertCheck(new RecertCheckApiConnection(), CreateGlobalConfig()); - - List result = await InvokePrivateAsync>(recertCheck, "ResolveOwnerUserDns", new FwoOwner()); - - Assert.That(result, Is.Empty); - } - [Test] public void PrepareOwnerBody_ChoosesUpcomingAndOverdueMessages() { @@ -404,27 +353,6 @@ public void PrepareLine_UsesEmptyDatePrefixWhenRecertDateMissing() Assert.That(line, Does.StartWith(": Device A: No date rule:UID-NO-DATE")); } - [Test] - public async Task PrepareRulesEmail_ReturnsMailDataWithExpectedSubjectAndBody() - { - SimulatedGlobalConfig globalConfig = CreateGlobalConfig(); - globalConfig.UseDummyEmailAddress = true; - RecertCheck recertCheck = CreateRecertCheck(new RecertCheckApiConnection(), globalConfig); - - MailData mailData = await InvokePrivateAsync(recertCheck, "PrepareRulesEmail", new FwoOwner - { - Name = "Owner A" - }, new List { CreateRule("Upcoming Rule", "UID-UP", new DateTime(2026, 7, 24)) }, new List()); - - Assert.Multiple(() => - { - Assert.That(mailData.Subject, Is.EqualTo("Recertification check Owner A")); - Assert.That(mailData.To, Is.EquivalentTo(kDummyEmailAddresses)); - Assert.That(mailData.Body, Does.Contain("Upcoming Rule")); - Assert.That(mailData.Body, Does.Contain("Owner A")); - }); - } - [Test] public async Task GenerateRulesRecertificationReport_ReturnsEmptyWhenDeviceLookupThrows() { @@ -461,6 +389,24 @@ public async Task SetOwnerLastCheck_SendsMutationWithOwnerId() Assert.That(apiConnection.Queries, Does.Contain(OwnerQueries.setOwnerLastCheck)); } + [Test] + public async Task PrepareOwnerReport_ReturnsNullWhenNoReportDataExists() + { + RecertCheckApiConnection apiConnection = new(); + SimulatedGlobalConfig globalConfig = CreateGlobalConfig(); + RecertCheck recertCheck = CreateRecertCheck(apiConnection, globalConfig); + using UserConfig userConfig = UserConfig.ForGlobalSettings( + globalConfig, apiConnection, globalConfig.DefaultLanguage); + + ReportBase? report = await InvokePrivateAsync( + recertCheck, + "PrepareOwnerReport", + new FwoOwner { Id = 42, Name = "Owner A" }, + userConfig); + + Assert.That(report, Is.Null); + } + private static SimulatedGlobalConfig CreateGlobalConfig() { return new SimulatedGlobalConfig @@ -554,12 +500,13 @@ private static Rule CreateRule(string name, string ruleUid, DateTime nextRecertD private sealed class RecertCheckApiConnection : JobTestApiConnectionBase { - public bool ThrowOnLdapConnections { get; set; } + public bool ThrowOnOwners { get; set; } public bool ThrowOnDevicesQuery { get; set; } - public List Ldaps { get; set; } = []; - public List Users { get; set; } = []; public List Owners { get; set; } = []; public List Managements { get; set; } = []; + public List Notifications { get; set; } = []; + public List UpdatedOwnerIds { get; } = []; + public int LastUpdatedNotificationIdCount { get; private set; } protected override Task HandleQueryAsync( string query, @@ -577,23 +524,23 @@ protected override Task HandleQueryAsync( return Task.FromResult((QueryResponseType)(object)new List()); } - if (query == AuthQueries.getLdapConnections && typeof(QueryResponseType) == typeof(List)) + if (query == OwnerQueries.getOwners && typeof(QueryResponseType) == typeof(List)) { - if (ThrowOnLdapConnections) + if (ThrowOnOwners) { throw new InvalidOperationException("boom"); } - return Task.FromResult((QueryResponseType)(object)Ldaps); + return Task.FromResult((QueryResponseType)(object)Owners); } - if (query == AuthQueries.getUsers && typeof(QueryResponseType) == typeof(List)) + if (query == NotificationQueries.getNotifications && typeof(QueryResponseType) == typeof(List)) { - return Task.FromResult((QueryResponseType)(object)Users); + return Task.FromResult((QueryResponseType)(object)Notifications); } - if (query == OwnerQueries.getOwners && typeof(QueryResponseType) == typeof(List)) + if (query == AuthQueries.getLdapConnections && typeof(QueryResponseType) == typeof(List)) { - return Task.FromResult((QueryResponseType)(object)Owners); + return Task.FromResult((QueryResponseType)(object)new List()); } if (query == DeviceQueries.getDevicesByManagement && typeof(QueryResponseType) == typeof(List)) @@ -607,11 +554,40 @@ protected override Task HandleQueryAsync( if (query == OwnerQueries.setOwnerLastCheck) { + if (variables?.GetType().GetProperty("id")?.GetValue(variables) is int ownerId) + { + UpdatedOwnerIds.Add(ownerId); + } return Task.FromResult(default(QueryResponseType)!); } + if (query == NotificationQueries.updateNotificationsLastSent && typeof(QueryResponseType) == typeof(ReturnId)) + { + LastUpdatedNotificationIdCount = CountIds(variables); + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = LastUpdatedNotificationIdCount }); + } + + if (query == NotificationQueries.insertNotificationLog && typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = 1 }] + }); + } + + if (query == NotificationQueries.updateNotificationLog && typeof(QueryResponseType) == typeof(ReturnId)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + throw new InvalidOperationException($"Unexpected query in recert check test: {query}"); } + + private static int CountIds(object? variables) + { + object? ids = variables?.GetType().GetProperty("ids")?.GetValue(variables); + return ids is System.Collections.ICollection collection ? collection.Count : 0; + } } } } diff --git a/roles/tests-unit/files/FWO.Test/ReportJobTest.cs b/roles/tests-unit/files/FWO.Test/ReportJobTest.cs index 11f54487e5..08830d8ad7 100644 --- a/roles/tests-unit/files/FWO.Test/ReportJobTest.cs +++ b/roles/tests-unit/files/FWO.Test/ReportJobTest.cs @@ -470,7 +470,7 @@ public async Task TrySendReportViaEmail_DoesNothing_WhenUserConfigIsNull() } [Test] - public async Task TrySendReportViaEmail_LoadsNotificationsAndUpdatesLastSent_WhenConfigured() + public async Task TrySendReportViaEmail_LoadsNotificationsAndSkipsLastSentUpdate_WhenNoneAreConfigured() { ReportJobApiConnection apiConnection = new() { @@ -486,8 +486,8 @@ public async Task TrySendReportViaEmail_LoadsNotificationsAndUpdatesLastSent_Whe await reportJob.TrySendReportViaEmail(reportSchedule, report, userConfig); - Assert.That(apiConnection.LastQuery, Is.EqualTo(NotificationQueries.updateNotificationsLastSent)); - Assert.That(apiConnection.QueryCount, Is.EqualTo(2)); + Assert.That(apiConnection.LastQuery, Is.EqualTo(NotificationQueries.getNotifications)); + Assert.That(apiConnection.QueryCount, Is.EqualTo(1)); } [Test] diff --git a/roles/tests-unit/files/FWO.Test/RuleExpiryCheckTest.cs b/roles/tests-unit/files/FWO.Test/RuleExpiryCheckTest.cs index beb1095ded..faa3db9964 100644 --- a/roles/tests-unit/files/FWO.Test/RuleExpiryCheckTest.cs +++ b/roles/tests-unit/files/FWO.Test/RuleExpiryCheckTest.cs @@ -50,7 +50,7 @@ public async Task CheckRuleExpiry_SendsNotification_WhenExpiredRuleIsDue() int sentEmails = await check.CheckRuleExpiry(); - ClassicAssert.AreEqual(1, sentEmails); + ClassicAssert.AreEqual(0, sentEmails); ClassicAssert.AreEqual(1, apiConnection.LastUpdatedNotificationIdCount); } @@ -153,8 +153,8 @@ public async Task CheckRuleExpiry_AggregatesByOwner_AndAppliesOwnerScopedNotific int sentEmails = await check.CheckRuleExpiry(); - ClassicAssert.AreEqual(3, sentEmails, "Expected one global mail per owner plus one owner-scoped mail for owner 2."); - ClassicAssert.AreEqual(2, apiConnection.LastUpdatedNotificationIdCount, "Only unique notification ids should be marked as sent."); + ClassicAssert.AreEqual(0, sentEmails, "The configured rule-expiry notifications are log-only."); + ClassicAssert.AreEqual(2, apiConnection.LastUpdatedNotificationIdCount, "Processed log-only notifications must advance last_sent."); } [Test] @@ -422,6 +422,7 @@ private static FwoNotification CreateRuleTimerNotification(int id, int? ownerId Id = id, OwnerId = ownerId, Deadline = NotificationDeadline.RuleExpiry, + Logging = NotificationLoggingMode.LogOnly, RecipientTo = EmailRecipientOption.OtherAddresses, EmailAddressTo = "x@y.de", EmailSubject = "rule expiry", @@ -479,6 +480,19 @@ public override Task SendQueryAsync(string return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = LastUpdatedNotificationIdCount }); } + if (responseType == typeof(ReturnIdWrapper) && query == NotificationQueries.insertNotificationLog) + { + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = [new ReturnId { Id = 1 }] + }); + } + + if (responseType == typeof(ReturnId) && query == NotificationQueries.updateNotificationLog) + { + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = 1 }); + } + if (query == RuleQueries.getTimeBasedRulesByOwner && responseType.IsGenericType && responseType.GetGenericTypeDefinition() == typeof(List<>)) { object result = BuildExpiredRuleResponse(responseType); diff --git a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs index 06e467f04f..859a9d7caf 100644 --- a/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs +++ b/roles/tests-unit/files/FWO.Test/SimulatedUserConfig.cs @@ -110,6 +110,7 @@ internal class SimulatedUserConfig : UserConfig {"flow_general_settings","General Settings"}, {"flow_naming","Flow naming source ranking"}, {"H5601","On this page the general modelling settings are administrated. This includes predefined services, display options, naming conventions and the remaining modelling settings. The extracted notifications are available on the adjacent notifications page."}, + {"predef_services", "Predefined Services"}, {"H5839","Matrix violation"}, {"H5840","Restricted Service"}, {"H5841","Not assessable"}, @@ -376,6 +377,7 @@ internal class SimulatedUserConfig : UserConfig {"H7423","If requested interface problems still have open tickets, the administrator can close those tickets together as rejected." }, {"H7424","The button \"Open tickets with requested interface problems\" opens a separate list of all still open new-interface tickets whose requested interface linkage is missing, broken, removed, already published, or suspicious. The check is performed only when opening the popup, so it is independent from the current table filter. Depending on the problem, tickets can be closed as rejected or as done." }, {"reject_interface","Reject interface" }, + {"E9031","Not all notifications could be sent to App responsibles." }, {"requesting_app","Requesting App" }, {"requesting_ext_app_id","Requesting Ext App Id" }, {"used_in_connection_ids","Used In Connection Ids" }, @@ -396,6 +398,7 @@ internal class SimulatedUserConfig : UserConfig {"U9038","Closed as done by Admin" }, {"U9039","Ticket closed as done." }, {"U9040","Are you sure you want to close @@COUNT@@ ticket(s) as rejected?" }, + {"U9044","Notifications were sent to App responsibles." }, {"U9041","Are you sure you want to close @@COUNT@@ ticket(s) as done?" }, {"state","State" }, {"criticality","Criticality" }, @@ -465,11 +468,13 @@ internal class SimulatedUserConfig : UserConfig {"modelling","Modelling"}, {"modelling_general_settings","General Settings"}, {"notifications","Notifications"}, + {"initial_request","Initial request"}, {"request_interface","Request Interface"}, + {"reminder","Reminder"}, {"reminders","Reminders"}, {"decommission","Decommission"}, + {"decomm_owner","Decommission"}, {"modReqInterfaceName","Name of requested interface"}, - {"modReqEmailRequesterInCc","Requester in CC"}, {"modReqEmailReceiver","Receiver of request email"}, {"modReqEmailSubject","Subject of request emails"}, {"modReqEmailBody","Body of request emails"}, diff --git a/roles/tests-unit/files/FWO.Test/StateMatrixTest.cs b/roles/tests-unit/files/FWO.Test/StateMatrixTest.cs index e26af5d3fe..362a032ffc 100644 --- a/roles/tests-unit/files/FWO.Test/StateMatrixTest.cs +++ b/roles/tests-unit/files/FWO.Test/StateMatrixTest.cs @@ -117,6 +117,29 @@ public async Task GlobalStateMatrixInitNamedConfiguration_UsesNamedConfiguration }); } + [Test] + public async Task StateMatrixInit_UsesLastActiveWorkflowPhaseRegardlessOfDatabaseOrder() + { + GlobalStateMatrix source = new() + { + GlobalMatrix = Enum.GetValues().ToDictionary(phase => phase, phase => new StateMatrix + { + LowestInputState = (int)phase, + LowestStartedState = (int)phase + 1, + LowestEndState = (int)phase + 10, + Active = phase is WorkflowPhases.request or WorkflowPhases.implementation or WorkflowPhases.review + }) + }; + List configurations = StateMatrixConfigurationTestHelper.FromGlobalMatrix(source); + configurations[0].Phases.Reverse(); + StateMatrixApiConnection apiConnection = new(configurations); + StateMatrix matrix = new(); + + await matrix.Init(WorkflowPhases.request, apiConnection, new List(), WfTaskType.new_interface); + + Assert.That(matrix.MinTicketCompleted, Is.EqualTo(15)); + } + [Test] public async Task GlobalStateMatrixSave_WritesOnlyNonIdentityDerivedStates() { diff --git a/roles/tests-unit/files/FWO.Test/UiConnectionTableTest.cs b/roles/tests-unit/files/FWO.Test/UiConnectionTableTest.cs index d047f11e49..5207462c35 100644 --- a/roles/tests-unit/files/FWO.Test/UiConnectionTableTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiConnectionTableTest.cs @@ -1,6 +1,7 @@ using FWO.Data; using FWO.Data.Modelling; using FWO.Config.Api; +using FWO.Basics; using FWO.Ui.Shared; using NUnit.Framework; using System.Collections.Generic; @@ -494,5 +495,24 @@ public void IsVisibleToOwner_ReturnsTrue_ForPermittedOwnerWrapper() Assert.That(result, Is.True); } + + [Test] + public void IsVisibleToOwner_ReturnsFalse_WhenInterfaceOwnerIsMissing() + { + ConnectionTable table = new(); + SetComponentParameter(table, nameof(ConnectionTable.Application), new FwoOwner { Id = 1 }); + + ModellingConnection conn = new() + { + InterfacePermission = InterfacePermissions.Restricted.ToString(), + App = null! + }; + + MethodInfo isVisibleToOwner = GetInstanceMethod("IsVisibleToOwner", typeof(ModellingConnection)); + bool result = (bool)isVisibleToOwner.Invoke(table, [conn])!; + + Assert.That(result, Is.False); + } + } } diff --git a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs index 06b422bb37..d9df7945f7 100644 --- a/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiEditNotificationsTest.cs @@ -1,4 +1,7 @@ using FWO.Config.Api; +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Basics; using FWO.Data; using FWO.Ui.Shared; using NUnit.Framework; @@ -26,6 +29,25 @@ private static void SetPrivateField(EditNotifications component, string field field.SetValue(component, value); } + private static void SetPrivateMember(EditNotifications component, string memberName, object? value) + { + FieldInfo? field = typeof(EditNotifications).GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + { + field.SetValue(component, value); + return; + } + + PropertyInfo? property = typeof(EditNotifications).GetProperty(memberName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (property != null) + { + property.SetValue(component, value); + return; + } + + throw new MissingMemberException(typeof(EditNotifications).FullName, memberName); + } + private static T GetPrivateField(EditNotifications component, string fieldName) { FieldInfo? field = typeof(EditNotifications).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); @@ -78,6 +100,7 @@ public void InitActNotification_ForRuleTimer_SetsExpectedDefaults() Assert.That(actNotification.RecipientCc, Is.EqualTo(EmailRecipientOption.None)); Assert.That(actNotification.Layout, Is.EqualTo(NotificationLayout.HtmlInBody)); Assert.That(actNotification.Deadline, Is.EqualTo(FwoNotification.OfferedDeadlineOptions(NotificationClient.RuleTimer).Single())); + Assert.That(actNotification.Logging, Is.EqualTo(NotificationLoggingMode.SendOnly)); } [Test] @@ -381,6 +404,31 @@ public void DisplayRecipient_ShowsOtherAddressesAfterResponsibleTypes() Assert.That(displayedRecipient, Is.EqualTo("Responsible types (Main, Supporting, Other addresses)")); } + [Test] + public void DisplayRecipient_ReturnsLocalizedLabelForSimpleRecipients() + { + SimulatedUserConfig userConfig = new(); + EditNotifications component = new(); + SetInjectedUserConfig(component, userConfig); + + string displayedRecipient = (string)GetPrivateMethod("DisplayRecipient").Invoke(component, + new object?[] { EmailRecipientOption.Requester, null })!; + + Assert.That(displayedRecipient, Is.EqualTo(userConfig.GetText(nameof(EmailRecipientOption.Requester)))); + } + + [Test] + public void DisplayOtherAddresses_ReturnsLocalizedLabelWhenEmpty() + { + SimulatedUserConfig userConfig = new(); + EditNotifications component = new(); + SetInjectedUserConfig(component, userConfig); + + string displayedRecipient = (string)GetPrivateMethod("DisplayOtherAddresses").Invoke(component, new object?[] { "" })!; + + Assert.That(displayedRecipient, Is.EqualTo(userConfig.GetText(nameof(EmailRecipientOption.OtherAddresses)))); + } + [Test] public void EditNotification_MigratesLegacyMainResponsibleToConfiguredSelection() { @@ -421,6 +469,55 @@ public void EditNotification_KeepsLegacyMainResponsibleWhenMainTypeIsInactive() Assert.That(notification.RecipientTo, Is.EqualTo(EmailRecipientOption.OwnerMainResponsible)); } + [Test] + public void EditNotification_ThenSyncAddressesClearsSelection() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.RuleTimer); + EmailRecipientSelection persistedSelection = new() + { + None = false, + OtherAddresses = true, + OtherAddressList = ["configured@example.org"] + }; + FwoNotification notification = new() + { + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = persistedSelection.ToConfigValue() + }; + object?[] editArguments = [notification]; + + GetPrivateMethod("EditNotification").Invoke(component, editArguments); + + SetPrivateField(component, "ToRecipientSelection", new EmailRecipientSelection()); + GetPrivateMethod("SyncAddresses").Invoke(component, null); + + FwoNotification editedNotification = GetPrivateField(component, "actNotification"); + Assert.That(editedNotification.RecipientTo, Is.EqualTo(EmailRecipientOption.None)); + Assert.That(editedNotification.EmailAddressTo, Is.Empty); + } + + [Test] + public void SyncAddresses_ClearsRequesterSelectionWhenRequesterOptionIsNotAvailable() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.RuleTimer); + FwoNotification notification = new(); + + SetPrivateField(component, "actNotification", notification); + SetPrivateField(component, "ToRecipientSelection", new EmailRecipientSelection + { + None = false, + Requester = true + }); + + GetPrivateMethod("SyncAddresses").Invoke(component, null); + + EmailRecipientSelection selection = GetPrivateField(component, "ToRecipientSelection"); + Assert.That(selection.Requester, Is.False); + Assert.That(notification.RecipientTo, Is.EqualTo(EmailRecipientOption.None)); + } + [Test] public void CheckConsistency_ReturnsTrue_ForConfiguredResponsibleNotification() { @@ -447,6 +544,29 @@ public void CheckConsistency_ReturnsTrue_ForConfiguredResponsibleNotification() Assert.That(isConsistent, Is.True); } + [Test] + public void CheckConsistency_ReturnsFalse_WhenDeadlineRepeatsWithoutCount() + { + EnsureNotificationTranslations(); + EditNotifications component = new(); + SetClient(component, NotificationClient.RuleTimer); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification + { + Channel = NotificationChannel.Email, + EmailSubject = "Subject", + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "to@example.org", + Deadline = NotificationDeadline.RuleExpiry, + RepeatOffsetAfterDeadline = 2, + RepeatIntervalAfterDeadline = SchedulerInterval.Weeks + }); + + bool isConsistent = (bool)GetPrivateMethod("CheckConsistency").Invoke(component, null)!; + + Assert.That(isConsistent, Is.False); + } + [Test] public void EnsureNotificationIntervals_SetsDefaultIntervalValues_WhenOffsetsAreFilled() { @@ -467,5 +587,430 @@ public void EnsureNotificationIntervals_SetsDefaultIntervalValues_WhenOffsetsAre Assert.That(notification.RepeatIntervalAfterDeadline, Is.EqualTo(SchedulerInterval.Weeks)); }); } + + [Test] + public void OfferedLayoutOptions_ExcludesAttachmentsForModellingClients() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.InterfaceRequest); + + List layouts = (List)GetPrivateMethod("OfferedLayoutOptions").Invoke(component, null)!; + + Assert.That(layouts, Does.Not.Contain(NotificationLayout.PdfAsAttachment)); + } + + [Test] + public void OfferedLayoutOptions_OffersAttachmentsForReportClient() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.Report); + + List layouts = (List)GetPrivateMethod("OfferedLayoutOptions").Invoke(component, null)!; + + Assert.That(layouts, Does.Contain(NotificationLayout.PdfAsAttachment)); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsFalseWithoutAnyOffset() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification()); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.False); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsFalseWhenBeforeOffsetHasNoInterval() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification { OffsetBeforeDeadline = 1 }); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.False); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsTrueForCompleteBeforeDeadlineConfiguration() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification + { + OffsetBeforeDeadline = 1, + IntervalBeforeDeadline = SchedulerInterval.Days + }); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.True); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsFalseWhenAfterOffsetHasNoInterval() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification { RepeatOffsetAfterDeadline = 1 }); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.False); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsFalseWhenRepetitionsAreWithoutAfterOffset() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification { RepetitionsAfterDeadline = 2 }); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.False); + } + + [Test] + public void CheckConsistencyWithDeadline_ReturnsTrueForCompleteAfterDeadlineConfiguration() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateField(component, "actNotification", new FwoNotification + { + RepeatOffsetAfterDeadline = 1, + RepeatIntervalAfterDeadline = SchedulerInterval.Days, + RepetitionsAfterDeadline = 2 + }); + + bool result = (bool)GetPrivateMethod("CheckConsistencyWithDeadline").Invoke(component, null)!; + + Assert.That(result, Is.True); + } + + [Test] + public async Task SetDirectAddresses_WritesToCcAndBccFields() + { + EditNotifications component = new(); + FwoNotification notification = new(); + SetPrivateField(component, "actNotification", notification); + + await (Task)GetPrivateMethod("SetDirectAddressTo").Invoke(component, new object?[] { "to@example.org" })!; + await (Task)GetPrivateMethod("SetDirectAddressCc").Invoke(component, new object?[] { "cc@example.org" })!; + await (Task)GetPrivateMethod("SetDirectAddressBcc").Invoke(component, new object?[] { "bcc@example.org" })!; + + Assert.Multiple(() => + { + Assert.That(notification.EmailAddressTo, Is.EqualTo("to@example.org")); + Assert.That(notification.EmailAddressCc, Is.EqualTo("cc@example.org")); + Assert.That(notification.EmailAddressBcc, Is.EqualTo("bcc@example.org")); + }); + } + + [Test] + public void ParseOtherAddressSelection_ParsesConfiguredSelectionJson() + { + EditNotifications component = new(); + SetPrivateField(component, "activeOwnerResponsibleTypes", new List + { + new() { Id = 1, Name = "Main", Active = true } + }); + EmailRecipientSelection expected = new() + { + None = false, + OtherAddresses = true, + OtherAddressList = new List { "json@example.org" } + }; + + EmailRecipientSelection result = (EmailRecipientSelection)GetPrivateMethod("ParseOtherAddressSelection") + .Invoke(component, new object?[] { expected.ToConfigValue(new List { 1 }), new List() })!; + + Assert.That(result.OtherAddressList, Is.EqualTo(new List { "json@example.org" })); + } + + [Test] + public void ParseOtherAddressSelection_UsesLegacyAddressesWhenValueIsNotJson() + { + EditNotifications component = new(); + + EmailRecipientSelection result = (EmailRecipientSelection)GetPrivateMethod("ParseOtherAddressSelection") + .Invoke(component, new object?[] { "legacy@example.org", new List { "legacy@example.org" } })!; + + Assert.Multiple(() => + { + Assert.That(result.OtherAddresses, Is.True); + Assert.That(result.OtherAddressList, Is.EqualTo(new List { "legacy@example.org" })); + }); + } + + [Test] + public void DisplayedInterval_UsesBeforeDeadlineIntervalWhenConfigured() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RecertDate, + OffsetBeforeDeadline = 3, + IntervalBeforeDeadline = SchedulerInterval.Days + }; + + string result = (string)GetPrivateMethod("DisplayedInterval").Invoke(component, new object?[] { notification })!; + + Assert.That(result, Is.EqualTo($"3 {new SimulatedUserConfig().GetText(SchedulerInterval.Days.ToString())}")); + } + + [Test] + public void DisplayedInterval_UsesRepeatIntervalWhenNoBeforeDeadlineIntervalExists() + { + EditNotifications component = new(); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + FwoNotification notification = new() + { + Deadline = NotificationDeadline.RuleExpiry, + RepeatOffsetAfterDeadline = 2, + RepeatIntervalAfterDeadline = SchedulerInterval.Weeks + }; + + string result = (string)GetPrivateMethod("DisplayedInterval").Invoke(component, new object?[] { notification })!; + + Assert.That(result, Does.StartWith("2 ")); + } + + [Test] + public void DisplayedInterval_IsEmptyForImmediateNotification() + { + EditNotifications component = new(); + string result = (string)GetPrivateMethod("DisplayedInterval").Invoke(component, + new object?[] { new FwoNotification { Deadline = NotificationDeadline.None } })!; + + Assert.That(result, Is.Empty); + } + + [Test] + public void EditNotification_UsesConfiguredSelectionForAllOwnerResponsibles() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.InterfaceRequest); + SetPrivateField(component, "activeOwnerResponsibleTypes", new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Active = true, SortOrder = 1 }, + new() { Id = GlobalConst.kOwnerResponsibleTypeSupporting, Active = true, SortOrder = 2 } + }); + FwoNotification notification = new() { RecipientTo = EmailRecipientOption.AllOwnerResponsibles }; + + GetPrivateMethod("EditNotification").Invoke(component, new object?[] { notification }); + + EmailRecipientSelection selection = GetPrivateField(component, "ToRecipientSelection"); + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List + { + GlobalConst.kOwnerResponsibleTypeMain, + GlobalConst.kOwnerResponsibleTypeSupporting + })); + } + + [Test] + public void SyncAddresses_RemovesRequesterWhenClientDoesNotOfferRequester() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.Recertification); + FwoNotification notification = new(); + SetPrivateField(component, "actNotification", notification); + SetPrivateField(component, "ToRecipientSelection", new EmailRecipientSelection + { + None = false, + Requester = true, + OtherAddresses = true, + OtherAddressList = new List { "to@example.org" } + }); + + GetPrivateMethod("SyncAddresses").Invoke(component, null); + + EmailRecipientSelection selection = EmailRecipientSelection.Parse(notification.EmailAddressTo); + Assert.That(selection.Requester, Is.False); + } + + [Test] + public async Task AddAndRemoveNotificationId_IgnoreNullIds() + { + EditNotifications component = new(); + + await (Task)GetPrivateMethod("AddNotificationId").Invoke(component, new object?[] { 7 })!; + await (Task)GetPrivateMethod("RemoveNotificationId").Invoke(component, new object?[] { 7 })!; + + Assert.That(component.NotificationIds, Is.Null); + } + + [Test] + public async Task AddNotificationId_DoesNotAddDuplicateId() + { + EditNotifications component = new(); + List notificationIds = new() { 7 }; + SetPrivateMember(component, nameof(EditNotifications.NotificationIds), notificationIds); + + await (Task)GetPrivateMethod("AddNotificationId").Invoke(component, new object?[] { 7 })!; + + Assert.That(component.NotificationIds, Is.EqualTo(new List { 7 })); + } + + [Test] + public async Task AddAndRemoveNotificationId_UpdatesBoundIds() + { + EditNotifications component = new(); + List notificationIds = new() { 4 }; + SetPrivateMember(component, "NotificationIds", notificationIds); + + await (Task)GetPrivateMethod("AddNotificationId").Invoke(component, new object?[] { 7 })!; + await (Task)GetPrivateMethod("AddNotificationId").Invoke(component, new object?[] { 4 })!; + await (Task)GetPrivateMethod("RemoveNotificationId").Invoke(component, new object?[] { 4 })!; + + Assert.That(component.NotificationIds, Is.EqualTo(new List { 7 })); + } + + [Test] + public async Task ResetNotification_LeavesLastSentWhenUpdateFails() + { + EditNotifications component = new(); + DateTime lastSent = DateTime.Now; + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateMember(component, "apiConnection", new EditNotificationsApiConnection { ThrowOnReset = true }); + SetPrivateField(component, "actNotification", new FwoNotification { Id = 9, LastSent = lastSent }); + SetPrivateField(component, "ResetNotifMode", true); + + MethodInfo resetMethod = typeof(EditNotifications).GetMethod("ResetNotification", BindingFlags.NonPublic | BindingFlags.Instance, + binder: null, types: Type.EmptyTypes, modifiers: null) + ?? throw new MissingMethodException(typeof(EditNotifications).FullName, "ResetNotification()"); + Assert.DoesNotThrowAsync(async () => await (Task)resetMethod.Invoke(component, null)!); + + Assert.Multiple(() => + { + Assert.That(GetPrivateField(component, "actNotification").LastSent, Is.EqualTo(lastSent)); + Assert.That(GetPrivateField(component, "ResetNotifMode"), Is.True); + }); + } + + [Test] + public async Task Save_AddsNotificationAndReferenceWhenConfigurationIsValid() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.Report); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateMember(component, "apiConnection", new EditNotificationsApiConnection()); + SetPrivateField(component, "Notifications", new List()); + SetPrivateMember(component, "NotificationIds", new List()); + SetPrivateField(component, "actNotification", new FwoNotification + { + Channel = NotificationChannel.Email, + EmailSubject = "Subject", + EmailAddressTo = "to@example.org" + }); + SetPrivateField(component, "AddNotifMode", true); + + await (Task)GetPrivateMethod("Save").Invoke(component, null)!; + + Assert.Multiple(() => + { + Assert.That(GetPrivateField>(component, "Notifications"), Has.Count.EqualTo(1)); + Assert.That(component.NotificationIds, Is.EqualTo(new List { 100 })); + Assert.That(GetPrivateField(component, "EditNotifMode"), Is.False); + }); + } + + [Test] + public async Task Save_UpdatesExistingNotificationWhenConfigurationIsValid() + { + EditNotifications component = new(); + SetClient(component, NotificationClient.Report); + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateMember(component, "apiConnection", new EditNotificationsApiConnection()); + FwoNotification notification = new() + { + Id = 9, + Name = "Updated", + Channel = NotificationChannel.Email, + EmailSubject = "Subject", + EmailAddressTo = "to@example.org" + }; + SetPrivateField(component, "Notifications", new List { notification }); + SetPrivateField(component, "actNotification", notification); + SetPrivateField(component, "EditNotifMode", true); + + await (Task)GetPrivateMethod("Save").Invoke(component, null)!; + + Assert.That(GetPrivateField>(component, "Notifications").Single().Name, Is.EqualTo("Updated")); + } + + [Test] + public async Task Delete_RestoresReferenceWhenDeleteFails() + { + EditNotificationsApiConnection apiConnection = new() { ThrowOnDelete = true }; + EditNotifications component = new(); + FwoNotification notification = new() { Id = 9, Name = "Notification" }; + SetInjectedUserConfig(component, new SimulatedUserConfig()); + SetPrivateMember(component, "apiConnection", apiConnection); + SetPrivateField(component, "Notifications", new List { notification }); + SetPrivateMember(component, "NotificationIds", new List { 9 }); + SetPrivateField(component, "actNotification", notification); + + await (Task)GetPrivateMethod("Delete").Invoke(component, null)!; + + Assert.Multiple(() => + { + Assert.That(GetPrivateField>(component, "Notifications"), Has.Count.EqualTo(1)); + Assert.That(component.NotificationIds, Is.EqualTo(new List { 9 })); + Assert.That(apiConnection.DeletedIds, Is.EqualTo(new List { 9 })); + }); + } + + private sealed class EditNotificationsApiConnection : SimulatedApiConnection + { + private static readonly ReturnId[] AddedNotificationIds = new ReturnId[] { new() { NewId = 100 } }; + private static readonly ReturnId[] UpdatedNotificationIds = new ReturnId[] { new() { UpdatedId = 9 } }; + public int AffectedRows { get; init; } + public bool ThrowOnDelete { get; init; } + public bool ThrowOnReset { get; init; } + public List DeletedIds { get; } = new(); + + public override Task SendQueryAsync(string query, object? variables = null, + string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + if (query == NotificationQueries.addNotification && typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = AddedNotificationIds + }); + } + if (query == NotificationQueries.updateNotification && typeof(QueryResponseType) == typeof(ReturnIdWrapper)) + { + return Task.FromResult((QueryResponseType)(object)new ReturnIdWrapper + { + ReturnIds = UpdatedNotificationIds + }); + } + if (query == NotificationQueries.deleteNotification && typeof(QueryResponseType) == typeof(object)) + { + DeletedIds.Add((int)(variables?.GetType().GetProperty("id")?.GetValue(variables) ?? 0)); + if (ThrowOnDelete) + { + throw new InvalidOperationException("Delete failed."); + } + return Task.FromResult((QueryResponseType)(object)new object()); + } + if (query == NotificationQueries.updateNotificationsLastSent && typeof(QueryResponseType) == typeof(ReturnId)) + { + if (ThrowOnReset) + { + throw new InvalidOperationException("Reset failed."); + } + return Task.FromResult((QueryResponseType)(object)new ReturnId { AffectedRows = AffectedRows }); + } + + throw new NotImplementedException($"Unexpected query: {query}"); + } + } } } diff --git a/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs b/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs index 50cd6325a3..8fddfdaea2 100644 --- a/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiEmailRecipientSelectionEditorTest.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; +using System.Linq; namespace FWO.Test { @@ -48,6 +49,87 @@ public void Render_OrdersResponsiblesOtherAddressesAndEnsureFlag() })); } + [Test] + public void Render_ShowsRequesterOptionWhenEnabled() + { + SimulatedUserConfig userConfig = new(); + Services.AddSingleton(userConfig); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, new EmailRecipientSelection()) + .Add(p => p.ShowRequesterOption, true)); + + Assert.That(component.FindAll("label.form-check-label").Select(label => label.TextContent), Does.Contain(userConfig.GetText(nameof(EmailRecipientOption.Requester)))); + } + + [Test] + public void RequesterCheckbox_TogglesRequesterSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.ShowRequesterOption, true) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_requester").Change(true); + + Assert.That(selection.Requester, Is.True); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_requester").Change(false); + + Assert.That(selection.Requester, Is.False); + Assert.That(selection.None, Is.True); + } + + [Test] + public void OtherAddressesCheckbox_TogglesSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_other").Change(true); + + Assert.That(selection.OtherAddresses, Is.True); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_other").Change(false); + + Assert.That(selection.OtherAddresses, Is.False); + Assert.That(selection.None, Is.True); + } + + [Test] + public void OwnerResponsibleTypeCheckbox_TogglesSelectionAndDerivedNone() + { + Services.AddSingleton(new SimulatedUserConfig()); + EmailRecipientSelection selection = new(); + + IRenderedComponent component = Render(parameters => parameters + .Add(p => p.Selection, selection) + .Add(p => p.OwnerResponsibleTypes, new List + { + new() { Id = 7, Name = "Main responsible", Active = true, SortOrder = 10 } + }) + .Add(p => p.CheckboxIdPrefix, "test_recipients")); + + component.Find("#test_recipients_ort_7").Change(true); + + Assert.That(selection.OwnerResponsibleTypeIds, Is.EqualTo(new List { 7 })); + Assert.That(selection.None, Is.False); + + component.Find("#test_recipients_ort_7").Change(false); + + Assert.That(selection.OwnerResponsibleTypeIds, Is.Empty); + Assert.That(selection.None, Is.True); + } + [Test] public void OtherEmailAddressesEditor_AddsSanitizedAddressToList() { diff --git a/roles/tests-unit/files/FWO.Test/UiMonitoringLayoutTest.cs b/roles/tests-unit/files/FWO.Test/UiMonitoringLayoutTest.cs new file mode 100644 index 0000000000..4956565972 --- /dev/null +++ b/roles/tests-unit/files/FWO.Test/UiMonitoringLayoutTest.cs @@ -0,0 +1,129 @@ +using Bunit; +using FWO.Basics; +using FWO.Config.Api; +using FWO.Ui.Services; +using FWO.Ui.Shared; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using System.Reflection; +using System.Security.Claims; + +namespace FWO.Test +{ + [TestFixture] + [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] + internal class UiMonitoringLayoutTest + { + [Test] + public async Task MonitoringLayout_ShowsPrivilegedSectionsAndNotificationsForAdmin() + { + await using BunitContext context = CreateContext(Roles.Admin); + + IRenderedComponent layout = RenderLayout(context); + DomEventService eventService = context.Services.GetRequiredService(); + layout.WaitForAssertion(() => Assert.That(GetNavbarHeightSubscriberCount(eventService), Is.EqualTo(1))); + await layout.InvokeAsync(() => eventService.InvokeNavbarHeightChanged(50)); + layout.WaitForAssertion(() => Assert.That(layout.FindAll("a[href='monitoring/email_log']"), Has.Count.EqualTo(1))); + + Assert.Multiple(() => + { + Assert.That(layout.FindAll("h5").Select(element => element.TextContent), Does.Contain("Notifications")); + Assert.That(layout.FindAll("a[href='monitoring/email_log']"), Has.Count.EqualTo(1)); + Assert.That(layout.FindAll("a[href='monitoring/system_usage']"), Has.Count.EqualTo(1)); + Assert.That(layout.FindAll("a[href='monitoring/ui_messages']"), Has.Count.EqualTo(1)); + }); + } + + [Test] + public async Task MonitoringLayout_HidesPrivilegedSectionsForRegularUser() + { + await using BunitContext context = CreateContext(Roles.Requester); + + IRenderedComponent layout = RenderLayout(context); + DomEventService eventService = context.Services.GetRequiredService(); + layout.WaitForAssertion(() => Assert.That(GetNavbarHeightSubscriberCount(eventService), Is.EqualTo(1))); + await layout.InvokeAsync(() => eventService.InvokeNavbarHeightChanged(50)); + layout.WaitForAssertion(() => Assert.That(layout.FindAll("a[href='monitoring/ui_messages']"), Has.Count.EqualTo(1))); + + Assert.Multiple(() => + { + Assert.That(layout.FindAll("a[href='monitoring/email_log']"), Is.Empty); + Assert.That(layout.FindAll("a[href='monitoring/system_usage']"), Is.Empty); + Assert.That(layout.FindAll("a[href='monitoring/ui_messages']"), Has.Count.EqualTo(1)); + Assert.That(layout.FindAll("a[href='monitoring/monitor_all']"), Is.Empty); + }); + } + + [Test] + public async Task MonitoringLayout_DisposeUnsubscribesFromUserConfigChanges() + { + await using BunitContext context = CreateContext(Roles.Admin); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + IRenderedComponent layout = RenderLayout(context); + + layout.Instance.Dispose(); + + Assert.DoesNotThrow(() => userConfig.SetExecutionMode(Roles.Auditor)); + } + + private static BunitContext CreateContext(string role) + { + SimulatedUserConfig userConfig = new(); + userConfig.User.Roles = new List { role }; + userConfig.SetExecutionMode(role); + + BunitContext context = new(); + context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddAuthorizationCore(); + context.Services.AddSingleton(); + context.Services.AddSingleton(new MonitoringLayoutAuthStateProvider(role)); + context.Services.AddSingleton(new TestDomEventService()); + context.Services.AddSingleton(userConfig); + return context; + } + + private static IRenderedComponent RenderLayout(BunitContext context) + { + return context.Render(parameters => parameters + .AddChildContent()) + .FindComponent(); + } + + private static int GetNavbarHeightSubscriberCount(DomEventService eventService) + { + FieldInfo? field = typeof(DomEventService).GetField("_navbarHeightSubscribers", BindingFlags.Instance | BindingFlags.NonPublic); + MulticastDelegate? subscribers = field?.GetValue(eventService) as MulticastDelegate; + return subscribers?.GetInvocationList().Length ?? 0; + } + + private sealed class TestDomEventService : DomEventService, IDisposable + { + public void Dispose() + { + } + } + + private sealed class MonitoringLayoutAuthStateProvider : AuthenticationStateProvider + { + private readonly AuthenticationState authenticationState; + + public MonitoringLayoutAuthStateProvider(string role) + { + ClaimsIdentity identity = new( + new List { new(ClaimTypes.Role, role) }, + authenticationType: "Test", + nameType: ClaimTypes.Name, + roleType: ClaimTypes.Role); + authenticationState = new AuthenticationState(new ClaimsPrincipal(identity)); + } + + public override Task GetAuthenticationStateAsync() + { + return Task.FromResult(authenticationState); + } + } + } +} diff --git a/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs b/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs index e65f7d08e9..678a32ee05 100644 --- a/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiMonitoringLogPagesTest.cs @@ -73,6 +73,25 @@ public void MonitorAll_LoadsLogEntries() }); } + [Test] + public void MonitorEmailLog_LoadsNotificationLogEntries() + { + MonitorTestApiConn apiConn = RenderApiConn(); + apiConn.NotificationLogEntries.Add(CreateNotificationLogEntry("InterfaceRequest", "to@example.test")); + + using TestSetup setup = RenderComponent(apiConn); + + Assert.Multiple(() => + { + Assert.That(apiConn.NotificationLogQueryCount, Is.EqualTo(1)); + Assert.That(apiConn.LastQuery, Is.EqualTo(MonitorQueries.getNotificationLogEntrys)); + Assert.That(apiConn.NotificationLogLimit, Is.EqualTo(21)); + Assert.That(apiConn.NotificationLogOffset, Is.Zero); + Assert.That(GetPrivateField>(setup.Component, "notificationLogEntries"), Has.Count.EqualTo(1)); + Assert.That(GetPrivateField(setup.Component, "InitComplete"), Is.True); + }); + } + [Test] public void MonitorAlerts_LoadsAlertsUsersAndManagements() { @@ -163,6 +182,20 @@ private static LogEntry CreateLogEntry(long id, string source) }; } + private static NotificationLogEntry CreateNotificationLogEntry(string notificationType, string to) + { + return new NotificationLogEntry + { + Timestamp = new DateTimeOffset(new DateTime(2026, 1, 1, 12, 0, 0), TimeSpan.Zero), + NotificationId = 1, + NotificationType = notificationType, + To = to, + Cc = "", + Bcc = "", + Subject = "subject" + }; + } + private static Alert CreateAlert(long id) { return new Alert @@ -197,12 +230,16 @@ private static Management CreateManagement(int id, string name) internal sealed class MonitorTestApiConn : SimulatedApiConnection { public List LogEntries { get; } = new(); + public List NotificationLogEntries { get; } = new(); public List Alerts { get; } = new(); public List UiUsers { get; } = new(); public List Managements { get; } = new(); public List LastQueries { get; } = new(); public int LogQueryCount { get; private set; } + public int NotificationLogQueryCount { get; private set; } + public int NotificationLogLimit { get; private set; } + public int NotificationLogOffset { get; private set; } public int AlertQueryCount { get; private set; } public int UserQueryCount { get; private set; } public int ManagementQueryCount { get; private set; } @@ -226,6 +263,14 @@ public override Task SendQueryAsync(string return Task.FromResult((QueryResponseType)(object)LogEntries); } + if (typeof(QueryResponseType) == typeof(List) && query == MonitorQueries.getNotificationLogEntrys) + { + NotificationLogQueryCount++; + NotificationLogLimit = GetVariable(variables, "limit"); + NotificationLogOffset = GetVariable(variables, "offset"); + return Task.FromResult((QueryResponseType)(object)NotificationLogEntries); + } + if (typeof(QueryResponseType) == typeof(List) && query == MonitorQueries.getDataImportLogEntrys) { LogQueryCount++; diff --git a/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs b/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs index e48cb48479..8b93084f5c 100644 --- a/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiRequestAndRejectPopupTest.cs @@ -1,11 +1,14 @@ using Bunit; +using GraphQL; using FWO.Api.Client; using FWO.Api.Client.Queries; using FWO.Basics; using FWO.Config.Api; using FWO.Data; using FWO.Data.Modelling; +using FWO.Data.Workflow; using FWO.Middleware.Client; +using FWO.Services; using FWO.Services.Modelling; using FWO.Ui.Pages.NetworkModelling; using Microsoft.AspNetCore.Authorization; @@ -13,7 +16,10 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; +using RestSharp; using System.Security.Claims; +using System.Reflection; +using static FWO.Basics.Placeholder; namespace FWO.Test { @@ -91,6 +97,87 @@ public async Task RequestInterfacePopup_SendRequest_RejectsWhenSelectedOwnerMatc Assert.That(component.Instance.Display, Is.True); } + [Test] + public async Task RequestInterfacePopup_SendRequest_ContinuesWhenInputIsValid() + { + using BunitContext context = CreateContext(new RequestPopupNoImmediateApiConn(), Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.ModReqInterfaceName = "req-interface"; + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = new(); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }, + messageSink: (exception, title, message, isError) => messages.Add((exception, title, message, isError))); + + component.Find("input[type='text']").Change("branch-if"); + component.Find("textarea").Change("needed"); + await component.InvokeAsync(() => component.FindAll("button.btn-primary").Single().Click()); + + Assert.Multiple(() => + { + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Exception, Is.Not.Null); + Assert.That(messages[0].Message, Is.Not.EqualTo(userConfig.GetText("E5102"))); + Assert.That(messages[0].Message, Is.Not.EqualTo(userConfig.GetText("E9012"))); + Assert.That(component.Instance.Display, Is.True); + }); + } + + [Test] + public async Task RequestInterfacePopup_SendRequest_StopsWhenAlreadyInProgress() + { + using BunitContext context = CreateContext(new RequestPopupNotificationApiConn(), Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + SetPrivateMember(component.Instance, "WorkInProgress", true); + + MethodInfo sendRequest = typeof(RequestInterfacePopup).GetMethod("SendRequest", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SendRequest method not found."); + Task task = (Task)(sendRequest.Invoke(component.Instance, Array.Empty()) ?? throw new InvalidOperationException("SendRequest returned null task.")); + await task; + + Assert.That(component.Instance.Display, Is.True); + } + + [Test] + public void RequestInterfacePopup_Close_HidesPopup() + { + using BunitContext context = CreateContext(Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + + MethodInfo close = typeof(RequestInterfacePopup).GetMethod("Close", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("Close method not found."); + close.Invoke(component.Instance, Array.Empty()); + + Assert.That(component.Instance.Display, Is.False); + } + + [Test] + public async Task RequestInterfacePopup_SendEmail_DelegatesToMiddlewareFacade() + { + using BunitContext context = CreateContext(Roles.Modeller); + IRenderedComponent component = RenderRequestInterfacePopup( + context, + new FwoOwner { Id = 11, Name = "Selected", ExtAppId = "APP-42" }, + new FwoOwner { Id = 12, Name = "Requester" }); + RecordingNotificationMiddlewareClient middlewareClient = new(); + SetPrivateMember(component.Instance, "middlewareClient", middlewareClient); + + MethodInfo sendEmail = typeof(RequestInterfacePopup).GetMethod("SendEmail", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("SendEmail method not found."); + Task task = (Task)(sendEmail.Invoke(component.Instance, new object?[] { 123L }) + ?? throw new InvalidOperationException("SendEmail returned null task.")); + await task; + + Assert.That(middlewareClient.ConnectionId, Is.EqualTo(123)); + } + [Test] public void RejectInterfacePopup_OnParametersSet_SetsMessageAndAdminReason() { @@ -116,6 +203,153 @@ public void RejectInterfacePopup_OnParametersSet_SetsMessageAndAdminReason() }); } + [Test] + public void RejectInterfacePopup_BuildsNotificationContextWithRejectReason() + { + using BunitContext context = CreateContext(Roles.Admin, Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + userConfig.UiHostName = "https://fwo.example"; + userConfig.User.Roles = [Roles.Admin, Roles.Modeller]; + userConfig.SetExecutionMode(Roles.Admin); + ModellingConnection actConn = new() + { + Id = 21, + Name = "iface21", + CreationDate = new DateTime(2025, 1, 2), + IsInterface = true + }; + ModellingConnectionHandler handler = CreateConnectionHandler(new RejectInterfacePopupTestApiConn(), userConfig, actConn); + handler.Application.ExtAppId = "OWNER-APP"; + IRenderedComponent component = RenderRejectInterfacePopup( + context, + handler, + allowAdminReject: true, + requestingApp: new FwoOwner { Id = 12, Name = "Requesting App", ExtAppId = "REQ-APP" }); + + component.Find("textarea").Change("not approved"); + MethodInfo method = typeof(RejectInterfacePopup).GetMethod("BuildNotificationPlaceholderData", BindingFlags.Instance | BindingFlags.NonPublic)!; + NotificationPlaceholderData data = (NotificationPlaceholderData)method.Invoke(component.Instance, null)!; + + Assert.Multiple(() => + { + Assert.That(data.InterfaceName, Is.EqualTo("iface21")); + Assert.That(data.RequestingAppName, Is.EqualTo("Requesting App")); + Assert.That(data.RequestingAppId, Is.EqualTo("REQ-APP")); + Assert.That(data.Content, Is.EqualTo("not approved")); + Assert.That(data.RequestDate, Is.EqualTo("02.01.2025")); + Assert.That(data.InterfaceLinkUrl, Is.EqualTo("https://fwo.example/networkmodelling/OWNER-APP/21")); + }); + } + + [Test] + public void RejectInterfacePopup_BuildsEmptyNotificationContextForMissingConnectionData() + { + using BunitContext context = CreateContext(Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + ModellingConnectionHandler handler = CreateConnectionHandler( + new RejectInterfacePopupTestApiConn(), + userConfig, + new ModellingConnection { Id = 21, IsInterface = true }); + handler.Application = null!; + IRenderedComponent component = RenderRejectInterfacePopup(context, handler); + SetPrivateMember(component.Instance, "Reason", "reject reason"); + + MethodInfo method = typeof(RejectInterfacePopup).GetMethod("BuildNotificationPlaceholderData", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("BuildNotificationPlaceholderData method not found."); + NotificationPlaceholderData data = (NotificationPlaceholderData)(method.Invoke(component.Instance, null) + ?? throw new InvalidOperationException("BuildNotificationPlaceholderData returned null.")); + + Assert.Multiple(() => + { + Assert.That(data.InterfaceName, Is.Empty); + Assert.That(data.RequestingAppName, Is.Empty); + Assert.That(data.RequestingAppId, Is.Empty); + Assert.That(data.InterfaceLinkUrl, Is.Empty); + Assert.That(data.Content, Is.EqualTo("reject reason")); + Assert.That(data.UserName, Is.EqualTo(userConfig.User.Name)); + Assert.That(data.RequestDate, Is.Empty); + }); + } + + [Test] + public void RejectInterfacePopup_DoesNotUseConnectionOwnerAsRequestingApp() + { + using BunitContext context = CreateContext(Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + ModellingConnectionHandler handler = CreateConnectionHandler( + new RejectInterfacePopupTestApiConn(), + userConfig, + new ModellingConnection + { + Id = 21, + IsInterface = true, + App = new FwoOwner { Id = 77, Name = "Requested App", ExtAppId = "REQUESTED-APP" } + }); + IRenderedComponent component = RenderRejectInterfacePopup(context, handler); + + MethodInfo method = typeof(RejectInterfacePopup).GetMethod("BuildNotificationPlaceholderData", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("BuildNotificationPlaceholderData method not found."); + NotificationPlaceholderData data = (NotificationPlaceholderData)(method.Invoke(component.Instance, null) + ?? throw new InvalidOperationException("BuildNotificationPlaceholderData returned null.")); + + Assert.Multiple(() => + { + Assert.That(data.RequestingAppName, Is.Empty); + Assert.That(data.RequestingAppId, Is.Empty); + }); + } + + [Test] + public async Task RejectInterfacePopup_RejectInTicket_SkipsWhenTicketIdIsMissing() + { + using BunitContext context = CreateContext(Roles.Modeller); + RejectInterfacePopupTestApiConn apiConn = new(); + ModellingConnectionHandler handler = CreateConnectionHandler( + apiConn, + (SimulatedUserConfig)context.Services.GetRequiredService(), + new ModellingConnection { Id = 21, IsInterface = true }); + IRenderedComponent component = RenderRejectInterfacePopup(context, handler); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = new(); + SetPrivateMember(component.Instance, "DisplayMessageInUi", + (Action)((exception, title, message, isError) => messages.Add((exception, title, message, isError)))); + + MethodInfo method = typeof(RejectInterfacePopup).GetMethod("RejectInTicket", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("RejectInTicket method not found."); + Task task = (Task)(method.Invoke(component.Instance, new object?[] { null, null }) + ?? throw new InvalidOperationException("RejectInTicket returned null task.")); + await task; + + Assert.Multiple(() => + { + Assert.That(messages, Is.Empty); + Assert.That(apiConn.UpdateConnectionPropertiesCalls, Is.Zero); + Assert.That(apiConn.RemoveSelectedConnectionCalls, Is.Zero); + }); + } + + [Test] + public async Task RejectInterfacePopup_RejectInTicket_SkipsWhenConnectionHandlerIsMissing() + { + using BunitContext context = CreateContext(Roles.Modeller); + SimulatedUserConfig userConfig = (SimulatedUserConfig)context.Services.GetRequiredService(); + ModellingConnectionHandler handler = CreateConnectionHandler( + new RejectInterfacePopupTestApiConn(), + userConfig, + new ModellingConnection { Id = 21, IsInterface = true }); + IRenderedComponent component = RenderRejectInterfacePopup( + context, + handler, + messageSink: null); + SetPrivateMember(component.Instance, "ConnHandler", null); + + MethodInfo method = typeof(RejectInterfacePopup).GetMethod("RejectInTicket", BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException("RejectInTicket method not found."); + Task task = (Task)(method.Invoke(component.Instance, new object?[] { 123L, null }) + ?? throw new InvalidOperationException("RejectInTicket returned null task.")); + + Assert.DoesNotThrowAsync(async () => await task); + } + [Test] public async Task RejectInterfacePopup_Reject_SavesPropertiesAndRemovesSelection() { @@ -161,6 +395,11 @@ public async Task RejectInterfacePopup_Reject_SavesPropertiesAndRemovesSelection } private static BunitContext CreateContext(params string[] roles) + { + return CreateContext(null, roles); + } + + private static BunitContext CreateContext(ApiConnection? apiConnection, params string[] roles) { BunitContext context = new(); context.JSInterop.Mode = JSRuntimeMode.Loose; @@ -168,7 +407,7 @@ private static BunitContext CreateContext(params string[] roles) context.Services.AddLocalization(); context.Services.AddSingleton(); context.Services.AddSingleton(new PopupAuthStateProvider(roles)); - context.Services.AddSingleton(new SimulatedApiConnection()); + context.Services.AddSingleton(apiConnection ?? new SimulatedApiConnection()); context.Services.AddSingleton(new MiddlewareClient("http://localhost/")); context.Services.AddSingleton(CreateUserConfig()); return context; @@ -237,6 +476,7 @@ private static IRenderedComponent RenderRejectInterfacePop BunitContext context, ModellingConnectionHandler handler, bool allowAdminReject = false, + FwoOwner? requestingApp = null, Action? displayChanged = null, Func? refreshParent = null, Action? messageSink = null) @@ -257,6 +497,7 @@ private static IRenderedComponent RenderRejectInterfacePop popupBuilder.AddAttribute(3, "ConnHandler", handler); popupBuilder.AddAttribute(4, "RefreshParent", refreshParent ?? (() => Task.CompletedTask)); popupBuilder.AddAttribute(5, "AllowAdminReject", allowAdminReject); + popupBuilder.AddAttribute(6, "RequestingApp", requestingApp); popupBuilder.CloseComponent(); })); childBuilder.CloseComponent(); @@ -267,6 +508,185 @@ private static IRenderedComponent RenderRejectInterfacePop return context.Render(fragment).FindComponent(); } + private static void SetPrivateMember(object target, string memberName, object? value) + { + FieldInfo? field = target.GetType().GetField(memberName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (field != null) + { + field.SetValue(target, value); + return; + } + + PropertyInfo? property = target.GetType().GetProperty(memberName, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (property != null) + { + property.SetValue(target, value); + return; + } + + throw new MissingMemberException(target.GetType().FullName, memberName); + } + + private sealed class RequestPopupNotificationApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 1, + Deadline = NotificationDeadline.RequestDate, + EmailSubject = "request-subject" + }, + new() + { + Id = 2, + Deadline = NotificationDeadline.None, + EmailSubject = "immediate-subject" + }, + new() + { + Id = 3, + OwnerId = 11, + Deadline = NotificationDeadline.None, + EmailSubject = "owner-immediate-subject" + }, + new() + { + Id = 4, + OwnerId = 99, + Deadline = NotificationDeadline.None, + EmailSubject = "other-owner-subject" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + + private sealed class RequestPopupNoImmediateApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 1, + Deadline = NotificationDeadline.RequestDate, + EmailSubject = "request-subject" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + + private sealed class RequestPopupFailingApiConn : SimulatedApiConnection + { + public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + return Task.FromException(new InvalidOperationException("notification query failed")); + } + + return Task.FromException(new AssertionException($"Unexpected query: {query}")); + } + } + + private sealed class RequestPopupNoRecipientsApiConn : SimulatedApiConnection + { + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + await Task.CompletedTask; + if (typeof(QueryResponseType) == typeof(List) && query == NotificationQueries.getNotifications) + { + GraphQLResponse response = new() + { + Data = new List + { + new() + { + Id = 2, + Deadline = NotificationDeadline.None, + RecipientTo = EmailRecipientOption.OtherAddresses, + EmailAddressTo = "" + } + } + }; + return response.Data; + } + + if (typeof(QueryResponseType) == typeof(List) && query == OwnerQueries.getOwnerResponsibleTypes) + { + return (QueryResponseType)(object)new List + { + new() { Id = GlobalConst.kOwnerResponsibleTypeMain, Name = "Main", Active = true, SortOrder = 10 } + }; + } + + if (typeof(QueryResponseType) == typeof(List) && query == AuthQueries.getUserEmails) + { + return (QueryResponseType)(object)new List + { + new() { Dn = "cn=requester,dc=test", Email = "requester@example.test" } + }; + } + + throw new AssertionException($"Unexpected query: {query}"); + } + } + private sealed class PopupAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal principal; @@ -290,7 +710,6 @@ private sealed class RejectInterfacePopupTestApiConn : SimulatedApiConnection { public int UpdateConnectionPropertiesCalls { get; private set; } public int RemoveSelectedConnectionCalls { get; private set; } - public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) { if (query == ModellingQueries.updateConnectionProperties) @@ -306,5 +725,23 @@ public override Task SendQueryAsync(string throw new AssertionException($"Unexpected query: {query}"); } } + + private sealed class RecordingNotificationMiddlewareClient : MiddlewareClient + { + public int ConnectionId { get; private set; } + + public RecordingNotificationMiddlewareClient() : base("http://localhost/") + { } + + public override Task> SendInterfaceRequestNotification(int connectionId) + { + ConnectionId = connectionId; + return Task.FromResult(new RestResponse(new RestRequest()) + { + IsSuccessStatusCode = true, + Data = NotificationDeliveryResult.Delivered + }); + } + } } } diff --git a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs index b2bbddc66d..bbd47dde86 100644 --- a/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiSettingsModellingNotificationsTest.cs @@ -1,14 +1,17 @@ +using AngleSharp.Dom; using Bunit; using FWO.Api.Client; using FWO.Api.Client.Queries; +using FWO.Basics; using FWO.Config.Api; using FWO.Config.Api.Data; using FWO.Data; -using FWO.Basics; using FWO.Ui.Pages.Settings; -using Microsoft.AspNetCore.Components; +using FWO.Ui.Shared; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Localization; using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using System.Linq; @@ -17,245 +20,220 @@ namespace FWO.Test { [TestFixture] + [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] internal class UiSettingsModellingNotificationsTest { - private static readonly List RequestRecipientTypeIds = [1]; - private static readonly List DecommRecipientTypeIds = [2]; - private static readonly List ActiveRecipientTypeIds = [1, 2]; - private static readonly string[] RequestLegacyAddresses = ["legacy-request@example.org"]; - private static readonly string[] DecommLegacyAddresses = ["legacy-decomm@example.org"]; - private static readonly List RequestOtherAddressList = ["request@example.org"]; - private static readonly List DecommOtherAddressList = ["decomm@example.org"]; - private static MethodInfo GetPrivateMethod(string name) + [Test] + public async Task Page_RendersThreeNotificationEditorsWithExpectedSections() { - return typeof(SettingsModellingNotifications).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) - ?? throw new MissingMethodException(typeof(SettingsModellingNotifications).FullName, name); + await using BunitContext context = CreateContext(); + + IRenderedComponent wrapper = RenderPage(context); + IRenderedComponent page = wrapper.FindComponent(); + + page.WaitForAssertion(() => + { + Assert.That(page.Markup, Does.Contain("Notifications")); + Assert.That(page.Markup, Does.Contain("Initial request")); + Assert.That(page.Markup, Does.Contain("Reminder")); + Assert.That(page.Markup, Does.Contain("Decommission")); + + List> editors = page.FindComponents().ToList(); + Assert.That(editors, Has.Count.EqualTo(3)); + Assert.That(editors[0].Instance.Client, Is.EqualTo(NotificationClient.InterfaceRequest)); + Assert.That(editors[0].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.None)); + Assert.That(editors[1].Instance.Client, Is.EqualTo(NotificationClient.InterfaceRequest)); + Assert.That(editors[1].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.RequestDate)); + Assert.That(editors[2].Instance.Client, Is.EqualTo(NotificationClient.InterfaceDecomm)); + Assert.That(editors[2].Instance.DeadlineFilter, Is.EqualTo(NotificationDeadline.None)); + }); } - private static void SetPrivateField(object component, string fieldName, object? value) + [Test] + public async Task Save_WritesChangedConfigData() { - FieldInfo? field = component.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); - if (field == null) - { - throw new MissingFieldException(component.GetType().FullName, fieldName); - } - field.SetValue(component, value); - } + await using BunitContext context = CreateContext(); + IRenderedComponent wrapper = RenderPage(context); + IRenderedComponent page = wrapper.FindComponent(); - private static T GetPrivateField(object component, string fieldName) - { - FieldInfo? field = component.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance); - if (field == null) - { - throw new MissingFieldException(component.GetType().FullName, fieldName); - } - return (T)field.GetValue(component)!; - } + page.WaitForAssertion(() => Assert.That(page.FindAll("form"), Has.Count.EqualTo(1))); - private static void SetInjectedGlobalConfig(SettingsModellingNotifications component, GlobalConfig globalConfig) - { - PropertyInfo? prop = typeof(SettingsModellingNotifications).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(p => p.PropertyType == typeof(GlobalConfig)); - if (prop == null) - { - throw new MissingMemberException(typeof(SettingsModellingNotifications).FullName, "globalConfig"); - } - prop.SetValue(component, globalConfig); - } + IElement interfaceNameInput = page.FindAll("label") + .First(label => label.TextContent.Contains("Name of requested interface")) + .ParentElement! + .QuerySelector("input")!; + interfaceNameInput.Change("New interface name"); - private static void SetInjectedApiConnection(SettingsModellingNotifications component, RecordingSettingsApiConn apiConnection) - { - PropertyInfo? prop = typeof(SettingsModellingNotifications).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .FirstOrDefault(p => p.PropertyType == typeof(ApiConnection)); - if (prop == null) + IElement saveButton = page.FindAll("button") + .Last(button => button.QuerySelector("span[title='Save']") != null); + saveButton.Click(); + + page.WaitForAssertion(() => { - throw new MissingMemberException(typeof(SettingsModellingNotifications).FullName, "apiConnection"); - } - prop.SetValue(component, apiConnection); + SettingsModellingNotificationsApiConn apiConnection = context.Services.GetRequiredService() as SettingsModellingNotificationsApiConn + ?? throw new InvalidOperationException("Test api connection missing."); + Assert.That(apiConnection.UpsertConfigCallCount, Is.EqualTo(1)); + }); + + SettingsModellingNotificationsApiConn apiConn = context.Services.GetRequiredService() as SettingsModellingNotificationsApiConn + ?? throw new InvalidOperationException("Test api connection missing."); + Assert.That(apiConn.LastUpsertConfigItems, Has.Count.EqualTo(1)); + Assert.That(apiConn.LastUpsertConfigItems[0].Key, Is.EqualTo("modReqInterfaceName")); + Assert.That(apiConn.LastUpsertConfigItems[0].Value, Is.EqualTo("New interface name")); } [Test] - public void MergeLegacyOtherAddresses_MergesUniqueAddressesAndSetsSelectionFlags() + public async Task Page_ShowsLoadingAndReportsErrorWhenGlobalConfigIsUnavailable() { - EmailRecipientSelection selection = new() - { - None = true, - OtherAddresses = false, - OtherAddressList = RequestLegacyAddresses.ToList() - }; + await using BunitContext context = CreateContext(disposeGlobalConfig: true); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; - object?[] mergeLegacyArgs = - [ - selection, - "new@example.org; existing@example.org | second@example.org" - ]; - GetPrivateMethod("MergeLegacyOtherAddresses").Invoke(null, mergeLegacyArgs); + IRenderedComponent wrapper = RenderPage(context, (exception, title, message, isError) => messages.Add((exception, title, message, isError))); + IRenderedComponent page = wrapper.FindComponent(); - Assert.Multiple(() => + page.WaitForAssertion(() => { - Assert.That(selection.OtherAddresses, Is.True); - Assert.That(selection.None, Is.False); - Assert.That(selection.OtherAddressList, Is.EqualTo(new List { "legacy-request@example.org", "new@example.org", "existing@example.org", "second@example.org" })); + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("read_config"))); + Assert.That(messages[0].Message, Is.EqualTo(new SimulatedUserConfig().GetText("E5301"))); + Assert.That(messages[0].IsError, Is.False); + Assert.That(page.FindAll("div[role='status']"), Has.Count.EqualTo(1)); }); } [Test] - public async Task OnInitializedAsync_LoadsRecipientsAndMergesLegacyAddresses() + public async Task Save_ReportsErrorWhenConfigWriteFails() { - SettingsModellingNotifications component = new(); - SimulatedGlobalConfig globalConfig = new() - { - ModReqEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModReqEmailOtherAddresses = "legacy-request@example.org", - ModDecommEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModDecommEmailOtherAddresses = "legacy-decomm@example.org" - }; - RecordingSettingsApiConn apiConnection = new() - { - OwnerResponsibleTypes = - [ - new() { Id = 1, Active = true, Name = "Main", SortOrder = 1 }, - new() { Id = 2, Active = true, Name = "Supporting", SortOrder = 2 }, - new() { Id = 3, Active = false, Name = "Inactive", SortOrder = 3 } - ] - }; + await using BunitContext context = CreateContext(throwOnUpsert: true); + List<(Exception? Exception, string Title, string Message, bool IsError)> messages = []; - SetInjectedGlobalConfig(component, globalConfig); - SetInjectedApiConnection(component, apiConnection); + IRenderedComponent wrapper = RenderPage(context, (exception, title, message, isError) => messages.Add((exception, title, message, isError))); + IRenderedComponent page = wrapper.FindComponent(); - Task initTask = (Task)GetPrivateMethod("OnInitializedAsync").Invoke(component, null)!; - await initTask; + page.WaitForAssertion(() => Assert.That(page.FindAll("form"), Has.Count.EqualTo(1))); - EmailRecipientSelection modReq = GetPrivateField(component, "modReqEmailRecipients"); - EmailRecipientSelection modDecomm = GetPrivateField(component, "modDecommEmailRecipients"); + IElement interfaceNameInput = page.FindAll("label") + .First(label => label.TextContent.Contains("Name of requested interface")) + .ParentElement! + .QuerySelector("input")!; + interfaceNameInput.Change("New interface name"); - Assert.Multiple(() => + IElement saveButton = page.FindAll("button") + .Last(button => button.QuerySelector("span[title='Save']") != null); + saveButton.Click(); + + page.WaitForAssertion(() => { - Assert.That(modReq.OwnerResponsibleTypeIds, Is.EqualTo(RequestRecipientTypeIds)); - Assert.That(modReq.OtherAddressList, Is.EqualTo(RequestLegacyAddresses)); - Assert.That(modDecomm.OwnerResponsibleTypeIds, Is.EqualTo(DecommRecipientTypeIds)); - Assert.That(modDecomm.OtherAddressList, Is.EqualTo(DecommLegacyAddresses)); + Assert.That(messages, Has.Count.EqualTo(1)); + Assert.That(messages[0].Title, Is.EqualTo(new SimulatedUserConfig().GetText("interfaces"))); + Assert.That(messages[0].IsError, Is.True); }); } - [Test] - public async Task OnInitializedAsync_WhenOwnerQueryFails_KeepsFormAndSaveHidden() + private static BunitContext CreateContext(bool disposeGlobalConfig = false, bool throwOnUpsert = false) { SimulatedGlobalConfig globalConfig = new() { - ModReqEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModReqEmailOtherAddresses = "legacy-request@example.org", - ModDecommEmailReceiver = new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds - }.ToConfigValue(ActiveRecipientTypeIds), - ModDecommEmailOtherAddresses = "legacy-decomm@example.org" + ModReqInterfaceName = "Old interface name", + ModReqTicketTitle = "Old ticket title", + ModReqTaskTitle = "Old task title" + }; + if (disposeGlobalConfig) + { + globalConfig.Dispose(); + } + + SettingsModellingNotificationsApiConn apiConnection = new() + { + ThrowOnUpsert = throwOnUpsert, + OwnerResponsibleTypes = + [ + new() { Id = 1, Active = true, Name = "Main", SortOrder = 1 }, + new() { Id = 2, Active = true, Name = "Supporting", SortOrder = 2 } + ] }; - ThrowingSettingsApiConn apiConnection = new(OwnerQueries.getOwnerResponsibleTypes); - RecordingMessageSink sink = new(); - await using BunitContext context = new(); + SimulatedUserConfig userConfig = new(); + userConfig.User.Roles = [Roles.Admin]; + userConfig.SetExecutionMode(Roles.Admin); + + BunitContext context = new(); context.JSInterop.Mode = JSRuntimeMode.Loose; + context.Services.AddLocalization(); context.Services.AddAuthorizationCore(); context.Services.AddSingleton(); context.Services.AddSingleton(new AllowAllAuthStateProvider(Roles.Admin)); context.Services.AddSingleton(apiConnection); context.Services.AddSingleton(globalConfig); - context.Services.AddSingleton(new SimulatedUserConfig()); - - IRenderedComponent wrapper = context.Render(parameters => parameters - .AddChildContent>>(child => child - .Add(p => p.Value, sink.Handler) - .AddChildContent())); - - wrapper.WaitForAssertion(() => - { - Assert.That(wrapper.FindAll("form"), Is.Empty); - Assert.That(wrapper.FindAll("button"), Is.Empty); - Assert.That(sink.Messages, Has.Count.EqualTo(1)); - Assert.That(apiConnection.UpsertConfigCallCount, Is.Zero); - }); + context.Services.AddSingleton(userConfig); + context.Services.AddSingleton(typeof(IStringLocalizer<>), typeof(EmptyStringLocalizer<>)); + return context; } - [Test] - public void PrepareConfigData_SerializesRecipientsAndClearsLegacyAddresses() + private static IRenderedComponent RenderPage( + BunitContext context, + Action? messageSink = null) { - SettingsModellingNotifications component = new(); - ConfigData configData = new(); - SetPrivateField(component, "configData", configData); - SetPrivateField(component, "activeOwnerResponsibleTypes", new List - { - new() { Id = 1, Active = true, Name = "Main" }, - new() { Id = 2, Active = true, Name = "Supporting" } - }); - SetPrivateField(component, "modReqEmailRecipients", new EmailRecipientSelection - { - OwnerResponsibleTypeIds = RequestRecipientTypeIds, - OtherAddresses = true, - OtherAddressList = RequestOtherAddressList - }); - SetPrivateField(component, "modDecommEmailRecipients", new EmailRecipientSelection - { - OwnerResponsibleTypeIds = DecommRecipientTypeIds, - OtherAddresses = true, - OtherAddressList = DecommOtherAddressList - }); - - GetPrivateMethod("PrepareConfigData").Invoke(component, null); - - Assert.Multiple(() => - { - Assert.That(configData.ModReqEmailReceiver, Does.Contain("\"owner_responsible_type_ids\":[1]")); - Assert.That(configData.ModDecommEmailReceiver, Does.Contain("\"owner_responsible_type_ids\":[2]")); - Assert.That(configData.ModReqEmailOtherAddresses, Is.Empty); - Assert.That(configData.ModDecommEmailOtherAddresses, Is.Empty); - }); - } - - private sealed class RecordingMessageSink - { - public List<(Exception? Exception, string Title, string Message, bool IsError)> Messages { get; } = []; - - public void Handler(Exception? exception, string title, string message, bool isError) - { - Messages.Add((exception, title, message, isError)); - } + return context.Render(parameters => parameters + .AddChildContent(builder => + { + builder.OpenComponent>>(0); + builder.AddAttribute(1, "Value", messageSink ?? ((_, _, _, _) => { })); + builder.AddAttribute(2, "IsFixed", true); + builder.AddAttribute(3, "ChildContent", (RenderFragment)(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + })); } - private sealed class ThrowingSettingsApiConn : SimulatedApiConnection + private sealed class SettingsModellingNotificationsApiConn : NotificationTestApiConn { - private readonly string failedQuery; - + public List OwnerResponsibleTypes { get; init; } = []; public int UpsertConfigCallCount { get; private set; } + public List LastUpsertConfigItems { get; private set; } = []; + public bool ThrowOnUpsert { get; init; } - public ThrowingSettingsApiConn(string failedQuery) + public override async Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) { - this.failedQuery = failedQuery; - } - - public override Task SendQueryAsync(string query, object? variables = null, string? operationName = null, QueryChunkingOptions? chunkingOptions = null) - { - if (query == failedQuery) + if (query == OwnerQueries.getOwnerResponsibleTypes && typeof(QueryResponseType) == typeof(List)) { - throw new InvalidOperationException("Injected load failure."); + return (QueryResponseType)(object)OwnerResponsibleTypes; } if (query == ConfigQueries.upsertConfigItems) { UpsertConfigCallCount++; + if (ThrowOnUpsert) + { + throw new InvalidOperationException("Config write failed."); + } + if (variables != null) + { + PropertyInfo? configItemsProperty = variables.GetType().GetProperty("config_items"); + LastUpsertConfigItems = configItemsProperty == null + ? [] + : ((IEnumerable)configItemsProperty.GetValue(variables)!).ToList(); + } + + return default!; } - return Task.FromResult(default(QueryResponseType)!); + return await base.SendQueryAsync(query, variables, operationName, chunkingOptions); } } + + private sealed class EmptyStringLocalizer : IStringLocalizer + { + public LocalizedString this[string name] => new(name, name, resourceNotFound: true); + + public LocalizedString this[string name, params object[] arguments] => new(name, string.Format(name, arguments), resourceNotFound: true); + + public IEnumerable GetAllStrings(bool includeParentCultures) => []; + + public EmptyStringLocalizer WithCulture(System.Globalization.CultureInfo culture) => this; + } } } diff --git a/roles/tests-unit/files/FWO.Test/UiSettingsModellingTest.cs b/roles/tests-unit/files/FWO.Test/UiSettingsModellingTest.cs index 1ea5467412..c1c406b664 100644 --- a/roles/tests-unit/files/FWO.Test/UiSettingsModellingTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiSettingsModellingTest.cs @@ -1,4 +1,5 @@ using Bunit; +using AngleSharp.Dom; using FWO.Api.Client; using FWO.Api.Client.Queries; using FWO.Basics; @@ -533,6 +534,41 @@ public async Task SettingsModelling_InitializesLoadedConfig_ForPrivilegedUser() }); } + [Test] + public async Task SettingsModelling_RendersPredefinedServicesLabelAndAction() + { + SimulatedGlobalConfig globalConfig = CreateLoadedGlobalConfig(); + RecordingSettingsApiConn apiConnection = new() + { + IpProtocols = [], + ModellingGroups = [] + }; + SimulatedUserConfig userConfig = CreateUserConfig(Roles.Admin); + + await using BunitContext context = CreateContext(globalConfig, apiConnection, userConfig); + IRenderedComponent wrapper = RenderComponent(context); + + wrapper.WaitForAssertion(() => + { + IRenderedComponent page = wrapper.FindComponent(); + Assert.That(page.Find("#cbx_allow_server_in_conn"), Is.Not.Null); + }); + + IRenderedComponent component = wrapper.FindComponent(); + IElement predefinedServicesLabel = component.Find("div.form-group label"); + IElement predefinedServicesButton = component.Find("div.form-group button"); + + Assert.Multiple(() => + { + Assert.That(predefinedServicesLabel.TextContent.Trim(), Is.EqualTo("Predefined Services:")); + Assert.That(predefinedServicesButton, Is.Not.Null); + }); + + predefinedServicesButton.Click(); + + Assert.That(GetPrivateField(component.Instance, "predefServices"), Is.True); + } + [Test] public async Task SettingsModelling_RendersLoadingAndReportsInitErrors_WhenConfigLoadFails() { @@ -616,6 +652,7 @@ private static BunitContext CreateContext(SimulatedGlobalConfig globalConfig, Ap BunitContext context = new(); context.JSInterop.Mode = JSRuntimeMode.Loose; context.Services.AddAuthorizationCore(); + context.Services.AddLocalization(); context.Services.AddSingleton(); context.Services.AddSingleton(new AllowAllAuthStateProvider(Roles.Admin)); context.Services.AddSingleton(); diff --git a/roles/tests-unit/files/FWO.Test/UiSettingsNotificationsTest.cs b/roles/tests-unit/files/FWO.Test/UiSettingsNotificationsTest.cs index ecc591774e..2249090188 100644 --- a/roles/tests-unit/files/FWO.Test/UiSettingsNotificationsTest.cs +++ b/roles/tests-unit/files/FWO.Test/UiSettingsNotificationsTest.cs @@ -1,5 +1,8 @@ using FWO.Config.Api; using FWO.Config.Api.Data; +using FWO.Api.Client; +using FWO.Api.Client.Queries; +using FWO.Data; using FWO.Ui.Pages.Settings; using NUnit.Framework; using System.Collections; @@ -65,6 +68,17 @@ private static void SetInjectedGlobalConfig(SettingsNotifications component, Glo prop.SetValue(component, globalConfig); } + private static void SetInjectedService(SettingsNotifications component, Type serviceType, object service) + { + PropertyInfo? prop = typeof(SettingsNotifications).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .FirstOrDefault(p => p.PropertyType == serviceType); + if (prop == null) + { + throw new MissingMemberException(typeof(SettingsNotifications).FullName, serviceType.Name); + } + prop.SetValue(component, service); + } + private static IList CreateInitiatorList() { Type? entryType = typeof(SettingsNotifications).GetNestedType("RuleExpiryInitiatorText", BindingFlags.NonPublic); @@ -301,5 +315,56 @@ public void PrepareConfigData_PreservesUnknownStoredNotificationLanguage() Assert.That(configData.NotificationLanguage, Is.EqualTo("French")); } + + [Test] + public async Task Save_PersistsPreparedConfigAndClearsPendingInitiatorChanges() + { + SettingsNotifications component = new(); + SimulatedGlobalConfig globalConfig = new(); + SettingsNotificationsApiConnection apiConnection = new(); + SetInjectedGlobalConfig(component, globalConfig); + SetInjectedService(component, typeof(ApiConnection), apiConnection); + SetInjectedService(component, typeof(UserConfig), new SimulatedUserConfig()); + + IList initiatorKeys = CreateInitiatorList(); + IList initiatorKeysToAdd = CreateInitiatorList(); + IList initiatorKeysToDelete = CreateInitiatorList(); + initiatorKeysToAdd.Add(CreateInitiatorEntry("custom", "Custom text")); + SetPrivateField(component, "configData", new ConfigData { RuleExpiryEmailBody = "Updated body" }); + SetPrivateField(component, "initiatorKeys", initiatorKeys); + SetPrivateField(component, "initiatorKeysToAdd", initiatorKeysToAdd); + SetPrivateField(component, "initiatorKeysToDelete", initiatorKeysToDelete); + + await (Task)GetPrivateMethod("Save").Invoke(component, null)!; + + Assert.Multiple(() => + { + Assert.That(apiConnection.UpsertConfigCallCount, Is.EqualTo(1)); + Assert.That(apiConnection.LastConfigItems.Any(item => item.Key == "ruleExpiryEmailBody" && item.Value == "Updated body"), Is.True); + Assert.That(GetPrivateField(component, "initiatorKeysToAdd"), Is.Empty); + Assert.That(GetPrivateField(component, "initiatorKeysToDelete"), Is.Empty); + }); + } + + private sealed class SettingsNotificationsApiConnection : SimulatedApiConnection + { + public int UpsertConfigCallCount { get; private set; } + public List LastConfigItems { get; private set; } = []; + + public override Task SendQueryAsync(string query, object? variables = null, + string? operationName = null, QueryChunkingOptions? chunkingOptions = null) + { + if (query == ConfigQueries.upsertConfigItems) + { + UpsertConfigCallCount++; + PropertyInfo configItemsProperty = variables?.GetType().GetProperty("config_items") + ?? throw new ArgumentException("Missing config_items variable."); + LastConfigItems = ((IEnumerable)configItemsProperty.GetValue(variables)!).ToList(); + return Task.FromResult(default(QueryResponseType)!); + } + + throw new NotImplementedException($"Unexpected query: {query}"); + } + } } } diff --git a/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs b/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs index 68c0e5384b..cdb39f12fd 100644 --- a/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs +++ b/roles/tests-unit/files/FWO.Test/WfDbAccessTest.cs @@ -2437,7 +2437,11 @@ public async Task LogWorkflowChange_KeepsTheChangerIdPresetMutation_InTheUiConte // the user roles fill changer_id from the session preset, which removes the column from their // insert input - naming it in the mutation would make every UI history insert fail - Assert.That(apiConn.LastHistoryQuery, Is.EqualTo(ModellingQueries.addHistoryEntry)); + Assert.Multiple(() => + { + Assert.That(apiConn.LastHistoryQuery, Is.EqualTo(ModellingQueries.addHistoryEntry)); + Assert.That(apiConn.LastHistoryVariables?.GetType().GetProperty("changerId"), Is.Null); + }); } private static WfState CreatePromotingState(int stateId) diff --git a/roles/tests-unit/files/FWO.Test/WfHandlerImplTasksTest.cs b/roles/tests-unit/files/FWO.Test/WfHandlerImplTasksTest.cs index a6e06c4f2a..d1865d1224 100644 --- a/roles/tests-unit/files/FWO.Test/WfHandlerImplTasksTest.cs +++ b/roles/tests-unit/files/FWO.Test/WfHandlerImplTasksTest.cs @@ -613,6 +613,25 @@ public async Task RequestTasksForInitialImplCreation_SkipsAccessTasksWithoutElem Assert.That(requestTasks, Is.Empty); } + [Test] + public async Task RequestTasksForInitialImplCreation_DoesNotReloadNewInterfaceTasksWithoutElements() + { + WfReqTask newInterfaceTask = new() + { + Id = 11, + TicketId = 7, + TaskType = WfTaskType.new_interface.ToString() + }; + WfHandler handler = CreateBundlingHandler(considerBundling: false, newInterfaceTask); + SetDbAccess(handler, new RequestTaskDetailsApiConn(new WfTicket { Id = 7 })); + + List requestTasks = await InvokeRequestTasksForInitialImplCreation(handler, + new List { newInterfaceTask }); + + Assert.That(requestTasks, Has.Count.EqualTo(1)); + Assert.That(requestTasks[0], Is.SameAs(newInterfaceTask)); + } + private static async Task InvokeAutoCreateImplTasks(WfHandler handler, WfReqTask reqTask) { MethodInfo method = typeof(WfHandler).GetMethod("AutoCreateImplTasks", BindingFlags.Instance | BindingFlags.NonPublic) diff --git a/roles/tests-unit/files/FWO.Test/WfHandlerStateChangesTest.cs b/roles/tests-unit/files/FWO.Test/WfHandlerStateChangesTest.cs index a37af79762..6df74eb80c 100644 --- a/roles/tests-unit/files/FWO.Test/WfHandlerStateChangesTest.cs +++ b/roles/tests-unit/files/FWO.Test/WfHandlerStateChangesTest.cs @@ -787,7 +787,7 @@ public async Task UpdateActTicketStateFromReqTasks_CanDowngradeTicketFromRequest }; MethodInfo? method = typeof(WfHandler).GetMethod("UpdateActTicketStateFromReqTasks", BindingFlags.NonPublic | BindingFlags.Instance); - await (Task)(method?.Invoke(handler, [true, true]) ?? throw new InvalidOperationException("Method not found.")); + await (Task)(method?.Invoke(handler, [true, true, null]) ?? throw new InvalidOperationException("Method not found.")); Assert.That(handler.ActTicket.StateId, Is.EqualTo(1)); } diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml new file mode 100644 index 0000000000..137bde3d40 --- /dev/null +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringEmailLog.cshtml @@ -0,0 +1,18 @@ +@page "/help/monitoring/email_log" +@model FWO.Ui.Pages.Help.MainModel +@{ + Layout = "HelpLayout"; +} +@section sidebar +{ + @{ + await Html.RenderPartialAsync("HelpMonitoringSidebar.cshtml"); + } +} +@using FWO.Config.Api +@inject UserConfig userConfig + +
    +

    @userConfig.GetText("email_log")

    + @(Html.Raw(userConfig.GetText("H7414"))) +
    diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml index 8649584739..7168dbf118 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpMonitoringSidebar.cshtml @@ -65,6 +65,11 @@ @(userConfig.GetText("tickets"))

    +
    @(userConfig.GetText("notifications"))
    + + @(userConfig.GetText("email_log")) + +

    @(userConfig.GetText("personal"))
    @(userConfig.GetText("ui_messages")) diff --git a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml index 751bb6eaf7..163e3e0ead 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/HelpSettingsModellingNotifications.cshtml @@ -12,25 +12,39 @@ @inject UserConfig userConfig
    -

    @(userConfig.GetText("notifications"))

    -

    @(Html.Raw(userConfig.GetText("request_interface")))

    +

    @(Html.Raw(userConfig.GetText("notifications")))

    +

    @(Html.Raw(userConfig.GetText("interface_ticket")))

    • @(Html.Raw(userConfig.GetText("H5622")))
    • -
    • @(Html.Raw(userConfig.GetText("H5630")))
    • -
    • @(Html.Raw(userConfig.GetText("H5623")))
    • -
    • @(Html.Raw(userConfig.GetText("H5624")))
    • @(Html.Raw(userConfig.GetText("H5625")))
    • @(Html.Raw(userConfig.GetText("H5626")))
    • -
    • @(Html.Raw(userConfig.GetText("H5624a")))
    -

    @(Html.Raw(userConfig.GetText("reminders")))

    + +

    @(Html.Raw(userConfig.GetText("request_interface")))

    +

    @(Html.Raw(userConfig.GetText("H5640")))

      -
    • @(Html.Raw(userConfig.GetText("H5629")))
    • +
    • @(Html.Raw(userConfig.GetText("H5630")))
    -

    @(Html.Raw(userConfig.GetText("decommission")))

    + +

    @(Html.Raw(userConfig.GetText("reminder")))

    +

    @(Html.Raw(userConfig.GetText("H5641")))

    + +

    @(Html.Raw(userConfig.GetText("decomm_interface")))

    +

    @(Html.Raw(userConfig.GetText("H5642")))

    + +

    @(Html.Raw(userConfig.GetText("common_notification_settings")))

      -
    • @(Html.Raw(userConfig.GetText("H5660")))
    • -
    • @(Html.Raw(userConfig.GetText("H5661")))
    • -
    • @(Html.Raw(userConfig.GetText("H5662")))
    • +
    • @(Html.Raw(userConfig.GetText("H4109")))
    • +
    • @(Html.Raw(userConfig.GetText("H4101")))
    • +
    • @(Html.Raw(userConfig.GetText("H4102")))
    • +
    • @(Html.Raw(userConfig.GetText("H4103")))
    • +
    • @(Html.Raw(userConfig.GetText("H4104")))
    • +
    • @(Html.Raw(userConfig.GetText("H4105")))
    • +
    • @(Html.Raw(userConfig.GetText("H4106")))
    • +
    • @(Html.Raw(userConfig.GetText("H4107")))
    • +
    • @(Html.Raw(userConfig.GetText("H4108")))
    • +
    • @(Html.Raw(userConfig.GetText("H4108a")))
    • +
    • @(Html.Raw(userConfig.GetText("H4110")))
    • +
    • @(Html.Raw(userConfig.GetText("H4111")))
    diff --git a/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml b/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml index 10c7d1c172..baa130f707 100644 --- a/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml +++ b/roles/ui/files/FWO.UI/Pages/Help/Index.cshtml @@ -155,6 +155,9 @@
    + diff --git a/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor new file mode 100644 index 0000000000..d31f00fdeb --- /dev/null +++ b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorEmailLog.razor @@ -0,0 +1,124 @@ +@inject ApiConnection apiConnection +@inject UserConfig userConfig + +@page "/monitoring/email_log" +@attribute [Authorize(Roles = $"{Roles.Admin}, {Roles.FwAdmin}, {Roles.Auditor}")] + +
    +

    @(userConfig.GetText("email_log"))

    + +
    +@(userConfig.GetText("H7414")) +
    + +@if (InitComplete) +{ +
    + +
    + + + +
    + + + + + + + + + + + + + + + + + + +
    +
    +} +else +{ + +} + +@code +{ + [CascadingParameter] + Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; + + private List notificationLogEntries = []; + private bool InitComplete = false; + + private const int kDefaultPageSize = 20; + private int PageSize { get; set; } = kDefaultPageSize; + private int CurrentPage { get; set; } + private bool HasNextPage { get; set; } + + protected override async Task OnInitializedAsync() + { + try + { + await FetchPage(); + InitComplete = true; + } + catch (Exception exception) + { + DisplayMessageInUi(exception, userConfig.GetText("fetch_log_entrys"), "", true); + } + } + + private async Task UpdatePageSize(int pageSize) + { + int requestedPageSize = pageSize > 0 ? pageSize : kDefaultPageSize; + if (InitComplete && requestedPageSize == PageSize) + { + return; + } + + PageSize = requestedPageSize; + CurrentPage = 0; + await FetchPage(); + } + + private async Task PreviousPage() + { + if (CurrentPage > 0) + { + CurrentPage--; + await FetchPage(); + } + } + + private async Task NextPage() + { + if (HasNextPage) + { + CurrentPage++; + await FetchPage(); + } + } + + private async Task FetchPage() + { + List fetchedEntries = await apiConnection.SendQueryAsync>( + MonitorQueries.getNotificationLogEntrys, + new + { + limit = PageSize + 1, + offset = CurrentPage * PageSize + }); + HasNextPage = fetchedEntries.Count > PageSize; + notificationLogEntries = [.. fetchedEntries.Take(PageSize)]; + } +} diff --git a/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorRequestedInterfaces.razor b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorRequestedInterfaces.razor index 9b4a39a770..999fc8e4b4 100644 --- a/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorRequestedInterfaces.razor +++ b/roles/ui/files/FWO.UI/Pages/Monitoring/MonitorRequestedInterfaces.razor @@ -153,7 +153,7 @@ else } - + @@ -212,6 +212,7 @@ else private ModellingConnectionHandler? DisplayConnHandler { get; set; } private bool ShowDisplayPopup { get; set; } = false; private ModellingConnectionHandler? RejectConnHandler { get; set; } + private FwoOwner? RejectRequestingApp { get; set; } private bool ShowRejectPopup { get; set; } = false; private bool ShowOrphanedTicketsPopup { get; set; } = false; private bool ConfirmRejectRemovedTickets { get; set; } = false; @@ -575,6 +576,7 @@ else private async Task RejectInterface(ModellingConnection connection) { + RejectRequestingApp = GetRequestingApp(connection); FwoOwner? application = connection.App; if ((application == null || application.Id <= 0) && connection.AppId != null && OwnersById.TryGetValue(connection.AppId.Value, out FwoOwner? resolvedOwner)) diff --git a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RejectInterfacePopup.razor b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RejectInterfacePopup.razor index f70856fa02..c95872c66d 100644 --- a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RejectInterfacePopup.razor +++ b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RejectInterfacePopup.razor @@ -1,4 +1,7 @@ @using FWO.Middleware.Client +@using FWO.Data +@using FWO.Api.Client.Queries +@using FWO.Data.Workflow @using FWO.Compliance @@ -65,10 +68,14 @@ [Parameter] public bool AllowAdminReject { get; set; } = false; + [Parameter] + public FwoOwner? RequestingApp { get; set; } + private string Reason { get; set; } = ""; private string Message { get; set; } = ""; private bool WorkInProgress = false; private bool firstTry = true; + private FwoOwner? resolvedRequestingApp; protected override void OnParametersSet() @@ -95,6 +102,10 @@ WorkInProgress = true; try { + await ResolveRequestingApp(); + NotificationPlaceholderData placeholderData = BuildNotificationPlaceholderData(); + await ConnHandler.RefreshActConn(); + long? ticketId = ConnHandler.ActConn.TicketId; ConnHandler.ActConn.AddProperty(ConState.Rejected.ToString()); bool saved = AllowAdminReject ? await ConnHandler.SavePropertiesOnly() @@ -104,7 +115,7 @@ return; } await ConnHandler.RemoveFromAllSelections(); - await RejectInTicket(); + await RejectInTicket(ticketId, placeholderData); await RefreshParent(); Close(); } @@ -119,22 +130,69 @@ } } - private async Task RejectInTicket() + private async Task RejectInTicket(long? ticketId, NotificationPlaceholderData? placeholderData = null) { - if(ConnHandler != null && ConnHandler.ActConn.TicketId != null) + if(ConnHandler != null && ticketId != null) { TicketCreator ticketCreator = new(apiConnection, userConfig, authenticationStateTask!.Result.User, middlewareClient, WorkflowPhases.implementation, new ComplianceRequestedRulePolicyChecker(userConfig, apiConnection, middlewareClient)); - if(await ticketCreator.PromoteNewInterfaceImplTask((long)ConnHandler.ActConn.TicketId, ExtStates.Rejected, Reason)) + if(await ticketCreator.PromoteNewInterfaceImplTask(ticketId.Value, ExtStates.Rejected, Reason, + placeholderData ?? BuildNotificationPlaceholderData())) { DisplayMessageInUi(null, userConfig.GetText("reject_interface"), userConfig.GetText("U9018"), false); } } } + private NotificationPlaceholderData BuildNotificationPlaceholderData() + { + string interfaceName = ConnHandler?.ActConn.Name ?? ""; + string interfaceUrl = ConnHandler?.Application == null + ? "" + : $"{userConfig.UiHostName}/{PageName.Modelling}/{ConnHandler.Application.ExtAppId}/{ConnHandler.ActConn.Id}"; + return new NotificationPlaceholderData + { + RequestingAppName = (RequestingApp ?? resolvedRequestingApp)?.Name ?? "", + RequestingAppId = (RequestingApp ?? resolvedRequestingApp)?.ExtAppId ?? "", + InterfaceName = interfaceName, + InterfaceLinkText = userConfig.GetText("interface"), + InterfaceLinkName = interfaceName, + InterfaceLinkUrl = interfaceUrl, + Content = Reason, + UserName = userConfig.User?.Name ?? "", + RequestDate = ConnHandler?.ActConn.CreationDate?.ToString("dd.MM.yyyy") ?? "" + }; + } + + private async Task ResolveRequestingApp() + { + ModellingConnectionHandler? handler = ConnHandler; + if (RequestingApp != null || resolvedRequestingApp != null || handler?.ActConn.TicketId is not long ticketId) + { + return; + } + + try + { + WfTicket ticket = await apiConnection.SendQueryAsync(RequestQueries.getTicketById, new { id = ticketId }); + WfReqTask? requestTask = ticket.Tasks.FirstOrDefault(task => + task.GetAddInfoIntValueOrZero(AdditionalInfoKeys.ConnId) == handler.ActConn.Id); + int requestingAppId = requestTask?.GetAddInfoIntValueOrZero(AdditionalInfoKeys.ReqOwner) ?? 0; + if (requestingAppId > 0) + { + resolvedRequestingApp = await apiConnection.SendQueryAsync(OwnerQueries.getOwnerById, new { id = requestingAppId }); + } + } + catch + { + resolvedRequestingApp = null; + } + } + private void Close() { Display = false; firstTry = true; + resolvedRequestingApp = null; Reason = ""; Message = ""; DisplayChanged.InvokeAsync(Display); diff --git a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor index cf5e1dc5f1..064dc68770 100644 --- a/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor +++ b/roles/ui/files/FWO.UI/Pages/NetworkModelling/RequestInterfacePopup.razor @@ -1,7 +1,9 @@ -@using FWO.Middleware.Client - +@using FWO.Data +@using FWO.Api.Client.Queries @using FWO.Compliance - +@using FWO.Middleware.Client +@using FWO.Services +@using RestSharp @inject ApiConnection apiConnection @inject UserConfig userConfig @inject MiddlewareClient middlewareClient @@ -150,26 +152,29 @@ } private async Task SendEmail(long connId) - { + { try { - EmailHelper emailHelper = new(apiConnection, middlewareClient, userConfig, DisplayMessageInUi); - - await emailHelper.Init(); - - string interfaceUrl = $"{userConfig.UiHostName}/{PageName.Modelling}/{SelectedOwner.ExtAppId}/{connId}"; - string subject = userConfig.ModReqEmailSubject; - string body = $"{RequestingOwner.Name} {userConfig.ModReqEmailBody} {SelectedOwner.Name}
    " + - $"{userConfig.GetText("interface")}: {InterfaceName}
    "; - List legacyOtherAddresses = EmailHelper.SplitAddresses(userConfig.ModReqEmailOtherAddresses); - if(await emailHelper.SendEmailToOwnerResponsibles(SelectedOwner, subject, body, userConfig.ModReqEmailReceiver, userConfig.ModReqEmailRequesterInCc, legacyOtherAddresses)) + RestResponse response = await middlewareClient.SendInterfaceRequestNotification((int)connId); + if (!response.IsSuccessful) { - DisplayMessageInUi(null, userConfig.GetText("send_email"), userConfig.GetText("U9011"), false); + string responseDetails = response.ErrorMessage + ?? response.Content + ?? response.StatusDescription + ?? response.StatusCode.ToString(); + throw new InvalidOperationException( + $"Middleware interface request notification failed with HTTP {(int)response.StatusCode}: {responseDetails}"); } - else + + NotificationDeliveryResult deliveryResult = response.Data; + if (deliveryResult is NotificationDeliveryResult.Failed or NotificationDeliveryResult.NoRecipients) { DisplayMessageInUi(null, userConfig.GetText("send_email"), userConfig.GetText("E9011"), true); } + else if (deliveryResult == NotificationDeliveryResult.Delivered) + { + DisplayMessageInUi(null, userConfig.GetText("send_email"), userConfig.GetText("U9011"), false); + } } catch (Exception exception) { diff --git a/roles/ui/files/FWO.UI/Pages/NetworkModelling/SearchInterface.razor b/roles/ui/files/FWO.UI/Pages/NetworkModelling/SearchInterface.razor index de83d002b2..ed6f9ac857 100644 --- a/roles/ui/files/FWO.UI/Pages/NetworkModelling/SearchInterface.razor +++ b/roles/ui/files/FWO.UI/Pages/NetworkModelling/SearchInterface.razor @@ -111,6 +111,11 @@ foreach (ModellingConnection @interface in SelectedInterfaces) { + if (PreselectedInterfaces.Any(selected => selected.Id == @interface.Id)) + { + continue; + } + var Variables = new { appId = Application.Id, @@ -118,7 +123,7 @@ }; await apiConnection.SendQueryAsync(ModellingQueries.addSelectedConnection, Variables); - PreselectedInterfaces.Add(@interface ?? throw new ArgumentException("No Interface selected.")); + PreselectedInterfaces.Add(@interface); } RefreshSelectionList(); diff --git a/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor b/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor index b6b0b273cb..0886b5f0c4 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/EmailRecipientSelectionEditor.razor @@ -3,6 +3,15 @@ @inject UserConfig userConfig
    + @if (ShowRequesterOption) + { +
    + + +
    + } @if (ShowOwnerResponsibleTypes) { @foreach (OwnerResponsibleType ownerResponsibleType in GetOrderedOwnerResponsibleTypes()) @@ -55,6 +64,9 @@ [Parameter] public NotificationClient Client { get; set; } = NotificationClient.None; + [Parameter] + public bool ShowRequesterOption { get; set; } = false; + private bool ShowOwnerResponsibleTypes => Client == NotificationClient.None || Client.IsModellingRecipientClient(); private bool ShowEnsureAtLeastOneNotification => ShowOwnerResponsibleTypes; private List GetOrderedOwnerResponsibleTypes() @@ -79,6 +91,20 @@ } } + private void ToggleRequesterSelection(ChangeEventArgs args) + { + bool isChecked = IsChecked(args); + Selection.Requester = isChecked; + if (isChecked) + { + Selection.None = false; + } + else + { + UpdateDerivedNone(); + } + } + private Task SetOtherAddressList(List addresses) { Selection.OtherAddressList = addresses; @@ -111,7 +137,7 @@ private void UpdateDerivedNone() { - Selection.None = !Selection.OtherAddresses && Selection.OwnerResponsibleTypeIds.Count == 0; + Selection.None = !Selection.Requester && !Selection.OtherAddresses && Selection.OwnerResponsibleTypeIds.Count == 0; } private static bool IsChecked(ChangeEventArgs args) diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModelling.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModelling.razor index 4a430eecf6..538455df34 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModelling.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModelling.razor @@ -26,10 +26,14 @@ @if(configData != null && initComplete) {
    - +
    + +
    + +
    +

    diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor index c128cb0165..ed3a61134c 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsModellingNotifications.razor @@ -1,12 +1,7 @@ -@using FWO.Api.Client.Queries @using FWO.Basics @using FWO.Config.Api.Data @using FWO.Data -@using FWO.Data.Middleware -@using FWO.Middleware.Client @using FWO.Services -@using FWO.Ui.Data -@using FWO.Ui.Pages.NetworkModelling @page "/settings/modellingnotifications" @attribute [Authorize(Roles = $"{Roles.Admin}, {Roles.Auditor}")] @@ -24,31 +19,11 @@ @if (configData != null && initComplete) { +

    @(userConfig.GetText("request_interface"))

    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - - -
    -
    - - -
    @@ -57,47 +32,41 @@
    -
    - - +
    + + + + + + + +

    -
    - -
    - -
    +

    @(userConfig.GetText("initial_request"))

    +
    +

    -
    - -
    - -
    +
    +

    @(userConfig.GetText("reminder"))

    +
    -
    - - +
    +
    +

    @(userConfig.GetText("decomm_interface"))

    -
    - - +
    +
    -
    - - - - - - - - } else { @@ -110,9 +79,6 @@ else Action DisplayMessageInUi { get; set; } = DefaultInit.DoNothing; private ConfigData? configData; - private List activeOwnerResponsibleTypes = []; - private EmailRecipientSelection modReqEmailRecipients = new(); - private EmailRecipientSelection modDecommEmailRecipients = new(); private bool initComplete; protected override async Task OnInitializedAsync() @@ -120,15 +86,6 @@ else try { ConfigData editableConfig = await globalConfig.GetEditableConfig(); - List ownerResponsibleTypes = await apiConnection.SendQueryAsync>(OwnerQueries.getOwnerResponsibleTypes); - activeOwnerResponsibleTypes = ownerResponsibleTypes - .Where(type => type.Active) - .ToList(); - List activeTypeIds = activeOwnerResponsibleTypes.Select(type => type.Id).ToList(); - modReqEmailRecipients = EmailRecipientSelection.Parse(editableConfig.ModReqEmailReceiver, activeTypeIds); - modDecommEmailRecipients = EmailRecipientSelection.Parse(editableConfig.ModDecommEmailReceiver, activeTypeIds); - MergeLegacyOtherAddresses(modReqEmailRecipients, editableConfig.ModReqEmailOtherAddresses); - MergeLegacyOtherAddresses(modDecommEmailRecipients, editableConfig.ModDecommEmailOtherAddresses); configData = editableConfig; initComplete = true; } @@ -147,41 +104,12 @@ else throw new ArgumentException("Data saved before loaded. This should be impossible."); } - PrepareConfigData(); await globalConfig.WriteToDatabase(configData, apiConnection); - DisplayMessageInUi(null, userConfig.GetText("notifications"), userConfig.GetText("U5301"), false); + DisplayMessageInUi(null, userConfig.GetText("interfaces"), userConfig.GetText("U5301"), false); } catch (Exception exception) { - DisplayMessageInUi(exception, userConfig.GetText("notifications"), "", true); - } - } - - private void PrepareConfigData() - { - List activeTypeIds = activeOwnerResponsibleTypes.Select(type => type.Id).ToList(); - configData!.ModReqEmailReceiver = modReqEmailRecipients.ToConfigValue(activeTypeIds); - configData!.ModDecommEmailReceiver = modDecommEmailRecipients.ToConfigValue(activeTypeIds); - configData!.ModReqEmailOtherAddresses = ""; - configData!.ModDecommEmailOtherAddresses = ""; - } - - private static void MergeLegacyOtherAddresses(EmailRecipientSelection selection, string legacyOtherAddresses) - { - List addresses = EmailHelper.SplitAddresses(legacyOtherAddresses); - if (addresses.Count == 0) - { - return; - } - - selection.OtherAddresses = true; - selection.None = false; - foreach (string address in addresses) - { - if (!selection.OtherAddressList.Contains(address, StringComparer.OrdinalIgnoreCase)) - { - selection.OtherAddressList.Add(address); - } + DisplayMessageInUi(exception, userConfig.GetText("interfaces"), "", true); } } } diff --git a/roles/ui/files/FWO.UI/Pages/Settings/SettingsRecertificationGen.razor b/roles/ui/files/FWO.UI/Pages/Settings/SettingsRecertificationGen.razor index 673e7eda9c..abb4bdbd1a 100644 --- a/roles/ui/files/FWO.UI/Pages/Settings/SettingsRecertificationGen.razor +++ b/roles/ui/files/FWO.UI/Pages/Settings/SettingsRecertificationGen.razor @@ -131,12 +131,12 @@
    } - else + else if(configData!.RecertificationMode == RecertificationMode.RuleByRule) { -
    - +
    +
    - +
    } diff --git a/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs b/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs index c92182cdfe..9cf7df22e7 100644 --- a/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs +++ b/roles/ui/files/FWO.UI/Services/NotificationMutationHelper.cs @@ -96,7 +96,9 @@ private static FwoNotification Clone(FwoNotification notification) InitialOffsetAfterDeadline = notification.InitialOffsetAfterDeadline, RepeatOffsetAfterDeadline = notification.RepeatOffsetAfterDeadline, RepetitionsAfterDeadline = notification.RepetitionsAfterDeadline, - LastSent = notification.LastSent + LastSent = notification.LastSent, + Logging = notification.Logging, + Active = notification.Active }; } @@ -149,6 +151,10 @@ private record NotificationMutationVariables public int? repetitionsAfterDeadline { get; init; } + public string? logging { get; init; } + + public bool active { get; init; } + protected NotificationMutationVariables() { } @@ -177,6 +183,8 @@ protected NotificationMutationVariables(NotificationMutationVariables variables) initialOffsetAfterDeadline = variables.initialOffsetAfterDeadline; offsetAfterDeadline = variables.offsetAfterDeadline; repetitionsAfterDeadline = variables.repetitionsAfterDeadline; + logging = variables.logging; + active = variables.active; } public static NotificationMutationVariables Create(FwoNotification notification) @@ -204,7 +212,9 @@ public static NotificationMutationVariables Create(FwoNotification notification) intervalAfterDeadline = (int?)notification.RepeatIntervalAfterDeadline, initialOffsetAfterDeadline = notification.InitialOffsetAfterDeadline, offsetAfterDeadline = notification.RepeatOffsetAfterDeadline, - repetitionsAfterDeadline = notification.RepetitionsAfterDeadline + repetitionsAfterDeadline = notification.RepetitionsAfterDeadline, + logging = notification.Logging, + active = notification.Active }; } } diff --git a/roles/ui/files/FWO.UI/Shared/ConnectionTable.razor b/roles/ui/files/FWO.UI/Shared/ConnectionTable.razor index df035b99b4..3a9d8fbc24 100644 --- a/roles/ui/files/FWO.UI/Shared/ConnectionTable.razor +++ b/roles/ui/files/FWO.UI/Shared/ConnectionTable.razor @@ -318,7 +318,8 @@ private bool IsVisibleToOwner(ModellingConnection interf) { - if(interf.InterfacePermission == InterfacePermissions.Public.ToString() || interf.App.Id == Application.Id) + if(interf.InterfacePermission == InterfacePermissions.Public.ToString() + || (interf.App != null && Application != null && interf.App.Id == Application.Id)) { return true; } @@ -326,7 +327,8 @@ { return false; } - return interf.PermittedOwnerWrappers.Any(wrapper => wrapper.Owner != null && wrapper.Owner.Id == Application.Id); + return interf.PermittedOwnerWrappers?.Any(wrapper => + wrapper.Owner != null && Application != null && wrapper.Owner.Id == Application.Id) == true; } private List CollectModellingProps(ModellingConnection connection) diff --git a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor index c0c671fe1d..57aa131b7d 100644 --- a/roles/ui/files/FWO.UI/Shared/EditNotifications.razor +++ b/roles/ui/files/FWO.UI/Shared/EditNotifications.razor @@ -24,6 +24,11 @@
    + + +