From 2971d38036cc6c61c95688600b15c9c0a8f0a6ab Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Mon, 20 Dec 2021 15:12:02 +0100 Subject: [PATCH 01/73] Adjust github CI for doc latest Signed-off-by: freddidierRTE --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8f718b18f3..93ca8e97d0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -141,6 +141,8 @@ jobs: run: | ./gradlew --build-cache generateSwaggerUI asciidoctor; ./CICD/github/upload_doc.sh --updateLatest true + env: + GH_DOC_TOKEN: ${{ secrets.GH_DOC_TOKEN}} - name : Push images to dockerhub if: ${{ github.event.inputs.dockerPush == 'true' || github.event_name == 'schedule' || github.ref_name == 'master' }} From 16173e0f3e55bbd521c1b8801ab6e3da85f3edff Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sun, 19 Dec 2021 04:16:11 +0000 Subject: [PATCH 02/73] Update dependency com.fasterxml.jackson.core:jackson-databind to v2.13.1 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b251203122..00410985fc 100755 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ commonsIO=2.11.0 lombok=1.18.22 feign=11.7 jacksonAnnotations=2.13.0 -jacksonDatabind=2.13.0 +jacksonDatabind=2.13.1 kavroSchemaRegistryClient=7.0.0 kavroAvroSerializer=7.0.0 springKafka=2.8.0 From d999865526c33a976fdf3166cd9ea67e756782dd Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 16 Dec 2021 23:07:45 +0000 Subject: [PATCH 03/73] Update dependency org.springframework:spring-webflux to v5.3.14 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 00410985fc..8ec793fdeb 100755 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # spring libs springBoot=2.6.1 springCloud=2021.0.0 -springWebflux=5.3.13 +springWebflux=5.3.14 springSecurity=5.6.0 springRetry=1.3.1 springOpenFeign=3.1.0 From 9fb2505eb7be9700e2bc938482d3f6f6d9812900 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Mon, 20 Dec 2021 21:23:44 +0100 Subject: [PATCH 04/73] Adding documentation (#2038) Signed-off-by: Alexandra Guironnet --- .../asciidoc/dev_env/project_structure.adoc | 1 + .../asciidoc/dev_env/testing_ext_dev.adoc | 31 +++ .../images/ExtDevArchitecture.drawio.png | Bin 0 -> 78579 bytes .../external_devices_service.adoc | 197 +++++++++++++++--- src/docs/asciidoc/resources/index.adoc | 2 - 5 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 src/docs/asciidoc/dev_env/testing_ext_dev.adoc create mode 100644 src/docs/asciidoc/images/ExtDevArchitecture.drawio.png diff --git a/src/docs/asciidoc/dev_env/project_structure.adoc b/src/docs/asciidoc/dev_env/project_structure.adoc index 8baba16a78..689f44e0fe 100644 --- a/src/docs/asciidoc/dev_env/project_structure.adoc +++ b/src/docs/asciidoc/dev_env/project_structure.adoc @@ -43,6 +43,7 @@ tests and demonstrations ** link:https://github.com/opfab/operatorfabric-core/tree/master/src/test[test] *** link:https://github.com/opfab/operatorfabric-core/tree/master/src/test/api[api] : karate code for automatic api testing (non-regression tests) *** link:https://github.com/opfab/operatorfabric-core/tree/master/src/test/cypress[cypress] : cypress code for automatic ui testing +*** link:https://github.com/opfab/operatorfabric-core/tree/master/src/test/dummyModbusDevice[dummyModbusDevice] : application emulating a Modbus device for test purposes *** link:https://github.com/opfab/operatorfabric-core/tree/master/src/test/resources[resources] : scripts and data for manual testing * link:https://github.com/opfab/operatorfabric-core/tree/master/tools[tools] ** link:https://github.com/opfab/operatorfabric-core/tree/master/tools/generic[generic]: Generic (as opposed to Spring-related) diff --git a/src/docs/asciidoc/dev_env/testing_ext_dev.adoc b/src/docs/asciidoc/dev_env/testing_ext_dev.adoc new file mode 100644 index 0000000000..1a1405e16c --- /dev/null +++ b/src/docs/asciidoc/dev_env/testing_ext_dev.adoc @@ -0,0 +1,31 @@ +// Copyright (c) 2021 RTE (http://www.rte-france.com) +// See AUTHORS.txt +// This document is subject to the terms of the Creative Commons Attribution 4.0 International license. +// If a copy of the license was not distributed with this +// file, You can obtain one at https://creativecommons.org/licenses/by/4.0/. +// SPDX-License-Identifier: CC-BY-4.0 + += Testing the External Devices service with dummy devices + +To test the External Devices service with dummy devices, launch an OperatorFabric instance using the +`$OF_HOME/config/docker/docker-compose.sh` script. + +As described in `$OF_HOME/config/docker/docker-compose.yml`, in addition to lauching all OperatorFabric services it +will also start up two containers based on the dummy-modbus-device image: `dummy-modbus-device_1` and +`dummy-modbus-device_2`, both listening on their port 4030. + +This matches default configuration provided: +.$OF_HOME/config/docker/external-devices-docker.yml + +[source,yaml] +---- +include::../../../../config/docker/external-devices-docker.yml[] +---- + +This means that provided the users have chosen to play sounds for these severities and to play sounds +on external devices, sending an ALARM card to operator1 should result in a message about writing 1 in the +dummy-modbus-device_1 logs, and sending an ACTION card to operator2 should result in a message about writing +6 in dummy-modbus-device_2. + +Since the watchdog is enabled, once the devices are connected (either by calling the /connect endpoint or +by sending the first signal), you should also see the corresponding messages in the logs. \ No newline at end of file diff --git a/src/docs/asciidoc/images/ExtDevArchitecture.drawio.png b/src/docs/asciidoc/images/ExtDevArchitecture.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..eeead04db53ebc43ee5ff109af350cd3b437debc GIT binary patch literal 78579 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

<@2AoN9=5}&& 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 EAC=LAyZ`_I literal 0 HcmV?d00001 diff --git a/src/docs/asciidoc/reference_doc/external_devices_service.adoc b/src/docs/asciidoc/reference_doc/external_devices_service.adoc index be9b2ea3bd..a6bf0d6833 100644 --- a/src/docs/asciidoc/reference_doc/external_devices_service.adoc +++ b/src/docs/asciidoc/reference_doc/external_devices_service.adoc @@ -43,37 +43,142 @@ protocols (and ultimately allow people to supply their own drivers) in the futur == Implementation -Given the use case described above, the following elements need to be configurable: - -* How to connect to a given external device (host, port) -* How to map an OperatorFabric signal key footnote:[currently, that means a severity, but in the future it could also +=== Architecture + +Given the use case described above, a new service was necessary to act as a link between the OperatorFabric UI and +the external devices. This service is in charge of: + +* Managing the configuration relative to external devices (see below for details) +* Process requests from the UI (e.g. "play the sound for ALARM for user operator1") and translate them as requests to +the appropriate device in their supported protocol, based on the above configuration +* Allow the pool of devices to be managed (connection, disconnection) + +This translates as three APIs. + +image::ExtDevArchitecture.drawio.png[Architecture diagram] + +Here is what happens when user operator1 receives a card with severity ALARM: + +. In the Angular code, the reception of the card triggers a sound notification. +. If the external devices feature is enabled and the user has chosen to play sounds on external devices +(instead of the browser), the UI code sends a POST request on the `external-devices/notifications` endpoint on the +NGINX gateway, with the following payload: ++ +[source,json] +---- +{ + "opfabSignalId": "ALARM" +} +---- ++ +. The NGINX server, acting as a gateway, forwards it to the `/notifications` endpoint on the External Devices service. +. The External Devices service queries the configuration repositories to find out which external device is configured +for operator1, how to connect to it and what signal "ALARM" translates to on this particular device. +. It then creates the appropriate connection if it doesn't exist yet, and sends the signal. + +=== Configuration + +The following elements need to be configurable: + +. For each user, which device to use: ++ +.userConfiguration +[source,json,] +---- +{ + "userLogin": "operator1", + "externalDeviceId": "CDS_1" +} +---- ++ +. How to connect to a given external device (host, port) ++ +.deviceConfiguration +[source,json,] +---- +{ + "id": "CDS_1", + "host": "localhost", + "port": 4300, + "signalMappingId": "default_CDS_mapping" +} +---- ++ +. How to map an OperatorFabric signal key footnote:[currently, that means a severity, but in the future it could also be a process id, or anything identifying the signal to be played] to a signal (sound, light) on the external system -* For each user, which device to use - - - -//TODO Schema 1-n deviceConf, userConf, signalMapping - -//TODO rename OpFab signal key - -//TODO schema web-ui external devices & API - - -The external devices system is - -//TODO Explain Device vs DeviceConfiguration (realtime) - - - -//TODO Explain the interface that a driver should implement (add javadoc) + Exceptions? - - ++ +.signalMapping +[source,json,] +---- +{ + "id": "default_CDS_mapping", + "supportedSignals": { + "ALARM": 1, + "ACTION": 2, + "COMPLIANT": 3, + "INFORMATION": 4 + } +} +---- + +This means that a single physical device allowing 2 different sets of sounds to be played (for example one set for desk +A and another set for desk B) would be represented as two different device configurations, with different ids. + +.Device configurations +[source,json,] +---- +[{ + "id": "CDS_A", + "host": "localhost", + "port": 4300, + "signalMappingId": "mapping_A" +}, +{ + "id": "CDS_B", + "host": "localhost", + "port": 4300, + "signalMappingId": "mapping_B" +}] +---- + +.Signal mappings +[source,json,] +---- +[{ + "id": "mapping_A", + "supportedSignals": { + "ALARM": 1, + "ACTION": 2, + "COMPLIANT": 3, + "INFORMATION": 4 + } +}, +{ + "id": "mapping_B", + "supportedSignals": { + "ALARM": 5, + "ACTION": 6, + "COMPLIANT": 7, + "INFORMATION": 8 + } +}] +---- + +NOTE: The signalMapping object is built as a Map with String keys (rather than the Severity enum or any otherwise +constrained type) because there is a strong possibility that in the future we might want to map something other than +severities. + +Please see the https://opfab.github.io/documentation/current/api/external-devices/[API documentation] for details. + +NOTE: There is a `Device` object distinct from `DeviceConfiguration` because the latter represents static information +about how to reach a device, while the former contains information about the actual connexion and its status. +For example, this is why the device configuration contains a `host` (which can be a hostname) while the device +has a `resolvedAddress`. +As a result, they are managed through separate endpoints, which might also make things easier if we need to secure +them differently (some people might be allowed to connect/disconnect devices but not change their configuration). == Configuration - - - == Connexion Management OperatorFabric doesn't automatically attempt to connect to configured external devices on start up as they might not @@ -84,11 +189,13 @@ of the actual activation. However, if a notification is received by the external devices service that needs to be passed on to a device that is configured but not connected yet, the connection will be performed automatically. +== Configuration Management - -//TODO No checks on existing related resources when creation (imposes order in creation, makes init more complex) -//but removal when deletion (same as users/groups/perimeters) - +In coherence with the way Entities, Perimeters, Users and Groups are managed, SignalMapping, UserConfiguration and +DeviceConfiguration resources can be deleted even if other resources link to them. +For example, if a device configuration lists `someMapping` as its `signalMappingId` and a DELETE request is sent +on `someMapping`, the deletion will be performed and return a 200 Success, and the device will have a `null` +`signalMappingId`. == Drivers @@ -97,4 +204,30 @@ the https://en.wikipedia.org/wiki/Modbus[Modbus protocol]. === Modbus Driver -//TODO Explain no response but ok because watchdog \ No newline at end of file +The Modbus driver is based on the https://github.com/kochedykov/jlibmodbus[jlibmodbus] library to create a +`ModbusMaster` for each device and then send requests through it using the +https://github.com/kochedykov/jlibmodbus/blob/master/src/com/intelligt/modbus/jlibmodbus/msg/request/WriteSingleRegisterRequest.java[WriteSingleRegisterRequest] +object. + +We are currently using the "BROADCAST" mode, which (at least in the jlibmodbus implementation) means that the Modbus +master doesn't expect any response to its requests (which makes sense because if there really are several clients +responding to the broadcast, ) +This is mitigated by the fact that if watchdog signals are enabled, the external devices will be able to detect that +they are not receiving signals correctly. +In the future, it could be interesting to switch to the TCP default so OperatorFabric can be informed of any exception +in the processing of the request, allowing for example to give a more meaningful connection status +(see https://github.com/opfab/operatorfabric-core/issues/2294[#2294]) + +=== Adding new drivers + +New drivers should implement the `ExternalDeviceDriver` interface, and a corresponding factory implementing the +`ExternalDeviceDriverFactory` interface should be created with it. + +The idea is that in the future, using dependency injection, Spring should be able to pick up any factory on the classpath implementing +the correct interface. + +NOTE: `ExternalDeviceDriver`, `ExternalDeviceDriverFactory` and the accompanying custom exceptions should be made +available as a jar on Maven Central if we want to allow project users to provide their own drivers. + +NOTE: If several drivers need to be used on a given OperatorFabric instance at the same time, we will need to introduce +a device type in the deviceConfiguration object. \ No newline at end of file diff --git a/src/docs/asciidoc/resources/index.adoc b/src/docs/asciidoc/resources/index.adoc index c53b24ffbe..fcacc8416c 100644 --- a/src/docs/asciidoc/resources/index.adoc +++ b/src/docs/asciidoc/resources/index.adoc @@ -10,8 +10,6 @@ = Resources -include::mock_pipeline.adoc[leveloffset=+1] - include::jar_publication.adoc[leveloffset=+1] include::migration_guide.adoc[leveloffset=+1] From 8bccfee4de94cde20363c9cea8973241bcb28528 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Mon, 20 Dec 2021 17:56:17 +0100 Subject: [PATCH 05/73] Admin screen : words can be cut and displayed in two lines sometimes (#2268) Signed-off-by: vlo-rte --- ui/main/src/scss/styles.scss | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/main/src/scss/styles.scss b/ui/main/src/scss/styles.scss index f051d333c7..a90430fd4b 100644 --- a/ui/main/src/scss/styles.scss +++ b/ui/main/src/scss/styles.scss @@ -85,7 +85,7 @@ body { animation: fa-spin 2s infinite linear; } -// Spinner when loading cards or table of cards +// Spinner when loading cards or table of cards // the spinner is in the center of the screen .opfab-card-loading-spinner { @@ -939,5 +939,9 @@ html, body { height: 100%; } padding-right: 0px;} .opfab-sev-information {background-color: $sev-information;font-size: 0px; padding-right: 0px; } + + .ag-cell-wrap-text { + word-break: normal; + } } From cea444ccc2ee17c67ca5d2a9125449602d75d866 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Mon, 20 Dec 2021 17:10:40 +0100 Subject: [PATCH 06/73] Logging export file : bad name of the file (#2277) Signed-off-by: vlo-rte --- ui/main/src/app/modules/logging/logging.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/main/src/app/modules/logging/logging.component.ts b/ui/main/src/app/modules/logging/logging.component.ts index efc4c21439..fca07cba0e 100644 --- a/ui/main/src/app/modules/logging/logging.component.ts +++ b/ui/main/src/app/modules/logging/logging.component.ts @@ -305,7 +305,7 @@ export class LoggingComponent implements OnDestroy, OnInit { [representativeColumnName]: this.translateColumn(card.representative) }); }); - ExportService.exportJsonToExcelFile(exportArchiveData, 'Archive'); + ExportService.exportJsonToExcelFile(exportArchiveData, 'Logging'); }); } From 705312c7c491a96cedc29063d188af87c48c459c Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 21 Dec 2021 08:54:27 +0000 Subject: [PATCH 07/73] Update dependency com.fasterxml.jackson.core:jackson-annotations to v2.13.1 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8ec793fdeb..14cd76767c 100755 --- a/gradle.properties +++ b/gradle.properties @@ -15,7 +15,7 @@ apacheCommonsCollections4=4.4 commonsIO=2.11.0 lombok=1.18.22 feign=11.7 -jacksonAnnotations=2.13.0 +jacksonAnnotations=2.13.1 jacksonDatabind=2.13.1 kavroSchemaRegistryClient=7.0.0 kavroAvroSerializer=7.0.0 From 30ab5a492f17a84400825e0c835d1ba1e68b008c Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Mon, 20 Dec 2021 14:49:52 +0100 Subject: [PATCH 08/73] Do not change button name for response on questionState for defaultProcess example (#2274) Signed-off-by: vlo-rte --- src/test/cypress/cypress/integration/ResponseCard.spec.js | 8 ++++---- src/test/resources/bundles/defaultProcess_V1/config.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/cypress/cypress/integration/ResponseCard.spec.js b/src/test/cypress/cypress/integration/ResponseCard.spec.js index fd01e6d514..cd425faef8 100644 --- a/src/test/cypress/cypress/integration/ResponseCard.spec.js +++ b/src/test/cypress/cypress/integration/ResponseCard.spec.js @@ -44,14 +44,14 @@ describe ('Response card tests',function () { // Response button is present cy.get('#opfab-card-details-btn-response'); - cy.get('#opfab-card-details-btn-response').should('have.text', 'SEND IMPACT'); + cy.get('#opfab-card-details-btn-response').should('have.text', 'SEND RESPONSE'); // Respond to the card cy.get('#question-choice1').click(); cy.get('#opfab-card-details-btn-response').click(); - // The label of the validate button must be "MODIFY IMPACT" now - cy.get('#opfab-card-details-btn-response').should('have.text', 'MODIFY IMPACT'); + // The label of the validate button must be "MODIFY RESPONSE" now + cy.get('#opfab-card-details-btn-response').should('have.text', 'MODIFY RESPONSE'); // See in the feed the fact that user has respond (icon) cy.get('#opfab-feed-lightcard-hasChildCardFromCurrentUserEntity'); @@ -71,7 +71,7 @@ describe ('Response card tests',function () { // Respond to the new card cy.get('#question-choice3').click(); - cy.get('#opfab-card-details-btn-response').should('have.text', 'SEND IMPACT'); + cy.get('#opfab-card-details-btn-response').should('have.text', 'SEND RESPONSE'); cy.get('#opfab-card-details-btn-response').click(); // See in the feed the fact that user has respond (icon) diff --git a/src/test/resources/bundles/defaultProcess_V1/config.json b/src/test/resources/bundles/defaultProcess_V1/config.json index a70ce1002a..481c00ea20 100644 --- a/src/test/resources/bundles/defaultProcess_V1/config.json +++ b/src/test/resources/bundles/defaultProcess_V1/config.json @@ -82,8 +82,8 @@ ], "acknowledgmentAllowed": "OnlyWhenResponseDisabledForUser", "type" : "INPROGRESS", - "validateAnswerButtonLabel" : "SEND IMPACT", - "modifyAnswerButtonLabel" : "MODIFY IMPACT" + "validateAnswerButtonLabel" : "SEND RESPONSE", + "modifyAnswerButtonLabel" : "MODIFY RESPONSE" }, "multipleOptionsResponseState": { "name": "Additional information required", From f2a94e1ccbcdeaa04b585262dd22523982e6ed20 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 12:04:52 +0100 Subject: [PATCH 09/73] Removing karma-istanbul-coverage (#2198) Signed-off-by: Alexandra Guironnet --- ui/main/package-lock.json | 132 +------------------------------------- ui/main/package.json | 3 +- ui/main/src/karma.conf.js | 8 +-- 3 files changed, 5 insertions(+), 138 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index a18cb5939f..38922fc272 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -5335,12 +5335,6 @@ "integrity": "sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==", "dev": true }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -5815,12 +5809,6 @@ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", "dev": true }, - "istanbul-lib-coverage": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz", - "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==", - "dev": true - }, "istanbul-lib-instrument": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz", @@ -5848,109 +5836,6 @@ } } }, - "istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "requires": { - "semver": "^6.0.0" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "istanbul-lib-source-maps": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", - "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^2.0.5", - "make-dir": "^2.1.0", - "rimraf": "^2.6.3", - "source-map": "^0.6.1" - }, - "dependencies": { - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "istanbul-lib-coverage": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", - "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", - "dev": true - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - } - } - }, - "istanbul-reports": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz", - "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, "jasmine-core": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.10.1.tgz", @@ -6231,19 +6116,6 @@ "which": "^1.2.1" } }, - "karma-coverage-istanbul-reporter": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/karma-coverage-istanbul-reporter/-/karma-coverage-istanbul-reporter-3.0.3.tgz", - "integrity": "sha512-wE4VFhG/QZv2Y4CdAYWDbMmcAHeS926ZIji4z+FkB2aF/EposRb6DP6G5ncT/wXhqUfAb/d7kZrNKPonbvsATw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^3.0.6", - "istanbul-reports": "^3.0.2", - "minimatch": "^3.0.4" - } - }, "karma-jasmine": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-4.0.1.tgz", @@ -6520,6 +6392,7 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", "dev": true, + "optional": true, "requires": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -7521,7 +7394,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true + "dev": true, + "optional": true }, "piscina": { "version": "3.1.0", diff --git a/ui/main/package.json b/ui/main/package.json index 6344335ee9..7222fa8671 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -6,7 +6,7 @@ "start": "ng serve", "build": "ng build", "test": "ng test", - "headless": "ng test --browsers ChromeHeadless --watch=false --code-coverage", + "headless": "ng test --browsers ChromeHeadless --watch=false", "lint": "ng lint", "e2e": "ng e2e", "audit": "npm audit --json | $(npm bin)/npm-audit-html --output reports/report.html" @@ -82,7 +82,6 @@ "jasmine-sse": "0.3.0", "karma": "6.3.9", "karma-chrome-launcher": "3.1.0", - "karma-coverage-istanbul-reporter": "3.0.3", "karma-jasmine": "4.0.1", "karma-jasmine-html-reporter": "1.7.0", "karma-junit-reporter": "2.0.1", diff --git a/ui/main/src/karma.conf.js b/ui/main/src/karma.conf.js index 80ada13858..081bde5f1b 100755 --- a/ui/main/src/karma.conf.js +++ b/ui/main/src/karma.conf.js @@ -9,7 +9,6 @@ module.exports = function (config) { require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), - require('karma-coverage-istanbul-reporter'), require('karma-mocha-reporter'), require('@angular-devkit/build-angular/plugins/karma'), require('karma-junit-reporter') @@ -17,11 +16,6 @@ module.exports = function (config) { client: { clearContext: false // leave Jasmine Spec Runner output visible in browser }, - coverageIstanbulReporter: { - dir: require('path').join(__dirname, '../reports/coverage'), - reports: ['html', 'lcovonly'], - fixWebpackSourcePaths: true - }, junitReporter: { outputDir: '../reports/test', // results will be saved as $outputDir/$browserName.xml outputFile: 'junit.xml', // if included, results will be saved as $outputDir/$browserName/$outputFile @@ -29,7 +23,7 @@ module.exports = function (config) { }, // add converage-istanbul for migration to angular 13 , see // https://stackoverflow.com/questions/70045859/after-upgrading-to-angular-13-the-tests-with-code-coverage-is-failing/70046050 - reporters: ['mocha', 'kjhtml', 'junit','coverage-istanbul'], + reporters: ['mocha', 'kjhtml', 'junit'], port: 9876, colors: true, logLevel: config.LOG_INFO, From cfcb59e25aa00197128a1d171c49a330b806b911 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 12:09:18 +0100 Subject: [PATCH 10/73] Bringing Angular CDK up to date with rest Signed-off-by: Alexandra Guironnet --- ui/main/package-lock.json | 8 ++++---- ui/main/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 38922fc272..0f07714362 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -296,12 +296,12 @@ } }, "@angular/cdk": { - "version": "12.2.13", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-12.2.13.tgz", - "integrity": "sha512-zSKRhECyFqhingIeyRInIyTvYErt4gWo+x5DQr0b7YLUbU8DZSwWnG4w76Ke2s4U8T7ry1jpJBHoX/e8YBpGMg==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.1.1.tgz", + "integrity": "sha512-66PyWg+zKdxTe3b1pc1RduT8hsMs/hJ0aD0JX0pSEWVq7O0OJWJ5f0z+Mk03T9tAERA3NK1GifcKEDq5k7R2Zw==", "requires": { "parse5": "^5.0.0", - "tslib": "^2.2.0" + "tslib": "^2.3.0" } }, "@angular/cli": { diff --git a/ui/main/package.json b/ui/main/package.json index 7222fa8671..e08378ab15 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -14,7 +14,7 @@ "private": true, "dependencies": { "@angular/animations": "13.1.1", - "@angular/cdk": "12.2.13", + "@angular/cdk": "13.1.1", "@angular/common": "13.1.1", "@angular/compiler": "13.1.1", "@angular/core": "13.1.1", From 2076c3d6d34e889940693d276b41fbe489f3ad5a Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Mon, 20 Dec 2021 16:22:32 +0100 Subject: [PATCH 11/73] Fix docker tasks (#2290) Signed-off-by: Alexandra Guironnet --- services/external-devices/build.gradle | 11 ++++++----- services/services.gradle | 11 ++++++----- src/test/dummyModbusDevice/dummyModbusDevice.gradle | 11 ++++++----- src/test/externalApp/externalApp.gradle | 11 ++++++----- web-ui/web-ui.gradle | 12 ++++++------ 5 files changed, 30 insertions(+), 26 deletions(-) diff --git a/services/external-devices/build.gradle b/services/external-devices/build.gradle index 2cf4221346..6aad1f324b 100755 --- a/services/external-devices/build.gradle +++ b/services/external-devices/build.gradle @@ -76,11 +76,12 @@ tasks.named("processResources") { } docker { - if (project.version.toUpperCase().equals("SNAPSHOT")) - name "lfeoperatorfabric/of-${project.name.toLowerCase()}:SNAPSHOT" /* more information : https://vsupalov.com/docker-latest-tag/ */ - else - name "lfeoperatorfabric/of-${project.name.toLowerCase()}" - tags "latest", "${project.version}" + /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) + * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ + name "lfeoperatorfabric/of-${project.name}:${project.version}" + tag "${project.version}", "lfeoperatorfabric/of-${project.name}:${project.version}" + if (!project.version.equals("SNAPSHOT")) + tag "latest", "latest" labels (['project':"${project.group}"]) files( "build/libs" , "../../config/docker/common-docker.yml" diff --git a/services/services.gradle b/services/services.gradle index 567b3b7e7c..a0b8a741b8 100755 --- a/services/services.gradle +++ b/services/services.gradle @@ -111,11 +111,12 @@ subprojects { } docker { - if (project.version.toUpperCase().equals("SNAPSHOT")) - name "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version.toUpperCase()}" /* more information : https://vsupalov.com/docker-latest-tag/ */ - else - name "lfeoperatorfabric/of-${project.name.toLowerCase()}" - tags "latest", "${project.version}" + /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) + * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ + name "lfeoperatorfabric/of-${project.name}:${project.version}" + tag "${project.version}", "lfeoperatorfabric/of-${project.name}:${project.version}" + if (!project.version.equals("SNAPSHOT")) + tag "latest", "latest" labels (['project':"${project.group}"]) files( "build/libs" , "../../config/docker/common-docker.yml" diff --git a/src/test/dummyModbusDevice/dummyModbusDevice.gradle b/src/test/dummyModbusDevice/dummyModbusDevice.gradle index d55b74e42c..e93917f496 100755 --- a/src/test/dummyModbusDevice/dummyModbusDevice.gradle +++ b/src/test/dummyModbusDevice/dummyModbusDevice.gradle @@ -24,11 +24,12 @@ bootJar { docker { - if (project.version.toUpperCase().equals("SNAPSHOT")) - name "lfeoperatorfabric/of-dummy-modbus-device:SNAPSHOT" /* more information : https://vsupalov.com/docker-latest-tag/ */ - else - name "lfeoperatorfabric/of-dummy-modbus-device" - tags "latest", "${project.version}" + /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) + * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ + name "lfeoperatorfabric/of-dummy-modbus-device:${project.version}" + tag "${project.version}", "lfeoperatorfabric/of-dummy-modbus-device:${project.version}" + if (!project.version.equals("SNAPSHOT")) + tag "latest", "latest" labels (['project':"${project.group}"]) files( "build/libs") dockerfile file('src/main/docker/Dockerfile') diff --git a/src/test/externalApp/externalApp.gradle b/src/test/externalApp/externalApp.gradle index 0396a4ebd1..f3377458a2 100755 --- a/src/test/externalApp/externalApp.gradle +++ b/src/test/externalApp/externalApp.gradle @@ -28,11 +28,12 @@ bootJar { docker { - if (project.version.toUpperCase().equals("SNAPSHOT")) - name "lfeoperatorfabric/of-external-app:${project.version.toUpperCase()}" /* more information : https://vsupalov.com/docker-latest-tag/ */ - else - name "lfeoperatorfabric/of-external-app" - tags "latest", "${project.version}" + /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) + * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ + name "lfeoperatorfabric/of-external-app:${project.version}" + tag "${project.version}", "lfeoperatorfabric/of-external-app:${project.version}" + if (!project.version.equals("SNAPSHOT")) + tag "latest", "latest" labels (['project':"${project.group}"]) files( "build/libs") dockerfile file('src/main/docker/Dockerfile') diff --git a/web-ui/web-ui.gradle b/web-ui/web-ui.gradle index efd0207a5f..17d1f62fc4 100755 --- a/web-ui/web-ui.gradle +++ b/web-ui/web-ui.gradle @@ -4,12 +4,12 @@ plugins { } docker { - - if (project.version.toUpperCase().equals("SNAPSHOT")) - name "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version.toUpperCase()}" /* more information : https://vsupalov.com/docker-latest-tag/ */ - else - name "lfeoperatorfabric/of-${project.name.toLowerCase()}" - tags "latest", "${project.version}" + /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) + * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ + name "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version}" + tag "${project.version}", "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version}" + if (!project.version.equals("SNAPSHOT")) + tag "latest", "latest" labels(['project': "${project.group}"]) copySpec.with { from('../ui/main/build/distribution') { From d2f64c8dd1837de079af017c79b2f352bf82d012 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 11:48:48 +0100 Subject: [PATCH 12/73] Dev mode for external devices (#1940) Signed-off-by: Alexandra Guironnet --- bin/run_all.sh | 17 +++++++- config/dev/external-devices-dev.yml | 43 +++++++++++++++++++ .../asciidoc/dev_env/testing_ext_dev.adoc | 29 ++++++++++++- .../src/main/bin/startDummy.sh | 9 ++++ .../src/main/bin/stopDummy.sh | 25 +++++++++++ .../dummy/modbusdevice/OwnDataHolder.java | 33 +++++++------- .../src/main/resources/application.yml | 1 + .../resources/externalDevices/README.adoc | 31 ------------- 8 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 config/dev/external-devices-dev.yml create mode 100755 src/test/dummyModbusDevice/src/main/bin/stopDummy.sh delete mode 100644 src/test/resources/externalDevices/README.adoc diff --git a/bin/run_all.sh b/bin/run_all.sh index 42998039ff..f99a1001c7 100755 --- a/bin/run_all.sh +++ b/bin/run_all.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright (c) 2018-2020, RTE (http://www.rte-france.com) +# Copyright (c) 2018-2021, RTE (http://www.rte-france.com) # See AUTHORS.txt # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this @@ -25,6 +25,7 @@ resetConfiguration=true businessServices=( "users" "cards-consultation" "cards-publication" "businessconfig") offline=false waitForOpfabToStart=false +externalDevices=false function join_by { local IFS="$1"; shift; echo "$*"; } @@ -39,6 +40,7 @@ function display_usage() { echo -e "\t-r, --reset\t: true or false. Resets service data. Defaults to $resetConfiguration." echo -e "\t-o, --offline\t: true or false. When gradle is invoked, it will be invoked offline. Defaults to $offline.\n" echo -e "\t-w, --waitForOpfabToStart\t: true or false , if true the script exits only when opfab is up. Defaults to false.\n" + echo -e "\t-e, --externalDevices\t: true or false , if true the external devices service is started as well. Defaults to $externalDevices.\n" } while [[ $# -gt 0 ]] @@ -65,6 +67,10 @@ case $key in waitForOpfabToStart=true shift # past argument ;; + -e|--externalDevices) + externalDevices=true + shift # past argument + ;; -h|--help) display_usage exit 0 @@ -96,6 +102,15 @@ for bservice in "${businessServices[@]}"; do i=$((i+$PRJ_STRC_FIELDS)) done +if [ "$externalDevices" = true ]; then + dependentProjects[$i]="external-devices-service" + dependentProjects[$i+1]="services/external-devices" + dependentProjects[$i+2]=0 + dependentProjects[$i+3]="" + dependentProjects[$i+4]="external-devices" + i=$((i+$PRJ_STRC_FIELDS)) +fi + debugPort=5005 version=$OF_VERSION diff --git a/config/dev/external-devices-dev.yml b/config/dev/external-devices-dev.yml new file mode 100644 index 0000000000..497d7e5d3f --- /dev/null +++ b/config/dev/external-devices-dev.yml @@ -0,0 +1,43 @@ +server: + port: 2105 +spring: + application: + name: external-devices +operatorfabric: + externaldevices: + watchdog: + enabled: true + default: + deviceConfigurations: + - id: CDS_1 + host: localhost + port: 4030 + signalMappingId: default_CDS_mapping + - id: CDS_2 + host: localhost + port: 4030 + signalMappingId: broken_CDS_mapping + signalMappings: + - id: default_CDS_mapping + supportedSignals: + ALARM: 1 + ACTION: 2 + COMPLIANT: 3 + INFORMATION: 4 + - id: broken_CDS_mapping + supportedSignals: + ALARM: 5 + ACTION: 6 + COMPLIANT: 7 + INFORMATION: 8 + userConfigurations: + - userLogin: operator1 + externalDeviceId: CDS_1 + - userLogin: operator2 + externalDeviceId: CDS_2 + - userLogin: operator3 + externalDeviceId: CDS_3 +logging.level.org.opfab: debug + + + diff --git a/src/docs/asciidoc/dev_env/testing_ext_dev.adoc b/src/docs/asciidoc/dev_env/testing_ext_dev.adoc index 1a1405e16c..9ba47cf6b7 100644 --- a/src/docs/asciidoc/dev_env/testing_ext_dev.adoc +++ b/src/docs/asciidoc/dev_env/testing_ext_dev.adoc @@ -7,6 +7,8 @@ = Testing the External Devices service with dummy devices +== Docker mode + To test the External Devices service with dummy devices, launch an OperatorFabric instance using the `$OF_HOME/config/docker/docker-compose.sh` script. @@ -28,4 +30,29 @@ dummy-modbus-device_1 logs, and sending an ACTION card to operator2 should resul 6 in dummy-modbus-device_2. Since the watchdog is enabled, once the devices are connected (either by calling the /connect endpoint or -by sending the first signal), you should also see the corresponding messages in the logs. \ No newline at end of file +by sending the first signal), you should also see the corresponding messages in the logs. + +== Dev mode + +To test in "dev" mode + +---- +$OF_HOME/config/dev/docker-compose.sh <1> +$OF_HOME/bin/run_all.sh status --externalDevices <2> +$OF_HOME/src/test/dummyModbusDevice/src/main/bin/startDummy.sh <3> +---- +<1> Launch supporting containers (MongoDB, Keycloak, etc.) +<2> Launch OperatorFabric services (including External Devices) +<3> Start a dummy modbus device + +NOTE: Configuration for the dummy modbus device can be provided in +`src/test/dummyModbusDevice/src/main/resources/application.yml`. + +NOTE: As only one dummy Modbus device is started in this case, the default configuration for external devices has been +adapted from the one provided in docker mode so both `CDS_1` and `CDS_2` point to the same device, but with different +signal mappings. The mapping for `CDS_2` is "broken" on purpose, as it attempts to write in registers that are outside +the allowed register range, to demonstrate exceptions. + +== Dummy devices configuration + +See the https://github.com/opfab/operatorfabric-core/blob/develop/src/test/dummyModbusDevice/README.adoc[README for the Dummy Modbus Device module]. diff --git a/src/test/dummyModbusDevice/src/main/bin/startDummy.sh b/src/test/dummyModbusDevice/src/main/bin/startDummy.sh index 26bedef427..51ba6e866d 100755 --- a/src/test/dummyModbusDevice/src/main/bin/startDummy.sh +++ b/src/test/dummyModbusDevice/src/main/bin/startDummy.sh @@ -1,3 +1,12 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021, RTE (http://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of the OperatorFabric project. applicationOptions="--spring.profiles.active=dev --spring.config.location=classpath:/application.yml,file:${OF_HOME}/config/dev/ --spring.config.name=common,dummy-modbus-device" projectBuildPath="src/test/dummyModbusDevice/build" diff --git a/src/test/dummyModbusDevice/src/main/bin/stopDummy.sh b/src/test/dummyModbusDevice/src/main/bin/stopDummy.sh new file mode 100755 index 0000000000..642fac5139 --- /dev/null +++ b/src/test/dummyModbusDevice/src/main/bin/stopDummy.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# Copyright (c) 2021, RTE (http://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# This file is part of the OperatorFabric project. + +cd $OF_HOME + +projectBuildPath="src/test/dummyModbusDevice/build" +projectPidFilePath="$projectBuildPath/PIDFILE" +echo "##########################################################" + if [ -f "$projectPidFilePath" ]; then + pid=$(<"$projectPidFilePath") + echo "Stopping $1 (pid: $pid)" + if ! kill $pid > /dev/null 2>&1; then + echo "$1: could not send SIGTERM to process $pid" >&2 + fi + else + echo "'$projectPidFilePath' not found" + fi +echo "##########################################################" \ No newline at end of file diff --git a/src/test/dummyModbusDevice/src/main/java/org/opfab/dummy/modbusdevice/OwnDataHolder.java b/src/test/dummyModbusDevice/src/main/java/org/opfab/dummy/modbusdevice/OwnDataHolder.java index 8a73fedb14..3a70dbab65 100644 --- a/src/test/dummyModbusDevice/src/main/java/org/opfab/dummy/modbusdevice/OwnDataHolder.java +++ b/src/test/dummyModbusDevice/src/main/java/org/opfab/dummy/modbusdevice/OwnDataHolder.java @@ -12,10 +12,12 @@ import com.intelligt.modbus.jlibmodbus.data.DataHolder; import com.intelligt.modbus.jlibmodbus.exception.IllegalDataAddressException; import com.intelligt.modbus.jlibmodbus.exception.IllegalDataValueException; +import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; +@Slf4j public class OwnDataHolder extends DataHolder { final List modbusEventListenerList = new ArrayList<>(); @@ -33,34 +35,31 @@ public boolean removeEventListener(ModbusEventListener listener) { } @Override - public void writeHoldingRegister(int offset, int value) throws IllegalDataAddressException, IllegalDataValueException { + public void writeHoldingRegister(int offset, int value) { for (ModbusEventListener l : modbusEventListenerList) { l.onWriteToSingleHoldingRegister(offset, value); } - super.writeHoldingRegister(offset, value); + try { + super.writeHoldingRegister(offset, value); + } catch (IllegalDataAddressException e) { + log.error("Attempting write on register with illegal address {}",offset,e); + } catch (IllegalDataValueException e) { + log.error("Attempting write on register with illegal value {}",value,e); + } } @Override - public void writeHoldingRegisterRange(int offset, int[] range) throws IllegalDataAddressException, IllegalDataValueException { - for (ModbusEventListener l : modbusEventListenerList) { - l.onWriteToMultipleHoldingRegisters(offset, range.length, range); - } - super.writeHoldingRegisterRange(offset, range); + public void writeHoldingRegisterRange(int offset, int[] range) { + // Not needed for our tests } @Override - public void writeCoil(int offset, boolean value) throws IllegalDataAddressException, IllegalDataValueException { - for (ModbusEventListener l : modbusEventListenerList) { - l.onWriteToSingleCoil(offset, value); - } - super.writeCoil(offset, value); + public void writeCoil(int offset, boolean value) { + // Not needed for our tests } @Override - public void writeCoilRange(int offset, boolean[] range) throws IllegalDataAddressException, IllegalDataValueException { - for (ModbusEventListener l : modbusEventListenerList) { - l.onWriteToMultipleCoils(offset, range.length, range); - } - super.writeCoilRange(offset, range); + public void writeCoilRange(int offset, boolean[] range) { + // Not needed for our tests } } \ No newline at end of file diff --git a/src/test/dummyModbusDevice/src/main/resources/application.yml b/src/test/dummyModbusDevice/src/main/resources/application.yml index 872a611897..12aafd56d5 100755 --- a/src/test/dummyModbusDevice/src/main/resources/application.yml +++ b/src/test/dummyModbusDevice/src/main/resources/application.yml @@ -5,4 +5,5 @@ spring: web-application-type: NONE modbus_client: port: 4030 +logging.level.org.opfab: debug diff --git a/src/test/resources/externalDevices/README.adoc b/src/test/resources/externalDevices/README.adoc deleted file mode 100644 index 615e4715a1..0000000000 --- a/src/test/resources/externalDevices/README.adoc +++ /dev/null @@ -1,31 +0,0 @@ -= External devices README - -This file gives a few pointers and command to test the external devices service in conjunction with the dummy modbus -device. It's intended as a first draft of the documentation. - -== Running in docker mode - ----- -cd $OF_HOME/config/docker -./docker-compose.sh ----- - -== Running in dev mode - ----- -cd $OF_HOME/config/dev -./docker-compose.sh -cd $OF_HOME -./run_all.sh --services users,cards-consultation,cards-publication,businessconfig,external-devices start -cd $OF_HOME/src/test/dummyModbusDevice/src/main/bin -./startDummy.sh ----- - -== Test - ----- -cd $OF_HOME/src/test/resources/externalDevices -./testExternalDevices.sh ----- - -Look at logs to see evidence of communication. \ No newline at end of file From 92bbd967761749af099223093494eff98064a767 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 21 Dec 2021 23:57:42 +0000 Subject: [PATCH 13/73] Update dependency @types/node to v16.11.15 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 0f07714362..1d7dd67804 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -2280,9 +2280,9 @@ } }, "@types/node": { - "version": "16.11.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.14.tgz", - "integrity": "sha512-mK6BKLpL0bG6v2CxHbm0ed6RcZrAtTHBTd/ZpnlVPVa3HkumsqLE4BC4u6TQ8D7pnrRbOU0am6epuALs+Ncnzw==", + "version": "16.11.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.15.tgz", + "integrity": "sha512-LMGR7iUjwZRxoYnfc9+YELxwqkaLmkJlo4/HUvOMyGvw9DaHO0gtAbH2FUdoFE6PXBTYZIT7x610r7kdo8o1fQ==", "dev": true }, "@types/parse-json": { diff --git a/ui/main/package.json b/ui/main/package.json index e08378ab15..1b2ee21622 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -74,7 +74,7 @@ "@types/jasminewd2": "2.0.10", "@types/lodash": "4.14.178", "@types/moment": "2.13.0", - "@types/node": "16.11.14", + "@types/node": "16.11.15", "codelyzer": "6.0.2", "jasmine-core": "3.10.1", "jasmine-marbles": "0.8.4", From 8ffe292dcb50f450bb3d456a9e3dcaf03ca262cc Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Dec 2021 01:47:11 +0000 Subject: [PATCH 14/73] Update spring security to v5.6.1 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 14cd76767c..2488acd686 100755 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ springBoot=2.6.1 springCloud=2021.0.0 springWebflux=5.3.14 -springSecurity=5.6.0 +springSecurity=5.6.1 springRetry=1.3.1 springOpenFeign=3.1.0 From e495899bf1261092905e28db20650a889a2de235 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 21 Dec 2021 23:57:50 +0000 Subject: [PATCH 15/73] Update dependency org.springframework.boot:spring-boot-starter-validation to v2.6.2 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2488acd686..98b0213538 100755 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # spring libs -springBoot=2.6.1 +springBoot=2.6.2 springCloud=2021.0.0 springWebflux=5.3.14 springSecurity=5.6.1 From 089f33152de6447725116a6dc17409dc9e0a9a10 Mon Sep 17 00:00:00 2001 From: Giovanni Ferrari Date: Thu, 16 Dec 2021 19:20:11 +0100 Subject: [PATCH 16/73] Use publish date to show card on the feed (#2205) Signed-off-by: Giovanni Ferrari --- .../CardCustomRepositoryImpl.java | 14 ++- .../CardOperationsControllerShould.java | 7 +- .../repositories/CardRepositoryShould.java | 90 +++++++++++++++-- .../model/CardPublicationData.java | 2 + .../cypress/integration/FeedTests.spec.js | 6 ++ .../cards/cypress/feed/futureEvent.json | 16 +++ .../lightcards/filter.service.spec.ts | 97 ++++++++++++++----- .../app/services/lightcards/filter.service.ts | 23 +++-- 8 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 src/test/resources/cards/cypress/feed/futureEvent.json diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/repositories/CardCustomRepositoryImpl.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/repositories/CardCustomRepositoryImpl.java index 732e269b3c..0be7fded17 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/repositories/CardCustomRepositoryImpl.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/repositories/CardCustomRepositoryImpl.java @@ -121,18 +121,24 @@ private Criteria computeCriteriaForProcessesStatesNotNotified(CurrentUserWithPer private Criteria getCriteriaForRange(Instant rangeStart,Instant rangeEnd) { - if (rangeStart==null) return where(END_DATE_FIELD).lte(rangeEnd); + if (rangeStart==null) return new Criteria().orOperator( + where(END_DATE_FIELD).lte(rangeEnd), + where(PUBLISH_DATE_FIELD).lte(rangeEnd) + ); if (rangeEnd==null) return new Criteria().orOperator( where(END_DATE_FIELD).gte(rangeStart), - where(START_DATE_FIELD).gte(rangeStart) + where(START_DATE_FIELD).gte(rangeStart), + where(PUBLISH_DATE_FIELD).gte(rangeStart) ); return new Criteria().orOperator( where(START_DATE_FIELD).gte(rangeStart).lte(rangeEnd), where(END_DATE_FIELD).gte(rangeStart).lte(rangeEnd), new Criteria().andOperator( where(START_DATE_FIELD).lte(rangeStart), - where(END_DATE_FIELD).gte(rangeEnd)) - ); + where(END_DATE_FIELD).gte(rangeEnd) + ), + where(PUBLISH_DATE_FIELD).gte(rangeStart).lte(rangeEnd) + ); } private Criteria publishDateCriteria(Instant publishFrom) { diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/controllers/CardOperationsControllerShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/controllers/CardOperationsControllerShould.java index e5b9cc2b20..c720555f32 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/controllers/CardOperationsControllerShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/controllers/CardOperationsControllerShould.java @@ -179,7 +179,7 @@ private void initCardData() { //create later published cards in past //this one overrides first - StepVerifier.create(repository.save(createSimpleCard(1, nowPlusOne, nowMinusTwo, nowMinusOne, "operator3", new String[]{"rte","operator"}, null))) + StepVerifier.create(repository.save(createSimpleCard(1, nowMinusOne, nowMinusTwo, nowMinusOne, "operator3", new String[]{"rte","operator"}, null))) .expectNextCount(1) .expectComplete() .verify(); @@ -214,7 +214,7 @@ void receiveOlderCards() { StepVerifier.FirstStep verifier = StepVerifier .create(publisher.map(s -> TestUtilities.readCardOperation(mapper, s)) .doOnNext(TestUtilities::logCardOperation)); - for (int i = 0; i < 7; i++) + for (int i = 0; i < 8; i++) verifier.assertNext(op -> { assertThat(op.getCard()).isNotNull(); results.put(op.getCard().getProcessInstanceId(), op); @@ -227,6 +227,7 @@ void receiveOlderCards() { CardOperation card5 = (CardOperation) results.get("PROCESS5"); CardOperation card6 = (CardOperation) results.get("PROCESS6"); CardOperation card8 = (CardOperation) results.get("PROCESS8"); + CardOperation card9 = (CardOperation) results.get("PROCESS9"); CardOperation card10 = (CardOperation) results.get("PROCESS10"); assertThat(card2.getCard().getId()).isEqualTo("PROCESS.PROCESS2"); @@ -241,6 +242,8 @@ void receiveOlderCards() { assertThat(card6.getPublishDate()).isEqualTo(nowMinusThree); assertThat(card8.getCard().getId()).isEqualTo("PROCESS.PROCESS8"); assertThat(card8.getPublishDate()).isEqualTo(nowMinusThree); + assertThat(card9.getCard().getId()).isEqualTo("PROCESS.PROCESS9"); + assertThat(card9.getPublishDate()).isEqualTo(nowPlusOne); assertThat(card10.getCard().getId()).isEqualTo("PROCESS.PROCESS10"); assertThat(card10.getPublishDate()).isEqualTo(nowPlusOne); diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/repositories/CardRepositoryShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/repositories/CardRepositoryShould.java index 509174ea1b..4e738139ce 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/repositories/CardRepositoryShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/repositories/CardRepositoryShould.java @@ -184,9 +184,9 @@ void getZeroCardInRange() @Test void getTwoCardsInRange() { - persistCard(createSimpleCard("1", now, now, nowPlusOne, LOGIN,null, null)); - persistCard(createSimpleCard("2", now, now, nowPlusTwo, LOGIN, null,null)); - persistCard(createSimpleCard("3", now, nowPlusTwo, nowPlusThree, LOGIN,null,null)); + persistCard(createSimpleCard("1", nowMinusOne, now, nowPlusOne, LOGIN,null, null)); + persistCard(createSimpleCard("2", nowMinusOne, now, nowPlusTwo, LOGIN, null,null)); + persistCard(createSimpleCard("3", nowMinusOne, nowPlusTwo, nowPlusThree, LOGIN,null,null)); StepVerifier.create(repository.getCardOperations(null, now,nowPlusOne, adminUser) .doOnNext(TestUtilities::logCardOperation)) @@ -230,11 +230,11 @@ void getThreeCardsInRange() } @Test - void getTwoCardsInRangeWitnNoEnd() + void getTwoCardsInRangeWithNoEnd() { - persistCard(createSimpleCard("1", now, nowMinusOne, nowPlusThree, LOGIN,null, null)); - persistCard(createSimpleCard("2", now, nowMinusOne, null, LOGIN, null,null)); - persistCard(createSimpleCard("3", now, nowPlusOne, null, LOGIN,null,null)); + persistCard(createSimpleCard("1", nowMinusOne, nowMinusOne, nowPlusThree, LOGIN,null, null)); + persistCard(createSimpleCard("2", nowMinusOne, nowMinusOne, null, LOGIN, null,null)); + persistCard(createSimpleCard("3", nowMinusOne, nowPlusOne, null, LOGIN,null,null)); HashMap results = new HashMap(); StepVerifier.create(repository.getCardOperations(null, now,nowPlusTwo, adminUser) @@ -255,6 +255,60 @@ void getTwoCardsInRangeWitnNoEnd() assertCard(card2, "PROCESS.PROCESS3", "PUBLISHER", "0"); } + + @Test + void getTwoCardsInRangeWithoutStart() + { + persistCard(createSimpleCard("1", now, now, nowPlusOne, LOGIN,null, null)); + persistCard(createSimpleCard("2", nowMinusTwo, now, nowPlusOne, LOGIN, null,null)); + persistCard(createSimpleCard("3", now, nowMinusTwo, nowMinusOne, LOGIN,null,null)); + + HashMap results = new HashMap(); + StepVerifier.create(repository.getCardOperations(null, null, nowMinusOne, adminUser) + .doOnNext(TestUtilities::logCardOperation)) + .assertNext(op -> { + assertThat(op.getCard()).isNotNull(); + results.put(op.getCard().getProcessInstanceId(), op); + }) + .assertNext(op -> { + assertThat(op.getCard()).isNotNull(); + results.put(op.getCard().getProcessInstanceId(), op); + }) + .expectComplete() + .verify(); + CardOperation card1 = (CardOperation) results.get("PROCESS2"); + CardOperation card2 = (CardOperation) results.get("PROCESS3"); + assertCard(card1, "PROCESS.PROCESS2", "PUBLISHER", "0"); + assertCard(card2, "PROCESS.PROCESS3", "PUBLISHER", "0"); + + } + + @Test + void getTwoCardsInRangeWithoutEnd() + { + persistCard(createSimpleCard("1", nowMinusOne, nowMinusOne, now, LOGIN,null, null)); + persistCard(createSimpleCard("2", now, nowPlusOne, nowPlusTwo, LOGIN, null,null)); + persistCard(createSimpleCard("3", nowPlusOne, nowMinusTwo, nowMinusOne, LOGIN,null,null)); + + HashMap results = new HashMap(); + StepVerifier.create(repository.getCardOperations(null, nowPlusOne, null, adminUser) + .doOnNext(TestUtilities::logCardOperation)) + .assertNext(op -> { + assertThat(op.getCard()).isNotNull(); + results.put(op.getCard().getProcessInstanceId(), op); + }) + .assertNext(op -> { + assertThat(op.getCard()).isNotNull(); + results.put(op.getCard().getProcessInstanceId(), op); + }) + .expectComplete() + .verify(); + CardOperation card1 = (CardOperation) results.get("PROCESS2"); + CardOperation card2 = (CardOperation) results.get("PROCESS3"); + assertCard(card1, "PROCESS.PROCESS2", "PUBLISHER", "0"); + assertCard(card2, "PROCESS.PROCESS3", "PUBLISHER", "0"); + + } @Test void getZeroCardAfterPublishDate() @@ -292,11 +346,11 @@ void getTwoCardsAfterPublishDate() } @Test - void getOneCardInRangeAndAfterPublishDate () + void getOneCardInRangeAndAfterPublishDate() { persistCard(createSimpleCard("1", now, now, nowPlusOne, LOGIN,null, null)); - persistCard(createSimpleCard("2", nowPlusTwo, now, nowPlusOne, LOGIN, null,null)); - persistCard(createSimpleCard("3", nowPlusTwo, nowPlusTwo, nowPlusThree, LOGIN,null,null)); + persistCard(createSimpleCard("2", nowPlusOne, now, nowPlusOne, LOGIN, null,null)); + persistCard(createSimpleCard("3", nowPlusOne, nowPlusTwo, nowPlusThree, LOGIN,null,null)); StepVerifier.create(repository.getCardOperations(nowPlusOne, nowPlusTwo,nowPlusThree, adminUser) .doOnNext(TestUtilities::logCardOperation)) @@ -309,6 +363,22 @@ void getOneCardInRangeAndAfterPublishDate () } + @Test + void getOneCardWithPublishDateInRange() + { + persistCard(createSimpleCard("1", nowMinusOne, nowPlusTwo, nowPlusThree, LOGIN,null, null)); + persistCard(createSimpleCard("2", now, nowPlusTwo, nowPlusThree, LOGIN, null,null)); + persistCard(createSimpleCard("3", nowPlusTwo, nowPlusTwo, nowPlusThree, LOGIN,null,null)); + + StepVerifier.create(repository.getCardOperations(nowMinusThree, now,nowPlusOne, adminUser) + .doOnNext(TestUtilities::logCardOperation)) + .assertNext(op -> { + assertThat(op.getCard()).isNotNull(); + assertCard(op,"PROCESS.PROCESS2", "PUBLISHER", "0"); + }) + .expectComplete() + .verify(); + } @Test void getNoCardAsRteUserEntity1IsNotAdminUser() { diff --git a/services/cards-publication/src/main/java/org/opfab/cards/publication/model/CardPublicationData.java b/services/cards-publication/src/main/java/org/opfab/cards/publication/model/CardPublicationData.java index 68b3551bea..d78c351a7d 100644 --- a/services/cards-publication/src/main/java/org/opfab/cards/publication/model/CardPublicationData.java +++ b/services/cards-publication/src/main/java/org/opfab/cards/publication/model/CardPublicationData.java @@ -74,7 +74,9 @@ public class CardPublicationData implements Card { private String summaryTranslated; @CreatedDate + @Indexed private Instant publishDate; + private Instant lttd; @Indexed diff --git a/src/test/cypress/cypress/integration/FeedTests.spec.js b/src/test/cypress/cypress/integration/FeedTests.spec.js index 79872dbedc..ad79620cb9 100644 --- a/src/test/cypress/cypress/integration/FeedTests.spec.js +++ b/src/test/cypress/cypress/integration/FeedTests.spec.js @@ -115,4 +115,10 @@ describe ('FeedScreen tests',function () { cy.get('of-card-details').should('not.exist'); }) + + it('Check card visibility by publish date when business period is after selected time range', function () { + cy.sendCard('cypress/feed/futureEvent.json'); + cy.loginOpFab('operator1','test'); + cy.get('of-light-card').should('have.length',1); + }) }) diff --git a/src/test/resources/cards/cypress/feed/futureEvent.json b/src/test/resources/cards/cypress/feed/futureEvent.json new file mode 100644 index 0000000000..7c2826481d --- /dev/null +++ b/src/test/resources/cards/cypress/feed/futureEvent.json @@ -0,0 +1,16 @@ +{ + "publisher" : "publisher_test", + "processVersion" : "1", + "process" :"cypress", + "processInstanceId" : "kitchenSink", + "state": "kitchenSink", + "entityRecipients": ["ENTITY1","ENTITY2"], + "entitiesRequiredToRespond" : ["ENTITY1"], + "severity" : "INFORMATION", + "startDate" : ${current_date_in_milliseconds_from_epoch_plus_24hours}, + "endDate" : ${current_date_in_milliseconds_from_epoch_plus_48hours} , + "summary" : {"key" : "kitchenSink.title"}, + "title" : {"key" : "kitchenSink.title"}, + "data" : {"message":"test"}, + "timeSpans" : [] +} \ No newline at end of file diff --git a/ui/main/src/app/services/lightcards/filter.service.spec.ts b/ui/main/src/app/services/lightcards/filter.service.spec.ts index a6b711d35b..0b76b9fe4e 100644 --- a/ui/main/src/app/services/lightcards/filter.service.spec.ts +++ b/ui/main/src/app/services/lightcards/filter.service.spec.ts @@ -23,7 +23,7 @@ describe('NewFilterService ', () => { }); - function getFourCard() { + function getFourCards() { let cards: LightCard[] = new Array(); cards = cards.concat(getSeveralRandomLightCards(1, { startDate: new Date().valueOf(), @@ -63,6 +63,35 @@ describe('NewFilterService ', () => { } + function getSevenCards() { + let cards = getFourCards(); + cards = cards.concat(getSeveralRandomLightCards(1, { + startDate: new Date().valueOf() + 36 * ONE_HOUR, + endDate: new Date().valueOf() + 48 * ONE_HOUR, + publishDate : new Date().valueOf() + ONE_HOUR * 25, + severity: Severity.INFORMATION, + hasBeenAcknowledged: true, + hasChildCardFromCurrentUserEntity: false + })); + cards = cards.concat(getSeveralRandomLightCards(1, { + startDate: new Date().valueOf() + 31 * ONE_HOUR, + endDate: new Date().valueOf() + 48 * ONE_HOUR, + publishDate : new Date().valueOf() - ONE_HOUR * 31, + severity: Severity.INFORMATION, + hasBeenAcknowledged: true, + hasChildCardFromCurrentUserEntity: false + })); + cards = cards.concat(getSeveralRandomLightCards(1, { + startDate: new Date().valueOf() + 31 * ONE_HOUR, + endDate: new Date().valueOf() + 48 * ONE_HOUR, + publishDate : new Date().valueOf() + ONE_HOUR * 51, + severity: Severity.INFORMATION, + hasBeenAcknowledged: true, + hasChildCardFromCurrentUserEntity: false + })); + return cards; + } + describe('ack filter', () => { it('filter 0 cards shall return 0 cards ', () => { const cards: LightCard[] = new Array(); @@ -70,7 +99,7 @@ describe('NewFilterService ', () => { expect( filteredCards.length).toBe(0); }); it('filter 4 cards with two ack shall return 2 cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); const filteredCards = service.filterLightCards(cards); expect(filteredCards.length).toBe(2); expect( filteredCards).toContain(cards[0]); @@ -78,7 +107,7 @@ describe('NewFilterService ', () => { }); it('filter 4 cards , filter is inative => shall return the 4 cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); const filteredCards = service.filterLightCards(cards); expect(filteredCards.length).toBe(4); @@ -93,7 +122,7 @@ describe('NewFilterService ', () => { describe('response form my own entity filter', () => { it('filter 1 with child card and 3 with no child card filter is active => shall return the 3 cards with no child ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.RESPONSE_FILTER, true, false); const filteredCards = service.filterLightCards(cards); @@ -109,7 +138,7 @@ describe('NewFilterService ', () => { describe('type filter', () => { it('filter 4 cards with 4 different severity , filter is set to alarm severity only => shall return the alarm card only ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.TYPE_FILTER, true, {alarm: true, action: false, compliant: false, information: false }); const filteredCards = service.filterLightCards(cards); @@ -120,7 +149,7 @@ describe('NewFilterService ', () => { it('filter 4 cards with 4 different severity , filter is set to action/compliant/information severity => shall return 3 cards', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.TYPE_FILTER, true, {alarm: false, action: true, compliant: true, information: true }); const filteredCards = service.filterLightCards(cards); @@ -135,7 +164,7 @@ describe('NewFilterService ', () => { describe('business date filter', () => { it('Filter with start date after card 1 startDate => shoud return 3 cards ', () => { - const cards = getFourCard(); + const cards = getSevenCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { start: new Date().valueOf() + 0.5 * ONE_HOUR, @@ -148,40 +177,64 @@ describe('NewFilterService ', () => { expect(filteredCards).toContain(cards[3]); }); - it('Filter with business period matching card 3 & 4 => shoud return 1 cards ', () => { - const cards = getFourCard(); + it('Filter with business period matching card 3 ,4 , 5 => shoud return 3 cards ', () => { + const cards = getSevenCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { start: new Date().valueOf() + 1.5 * ONE_HOUR, end : new Date().valueOf() + 30 * ONE_HOUR }); const filteredCards = service.filterLightCards(cards); - expect(filteredCards.length).toBe(2); + expect(filteredCards.length).toBe(3); expect(filteredCards).toContain(cards[2]); expect(filteredCards).toContain(cards[3]); + expect(filteredCards).toContain(cards[4]); }); it('Filter with business period matching card 4 only => shoud return 1 cards ', () => { - const cards = getFourCard(); + const cards = getSevenCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { start: new Date().valueOf() + 2.5 * ONE_HOUR, - end : new Date().valueOf() + 30 * ONE_HOUR + end : new Date().valueOf() + 20 * ONE_HOUR }); const filteredCards = service.filterLightCards(cards); expect(filteredCards.length).toBe(1); expect(filteredCards).toContain(cards[3]); }); - it('Filter with start date after all business period => shoud return 0 cards ', () => { - const cards = getFourCard(); + it('Filter with start date after all business period, card 5 has publish date in business period => shoud return 1 cards ', () => { + const cards = getSevenCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { start: new Date().valueOf() + 20 * ONE_HOUR, end : new Date().valueOf() + 30 * ONE_HOUR }); const filteredCards = service.filterLightCards(cards); - expect(filteredCards.length).toBe(0); + expect(filteredCards.length).toBe(1); + expect(filteredCards).toContain(cards[4]); + }); + + it('Filter with end date before all business period, card 6 has publish date before end date => shoud return 1 cards ', () => { + const cards = getSevenCards(); + service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); + service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { + end : new Date().valueOf() - 30 * ONE_HOUR + }); + const filteredCards = service.filterLightCards(cards); + expect(filteredCards.length).toBe(1); + expect(filteredCards).toContain(cards[5]); + }); + + it('Filter with start date after all business periods, card 7 has publish date after start date => shoud return 1 cards ', () => { + const cards = getSevenCards(); + service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); + service.updateFilter(FilterType.BUSINESSDATE_FILTER, true, { + start: new Date().valueOf() + 50 * ONE_HOUR, + }); + const filteredCards = service.filterLightCards(cards); + expect(filteredCards.length).toBe(1); + expect(filteredCards).toContain(cards[6]); }); }); @@ -189,7 +242,7 @@ describe('NewFilterService ', () => { describe('publish date filter', () => { it('Filter with start date before all date => shoud return the four cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {start: new Date().valueOf() - 4 * ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -202,7 +255,7 @@ describe('NewFilterService ', () => { it('Filter with start date before two date => shoud return two cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {start: new Date().valueOf() - 1.5 * ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -212,7 +265,7 @@ describe('NewFilterService ', () => { }); it('Filter with start date after all date => shoud return no cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {start: new Date().valueOf() + ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -220,7 +273,7 @@ describe('NewFilterService ', () => { }); it('Filter with end date after all date => shoud return the four cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {end: new Date().valueOf() + 4 * ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -233,7 +286,7 @@ describe('NewFilterService ', () => { it('Filter with end date before two date => shoud return two cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {end: new Date().valueOf() - 1.5 * ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -243,7 +296,7 @@ describe('NewFilterService ', () => { }); it('Filter with end date before all date => shoud return no cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, {end: new Date().valueOf() - 5 * ONE_HOUR} ); const filteredCards = service.filterLightCards(cards); @@ -251,7 +304,7 @@ describe('NewFilterService ', () => { }); it('Filter with [start date ; end date ] => shoud return two cards ', () => { - const cards = getFourCard(); + const cards = getFourCards(); service.updateFilter(FilterType.ACKNOWLEDGEMENT_FILTER, false, false); service.updateFilter(FilterType.PUBLISHDATE_FILTER, true, { start: new Date().valueOf() - 2.5 * ONE_HOUR, diff --git a/ui/main/src/app/services/lightcards/filter.service.ts b/ui/main/src/app/services/lightcards/filter.service.ts index 57f5443a6e..398cb052af 100644 --- a/ui/main/src/app/services/lightcards/filter.service.ts +++ b/ui/main/src/app/services/lightcards/filter.service.ts @@ -111,16 +111,11 @@ export class FilterService { return new Filter( (card: LightCard, status) => { if (!!status.start && !!status.end) { - if (!card.endDate) { - return status.start <= card.startDate && card.startDate <= status.end; - } - return status.start <= card.startDate && card.startDate <= status.end - || status.start <= card.endDate && card.endDate <= status.end - || card.startDate <= status.start && status.end <= card.endDate; + return this.chechCardVisibilityinRange(card, status.start, status.end); } else if (!!status.start) { - return (!card.endDate && card.startDate >= status.start) || (!!card.endDate && status.start <= card.endDate); + return card.publishDate >= status.start || (!card.endDate && card.startDate >= status.start) || (!!card.endDate && status.start <= card.endDate); } else if (!!status.end) { - return card.startDate <= status.end; + return card.publishDate <= status.end || card.startDate <= status.end; } console.warn(new Date().toISOString(), 'Unexpected business date filter situation'); return false; @@ -132,6 +127,18 @@ export class FilterService { }); } + private chechCardVisibilityinRange(card: LightCard, start, end ) { + if (start <= card.publishDate && card.publishDate <= end) { + return true; + } + if (!card.endDate) { + return start <= card.startDate && card.startDate <= end; + } + return start <= card.startDate && card.startDate <= end + || start <= card.endDate && card.endDate <= end + || card.startDate <= start && end <= card.endDate; + } + private initPublishDateFilter(): Filter { return new Filter( From e9af3b7bab200ed07de59df9341ce7e504992573 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 16 Dec 2021 23:09:23 +0000 Subject: [PATCH 17/73] Update dependency @ng-bootstrap/ng-bootstrap to v11 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 8 ++++---- ui/main/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 1d7dd67804..bd4adbec66 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -1874,11 +1874,11 @@ "dev": true }, "@ng-bootstrap/ng-bootstrap": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-10.0.0.tgz", - "integrity": "sha512-Sz+QaxjuyJYJ+zyUbf0TevgcgVesCPQiiFiggEzxKjzY5R+Hvq3YgryLdXf2r/ryePL+C3FXCcmmKpTM5bfczQ==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-11.0.0.tgz", + "integrity": "sha512-qDnB0+jbpQ4wjXpM4NPRAtwmgTDUCjGavoeRDZHOvFfYvx/MBf1RTjZEqTJ1Yqq1pKP4BWpzxCgVTunfnpmsjA==", "requires": { - "tslib": "^2.1.0" + "tslib": "^2.3.0" } }, "@ngrx/effects": { diff --git a/ui/main/package.json b/ui/main/package.json index 1b2ee21622..c12f7b90f0 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -32,7 +32,7 @@ "@fullcalendar/daygrid": "5.10.0", "@fullcalendar/interaction": "5.10.0", "@fullcalendar/timegrid": "5.10.0", - "@ng-bootstrap/ng-bootstrap": "10.0.0", + "@ng-bootstrap/ng-bootstrap": "11.0.0", "@ngrx/effects": "13.0.2", "@ngrx/entity": "13.0.2", "@ngrx/router-store": "13.0.2", From 2e29487184ec17573d13b9f59fdc10862649ba22 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 23 Dec 2021 00:31:10 +0000 Subject: [PATCH 18/73] Update plugin org.hidetake.swagger.generator to v2.19.1 Signed-off-by: Renovate Bot --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5c910ac583..6c3223961f 100755 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { id 'org.sonarqube' version '3.3' id "com.github.davidmc24.gradle.plugin.avro" version "1.3.0" apply false id "com.palantir.docker" version "0.31.0" apply false - id "org.hidetake.swagger.generator" version "2.18.2" apply false + id "org.hidetake.swagger.generator" version "2.19.1" apply false } ext { From 259c7f5000a5134220491330360c2b6c9da6bf86 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Dec 2021 23:07:04 +0000 Subject: [PATCH 19/73] Update dependency cypress to v9.2.0 Signed-off-by: Renovate Bot --- src/test/cypress/package-lock.json | 12 ++++++------ src/test/cypress/package.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/test/cypress/package-lock.json b/src/test/cypress/package-lock.json index 6917b17599..75c5e8c725 100644 --- a/src/test/cypress/package-lock.json +++ b/src/test/cypress/package-lock.json @@ -52,9 +52,9 @@ } }, "@types/node": { - "version": "14.18.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.0.tgz", - "integrity": "sha512-0GeIl2kmVMXEnx8tg1SlG6Gg8vkqirrW752KqolYo1PHevhhZN3bhJ67qHj+bQaINhX0Ra3TlWwRvMCd9iEfNQ==", + "version": "14.18.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.2.tgz", + "integrity": "sha512-fqtSN5xn/bBzDxMT77C1rJg6CsH/R49E7qsGuvdPJa20HtV5zSTuLJPNfnlyVH3wauKnkHdLggTVkOW/xP9oQg==", "dev": true }, "@types/sinonjs__fake-timers": { @@ -362,9 +362,9 @@ } }, "cypress": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-9.1.1.tgz", - "integrity": "sha512-yWcYD8SEQ8F3okFbRPqSDj5V0xhrZBT5QRIH+P1J2vYvtEmZ4KGciHE7LCcZZLILOrs7pg4WNCqkj/XRvReQlQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-9.2.0.tgz", + "integrity": "sha512-Jn26Tprhfzh/a66Sdj9SoaYlnNX6Mjfmj5PHu2a7l3YHXhrgmavM368wjCmgrxC6KHTOv9SpMQGhAJn+upDViA==", "dev": true, "requires": { "@cypress/request": "^2.88.10", diff --git a/src/test/cypress/package.json b/src/test/cypress/package.json index 9f7377590d..478efed110 100644 --- a/src/test/cypress/package.json +++ b/src/test/cypress/package.json @@ -9,7 +9,7 @@ "author": "", "license": "MPL-2.0", "devDependencies": { - "cypress": "9.1.1", + "cypress": "9.2.0", "cypress-terminal-report": "3.4.1" } } From 32b6e662db8780e8051e73df815c4a57287330fe Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Dec 2021 23:05:35 +0000 Subject: [PATCH 20/73] Update dependency gradle to v7.3.3 Signed-off-by: Renovate Bot --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d2880ba800..2e6e5897b5 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 7c0d4990fc12c89b4e67f8ddf55400c9e1971c92 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Thu, 23 Dec 2021 02:58:35 +0000 Subject: [PATCH 21/73] Update dependency io.github.openfeign:feign-mock to v11.8 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 98b0213538..852ccc6e64 100755 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ apacheCommonsCompress=1.21 apacheCommonsCollections4=4.4 commonsIO=2.11.0 lombok=1.18.22 -feign=11.7 +feign=11.8 jacksonAnnotations=2.13.1 jacksonDatabind=2.13.1 kavroSchemaRegistryClient=7.0.0 From f26351bb73335b0b2deb67839df9768d6ff9d64f Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 16:15:28 +0100 Subject: [PATCH 22/73] Handling empty configurations (#2301) Signed-off-by: Alexandra Guironnet --- .../services/ConfigService.java | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/services/external-devices/src/main/java/org/opfab/externaldevices/services/ConfigService.java b/services/external-devices/src/main/java/org/opfab/externaldevices/services/ConfigService.java index db929758f2..e0187f31a0 100644 --- a/services/external-devices/src/main/java/org/opfab/externaldevices/services/ConfigService.java +++ b/services/external-devices/src/main/java/org/opfab/externaldevices/services/ConfigService.java @@ -35,7 +35,10 @@ public class ConfigService { public static final String DEBUG_RETRIEVED_CONFIG = "Retrieved configuration for"; public static final String UNSUPPORTED_SIGNAL ="Signal %1$s is not supported in mapping %2$s"; public static final String NULL_AFTER_DELETE = "Following deletion of {}, no {} is configured for {} {}"; - public static final String DEVICE_NAME = "device"; + public static final String CANNOT_RETRIEVE_FOR_NULL_OR_EMPTY_ID = "Cannot retrieve %1$s with null or empty id."; + public static final String DEVICE_CONFIG = "device configuration"; + public static final String SIGNAL_CONFIG = "signal mapping"; + public static final String USER_CONFIG = "user configuration"; private final UserConfigurationRepository userConfigurationRepository; private final DeviceConfigurationRepository deviceConfigurationRepository; @@ -82,39 +85,45 @@ public ResolvedConfiguration getResolvedConfiguration(String opFabSignalKey, Str } public DeviceConfiguration retrieveDeviceConfiguration(String deviceId) throws ExternalDeviceConfigurationException { - - Optional deviceConfiguration = deviceConfigurationRepository.findById(deviceId); - if(deviceConfiguration.isPresent()) { - DeviceConfiguration retrievedDeviceConfig = deviceConfiguration.get(); - log.debug("{} for device {} : {}", DEBUG_RETRIEVED_CONFIG, deviceId, retrievedDeviceConfig.toString()); - return retrievedDeviceConfig; + if(deviceId == null || deviceId.isEmpty()) { + throw new ExternalDeviceConfigurationException(String.format(CANNOT_RETRIEVE_FOR_NULL_OR_EMPTY_ID, DEVICE_CONFIG, deviceId)); } else { - throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, DEVICE_NAME, deviceId)); + Optional deviceConfiguration = deviceConfigurationRepository.findById(deviceId); + if(deviceConfiguration.isPresent()) { + DeviceConfiguration retrievedDeviceConfig = deviceConfiguration.get(); + log.debug("{} for device {} : {}", DEBUG_RETRIEVED_CONFIG, deviceId, retrievedDeviceConfig.toString()); + return retrievedDeviceConfig; + } else { + throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, DEVICE_CONFIG, deviceId)); + } } - } public UserConfiguration retrieveUserConfiguration(String userLogin) throws ExternalDeviceConfigurationException { - - Optional userConfiguration = userConfigurationRepository.findById(userLogin); - if(userConfiguration.isPresent()) { - UserConfiguration retrievedUserConfig = userConfiguration.get(); - log.debug("{} for user {} : {}", DEBUG_RETRIEVED_CONFIG, userLogin, retrievedUserConfig.toString()); - return retrievedUserConfig; + if(userLogin == null || userLogin.isEmpty()) { + throw new ExternalDeviceConfigurationException(String.format(CANNOT_RETRIEVE_FOR_NULL_OR_EMPTY_ID, USER_CONFIG)); } else { - throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, "user", userLogin)); + Optional userConfiguration = userConfigurationRepository.findById(userLogin); + if(userConfiguration.isPresent()) { + UserConfiguration retrievedUserConfig = userConfiguration.get(); + log.debug("{} for user {} : {}", DEBUG_RETRIEVED_CONFIG, userLogin, retrievedUserConfig.toString()); + return retrievedUserConfig; + } else { + throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, USER_CONFIG, userLogin)); + } } - } public SignalMapping retrieveSignalMapping(String signalMappingId) throws ExternalDeviceConfigurationException { - + if(signalMappingId == null || signalMappingId.isEmpty()) { + throw new ExternalDeviceConfigurationException(String.format(CANNOT_RETRIEVE_FOR_NULL_OR_EMPTY_ID, SIGNAL_CONFIG)); + } Optional signalMapping = signalMappingRepository.findById(signalMappingId); if(signalMapping.isPresent()) { SignalMapping retrievedSignalMapping = signalMapping.get(); log.debug("{} for signal {} : {}", DEBUG_RETRIEVED_CONFIG, signalMappingId, retrievedSignalMapping.toString()); return retrievedSignalMapping; } else { - throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, "signal", signalMappingId)); + throw new ExternalDeviceConfigurationException(String.format(CONFIGURATION_NOT_FOUND, SIGNAL_CONFIG, signalMappingId)); } } @@ -129,7 +138,7 @@ public void deleteDeviceConfiguration(String deviceId) throws ExternalDeviceConf if (foundUserConfigurations != null) { for (UserConfigurationData userConfigurationData : foundUserConfigurations) { userConfigurationData.setExternalDeviceId(null); - log.warn(NULL_AFTER_DELETE, deviceId, DEVICE_NAME, "user", userConfigurationData.getUserLogin()); + log.warn(NULL_AFTER_DELETE, deviceId, DEVICE_CONFIG, "user", userConfigurationData.getUserLogin()); } userConfigurationRepository.saveAll(foundUserConfigurations); } @@ -150,7 +159,7 @@ public void deleteSignalMapping(String signalMappingId) throws ExternalDeviceCon if (foundDeviceConfigurations != null) { for (DeviceConfigurationData deviceConfigurationData : foundDeviceConfigurations) { deviceConfigurationData.setSignalMappingId(null); - log.warn(NULL_AFTER_DELETE, signalMappingId, "signalMapping", DEVICE_NAME, deviceConfigurationData.getId()); + log.warn(NULL_AFTER_DELETE, signalMappingId, "signalMapping", DEVICE_CONFIG, deviceConfigurationData.getId()); } deviceConfigurationRepository.saveAll(foundDeviceConfigurations); } From 037b241b7ee81d1a3f3229fe200735fbb91f1cde Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 16:17:10 +0100 Subject: [PATCH 23/73] External Devices API security (#2283) Signed-off-by: Alexandra Guironnet --- .../oauth2/WebSecurityConfiguration.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java index 06225b17ef..231800a06c 100644 --- a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java @@ -31,9 +31,8 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; - public static final String CONFIGURATIONS_ROOT_PATH = "/configurations"; - public static final String DEVICE_CONFIGURATIONS_PATH = CONFIGURATIONS_ROOT_PATH+"/devices/**"; - public static final String DEVICES_ROOT_PATH = "/devices"; + public static final String CONFIGURATIONS_ROOT_PATH = "/configurations/"; + public static final String DEVICES_ROOT_PATH = "/devices/"; public static final String NOTIFICATIONS_ROOT_PATH = "/notifications"; public static final String AUTH_AND_IP_ALLOWED = "isAuthenticated() and @webSecurityChecks.checkUserIpAddress(authentication)"; @@ -59,11 +58,8 @@ public static void configureCommon(final HttpSecurity http) throws Exception { .authorizeRequests() .antMatchers(HttpMethod.GET,PROMETHEUS_PATH).permitAll() .antMatchers(HttpMethod.POST,NOTIFICATIONS_ROOT_PATH).access(AUTH_AND_IP_ALLOWED) - .antMatchers(HttpMethod.POST, DEVICE_CONFIGURATIONS_PATH).access(ADMIN_AND_IP_ALLOWED) - .antMatchers(HttpMethod.PATCH, DEVICE_CONFIGURATIONS_PATH).access(ADMIN_AND_IP_ALLOWED) - .antMatchers(HttpMethod.DELETE, DEVICE_CONFIGURATIONS_PATH).access(ADMIN_AND_IP_ALLOWED) - .antMatchers(HttpMethod.GET, DEVICES_ROOT_PATH).access(ADMIN_AND_IP_ALLOWED) - .antMatchers(HttpMethod.POST, DEVICES_ROOT_PATH).access(ADMIN_AND_IP_ALLOWED) + .antMatchers(CONFIGURATIONS_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) + .antMatchers(DEVICES_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) .anyRequest().access(AUTH_AND_IP_ALLOWED); } From 3f0274b2967f851b3c997aefb1b536e24bf9fb7d Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 17:35:00 +0100 Subject: [PATCH 24/73] Karate tests for External Devices (#2281) Signed-off-by: Alexandra Guironnet --- src/test/api/karate/externalDevicesTests.txt | 10 ++- ...fig.feature => createDeviceConfig.feature} | 14 ++-- .../createSignalConfig.feature | 68 +++++++++++++++++++ .../externaldevices/createUserConfig.feature | 68 +++++++++++++++++++ .../deleteDeviceConfig.feature | 41 +++++++++++ .../deleteSignalConfig.feature | 41 +++++++++++ .../externaldevices/deleteUserConfig.feature | 41 +++++++++++ .../externaldevices/fetchDeviceConfig.feature | 63 +++++++++++++++++ .../externaldevices/fetchSignalConfig.feature | 63 +++++++++++++++++ .../externaldevices/fetchUserConfig.feature | 63 +++++++++++++++++ .../signalMappings/broken_signal_mapping.json | 4 ++ .../duplicate_signal_mapping.json | 9 +++ .../signalMappings/new_signal_mapping.json | 9 +++ .../userConfigurations/broken_config.json | 3 + .../duplicate_operator1.json | 4 ++ .../userConfigurations/operator5.json | 4 ++ 16 files changed, 496 insertions(+), 9 deletions(-) rename src/test/api/karate/externaldevices/{manageDeviceConfig.feature => createDeviceConfig.feature} (81%) create mode 100644 src/test/api/karate/externaldevices/createSignalConfig.feature create mode 100644 src/test/api/karate/externaldevices/createUserConfig.feature create mode 100644 src/test/api/karate/externaldevices/deleteDeviceConfig.feature create mode 100644 src/test/api/karate/externaldevices/deleteSignalConfig.feature create mode 100644 src/test/api/karate/externaldevices/deleteUserConfig.feature create mode 100644 src/test/api/karate/externaldevices/fetchDeviceConfig.feature create mode 100644 src/test/api/karate/externaldevices/fetchSignalConfig.feature create mode 100644 src/test/api/karate/externaldevices/fetchUserConfig.feature create mode 100644 src/test/api/karate/externaldevices/resources/signalMappings/broken_signal_mapping.json create mode 100644 src/test/api/karate/externaldevices/resources/signalMappings/duplicate_signal_mapping.json create mode 100644 src/test/api/karate/externaldevices/resources/signalMappings/new_signal_mapping.json create mode 100644 src/test/api/karate/externaldevices/resources/userConfigurations/broken_config.json create mode 100644 src/test/api/karate/externaldevices/resources/userConfigurations/duplicate_operator1.json create mode 100644 src/test/api/karate/externaldevices/resources/userConfigurations/operator5.json diff --git a/src/test/api/karate/externalDevicesTests.txt b/src/test/api/karate/externalDevicesTests.txt index f23c81adb4..ca2469c08c 100644 --- a/src/test/api/karate/externalDevicesTests.txt +++ b/src/test/api/karate/externalDevicesTests.txt @@ -1,2 +1,10 @@ - externaldevices/manageDeviceConfig.feature \ + externaldevices/fetchDeviceConfig.feature \ + externaldevices/fetchUserConfig.feature \ + externaldevices/fetchSignalConfig.feature \ + externaldevices/createDeviceConfig.feature \ + externaldevices/deleteDeviceConfig.feature \ + externaldevices/createUserConfig.feature \ + externaldevices/deleteUserConfig.feature \ + externaldevices/createSignalConfig.feature \ + externaldevices/deleteSignalConfig.feature \ externaldevices/sendNotification.feature \ \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/manageDeviceConfig.feature b/src/test/api/karate/externaldevices/createDeviceConfig.feature similarity index 81% rename from src/test/api/karate/externaldevices/manageDeviceConfig.feature rename to src/test/api/karate/externaldevices/createDeviceConfig.feature index 793a38bf0c..ed5b03864e 100644 --- a/src/test/api/karate/externaldevices/manageDeviceConfig.feature +++ b/src/test/api/karate/externaldevices/createDeviceConfig.feature @@ -1,4 +1,4 @@ -Feature: Device Configuration Management +Feature: Device Configuration Management (Create) Background: # Get admin token @@ -11,7 +11,7 @@ Feature: Device Configuration Management * def deviceConfigEndpoint = 'externaldevices/configurations/devices' - Scenario: Create device with correct configuration + Scenario: Create deviceConfiguration with correct configuration * def configuration = read("resources/deviceConfigurations/CDS_5.json") @@ -22,7 +22,7 @@ Feature: Device Configuration Management When method post Then status 201 - Scenario: Create device with correct configuration but duplicate id + Scenario: Create deviceConfiguration with correct configuration but duplicate id * def configuration = read("resources/deviceConfigurations/duplicate_CDS_5.json") @@ -33,7 +33,7 @@ Feature: Device Configuration Management When method post Then status 400 - Scenario: Create device with incorrect configuration + Scenario: Create deviceConfiguration with incorrect configuration * def configuration = read("resources/deviceConfigurations/broken_config.json") @@ -44,7 +44,7 @@ Feature: Device Configuration Management When method post Then status 400 - Scenario: Create device without authentication + Scenario: Create deviceConfiguration without authentication * def configuration = read("resources/deviceConfigurations/CDS_5.json") @@ -54,8 +54,7 @@ Feature: Device Configuration Management When method post Then status 401 - - Scenario: Create device without admin role + Scenario: Create deviceConfiguration without admin role * def configuration = read("resources/deviceConfigurations/CDS_5.json") @@ -67,4 +66,3 @@ Feature: Device Configuration Management Then status 403 - diff --git a/src/test/api/karate/externaldevices/createSignalConfig.feature b/src/test/api/karate/externaldevices/createSignalConfig.feature new file mode 100644 index 0000000000..689997be9f --- /dev/null +++ b/src/test/api/karate/externaldevices/createSignalConfig.feature @@ -0,0 +1,68 @@ +Feature: Signal Configuration Management (Create) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def signalConfigEndpoint = 'externaldevices/configurations/signals' + + Scenario: Create signalMapping with correct configuration + + * def configuration = read("resources/signalMappings/new_signal_mapping.json") + + # Push configuration + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 201 + + Scenario: Create signalMapping with correct configuration but duplicate id + + * def configuration = read("resources/signalMappings/duplicate_signal_mapping.json") + + # Push configuration + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 400 + + Scenario: Create signalMapping with incorrect configuration + + * def configuration = read("resources/signalMappings/broken_signal_mapping.json") + + # Push configuration + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 400 + + Scenario: Create signalMapping without authentication + + * def configuration = read("resources/signalMappings/new_signal_mapping.json") + + # Push configuration + Given url opfabUrl + signalConfigEndpoint + And request configuration + When method post + Then status 401 + + Scenario: Create signalMapping without admin role + + * def configuration = read("resources/signalMappings/new_signal_mapping.json") + + # Push configuration + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authTokenAsTSO + And request configuration + When method post + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/createUserConfig.feature b/src/test/api/karate/externaldevices/createUserConfig.feature new file mode 100644 index 0000000000..61c8a9e59d --- /dev/null +++ b/src/test/api/karate/externaldevices/createUserConfig.feature @@ -0,0 +1,68 @@ +Feature: User Configuration Management (Create) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def userConfigEndpoint = 'externaldevices/configurations/users' + + Scenario: Create userConfiguration with correct configuration + + * def configuration = read("resources/userConfigurations/operator5.json") + + # Push configuration + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 201 + + Scenario: Create userConfiguration with correct configuration but duplicate id + + * def configuration = read("resources/userConfigurations/duplicate_operator1.json") + + # Push configuration + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 400 + + Scenario: Create userConfiguration with incorrect configuration + + * def configuration = read("resources/userConfigurations/broken_config.json") + + # Push configuration + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authToken + And request configuration + When method post + Then status 400 + + Scenario: Create userConfiguration without authentication + + * def configuration = read("resources/userConfigurations/operator5.json") + + # Push configuration + Given url opfabUrl + userConfigEndpoint + And request configuration + When method post + Then status 401 + + Scenario: Create userConfiguration without admin role + + * def configuration = read("resources/userConfigurations/operator5.json") + + # Push configuration + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authTokenAsTSO + And request configuration + When method post + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/deleteDeviceConfig.feature b/src/test/api/karate/externaldevices/deleteDeviceConfig.feature new file mode 100644 index 0000000000..c292964ad4 --- /dev/null +++ b/src/test/api/karate/externaldevices/deleteDeviceConfig.feature @@ -0,0 +1,41 @@ +Feature: Device Configuration Management (Delete) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def deviceConfigEndpoint = 'externaldevices/configurations/devices' + + Scenario: Delete existing deviceConfiguration + + Given url opfabUrl + deviceConfigEndpoint + "/CDS_3" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 200 + + Scenario: Attempt to delete deviceConfiguration that doesn't exist + + Given url opfabUrl + deviceConfigEndpoint + "/CDS_that_doesnt_exist" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 404 + + Scenario: Delete deviceConfiguration without authentication + + Given url opfabUrl + deviceConfigEndpoint + "/CDS_3" + When method delete + Then status 401 + + Scenario: Delete deviceConfiguration without admin role + + Given url opfabUrl + deviceConfigEndpoint + "/CDS_3" + And header Authorization = 'Bearer ' + authTokenAsTSO + When method delete + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/deleteSignalConfig.feature b/src/test/api/karate/externaldevices/deleteSignalConfig.feature new file mode 100644 index 0000000000..400c3398a3 --- /dev/null +++ b/src/test/api/karate/externaldevices/deleteSignalConfig.feature @@ -0,0 +1,41 @@ +Feature: Signal Configuration Management (Delete) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def signalConfigEndpoint = 'externaldevices/configurations/signals' + + Scenario: Delete existing signalMapping + + Given url opfabUrl + signalConfigEndpoint + "/exotic_CDS_mapping" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 200 + + Scenario: Attempt to delete signalMapping that doesn't exist + + Given url opfabUrl + signalConfigEndpoint + "/mapping_that_doesnt_exist" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 404 + + Scenario: Delete signalMapping without authentication + + Given url opfabUrl + signalConfigEndpoint + "/exotic_CDS_mapping" + When method delete + Then status 401 + + Scenario: Delete signalMapping without admin role + + Given url opfabUrl + signalConfigEndpoint + "/exotic_CDS_mapping" + And header Authorization = 'Bearer ' + authTokenAsTSO + When method delete + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/deleteUserConfig.feature b/src/test/api/karate/externaldevices/deleteUserConfig.feature new file mode 100644 index 0000000000..4c79706449 --- /dev/null +++ b/src/test/api/karate/externaldevices/deleteUserConfig.feature @@ -0,0 +1,41 @@ +Feature: User Configuration Management (Delete) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def userConfigEndpoint = 'externaldevices/configurations/users' + + Scenario: Delete existing userConfiguration + + Given url opfabUrl + userConfigEndpoint + "/operator2" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 200 + + Scenario: Attempt to delete userConfiguration that doesn't exist + + Given url opfabUrl + userConfigEndpoint + "/user_that_doesnt_exist" + And header Authorization = 'Bearer ' + authToken + When method delete + Then status 404 + + Scenario: Delete userConfiguration without authentication + + Given url opfabUrl + userConfigEndpoint + "/operator2" + When method delete + Then status 401 + + Scenario: Delete userConfiguration without admin role + + Given url opfabUrl + userConfigEndpoint + "/operator2" + And header Authorization = 'Bearer ' + authTokenAsTSO + When method delete + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/fetchDeviceConfig.feature b/src/test/api/karate/externaldevices/fetchDeviceConfig.feature new file mode 100644 index 0000000000..a194057c26 --- /dev/null +++ b/src/test/api/karate/externaldevices/fetchDeviceConfig.feature @@ -0,0 +1,63 @@ +Feature: Device Configuration Management (Fetch) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def deviceConfigEndpoint = 'externaldevices/configurations/devices' + + Scenario: Fetch all deviceConfigurations + + Given url opfabUrl + deviceConfigEndpoint + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == '#array' + + Scenario: Fetch existing deviceConfiguration + + Given url opfabUrl + deviceConfigEndpoint + '/CDS_1' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == { id: 'CDS_1', host: 'dummy-modbus-device_1', port: 4030, signalMappingId: 'default_CDS_mapping'} + + Scenario: Attempt to fetch deviceConfiguration that doesn't exist + + Given url opfabUrl + deviceConfigEndpoint + '/device_that_doesnt_exist' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 404 + + Scenario: Fetch deviceConfigurations without authentication + + Given url opfabUrl + deviceConfigEndpoint + When method GET + Then status 401 + + Scenario: Fetch deviceConfigurations without admin role + + Given url opfabUrl + deviceConfigEndpoint + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + Scenario: Fetch a deviceConfiguration without authentication + + Given url opfabUrl + deviceConfigEndpoint + '/CDS_1' + When method GET + Then status 401 + + Scenario: Fetch a deviceConfiguration without admin role + + Given url opfabUrl + deviceConfigEndpoint + '/CDS_1' + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/fetchSignalConfig.feature b/src/test/api/karate/externaldevices/fetchSignalConfig.feature new file mode 100644 index 0000000000..a7f9c72b7f --- /dev/null +++ b/src/test/api/karate/externaldevices/fetchSignalConfig.feature @@ -0,0 +1,63 @@ +Feature: Signal Configuration Management (Fetch) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def signalConfigEndpoint = 'externaldevices/configurations/signals' + + Scenario: Fetch all signalMappings + + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == '#array' + + Scenario: Fetch existing signalMapping + + Given url opfabUrl + signalConfigEndpoint + '/default_CDS_mapping' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == {id:"default_CDS_mapping", supportedSignals:{ALARM:1,ACTION:2,COMPLIANT:3,INFORMATION:4}} + + Scenario: Attempt to fetch signalMapping that doesn't exist + + Given url opfabUrl + signalConfigEndpoint + '/mapping_that_doesnt_exist' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 404 + + Scenario: Fetch signalMappings without authentication + + Given url opfabUrl + signalConfigEndpoint + When method GET + Then status 401 + + Scenario: Fetch signalMappings without admin role + + Given url opfabUrl + signalConfigEndpoint + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + Scenario: Fetch a signalMapping without authentication + + Given url opfabUrl + signalConfigEndpoint + '/default_CDS_mapping' + When method GET + Then status 401 + + Scenario: Fetch a signalMapping without admin role + + Given url opfabUrl + signalConfigEndpoint + '/default_CDS_mapping' + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/fetchUserConfig.feature b/src/test/api/karate/externaldevices/fetchUserConfig.feature new file mode 100644 index 0000000000..11c0199957 --- /dev/null +++ b/src/test/api/karate/externaldevices/fetchUserConfig.feature @@ -0,0 +1,63 @@ +Feature: User Configuration Management (Fetch) + + Background: + # Get admin token + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + + # Get TSO-operator + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'operator1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def userConfigEndpoint = 'externaldevices/configurations/users' + + Scenario: Fetch all userConfigurations + + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == '#array' + + Scenario: Fetch existing userConfiguration + + Given url opfabUrl + userConfigEndpoint + '/operator1' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 200 + And match response == { userLogin: 'operator1', externalDeviceId: 'CDS_1'} + + Scenario: Attempt to fetch userConfiguration that doesn't exist + + Given url opfabUrl + userConfigEndpoint + '/user_that_doesnt_exist' + And header Authorization = 'Bearer ' + authToken + When method GET + Then status 404 + + Scenario: Fetch userConfigurations without authentication + + Given url opfabUrl + userConfigEndpoint + When method GET + Then status 401 + + Scenario: Fetch userConfigurations without admin role + + Given url opfabUrl + userConfigEndpoint + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + Scenario: Fetch a userConfiguration without authentication + + Given url opfabUrl + userConfigEndpoint + '/operator1' + When method GET + Then status 401 + + Scenario: Fetch a userConfiguration without admin role + + Given url opfabUrl + userConfigEndpoint + '/operator1' + And header Authorization = 'Bearer ' + authTokenAsTSO + When method GET + Then status 403 + + diff --git a/src/test/api/karate/externaldevices/resources/signalMappings/broken_signal_mapping.json b/src/test/api/karate/externaldevices/resources/signalMappings/broken_signal_mapping.json new file mode 100644 index 0000000000..5c0717c658 --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/signalMappings/broken_signal_mapping.json @@ -0,0 +1,4 @@ +{ + "id":"default_CDS_mapping", + "supportedSignals": "this mapping is broken because supportedSignals is a string" +} \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/resources/signalMappings/duplicate_signal_mapping.json b/src/test/api/karate/externaldevices/resources/signalMappings/duplicate_signal_mapping.json new file mode 100644 index 0000000000..91f81db5e2 --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/signalMappings/duplicate_signal_mapping.json @@ -0,0 +1,9 @@ +{ + "id":"default_CDS_mapping", + "supportedSignals": { + "ALARM":5, + "ACTION":6, + "COMPLIANT":7, + "INFORMATION":8 + } +} \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/resources/signalMappings/new_signal_mapping.json b/src/test/api/karate/externaldevices/resources/signalMappings/new_signal_mapping.json new file mode 100644 index 0000000000..cb634e5374 --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/signalMappings/new_signal_mapping.json @@ -0,0 +1,9 @@ +{ + "id":"new_CDS_mapping", + "supportedSignals": { + "ALARM":1, + "ACTION":2, + "COMPLIANT":3, + "INFORMATION":4 + } +} \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/resources/userConfigurations/broken_config.json b/src/test/api/karate/externaldevices/resources/userConfigurations/broken_config.json new file mode 100644 index 0000000000..989d00ccfc --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/userConfigurations/broken_config.json @@ -0,0 +1,3 @@ +{ + "externalDeviceId": "this_config_is_no_good_because_it_doesnt_have_a_userLogin" +} \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/resources/userConfigurations/duplicate_operator1.json b/src/test/api/karate/externaldevices/resources/userConfigurations/duplicate_operator1.json new file mode 100644 index 0000000000..2a4352b128 --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/userConfigurations/duplicate_operator1.json @@ -0,0 +1,4 @@ +{ + "userLogin": "operator1", + "externalDeviceId": "CDS_2" +} \ No newline at end of file diff --git a/src/test/api/karate/externaldevices/resources/userConfigurations/operator5.json b/src/test/api/karate/externaldevices/resources/userConfigurations/operator5.json new file mode 100644 index 0000000000..8eea481cdb --- /dev/null +++ b/src/test/api/karate/externaldevices/resources/userConfigurations/operator5.json @@ -0,0 +1,4 @@ +{ + "userLogin": "operator5", + "externalDeviceId": "CDS_1" +} \ No newline at end of file From a2056c3debd62e0d64a4fe63024b564a2817d1d0 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 17:35:39 +0100 Subject: [PATCH 25/73] Remove obsolete testing resources Signed-off-by: Alexandra Guironnet --- src/test/resources/externalDevices/CDS_5.json | 6 ----- .../externalDevices/connectDevice.sh | 24 ----------------- .../externalDevices/notification.json | 3 --- .../externalDevices/sendDeviceConfig.sh | 24 ----------------- .../externalDevices/sendNotification.sh | 26 ------------------- .../externalDevices/testExternalDevices.sh | 22 ---------------- 6 files changed, 105 deletions(-) delete mode 100644 src/test/resources/externalDevices/CDS_5.json delete mode 100755 src/test/resources/externalDevices/connectDevice.sh delete mode 100644 src/test/resources/externalDevices/notification.json delete mode 100755 src/test/resources/externalDevices/sendDeviceConfig.sh delete mode 100755 src/test/resources/externalDevices/sendNotification.sh delete mode 100755 src/test/resources/externalDevices/testExternalDevices.sh diff --git a/src/test/resources/externalDevices/CDS_5.json b/src/test/resources/externalDevices/CDS_5.json deleted file mode 100644 index f347216520..0000000000 --- a/src/test/resources/externalDevices/CDS_5.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id" : "CDS_5", - "host" : "dummy-modbus-device", - "port" : 8080, - "signalMappingId": "default_CDS_mapping" -} \ No newline at end of file diff --git a/src/test/resources/externalDevices/connectDevice.sh b/src/test/resources/externalDevices/connectDevice.sh deleted file mode 100755 index a529cef803..0000000000 --- a/src/test/resources/externalDevices/connectDevice.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2021, RTE (http://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of the OperatorFabric project. - - -url=$2 -if [ -z $url ] -then - url="http://localhost" -fi -if [ -z $1 ] -then - echo "Usage connectDevice deviceId opfab_url" -else - source ../getToken.sh "admin" $url - echo "connect device $1 (url: $url)" - curl -v -X POST $url:2105/devices/$1/connect -H "Authorization: Bearer $token" -H "Content-type:application/json" -fi \ No newline at end of file diff --git a/src/test/resources/externalDevices/notification.json b/src/test/resources/externalDevices/notification.json deleted file mode 100644 index aa9e111c18..0000000000 --- a/src/test/resources/externalDevices/notification.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "signalId" : "INFORMATION" -} \ No newline at end of file diff --git a/src/test/resources/externalDevices/sendDeviceConfig.sh b/src/test/resources/externalDevices/sendDeviceConfig.sh deleted file mode 100755 index 6ca2f0ddbe..0000000000 --- a/src/test/resources/externalDevices/sendDeviceConfig.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2021, RTE (http://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of the OperatorFabric project. - - -url=$2 -if [ -z $url ] -then - url="http://localhost" -fi -if [ -z $1 ] -then - echo "Usage sendDeviceConfig configFile opfab_url" -else - source ../getToken.sh "admin" $url - echo "send config file $1 (url: $url)" - curl -v -X POST $url:2105/configurations/devices/ -H "Authorization: Bearer $token" -H "Content-type:application/json" --data "$(envsubst <$1)" -fi \ No newline at end of file diff --git a/src/test/resources/externalDevices/sendNotification.sh b/src/test/resources/externalDevices/sendNotification.sh deleted file mode 100755 index 01a51d36a0..0000000000 --- a/src/test/resources/externalDevices/sendNotification.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2021, RTE (http://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of the OperatorFabric project. - - -url=$2 -if [ -z $url ] -then - url="http://localhost" -fi -if [ -z $1 ] -then - echo "Usage sendNotification notificationFile opfab_url" -else - source ../getToken.sh "operator2" $url - echo "send notification file $1 (url: $url)" - #curl -v -X POST $url:2105/notifications/ -H "Authorization: Bearer $token" -H "Content-type:application/json" --data "$(envsubst <$1)" - curl -v -X POST $url:2002/externaldevices/notifications/ -H "Authorization: Bearer $token" -H "Content-type:application/json" --data "$(envsubst <$1)" -fi - diff --git a/src/test/resources/externalDevices/testExternalDevices.sh b/src/test/resources/externalDevices/testExternalDevices.sh deleted file mode 100755 index e67aa9bca1..0000000000 --- a/src/test/resources/externalDevices/testExternalDevices.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2021, RTE (http://www.rte-france.com) -# See AUTHORS.txt -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at http://mozilla.org/MPL/2.0/. -# SPDX-License-Identifier: MPL-2.0 -# This file is part of the OperatorFabric project. - - -url=$1 -if [ -z $url ] -then - url="http://localhost" -fi -#./sendDeviceConfig.sh CDS_5.json $url -./connectDevice.sh CDS_2 $url -./sendNotification.sh notification.json $url - -# TODO Devices doesn't seem to work without the trailing slash, how come? - From 7e63c6af3c3fdf0c666714a21def852a9ed4bd38 Mon Sep 17 00:00:00 2001 From: Alexandra Guironnet Date: Tue, 21 Dec 2021 21:40:15 +0100 Subject: [PATCH 26/73] Adding unit tests for edge cases Signed-off-by: Alexandra Guironnet --- .../services/ConfigServiceShould.java | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/services/external-devices/src/test/java/org/opfab/externaldevices/services/ConfigServiceShould.java b/services/external-devices/src/test/java/org/opfab/externaldevices/services/ConfigServiceShould.java index 09a4c798b8..1da693a460 100644 --- a/services/external-devices/src/test/java/org/opfab/externaldevices/services/ConfigServiceShould.java +++ b/services/external-devices/src/test/java/org/opfab/externaldevices/services/ConfigServiceShould.java @@ -125,14 +125,6 @@ void retrieveExistingDeviceConfiguration() throws ExternalDeviceConfigurationExc } - @Test - void throwExceptionIfDeviceConfigurationToRetrieveDoesNotExist() { - - assertThrows(ExternalDeviceConfigurationException.class, - () -> configService.retrieveDeviceConfiguration("device_configuration_that_doesnt_exist")); - - } - @Test void deleteExistingDeviceConfiguration() throws ExternalDeviceConfigurationException { @@ -217,14 +209,6 @@ void retrieveExistingUserConfiguration() throws ExternalDeviceConfigurationExcep } - @Test - void throwExceptionIfUserConfigurationToRetrieveDoesNotExist() { - - assertThrows(ExternalDeviceConfigurationException.class, - () -> configService.retrieveUserConfiguration("user_configuration_that_doesnt_exist")); - - } - @Test void deleteExistingSignalMapping() throws ExternalDeviceConfigurationException { @@ -331,6 +315,27 @@ void throwErrorIfConfigurationCantBeResolved(String userLogin, String opFabSigna () -> configService.getResolvedConfiguration(opFabSignalKey, userLogin)); } + @ParameterizedTest + @MethodSource("retrieveConfigurationErrorParams") + void throwExceptionWhenAttemptingToRetrieveUserConfiguration(String userLogin) { + assertThrows(ExternalDeviceConfigurationException.class, + () -> configService.retrieveUserConfiguration(userLogin)); + } + + @ParameterizedTest + @MethodSource("retrieveConfigurationErrorParams") + void throwExceptionWhenAttemptingToRetrieveDeviceConfiguration(String deviceId) { + assertThrows(ExternalDeviceConfigurationException.class, + () -> configService.retrieveDeviceConfiguration(deviceId)); + } + + @ParameterizedTest + @MethodSource("retrieveConfigurationErrorParams") + void throwExceptionWhenAttemptingToRetrieveSignalMapping(String signalMappingId) { + assertThrows(ExternalDeviceConfigurationException.class, + () -> configService.retrieveSignalMapping(signalMappingId)); + } + @AfterEach public void clean(){ signalMappingRepository.deleteAll(); @@ -434,6 +439,14 @@ private static Stream getResolvedConfigurationErrorParams() { ); } + private static Stream retrieveConfigurationErrorParams() { + return Stream.of( + Arguments.of("item_that_doesnt_exist"), + Arguments.of(""), + null + ); + } + } From 833b37567eac68c4a027d314d584973379f6cb38 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Tue, 21 Dec 2021 20:41:18 +0100 Subject: [PATCH 27/73] Admin screen : filtering on several columns doesn't work (#2269) Signed-off-by: vlo-rte --- .../entity-cell-renderer.component.ts | 4 + .../group-cell-renderer.component.ts | 4 + .../components/table/admin-table.directive.ts | 90 ++++++++++++++++++- .../table/entities-table.component.ts | 6 +- 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/ui/main/src/app/modules/admin/components/cell-renderers/entity-cell-renderer.component.ts b/ui/main/src/app/modules/admin/components/cell-renderers/entity-cell-renderer.component.ts index aa3c91b922..b8f30543dd 100644 --- a/ui/main/src/app/modules/admin/components/cell-renderers/entity-cell-renderer.component.ts +++ b/ui/main/src/app/modules/admin/components/cell-renderers/entity-cell-renderer.component.ts @@ -23,4 +23,8 @@ export class EntityCellRendererComponent extends ArrayCellRendererComponent { * */ itemType = AdminItemType.ENTITY; + + agInit(params: any): void { + super.agInit(params); + } } diff --git a/ui/main/src/app/modules/admin/components/cell-renderers/group-cell-renderer.component.ts b/ui/main/src/app/modules/admin/components/cell-renderers/group-cell-renderer.component.ts index 07b937cf2e..fc1ed13795 100644 --- a/ui/main/src/app/modules/admin/components/cell-renderers/group-cell-renderer.component.ts +++ b/ui/main/src/app/modules/admin/components/cell-renderers/group-cell-renderer.component.ts @@ -22,4 +22,8 @@ export class GroupCellRendererComponent extends ArrayCellRendererComponent { * */ itemType = AdminItemType.GROUP; + + agInit(params: any): void { + super.agInit(params); + } } diff --git a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts index 5f7ee14281..31d34ceee8 100644 --- a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts +++ b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts @@ -24,19 +24,27 @@ import {takeUntil} from 'rxjs/operators'; import {StateRightsCellRendererComponent} from '../cell-renderers/state-rights-cell-renderer.component'; import {ProcessesService} from "@ofServices/processes.service"; import {Process} from "@ofModel/processes.model"; +import {GroupsService} from "@ofServices/groups.service"; +import {Group} from "@ofModel/group.model"; +import {Entity} from "@ofModel/entity.model"; +import {EntitiesService} from "@ofServices/entities.service"; @Directive() @Injectable() export abstract class AdminTableDirective implements OnInit, OnDestroy { processesDefinition: Process[]; + groupsDefinition: Group[]; + entitiesDefinition: Entity[]; constructor( protected translateService: TranslateService, protected confirmationDialogService: ConfirmationDialogService, protected modalService: NgbModal, protected dataHandlingService: SharingService, - private processesService: ProcessesService) { + private processesService: ProcessesService, + private groupsService: GroupsService, + private entitiesService: EntitiesService) { this.processesDefinition = this.processesService.getAllProcesses(); this.gridOptions = { @@ -71,6 +79,70 @@ export abstract class AdminTableDirective implements OnInit, OnDestroy { autoHeight: true, flex: 4, }, + 'groupsColumn': { + sortable: true, + filter: "agTextColumnFilter", + filterParams: { + valueGetter: params => { + let text = ''; + params.data.groups.forEach(group => { + text += (this.groupsDefinition.filter(groupDefinition => group === groupDefinition.id) + .map(groupDefinition => groupDefinition.name) + ' '); + }); + return text; + } + }, + wrapText: true, + autoHeight: true, + flex: 4, + }, + 'entitiesColumn': { + sortable: true, + filter: "agTextColumnFilter", + filterParams: { + valueGetter: params => { + let text = ''; + params.data.entities.forEach(entity => { + text += (this.entitiesDefinition.filter(entityDefinition => entity === entityDefinition.id) + .map(entityDefinition => entityDefinition.name) + ' '); + }); + return text; + } + }, + wrapText: true, + autoHeight: true, + flex: 4, + }, + 'entityAllowedToSendCardColumn': { + sortable: true, + filter: "agTextColumnFilter", + filterParams: { + valueGetter: params => { + return params.data.entityAllowedToSendCard ? this.translateService.instant('admin.input.entity.true') + : this.translateService.instant('admin.input.entity.false'); + } + }, + wrapText: true, + autoHeight: true, + flex: 4, + }, + 'parentsColumn': { + sortable: true, + filter: "agTextColumnFilter", + filterParams: { + valueGetter: params => { + let text = ''; + params.data.parents.forEach(parent => { + text += (this.entitiesDefinition.filter(entityDefinition => parent === entityDefinition.id) + .map(entityDefinition => entityDefinition.name) + ' '); + }); + return text; + } + }, + wrapText: true, + autoHeight: true, + flex: 4, + }, 'stateRightsColumn': { sortable: false, filter: "agTextColumnFilter", @@ -155,6 +227,8 @@ export abstract class AdminTableDirective implements OnInit, OnDestroy { .subscribe(pageSize => { this.gridApi.paginationSetPageSize(pageSize); }); + this.groupsDefinition = this.groupsService.getGroups(); + this.entitiesDefinition = this.entitiesService.getEntities(); } /** This function generates the ag-grid `ColumnDefs` for the grid from a list of fields @@ -177,9 +251,21 @@ export abstract class AdminTableDirective implements OnInit, OnDestroy { field: field.name }; - if (field.name === 'stateRights') + if ((this.tableType === AdminItemType.USER) && field.name === 'groups') + columnDef.type = 'groupsColumn'; + + if ((this.tableType === AdminItemType.USER) && (field.name === 'entities')) + columnDef.type = 'entitiesColumn'; + + if ((this.tableType === AdminItemType.PERIMETER) && (field.name === 'stateRights')) columnDef.type = 'stateRightsColumn'; + if ((this.tableType === AdminItemType.ENTITY) && (field.name === 'entityAllowedToSendCard')) + columnDef.type = 'entityAllowedToSendCardColumn'; + + if ((this.tableType === AdminItemType.ENTITY) && (field.name === 'parents')) + columnDef.type = 'parentsColumn'; + if (!!field.flex) columnDef['flex'] = field.flex; if (!!field.cellRendererName) columnDef['cellRenderer'] = field.cellRendererName; if (!!field.valueFormatter) { diff --git a/ui/main/src/app/modules/admin/components/table/entities-table.component.ts b/ui/main/src/app/modules/admin/components/table/entities-table.component.ts index d12b2ebe91..c0ab9f9274 100644 --- a/ui/main/src/app/modules/admin/components/table/entities-table.component.ts +++ b/ui/main/src/app/modules/admin/components/table/entities-table.component.ts @@ -21,7 +21,11 @@ export class EntitiesTableComponent extends AdminTableDirective implements OnIni tableType = AdminItemType.ENTITY; - fields = [new Field('id', 3), new Field('name', 3), new Field('description', 5), new Field('entityAllowedToSendCard', 3, null, this.translateValue), new Field('parents', 5, 'entityCellRenderer')]; + fields = [new Field('id', 3), + new Field('name', 3), + new Field('description', 5), + new Field('entityAllowedToSendCard', 3, null, this.translateValue), + new Field('parents', 5, 'entityCellRenderer')]; idField = 'id'; editModalComponent = EditEntityModalComponent; From ea9a36323e42e209ad015f3d6d3ac11e523d342f Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Wed, 22 Dec 2021 17:29:56 +0100 Subject: [PATCH 28/73] Solve BUG : Timeline automatic shifting does not work properly (#2309) Signed-off-by: freddidierRTE --- .../custom-timeline-chart.component.spec.ts | 32 ----------------- .../custom-timeline-chart.component.ts | 35 +++++++++---------- .../timeline-buttons.component.ts | 1 + 3 files changed, 18 insertions(+), 50 deletions(-) diff --git a/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.spec.ts b/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.spec.ts index 8f15b55189..a47f1ced13 100644 --- a/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.spec.ts +++ b/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.spec.ts @@ -72,38 +72,6 @@ describe('CustomTimelineChartComponent', () => { }); - it('should test checkFollowClockTick && updateRealTimeDate functions with : ' + - 'an empty ticks list, ' + - 'a ticks list of moment with a length biggest than 5, ' + - 'followClockTick set to true', () => { - fixture.detectChanges(); - expect(component.checkFollowClockTick()).toBeFalsy(); - component.followClockTick = true; - component.updateRealTimeDate(); - component.xTicks = []; - component.xDomain = [0, 1]; - const tmp = moment(); - tmp.millisecond(0); - for (let i = 0; i < 6; i++) { - component.xTicks.push(tmp); - } - expect(component.checkFollowClockTick()).toBeTruthy(); - expect(component).toBeTruthy(); - }); - - it('should test checkFollowClockTick function with a ticks list ' + - 'of moment (next day) with a length biggest than 5', () => { - fixture.detectChanges(); - component.xTicks = []; - component.xDomain = [0, 1]; - const tmp = moment(); - tmp.add(1, 'day'); - for (let i = 0; i < 6; i++) { - component.xTicks.push(tmp); - } - expect(component.checkFollowClockTick()).toBeFalsy(); - expect(component).toBeTruthy(); - }); diff --git a/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.ts b/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.ts index d9d0ae97c0..724a3e3447 100644 --- a/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.ts +++ b/ui/main/src/app/modules/feed/components/time-line/custom-timeline-chart/custom-timeline-chart.component.ts @@ -41,6 +41,8 @@ import {takeUntil} from 'rxjs/operators'; import {getNextTimeForRepeating} from '@ofServices/reminder/reminderUtils'; import {NgbPopover} from '@ng-bootstrap/ng-bootstrap'; import {LightCardsFeedFilterService} from '@ofServices/lightcards/lightcards-feed-filter.service'; +import {FilterType} from '@ofModel/feed-filter.model'; +import {FilterService} from '@ofServices/lightcards/filter.service'; @Component({ @@ -152,7 +154,8 @@ export class CustomTimelineChartComponent extends BaseChartComponent implements private store: Store, private router: Router, @Inject(PLATFORM_ID) platformId: any, - private lightCardsFeedFilterService: LightCardsFeedFilterService) { + private lightCardsFeedFilterService: LightCardsFeedFilterService, + private filterService: FilterService) { super(chartElement, zone, cd, platformId); } @@ -164,7 +167,7 @@ export class CustomTimelineChartComponent extends BaseChartComponent implements } }); this.initGraph(); - this.updateRealTimeDate(); + this.updateRealtime(); this.initDataPipe(); this.updateDimensions(); // need to init here only for unit test , otherwise dims is null } @@ -192,31 +195,27 @@ export class CustomTimelineChartComponent extends BaseChartComponent implements * update the domain if check follow clock tick is true * Stop it when destroying component to avoid memory leak */ - updateRealTimeDate(): void { + updateRealtime(): void { this.xRealTimeLine = moment(); - if (this.followClockTick) { - if (this.checkFollowClockTick()) { - this.update(); - } - } + if (this.followClockTick) this.shiftTimeLineIfNecessary(); setTimeout(() => { - if (!this.isDestroyed) this.updateRealTimeDate(); + if (!this.isDestroyed) this.updateRealtime(); }, 1000); } - /** - * change domain start with the second tick value - * if moment is equal to the 4th tick return true - */ - checkFollowClockTick(): boolean { - if (this.xTicks && this.xTicks.length > 5) { - if (this.xTicks[4].valueOf() <= moment().millisecond(0).valueOf()) { + shiftTimeLineIfNecessary() { + if (this.xTicks) { + if (this.xTicks[10].valueOf() <= moment().valueOf()) { this.valueDomain = [this.xTicks[1].valueOf(), this.xDomain[1] + (this.xTicks[1] - this.xDomain[0])]; - return true; + this.filterService.updateFilter( + FilterType.BUSINESSDATE_FILTER, + true, + {start: this.valueDomain[0], end: this.valueDomain[1], domainId: this.domainId} + ); + this.update(); } } - return false; } /** diff --git a/ui/main/src/app/modules/share/timeline-buttons/timeline-buttons.component.ts b/ui/main/src/app/modules/share/timeline-buttons/timeline-buttons.component.ts index 34b083cd63..42be866cf1 100644 --- a/ui/main/src/app/modules/share/timeline-buttons/timeline-buttons.component.ts +++ b/ui/main/src/app/modules/share/timeline-buttons/timeline-buttons.component.ts @@ -80,6 +80,7 @@ export class TimelineButtonsComponent implements OnInit { }, TR: { buttonTitle: 'timeline.buttonTitle.TR', domainId : 'TR', + followClockTick: true }, '7D': { buttonTitle: 'timeline.buttonTitle.7D', domainId:'7D', From 264711faf8a16a6b9abe451a538776cdb5e5271b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Fri, 17 Dec 2021 22:30:03 +0000 Subject: [PATCH 29/73] Update dependency ag-grid-community to v26.2.1 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index bd4adbec66..58f2299ddd 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -2549,9 +2549,9 @@ } }, "ag-grid-community": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-26.2.0.tgz", - "integrity": "sha512-YkNQUJ7EsmXbwfSrT4sIBYp1MbFku+ebEiqKc4+YFVZ5+nniHFzvSomHzFqbbkOtEfb42Gmo8cMNvBz9aPnZsQ==" + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-26.2.1.tgz", + "integrity": "sha512-aChSGNdPkBda4BhOUUEAmAkRlIG7rFU8UTXx3NPStavrCOHKLDRV90djIKuiXfM6ONBqKmeqw2as0yuLnSN8dw==" }, "agent-base": { "version": "6.0.2", diff --git a/ui/main/package.json b/ui/main/package.json index c12f7b90f0..b1ca711706 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -42,7 +42,7 @@ "@swimlane/ngx-charts": "19.2.0", "@types/jwt-decode": "2.2.1", "ag-grid-angular": "26.2.0", - "ag-grid-community": "26.2.0", + "ag-grid-community": "26.2.1", "angular-oauth2-oidc": "13.0.1", "angular2-multiselect-dropdown": "5.0.4", "bootstrap": "4.6.1", From 3e4a0156f3a52f2e555771fa833743e353a247e5 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 28 Dec 2021 22:20:36 +0000 Subject: [PATCH 30/73] Update dependency io.swagger:swagger-codegen-cli to v2.4.25 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 852ccc6e64..35aae46687 100755 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ karate=1.1.0 reactor=3.4.13 # code generators -swagger=2.4.24 +swagger=2.4.25 swaggerUI=4.1.3 From 1522ab8fe2e753d217766de8f5609484374dc5d4 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Sat, 1 Jan 2022 21:24:30 +0000 Subject: [PATCH 31/73] Update dependency jasmine-core to v4 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 58f2299ddd..d3684d496e 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -5837,9 +5837,9 @@ } }, "jasmine-core": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.10.1.tgz", - "integrity": "sha512-ooZWSDVAdh79Rrj4/nnfklL3NQVra0BcuhcuWoAwwi+znLDoUeH87AFfeX8s+YeYi6xlv5nveRyaA1v7CintfA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.0.0.tgz", + "integrity": "sha512-tq24OCqHElgU9KDpb/8O21r1IfotgjIzalfW9eCmRR40LZpvwXT68iariIyayMwi0m98RDt16aljdbwK0sBMmQ==", "dev": true }, "jasmine-marbles": { diff --git a/ui/main/package.json b/ui/main/package.json index b1ca711706..4f33dcd224 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -76,7 +76,7 @@ "@types/moment": "2.13.0", "@types/node": "16.11.15", "codelyzer": "6.0.2", - "jasmine-core": "3.10.1", + "jasmine-core": "4.0.0", "jasmine-marbles": "0.8.4", "jasmine-spec-reporter": "7.0.0", "jasmine-sse": "0.3.0", From 8054f7cfb99d4c7aa6c19b0ebf00954b2d2d241f Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Wed, 29 Dec 2021 18:31:28 +0100 Subject: [PATCH 32/73] Docker recreates volumes everytime a docker-compose up is triggered (#1946) Signed-off-by: vlo-rte --- config/dev/stopOpfab.sh | 3 +++ config/docker/stopOpfab.sh | 3 +++ 2 files changed, 6 insertions(+) create mode 100755 config/dev/stopOpfab.sh create mode 100755 config/docker/stopOpfab.sh diff --git a/config/dev/stopOpfab.sh b/config/dev/stopOpfab.sh new file mode 100755 index 0000000000..354ca1f432 --- /dev/null +++ b/config/dev/stopOpfab.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose down -v \ No newline at end of file diff --git a/config/docker/stopOpfab.sh b/config/docker/stopOpfab.sh new file mode 100755 index 0000000000..354ca1f432 --- /dev/null +++ b/config/docker/stopOpfab.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +docker-compose down -v \ No newline at end of file From 6740cae16f2371b098f4002acc10800f1c9ba120 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 28 Dec 2021 00:54:35 +0000 Subject: [PATCH 33/73] Update dependency rxjs to v7.5.1 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 15 ++++----------- ui/main/package.json | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index d3684d496e..57f6d306d0 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -9073,18 +9073,11 @@ } }, "rxjs": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.4.0.tgz", - "integrity": "sha512-7SQDi7xeTMCJpqViXh8gL/lebcwlp3d831F05+9B44A4B0WfsEwUQHR64gsH1kvJ+Ep/J9K2+n1hVl1CsGN23w==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.1.tgz", + "integrity": "sha512-KExVEeZWxMZnZhUZtsJcFwz8IvPvgu4G2Z2QyqjZQzUGr32KDYuSxrEYO4w3tFFNbfLozcrKUTvTPi+E9ywJkQ==", "requires": { - "tslib": "~2.1.0" - }, - "dependencies": { - "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" - } + "tslib": "^2.1.0" } }, "safe-buffer": { diff --git a/ui/main/package.json b/ui/main/package.json index 4f33dcd224..6c5e0cd444 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -54,7 +54,7 @@ "moment": "2.29.1", "moment-timezone": "0.5.34", "ng-event-source": "1.0.14", - "rxjs": "7.4.0", + "rxjs": "7.5.1", "svg-pan-zoom": "3.6.1", "tslib": "2.3.1", "xlsx": "0.17.4" From 0e411cb1de3ae14d402fb274bb8307a4586c0594 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 29 Dec 2021 23:56:43 +0000 Subject: [PATCH 34/73] Update nginx Docker tag to v1.21.5 Signed-off-by: Renovate Bot --- web-ui/src/main/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-ui/src/main/docker/Dockerfile b/web-ui/src/main/docker/Dockerfile index 94741a8499..9e33136527 100755 --- a/web-ui/src/main/docker/Dockerfile +++ b/web-ui/src/main/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:1.21.4-alpine +FROM nginx:1.21.5-alpine VOLUME /tmp ARG http_proxy ARG https_proxy From f356d89f3a2f0a1542bd8393d21fa30c9c3ca45f Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Tue, 28 Dec 2021 18:10:33 +0100 Subject: [PATCH 35/73] Possibility to get entity information and the list of all entities (#2296) Signed-off-by: vlo-rte --- .../reference_doc/template_description.adoc | 20 +++++++++++++++- .../cypress/integration/CardDetail.spec.js | 8 +++++-- .../cypress/template/kitchenSink.handlebars | 19 +++++++++++++++ ui/main/src/app/services/entities.service.ts | 14 +++++++++++ ui/main/src/assets/js/templateGateway.js | 23 ++++++++++++++++++- 5 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/docs/asciidoc/reference_doc/template_description.adoc b/src/docs/asciidoc/reference_doc/template_description.adoc index bf7520361b..b6fc6773c5 100644 --- a/src/docs/asciidoc/reference_doc/template_description.adoc +++ b/src/docs/asciidoc/reference_doc/template_description.adoc @@ -570,4 +570,22 @@ For example: include::../../../test/resources/bundles/defaultProcess_V1/template/chart.handlebars[tag=templateGateway.redirectToBusinessMenu_example] ---- -This can be useful to pass context from the card to the business application. \ No newline at end of file +This can be useful to pass context from the card to the business application. + +=== Get list of all entities + +To have the list of all entities in OperatorFabric, you can call the javascript function _templateGateway.getAllEntities()_. +The function returns an array of entity object : + +Entity object has the following fields : + +- 'id' : id of the entity +- 'name' : name of the entity +- 'description' : description of the entity +- 'entityAllowedToSendCard' : boolean indicating whether the entity is allowed to send card or not +- 'parents' : list of parent entities + +=== Get information about an entity + +To have information about an entity in particular, you can call the javascript function _templateGateway.getEntity(entityId)_. +The function returns an entity object whose fields are mentioned above. \ No newline at end of file diff --git a/src/test/cypress/cypress/integration/CardDetail.spec.js b/src/test/cypress/cypress/integration/CardDetail.spec.js index 5a9f344b39..f1141c6056 100644 --- a/src/test/cypress/cypress/integration/CardDetail.spec.js +++ b/src/test/cypress/cypress/integration/CardDetail.spec.js @@ -51,8 +51,12 @@ describe('Card detail', function () { cy.get("#templateGateway-isUserMemberOfAnEntityRequiredToRespond").contains("true"); cy.get("#templateGateway-getEntityUsedForUserResponse").contains(/^ENTITY1$/); cy.get("#templateGateway-getDisplayContext").contains(/^realtime$/); - - + cy.get("#templateGateway-getAllEntities").contains("entity[0]:id=ENTITY1,name=Control Room 1,description=Control Room 1,entityAllowedToSendCard=true,parents=ALLCONTROLROOMS"); + cy.get("#templateGateway-getAllEntities").contains("entity[1]:id=ENTITY2,name=Control Room 2,description=Control Room 2,entityAllowedToSendCard=true,parents=ALLCONTROLROOMS"); + cy.get("#templateGateway-getAllEntities").contains("entity[2]:id=ENTITY3,name=Control Room 3,description=Control Room 3,entityAllowedToSendCard=true,parents=ALLCONTROLROOMS"); + cy.get("#templateGateway-getAllEntities").contains("entity[3]:id=ALLCONTROLROOMS,name=All Control Rooms,description=All Control Rooms,entityAllowedToSendCard=false,parents="); + cy.get("#templateGateway-getAllEntities").contains("entity[4]:id=ENTITY4,name=IT Supervision Center,description=IT Supervision Center,entityAllowedToSendCard=true,parents="); + cy.get("#templateGateway-getEntity-ENTITY1").contains(/^ENTITY1,Control Room 1,Control Room 1,true,ALLCONTROLROOMS$/); cy.get("#screenSize").contains("md"); // see card in full screen diff --git a/src/test/resources/bundles/cypress/template/kitchenSink.handlebars b/src/test/resources/bundles/cypress/template/kitchenSink.handlebars index 1d22aa5129..0485090651 100644 --- a/src/test/resources/bundles/cypress/template/kitchenSink.handlebars +++ b/src/test/resources/bundles/cypress/template/kitchenSink.handlebars @@ -58,6 +58,25 @@ function loadData() { responses += templateGateway.getDisplayContext(); responses += ''; + responses += '

'; + + responses += '
getEntity("ENTITY1") : '; + responses += templateGateway.getEntity('ENTITY1').id + ','; + responses += templateGateway.getEntity('ENTITY1').name + ','; + responses += templateGateway.getEntity('ENTITY1').description + ','; + responses += templateGateway.getEntity('ENTITY1').entityAllowedToSendCard + ','; + responses += templateGateway.getEntity('ENTITY1').parents; + responses += '
'; + templateGatewayResults.innerHTML = responses; } diff --git a/ui/main/src/app/services/entities.service.ts b/ui/main/src/app/services/entities.service.ts index 799adb0870..0561977e72 100644 --- a/ui/main/src/app/services/entities.service.ts +++ b/ui/main/src/app/services/entities.service.ts @@ -92,6 +92,7 @@ export class EntitiesService extends CachedCrudService implements OnDestroy { if (!!entities) { this._entities = entities; this.setEntityNamesInTemplateGateway(); + this.setEntitiesInTemplateGateway(); console.log(new Date().toISOString(), 'List of entities loaded'); } }, @@ -125,6 +126,19 @@ export class EntitiesService extends CachedCrudService implements OnDestroy { templateGateway.setEntityNames(entityNames); } + private setEntitiesInTemplateGateway(): void { + const entities = new Map(); + this._entities.forEach(entity => entities.set(entity.id, + { id: entity.id, + name: entity.name, + description: entity.description, + entityAllowedToSendCard: entity.entityAllowedToSendCard, + parents: entity.parents + }) + ); + templateGateway.setEntities(entities); + } + /** Given a list of entities that might contain parent entities, this method returns the list of entities * that can actually send cards * */ diff --git a/ui/main/src/assets/js/templateGateway.js b/ui/main/src/assets/js/templateGateway.js index c383d103b3..84a3c9b613 100644 --- a/ui/main/src/assets/js/templateGateway.js +++ b/ui/main/src/assets/js/templateGateway.js @@ -8,7 +8,8 @@ */ const templateGateway = { - opfabEntityNames : null, + opfabEntityNames : null, + opfabEntities : null, childCards: [], userAllowedToRespond : false, userMemberOfAnEntityRequiredToRespond : false, @@ -21,6 +22,10 @@ const templateGateway = { this.opfabEntityNames = entityNames; }, + setEntities: function(entities){ + this.opfabEntities = entities; + }, + // UTILITIES FOR TEMPLATE @@ -36,6 +41,22 @@ const templateGateway = { return this.opfabEntityNames.get(entityId); }, + getEntity: function (entityId) { + if (!this.opfabEntities) { + console.log(new Date().toISOString() , ` Template.js : no entities information loaded`); + return entityId; + } + if (!this.opfabEntities.has(entityId)) { + console.log(new Date().toISOString() , ` Template.js : entityId ${entityId} is unknown`); + return entityId; + } + return this.opfabEntities.get(entityId); + }, + + getAllEntities: function() { + return Array.from(this.opfabEntities.values()); + }, + redirectToBusinessMenu: function(menuId,menuItemId,params){ const urlSplit = document.location.href.split('#'); var newUrl = urlSplit[0] + '#/businessconfigparty/' + menuId + '/' + menuItemId ; From 45c6bc718177ca45d712cb0b6440706a6febe0df Mon Sep 17 00:00:00 2001 From: Giovanni Ferrari Date: Thu, 23 Dec 2021 11:55:57 +0100 Subject: [PATCH 36/73] Permit to access to log api only to members of admin group (#2257) Signed-off-by: Giovanni Ferrari --- .../oauth2/WebSecurityConfiguration.java | 2 + .../webflux/WebSecurityConfiguration.java | 2 + .../oauth2/WebSecurityConfiguration.java | 7 +- .../oauth2/WebSecurityConfiguration.java | 5 +- .../oauth2/WebSecurityConfiguration.java | 3 + src/docs/asciidoc/deployment/index.adoc | 42 ++++++ src/test/api/karate/admin/loggersApi.feature | 124 ++++++++++++++++++ src/test/api/karate/adminTests.txt | 4 +- src/test/api/karate/karate-config.js | 4 +- 9 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/test/api/karate/admin/loggersApi.feature diff --git a/services/businessconfig/src/main/java/org/opfab/businessconfig/configuration/oauth2/WebSecurityConfiguration.java b/services/businessconfig/src/main/java/org/opfab/businessconfig/configuration/oauth2/WebSecurityConfiguration.java index b240f79cd8..d7738cd6de 100644 --- a/services/businessconfig/src/main/java/org/opfab/businessconfig/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/businessconfig/src/main/java/org/opfab/businessconfig/configuration/oauth2/WebSecurityConfiguration.java @@ -34,6 +34,7 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; + public static final String LOGGERS_PATH ="/actuator/loggers/**"; public static final String ADMIN_ROLE = "ADMIN"; public static final String THIRDS_PATH = "/businessconfig/**"; private static final String STYLE_URL_PATTERN = "/businessconfig/processes/*/css/*"; @@ -66,6 +67,7 @@ public static void configureCommon(final HttpSecurity http) throws Exception { .antMatchers(HttpMethod.POST, THIRDS_PATH).access(ADMIN_AND_IP_ALLOWED) .antMatchers(HttpMethod.PUT, THIRDS_PATH).access(ADMIN_AND_IP_ALLOWED) .antMatchers(HttpMethod.DELETE, THIRDS_PATH).access(ADMIN_AND_IP_ALLOWED) + .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .anyRequest().access(AUTH_AND_IP_ALLOWED) ; diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/configuration/webflux/WebSecurityConfiguration.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/configuration/webflux/WebSecurityConfiguration.java index 4aa35afdb6..8aac2a3abd 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/configuration/webflux/WebSecurityConfiguration.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/configuration/webflux/WebSecurityConfiguration.java @@ -35,6 +35,7 @@ public class WebSecurityConfiguration { public static final String PROMETHEUS_PATH = "/actuator/prometheus**"; + public static final String LOGGERS_PATH ="/actuator/loggers/**"; public static final String CONNECTIONS_PATH ="/connections**"; public static final String ADMIN_ROLE = "ADMIN"; @@ -69,6 +70,7 @@ public static void configureCommon(final ServerHttpSecurity http) { .authorizeExchange() .pathMatchers(HttpMethod.GET, PROMETHEUS_PATH).permitAll() .pathMatchers(CONNECTIONS_PATH).hasRole(ADMIN_ROLE) + .pathMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .anyExchange().access(new IpAddressAuthorizationManager()); } } diff --git a/services/cards-publication/src/main/java/org/opfab/cards/publication/configuration/oauth2/WebSecurityConfiguration.java b/services/cards-publication/src/main/java/org/opfab/cards/publication/configuration/oauth2/WebSecurityConfiguration.java index c61a6b6935..a3d7e614b1 100644 --- a/services/cards-publication/src/main/java/org/opfab/cards/publication/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/cards-publication/src/main/java/org/opfab/cards/publication/configuration/oauth2/WebSecurityConfiguration.java @@ -37,7 +37,10 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; - + public static final String LOGGERS_PATH ="/actuator/loggers/**"; + + public static final String ADMIN_ROLE = "ADMIN"; + public static final String AUTH_AND_IP_ALLOWED = "isAuthenticated() and @webSecurityChecks.checkUserIpAddress(authentication)"; public static final String ADMIN_AND_IP_ALLOWED = "hasRole('ADMIN') and @webSecurityChecks.checkUserIpAddress(authentication)"; @@ -65,6 +68,7 @@ public static void configureCommon(final HttpSecurity http, boolean checkAuthent http .authorizeRequests() .antMatchers(HttpMethod.GET,PROMETHEUS_PATH).permitAll() + .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .antMatchers("/cards/userCard/**").access(AUTH_AND_IP_ALLOWED) .antMatchers("/cards/translateCardField").access(AUTH_AND_IP_ALLOWED) .antMatchers(HttpMethod.DELETE, "/cards").access(ADMIN_AND_IP_ALLOWED) @@ -72,6 +76,7 @@ public static void configureCommon(final HttpSecurity http, boolean checkAuthent } else { http .authorizeRequests() + .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .antMatchers("/cards/userCard/**").access(AUTH_AND_IP_ALLOWED) .antMatchers("/cards/translateCardField").access(AUTH_AND_IP_ALLOWED) .antMatchers("/**").permitAll(); diff --git a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java index 231800a06c..60a2d76e6d 100644 --- a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java @@ -30,11 +30,13 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; - + public static final String LOGGERS_PATH ="/actuator/loggers/**"; public static final String CONFIGURATIONS_ROOT_PATH = "/configurations/"; public static final String DEVICES_ROOT_PATH = "/devices/"; public static final String NOTIFICATIONS_ROOT_PATH = "/notifications"; + public static final String ADMIN_ROLE = "ADMIN"; + public static final String AUTH_AND_IP_ALLOWED = "isAuthenticated() and @webSecurityChecks.checkUserIpAddress(authentication)"; public static final String ADMIN_AND_IP_ALLOWED = "hasRole('ADMIN') and @webSecurityChecks.checkUserIpAddress(authentication)"; @@ -57,6 +59,7 @@ public static void configureCommon(final HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(HttpMethod.GET,PROMETHEUS_PATH).permitAll() + .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .antMatchers(HttpMethod.POST,NOTIFICATIONS_ROOT_PATH).access(AUTH_AND_IP_ALLOWED) .antMatchers(CONFIGURATIONS_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) .antMatchers(DEVICES_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) diff --git a/services/users/src/main/java/org/opfab/users/configuration/oauth2/WebSecurityConfiguration.java b/services/users/src/main/java/org/opfab/users/configuration/oauth2/WebSecurityConfiguration.java index a1447bc7bf..4d6feff10f 100644 --- a/services/users/src/main/java/org/opfab/users/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/users/src/main/java/org/opfab/users/configuration/oauth2/WebSecurityConfiguration.java @@ -33,6 +33,8 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; + public static final String LOGGERS_PATH ="/actuator/loggers/**"; + public static final String USER_PATH = "/users/{login}"; public static final String USERS_SETTINGS_PATH = "/users/{login}/settings"; public static final String USERS_PERIMETERS_PATH = "/users/{login}/perimeters"; @@ -87,6 +89,7 @@ public static void configureCommon(final HttpSecurity http) throws Exception { .antMatchers(ENTITIES_PATH).access(IS_ADMIN_AND_IP_ALLOWED) .antMatchers(PERIMETERS_PATH).access(IS_ADMIN_AND_IP_ALLOWED) .antMatchers(CURRENTUSER_INTERNAL_PATH).authenticated() + .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .anyRequest().access(AUTH_AND_IP_ALLOWED); } diff --git a/src/docs/asciidoc/deployment/index.adoc b/src/docs/asciidoc/deployment/index.adoc index f4423fc90b..c17269a3dd 100644 --- a/src/docs/asciidoc/deployment/index.adoc +++ b/src/docs/asciidoc/deployment/index.adoc @@ -36,6 +36,48 @@ include::RABBITMQ.adoc[leveloffset=+1] Operator Fabric provides end points for monitoring via link:https://prometheus.io/[prometheus]. The monitoring is available for the four following services: user, businessconfig, cards-consultation, cards-publication. You can start a test prometheus instance via `config/monitoring/startPrometheus.sh` , the monitoring will be accessible on http://localhost:9090/ +== Logging Administration + +Operator Fabric includes the ability to view and configure the log levels at runtime through APIs. It is possible to configure and view an individual logger configuration, which is made up of both the explicitly configured logging level as well as the effective logging level given to it by the logging framework. These levels can be one of: + +* TRACE +* DEBUG +* INFO +* WARN +* ERROR +* FATAL +* OFF +* null + +null indicates that there is no explicit configuration. + +Querying and setting logging levels is restricted to administrators. + +To view the configured logging level for a given logger it is possible to send a GET request to the '/actuator/logger' URI as follows: +---- +curl http://:/actuator/loggers/${logger} -H "Authorization: Bearer ${token}" -H "Content-type:application/json" +---- +where `${token}` is a valid OAuth2 JWT for a user with administration privileges +and `${logger}` is the logger (ex: org.opfab) + +The response will be a json object like the following: + +---- +{ + "configuredLevel" : "INFO", + "effectiveLevel" : "INFO" +} +---- + +To configure a given logger, POST a json entity to the '/actuator/logger' URI, as follows: + +---- +curl -i -X POST http://:/actuator/loggers/${logger} -H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' -d '{"configuredLevel": "DEBUG"}' +---- + +To “reset” the specific level of the logger (and use the default configuration instead) it is possible to pass a value of null as the configuredLevel. + + include::users_groups_admin.adoc[leveloffset=+1] diff --git a/src/test/api/karate/admin/loggersApi.feature b/src/test/api/karate/admin/loggersApi.feature new file mode 100644 index 0000000000..c2b9c08fe5 --- /dev/null +++ b/src/test/api/karate/admin/loggersApi.feature @@ -0,0 +1,124 @@ +Feature: Log Level Access + + Background: + #Getting token for admin and operator1 user calling getToken.feature + * def signIn = callonce read('../common/getToken.feature') { username: 'admin'} + * def authToken = signIn.authToken + * def signInAsTSO = callonce read('../common/getToken.feature') { username: 'user_test_api_1'} + * def authTokenAsTSO = signInAsTSO.authToken + + * def businessConfigLoggersUrl = opfabBusinessConfigUrl + 'actuator/loggers/org.opfab' + * def userLoggersUrl = opfabUserUrl + 'actuator/loggers/org.opfab' + * def cardsPublicationLoggersUrl = opfabCardsPublicationUrl + 'actuator/loggers/org.opfab' + * def cardsConsultationLoggersUrl = opfabCardsConsultationUrl + 'actuator/loggers/org.opfab' + * def externalDevicesLoggersUrl = opfabExternalDevicesUrl + 'actuator/loggers/org.opfab' + + + + * def info_level = + """ + { + "configuredLevel": "INFO" + } + """ + + * def debug_level = + """ + { + "configuredLevel": "DEBUG" + } + """ + + Scenario Outline: get logging level is restricted to admin user + + Given url + And header Authorization = 'Bearer ' + + When method + Then status + + Examples: + | url | method | token | expected | + | businessConfigLoggersUrl | get | authTokenAsTSO | 403 | + | businessConfigLoggersUrl | get | authToken | 200 | + | userLoggersUrl | get | authTokenAsTSO | 403 | + | userLoggersUrl | get | authToken | 200 | + | cardsPublicationLoggersUrl | get | authTokenAsTSO | 403 | + | cardsPublicationLoggersUrl | get | authToken | 200 | + | cardsConsultationLoggersUrl | get | authTokenAsTSO | 403 | + | cardsConsultationLoggersUrl | get | authToken | 200 | + | externalDevicesLoggersUrl | get | authTokenAsTSO | 403 | + | externalDevicesLoggersUrl | get | authToken | 200 | + + + + Scenario Outline: set logging level to DEBUG is restricted to admin user + + Given url + And header Authorization = 'Bearer ' + + And request debug_level + When method + Then status + + Examples: + | url | method | token | expected | + | businessConfigLoggersUrl | post | authTokenAsTSO | 403 | + | businessConfigLoggersUrl | post | authToken | 204 | + | userLoggersUrl | post | authTokenAsTSO | 403 | + | userLoggersUrl | post | authToken | 204 | + | cardsPublicationLoggersUrl | post | authTokenAsTSO | 403 | + | cardsPublicationLoggersUrl | post | authToken | 204 | + | cardsConsultationLoggersUrl | post | authTokenAsTSO | 403 | + | cardsConsultationLoggersUrl | post | authToken | 204 | + | externalDevicesLoggersUrl | post | authTokenAsTSO | 403 | + | externalDevicesLoggersUrl | post | authToken | 204 | + + Scenario Outline: check logging level is 'DEBUG' as admin + + Given url + And header Authorization = 'Bearer ' + authToken + When method get + Then status + And match response.configuredLevel == 'DEBUG' + + Examples: + | url | expected | + | businessConfigLoggersUrl | 200 | + | userLoggersUrl | 200 | + | cardsPublicationLoggersUrl | 200 | + | cardsConsultationLoggersUrl | 200 | + | externalDevicesLoggersUrl | 200 | + + + + Scenario Outline: set logging level to INFO as admin + + Given url + And header Authorization = 'Bearer ' + authToken + And request info_level + When method post + Then status + + Examples: + | url | expected | + | businessConfigLoggersUrl | 204 | + | userLoggersUrl | 204 | + | cardsPublicationLoggersUrl | 204 | + | cardsConsultationLoggersUrl | 204 | + | externalDevicesLoggersUrl | 204 | + + Scenario Outline: get logging level is 'INFO' as admin + + Given url + And header Authorization = 'Bearer ' + authToken + When method get + Then status + And match response.configuredLevel == 'INFO' + + Examples: + | url | expected | + | businessConfigLoggersUrl | 200 | + | userLoggersUrl | 200 | + | cardsPublicationLoggersUrl | 200 | + | cardsConsultationLoggersUrl | 200 | + | externalDevicesLoggersUrl | 200 | + diff --git a/src/test/api/karate/adminTests.txt b/src/test/api/karate/adminTests.txt index d7e4081ef8..08f72da62c 100644 --- a/src/test/api/karate/adminTests.txt +++ b/src/test/api/karate/adminTests.txt @@ -1 +1,3 @@ -admin/getPrometheusMonitoring.feature \ \ No newline at end of file + admin/getPrometheusMonitoring.feature \ + admin/loggersApi.feature \ + diff --git a/src/test/api/karate/karate-config.js b/src/test/api/karate/karate-config.js index f46934e6a6..de384e4e21 100644 --- a/src/test/api/karate/karate-config.js +++ b/src/test/api/karate/karate-config.js @@ -17,7 +17,9 @@ function() { opfabUserUrl: opfab_server +":2103/", opfabBusinessConfigUrl: opfab_server +":2100/", opfabCardsConsultationUrl: opfab_server +":2104/", - opfabCardsPublicationUrl: opfab_server +":2102/" + opfabCardsPublicationUrl: opfab_server +":2102/", + opfabExternalDevicesUrl: opfab_server +":2105/" + }; karate.logger.debug('url opfab :' + config.opfabUrl ); From c70aeaa5e64b98f1774d71b045f352a4da7ddf13 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 3 Jan 2022 08:07:06 +0000 Subject: [PATCH 37/73] Update dependency @types/node to v16.11.18 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 57f6d306d0..9373ba2a70 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -2280,9 +2280,9 @@ } }, "@types/node": { - "version": "16.11.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.15.tgz", - "integrity": "sha512-LMGR7iUjwZRxoYnfc9+YELxwqkaLmkJlo4/HUvOMyGvw9DaHO0gtAbH2FUdoFE6PXBTYZIT7x610r7kdo8o1fQ==", + "version": "16.11.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.18.tgz", + "integrity": "sha512-7N8AOYWWYuw0g+K+GKCmIwfU1VMHcexYNpLPYzFZ4Uq2W6C/ptfeC7XhXgy/4pcwhz/9KoS5yijMfnYQ0u0Udw==", "dev": true }, "@types/parse-json": { diff --git a/ui/main/package.json b/ui/main/package.json index 6c5e0cd444..d6b585eab0 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -74,7 +74,7 @@ "@types/jasminewd2": "2.0.10", "@types/lodash": "4.14.178", "@types/moment": "2.13.0", - "@types/node": "16.11.15", + "@types/node": "16.11.18", "codelyzer": "6.0.2", "jasmine-core": "4.0.0", "jasmine-marbles": "0.8.4", From e67d756910a3cc52ba159aa9fedb519b02861b50 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Wed, 29 Dec 2021 14:28:04 +0100 Subject: [PATCH 38/73] Admin screen : the popup for the filters is sometimes half hidden (#2235) Signed-off-by: vlo-rte --- .../modules/admin/components/table/admin-table.directive.ts | 1 + .../components/monitoring-table/monitoring-table.component.ts | 3 ++- ui/main/src/scss/styles.scss | 4 ++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts index 31d34ceee8..891e997bac 100644 --- a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts +++ b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts @@ -170,6 +170,7 @@ export abstract class AdminTableDirective implements OnInit, OnDestroy { headerHeight: 70, suppressPaginationPanel: true, suppressHorizontalScroll: true, + popupParent: document.querySelector("body") }; // Defining a custom cellRenderer was necessary (instead of using onCellClicked & an inline cellRenderer) because of // the need to call a method from the parent component diff --git a/ui/main/src/app/modules/monitoring/components/monitoring-table/monitoring-table.component.ts b/ui/main/src/app/modules/monitoring/components/monitoring-table/monitoring-table.component.ts index 0599cfe1a7..cfddcf812f 100644 --- a/ui/main/src/app/modules/monitoring/components/monitoring-table/monitoring-table.component.ts +++ b/ui/main/src/app/modules/monitoring/components/monitoring-table/monitoring-table.component.ts @@ -176,7 +176,8 @@ export class MonitoringTableComponent implements OnChanges, OnDestroy { suppressPaginationPanel: true, suppressHorizontalScroll: true, columnDefs: this.columnDefs, - rowHeight: 45 + rowHeight: 45, + popupParent: document.querySelector("body") }; this.rowData$ = this.rowDataSubject.asObservable(); } diff --git a/ui/main/src/scss/styles.scss b/ui/main/src/scss/styles.scss index a90430fd4b..23c9c9d048 100644 --- a/ui/main/src/scss/styles.scss +++ b/ui/main/src/scss/styles.scss @@ -945,3 +945,7 @@ html, body { height: 100%; } } } +.ag-popup { + @extend .opfab-ag-grid-theme; +} + From a2348a51f6cc8530cabb5e06dd26402f4fdb8f50 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Wed, 29 Dec 2021 11:57:28 +0100 Subject: [PATCH 39/73] Admin screen : Incoherence between pagination and displayed results after deleting a filter (#2270) Signed-off-by: vlo-rte --- .../admin/components/table/admin-table.directive.html | 1 + .../modules/admin/components/table/admin-table.directive.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/ui/main/src/app/modules/admin/components/table/admin-table.directive.html b/ui/main/src/app/modules/admin/components/table/admin-table.directive.html index cbdcb1b5c0..f01f3f1586 100644 --- a/ui/main/src/app/modules/admin/components/table/admin-table.directive.html +++ b/ui/main/src/app/modules/admin/components/table/admin-table.directive.html @@ -16,6 +16,7 @@ [gridOptions]="gridOptions" [rowData]="rowData" class="opfab-ag-grid-theme" + (filterChanged)="onFilterChanged($event)" > diff --git a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts index 891e997bac..32db1d4873 100644 --- a/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts +++ b/ui/main/src/app/modules/admin/components/table/admin-table.directive.ts @@ -217,6 +217,11 @@ export abstract class AdminTableDirective implements OnInit, OnDestroy { return this.translateService.instant(this.i18NPrefix + headerIdentifier); } + onFilterChanged(event) { + this.page = 1; + this.gridApi.paginationGoToPage(0); + } + onGridReady(params) { this.gridApi = params.api; // Column definitions can't be managed in the constructor like the other grid options because they rely on the `fields` From 9606811e3148dfefc705cee41cac0d0010884c70 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 3 Jan 2022 23:50:47 +0000 Subject: [PATCH 40/73] Update dependency @types/jasmine to v3.10.3 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 9373ba2a70..5e188877fb 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -2239,9 +2239,9 @@ } }, "@types/jasmine": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.10.2.tgz", - "integrity": "sha512-qs4xjVm4V/XjM6owGm/x6TNmhGl5iKX8dkTdsgdgl9oFnqgzxLepnS7rN9Tdo7kDmnFD/VEqKrW57cGD2odbEg==", + "version": "3.10.3", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.10.3.tgz", + "integrity": "sha512-SWyMrjgdAUHNQmutvDcKablrJhkDLy4wunTme8oYLjKp41GnHGxMRXr2MQMvy/qy8H3LdzwQk9gH4hZ6T++H8g==", "dev": true }, "@types/jasminewd2": { diff --git a/ui/main/package.json b/ui/main/package.json index d6b585eab0..96d82b0c8e 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -70,7 +70,7 @@ "@ngrx/store-devtools": "13.0.2", "@types/file-saver": "2.0.4", "@types/handlebars": "4.0.40", - "@types/jasmine": "3.10.2", + "@types/jasmine": "3.10.3", "@types/jasminewd2": "2.0.10", "@types/lodash": "4.14.178", "@types/moment": "2.13.0", From dbc58603a540f4c9cfa8be2816def7fd0adfba9a Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 3 Jan 2022 23:51:09 +0000 Subject: [PATCH 41/73] Update dependency org.assertj:assertj-core to v3.22.0 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 35aae46687..f27b317e63 100755 --- a/gradle.properties +++ b/gradle.properties @@ -24,7 +24,7 @@ micrometer=1.8.1 avro=1.11.0 # testing libs -assertj=3.21.0 +assertj=3.22.0 junit5=5.8.2 awaitility=4.1.1 karate=1.1.0 From 148aeda1c8139fd132123b74570406be8876240f Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Tue, 4 Jan 2022 11:51:46 +0100 Subject: [PATCH 42/73] Impossible to save settings for technical reasons - Issue when the user account ID contains a special character (#2310) Signed-off-by: vlo-rte --- src/docs/asciidoc/reference_doc/users_management.adoc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/asciidoc/reference_doc/users_management.adoc b/src/docs/asciidoc/reference_doc/users_management.adoc index d730b5b0bd..641dd5b0d4 100644 --- a/src/docs/asciidoc/reference_doc/users_management.adoc +++ b/src/docs/asciidoc/reference_doc/users_management.adoc @@ -40,6 +40,8 @@ The access to this service has to be authorized, in the `OAuth2` service used by NOTE: User login must be lowercase. Otherwise, it will be converted to lowercase before saving to the database. +WARNING: Resource identifiers such as login, group id, entity id and perimeter id must only contain the following characters: letters, _, - or digits. + ==== Automated user creation From c4ec2429991930f2516b775e4c75a2607de2a32d Mon Sep 17 00:00:00 2001 From: Giovanni Ferrari Date: Thu, 23 Dec 2021 17:06:32 +0100 Subject: [PATCH 43/73] Wrong behaviour trying to load a not existent card (#2282) Signed-off-by: Giovanni Ferrari --- .../components/card-details/card-details.component.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui/main/src/app/modules/cards/components/card-details/card-details.component.ts b/ui/main/src/app/modules/cards/components/card-details/card-details.component.ts index 4f90c45afc..dd4f6c9ad9 100644 --- a/ui/main/src/app/modules/cards/components/card-details/card-details.component.ts +++ b/ui/main/src/app/modules/cards/components/card-details/card-details.component.ts @@ -39,6 +39,7 @@ export class CardDetailsComponent implements OnInit, OnDestroy { cardState: State; unsubscribe$: Subject = new Subject(); cardLoadingInProgress = false; + cardNotFound = false; currentSelectedCardId: string; protected _currentPath: string; @@ -54,6 +55,8 @@ export class CardDetailsComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.unsubscribe$)) .subscribe(([card, childCards]: [Card, Card[]]) => { if (!!card) { + this.cardNotFound = false; + this.businessconfigService.queryProcess(card.process, card.processVersion) .subscribe({ next: businessconfig => { @@ -82,9 +85,11 @@ export class CardDetailsComponent implements OnInit, OnDestroy { this.cardState = new State(); } }); + } else { + this.cardNotFound = true; + console.log(new Date().toISOString(), 'WARNING card not found.'); } }); - this.store.select(selectCurrentUrl) .pipe(takeUntil(this.unsubscribe$)) .subscribe(url => { @@ -94,7 +99,8 @@ export class CardDetailsComponent implements OnInit, OnDestroy { this._currentPath = urlParts[CURRENT_PAGE_INDEX]; } }); - this.checkForCardLoadingInProgressForMoreThanOneSecond(); + if(!this.cardNotFound) + this.checkForCardLoadingInProgressForMoreThanOneSecond(); } From 1e456c607a3a140c7a12eca3469b4dacd9a36935 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Wed, 22 Dec 2021 01:47:06 +0000 Subject: [PATCH 44/73] Update spring kafka to v2.8.1 Signed-off-by: Renovate Bot --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f27b317e63..6d26d28aa1 100755 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ jacksonAnnotations=2.13.1 jacksonDatabind=2.13.1 kavroSchemaRegistryClient=7.0.0 kavroAvroSerializer=7.0.0 -springKafka=2.8.0 +springKafka=2.8.1 micrometer=1.8.1 avro=1.11.0 From 5e07fcd7f964c12fa7b85f11e39a01a2110b60a9 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 23 Dec 2021 22:08:02 +0100 Subject: [PATCH 45/73] Improve subscription mechanism to be more resilient with cut or slow networks (#2293) Signed-off-by: freddidierRTE --- .../controllers/CardOperationsController.java | 5 +- .../services/CardSubscription.java | 28 +--- .../services/CardSubscriptionService.java | 129 +++++------------- .../CardSubscriptionServiceShould.java | 38 +----- ui/main/src/app/services/card.service.ts | 3 - 5 files changed, 46 insertions(+), 157 deletions(-) diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java index 2e44e1ae67..7c76234558 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java @@ -85,11 +85,10 @@ public Mono updateSubscriptionAndPublish(MonoCentralize request for generating {@link CardSubscription} and deleting them.

- * - *

Uses a {@link ThreadPoolTaskScheduler} delay definitive deletion of subscription (defaults to 10s)

- * - * - */ + + + @Service @Slf4j public class CardSubscriptionService { - private final ThreadPoolTaskScheduler taskScheduler; + private final FanoutExchange cardExchange; private final FanoutExchange processExchange; private final FanoutExchange userExchange; private final AmqpAdmin amqpAdmin; - private final long deletionDelay; private final long heartbeatDelay; private final ConnectionFactory connectionFactory; private Map cache = new ConcurrentHashMap<>(); - private Map> pendingEvict = new ConcurrentHashMap<>(); @Autowired protected UserServiceCache userServiceCache; @@ -59,25 +48,22 @@ public class CardSubscriptionService { @Autowired - public CardSubscriptionService(ThreadPoolTaskScheduler taskScheduler, + public CardSubscriptionService( FanoutExchange cardExchange, FanoutExchange processExchange, FanoutExchange userExchange, ConnectionFactory connectionFactory, AmqpAdmin amqpAdmin, - @Value("${operatorfabric.subscriptiondeletion.delay:10000}") - long deletionDelay, @Value("${operatorfabric.heartbeat.delay:10000}") long heartbeatDelay) { this.cardExchange = cardExchange; this.processExchange = processExchange; this.userExchange = userExchange; - this.taskScheduler = taskScheduler; this.amqpAdmin = amqpAdmin; this.connectionFactory = connectionFactory; - this.deletionDelay = deletionDelay; this.heartbeatDelay = heartbeatDelay; Thread heartbeat = new Thread(){ + @Override public void run(){ sendHeartbeatMessageInAllSubscriptions(); } @@ -103,8 +89,14 @@ private void sendHeartbeatMessageInAllSubscriptions() } log.debug("Send heartbeat to all subscription"); cache.keySet().forEach(key -> { - log.debug("Send heartbeat to {}",key); - sendHeartbeat(cache.get(key)); + CardSubscription sub = cache.get(key); + if (key!=null) // subscription can be null if it has been evict during the process of going throw the keys + { + if (!sub.isClosed()) { + log.debug("Send heartbeat to {}",key); + sendHeartbeat(sub); + } + } }); } @@ -117,15 +109,15 @@ private void sendHeartbeat(CardSubscription subscription) /** *

Generates a {@link CardSubscription} or retrieve it from a local {@link CardSubscription} cache.

- *

If it finds a {@link CardSubscription} from cache, it will try to cancel possible scheduled evict

*/ - public synchronized CardSubscription subscribe( + public CardSubscription subscribe( CurrentUserWithPerimeters currentUserWithPerimeters, String clientId) { String subId = CardSubscription.computeSubscriptionId(currentUserWithPerimeters.getUserData().getLogin(), clientId); CardSubscription cardSubscription = cache.get(subId); - // The builder may seem declare a bit to early but it allows usage in both branch of the later condition - CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder = CardSubscription.builder() + + if (cardSubscription == null) { + CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder = CardSubscription.builder() .currentUserWithPerimeters(currentUserWithPerimeters) .clientId(clientId) .amqpAdmin(amqpAdmin) @@ -133,86 +125,37 @@ public synchronized CardSubscription subscribe( .processExchange(this.processExchange) .userExchange(this.userExchange) .connectionFactory(this.connectionFactory); - if (cardSubscription == null) { cardSubscription = buildSubscription(subId, cardSubscriptionBuilder); - } else { - if (!cancelEviction(subId)) { - cardSubscription = cache.get(subId); - if (cardSubscription == null) { - cardSubscription = buildSubscription(subId, cardSubscriptionBuilder); - } - } - } + } return cardSubscription; } private CardSubscription buildSubscription(String subId, CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder) { CardSubscription cardSubscription; cardSubscription = cardSubscriptionBuilder.build(); - cardSubscription.initSubscription(retries, retryInterval, () -> scheduleEviction(subId)); + cardSubscription.initSubscription(retries, retryInterval, () -> evictSubscription(subId)); cache.put(subId, cardSubscription); - log.debug("Subscription created with id {}", cardSubscription.getId()); + log.info("Subscription created with id {} for user {} ", cardSubscription.getId(),cardSubscription.getUserLogin()); cardSubscription.userServiceCache = this.userServiceCache; return cardSubscription; } - /** - * Schedule deletion of subscription in deletionDelay millis - * - * @param subId - * Subscription computed id - */ - public void scheduleEviction(String subId) { - if (!pendingEvict.containsKey(subId)) { - ScheduledFuture scheduled = taskScheduler.schedule(createEvictTask(subId), - new Date(System.currentTimeMillis() + deletionDelay)); - pendingEvict.put(subId, scheduled); - log.debug("Eviction scheduled for id {}", subId); - } - } - - /** - * Cancel scheduled evict if any - * - * @param subId - * subscription auto-generated id - * @return true if eviction was successfully cancelled, false may indicate that either no cancellation was - * possible or no eviction was previously scheduled - */ - public synchronized boolean cancelEviction(String subId) { - ScheduledFuture scheduled = pendingEvict.get(subId); - if (scheduled != null) { - boolean canceled = scheduled.cancel(false); - pendingEvict.remove(subId); - log.debug("Eviction canceled with id {}", subId); - return canceled; - } - return false; - } - /** - * Evict subscription definitively - * - * @param subId subscription unique id - * subscription autogenerated id - */ - public synchronized void evict(String subId) { - log.debug("Trying to evict subscription with id {}", subId); - cache.get(subId).clearSubscription(); - cache.remove(subId); - pendingEvict.remove(subId); - log.debug("Subscription with id {} evicted ", subId); - } - /** - * Create a runnable to to launch {@link #evict(String)} - * - * @param subId - * subscription autogenerated id - * @return the generated task - */ - private Runnable createEvictTask(String subId) { - return () -> evict(subId); + public void evictSubscription(String subId) { + log.info("Trying to evict subscription with id {}", subId); + + CardSubscription sub = cache.get(subId); + if (sub==null) { + log.info("Subscription {} is not existing anymore ", subId); + return; + } + // remove first in cache to avoid the user getting a close subscription + // if it happens it is not really an issue as the ui will reopen it as it will see + // it is closed + cache.remove(subId); + sub.clearSubscription(); + log.info("Subscription with id {} evicted (user {})", subId , sub.getUserLogin()); } /** @@ -231,10 +174,10 @@ public CardSubscription findSubscription(CurrentUserWithPerimeters currentUserWi return cache.get(subId); } + // only use for testing purpose public void clearSubscriptions() { this.cache.forEach((k,v)->v.clearSubscription()); this.cache.clear(); - this.pendingEvict.clear(); } public Collection getSubscriptions() diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java index bc24f73d32..8d2af3a333 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java @@ -101,48 +101,12 @@ void createAndDeleteSubscription(){ CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); subscription.getPublisher().subscribe(log::info); Assertions.assertThat(subscription.checkActive()).isTrue(); - service.evict(subscription.getId()); + service.evictSubscription(subscription.getId()); Assertions.assertThat(subscription.isCleared()).isTrue(); Assertions.assertThat(subscription.checkActive()).isFalse(); -// await().atMost(10, TimeUnit.SECONDS).until(() -> !subscription.checkActive()); } - @Test - void deleteSubscriptionWithDelay(){ - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - subscription.getPublisher().subscribe(log::info); - Assertions.assertThat(subscription.checkActive()).isTrue(); - service.scheduleEviction(subscription.getId()); - Assertions.assertThat(subscription.checkActive()).isTrue(); - Assertions.assertThat(subscription.isCleared()).isFalse(); - await().atMost(15, TimeUnit.SECONDS).until(() -> !subscription.checkActive() && subscription.isCleared()); - } - @Test - void reviveSubscription(){ - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - subscription.getPublisher().subscribe(log::info); - Assertions.assertThat(subscription.checkActive()).isTrue(); - service.scheduleEviction(subscription.getId()); - Assertions.assertThat(subscription.checkActive()).isTrue(); - try { - await().atMost(6, TimeUnit.SECONDS).until(() -> !subscription.checkActive() && subscription.isCleared()); - Assertions.assertThat(false).describedAs("An exception was expected here").isFalse(); - }catch (ConditionTimeoutException e){ - //nothing, everything is alright - } - CardSubscription subscription2 = service.subscribe(currentUserWithPerimeters, TEST_ID); - Assertions.assertThat(subscription2).isSameAs(subscription); - try { - await().atMost(6, TimeUnit.SECONDS).until(() -> !subscription.checkActive() && subscription.isCleared()); - Assertions.assertThat(false).describedAs("An exception was expected here").isFalse(); - }catch (ConditionTimeoutException e){ - //nothing, everything is alright - } - service.evict(subscription.getId()); - Assertions.assertThat(subscription.isCleared()).isTrue(); - Assertions.assertThat(subscription.checkActive()).isFalse(); - } @Test void receiveCards(){ diff --git a/ui/main/src/app/services/card.service.ts b/ui/main/src/app/services/card.service.ts index a757ff98e3..ff0a7dfcb4 100644 --- a/ui/main/src/app/services/card.service.ts +++ b/ui/main/src/app/services/card.service.ts @@ -152,9 +152,6 @@ export class CardService { this.lastHeardBeatDate = new Date().valueOf(); console.log(new Date().toISOString(), `CardService - HEARTBEAT received - Connection alive `); break; - case 'RESTORE': - console.log(new Date().toISOString(), `CardService - Subscription restored with server`); - break; case 'BUSINESS_CONFIG_CHANGE': this.store.dispatch(new BusinessConfigChangeAction()); console.log(new Date().toISOString(), `CardService - BUSINESS_CONFIG_CHANGE received`); From 7d593afcb939d7bf492557106634bcf9f2468ed2 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Sun, 26 Dec 2021 21:30:30 +0100 Subject: [PATCH 46/73] Correct error in test (#2293) Signed-off-by: freddidierRTE --- .../services/CardSubscriptionServiceShould.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java index 8d2af3a333..41003e3538 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java @@ -199,12 +199,14 @@ void testCheckIfUserMustReceiveTheCard() { void testCreateDeleteCardMessageForUserNotRecipient(){ CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - String messageBodyAdd = "{\"cards\":[{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446}],\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"ADD\"}"; - String messageBodyUpdate = "{\"cards\":[{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5c\",\"state\":\"messageState\",\"startDate\":1592396243446}],\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"UPDATE\"}"; - String messageBodyDelete = "{\"cards\":[{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5d\",\"state\":\"messageState\",\"startDate\":1592396243446}],\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\"}"; - - Assertions.assertThat(subscription.createDeleteCardMessageForUserNotRecipient(createJSONObjectFromString(messageBodyAdd)).equals("{\"cards\":[{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446}],\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\",\"cardIds\":\"api_test_process5b\"}")); - Assertions.assertThat(subscription.createDeleteCardMessageForUserNotRecipient(createJSONObjectFromString(messageBodyUpdate)).equals("{\"cards\":[{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446}],\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\",\"cardIds\":\"api_test_process5c\"}")); - Assertions.assertThat(subscription.createDeleteCardMessageForUserNotRecipient(createJSONObjectFromString(messageBodyDelete)).equals(messageBodyDelete)); //message must not be changed + JSONObject cardAdd = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"ADD\"}"); + JSONObject cardAddWantedOutput = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\",\"cardId\":\"api_test_process5b\"}"); + JSONObject cardAddOutput = createJSONObjectFromString(subscription.createDeleteCardMessageForUserNotRecipient(cardAdd)); + Assertions.assertThat(cardAddOutput).isEqualTo(cardAddWantedOutput); + + String messageBodyDelete = "{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5d\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\"}"; + JSONObject inputDelete = createJSONObjectFromString(messageBodyDelete); + String outputDelete = subscription.createDeleteCardMessageForUserNotRecipient(inputDelete); + Assertions.assertThat(outputDelete).isEmpty(); } } From 37592d20b2f7cbe826c85bede0937d01bb9f2ef1 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Mon, 27 Dec 2021 17:21:11 +0100 Subject: [PATCH 47/73] Isolate code regarding rabbitMq connection (#2293) Signed-off-by: freddidierRTE --- .../services/CardSubscription.java | 118 ++---------------- .../services/CardSubscriptionService.java | 78 +++++------- .../services/RabbitMQEntryPoint.java | 104 +++++++++++++++ .../routes/ConnectionRoutesShould.java | 16 +-- .../CardSubscriptionServiceShould.java | 52 +------- 5 files changed, 148 insertions(+), 220 deletions(-) create mode 100644 services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/RabbitMQEntryPoint.java diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java index ed457f67dd..b464d0f35e 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java @@ -22,11 +22,6 @@ import org.opfab.springtools.configuration.oauth.UserServiceCache; import org.opfab.users.model.CurrentUserWithPerimeters; import org.opfab.users.model.RightsEnum; -import org.opfab.utilities.AmqpUtils; -import org.springframework.amqp.core.*; -import org.springframework.amqp.rabbit.connection.ConnectionFactory; -import org.springframework.amqp.rabbit.listener.MessageListenerContainer; -import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; import reactor.core.publisher.Flux; import reactor.core.publisher.FluxSink; @@ -46,13 +41,8 @@ @EqualsAndHashCode public class CardSubscription { public static final String GROUPS_SUFFIX = "Groups"; - public static final String PROCESS_SUFFIX = "Process"; - public static final String USER_SUFFIX = "User"; public static final String DELETE_OPERATION = "DELETE"; public static final String ERROR_MESSAGE_PARSING = "ERROR during received message parsing"; - private String cardsQueueName; - private String processQueueName; - private String userQueueName; private long current = 0; @Getter private CurrentUserWithPerimeters currentUserWithPerimeters; @@ -62,18 +52,7 @@ public class CardSubscription { private Flux publisher; private Flux amqpPublisher; private FluxSink messageSink; - private AmqpAdmin amqpAdmin; - private FanoutExchange cardExchange; - private FanoutExchange processExchange; - private FanoutExchange userExchange; - private ConnectionFactory connectionFactory; - private MessageListenerContainer cardListener; - private MessageListenerContainer processListener; - private MessageListenerContainer userListener; - @Getter - private boolean cleared = false; - @Getter - private boolean closed = false; + private final String clientId; private String userLogin; @@ -84,24 +63,12 @@ public class CardSubscription { */ @Builder public CardSubscription(CurrentUserWithPerimeters currentUserWithPerimeters, - String clientId, - AmqpAdmin amqpAdmin, - FanoutExchange cardExchange, - FanoutExchange processExchange, - FanoutExchange userExchange, - ConnectionFactory connectionFactory) { + String clientId + ) { userLogin = currentUserWithPerimeters.getUserData().getLogin(); this.id = computeSubscriptionId(userLogin, clientId); this.currentUserWithPerimeters = currentUserWithPerimeters; - this.amqpAdmin = amqpAdmin; - this.cardExchange = cardExchange; - this.processExchange = processExchange; - this.userExchange = userExchange; - this.connectionFactory = connectionFactory; this.clientId = clientId; - this.cardsQueueName = computeSubscriptionId(userLogin + GROUPS_SUFFIX, this.clientId); - this.processQueueName = computeSubscriptionId(userLogin + PROCESS_SUFFIX, this.clientId); - this.userQueueName = computeSubscriptionId(userLogin + USER_SUFFIX, this.clientId); } public String getUserLogin() @@ -132,101 +99,36 @@ public static String computeSubscriptionId(String prefix, String clientId) { } - public void initSubscription(int retries, long retryInterval, Runnable doOnCancel) { - AmqpUtils.createQueue(amqpAdmin, cardsQueueName, cardExchange, retries, retryInterval); - AmqpUtils.createQueue(amqpAdmin, processQueueName, processExchange, retries, retryInterval); - AmqpUtils.createQueue(amqpAdmin, userQueueName, userExchange, retries, retryInterval); - this.cardListener = createMessageListenerContainer(cardsQueueName); - this.processListener = createMessageListenerContainer(processQueueName); - this.userListener = createMessageListenerContainer(userQueueName); + public void initSubscription(Runnable doOnCancel) { this.publisher = Flux.create(emitter -> { - log.debug("Create message flux for user {}", userLogin); + log.info("Create subscription for user {}", userLogin); this.messageSink = emitter; - registerListener(cardListener); - registerProcessListener(processListener); - registerUserListener(userListener); - emitter.onRequest(v -> log.debug("STARTING subscription for user {}", userLogin)); + emitter.onRequest(v -> log.debug("Starting subscription for user {}", userLogin)); emitter.onDispose(() -> { - log.debug("DISPOSING subscription for user {}", userLogin); + log.info("Disposing subscription for user {}", userLogin); doOnCancel.run(); }); emitter.next("INIT"); - cardListener.start(); - processListener.start(); - userListener.start(); }); } - private void registerListener(MessageListenerContainer mlc) { - mlc.setupMessageListener(message -> { + public void processNewCard(String message) { JSONObject card; try { - card = (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(message.getBody()); + card = (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(message); } catch(ParseException e){ log.error(ERROR_MESSAGE_PARSING, e); return;} if (checkIfUserMustReceiveTheCard(card)){ - publishDataIntoSubscription(new String(message.getBody())); + publishDataIntoSubscription(message); } // In case of ADD or UPDATE, we send a delete card operation (to delete the card from the feed, more information in OC-297) else { String deleteMessage = createDeleteCardMessageForUserNotRecipient(card); if (! deleteMessage.isEmpty()) publishDataIntoSubscription(deleteMessage); } - }); - } - - private void registerProcessListener(MessageListenerContainer mlc) { - mlc.setupMessageListener(message -> publishDataIntoSubscription(new String(message.getBody()))); - } - - private void registerUserListener(MessageListenerContainer mlc) { - mlc.setupMessageListener(message -> { - String modifiedUserLogin = new String(message.getBody()); - - if (this.userLogin.equals(modifiedUserLogin)) - publishDataIntoSubscription("USER_CONFIG_CHANGE"); - }); - } - - /** - * Stops associated {@link MessageListenerContainer} and delete queues - */ - public void clearSubscription() { - log.debug("Clear subscription for user {}", userLogin); - closed = true; - cardListener.stop(); - amqpAdmin.deleteQueue(cardsQueueName); - processListener.stop(); - amqpAdmin.deleteQueue(processQueueName); - userListener.stop(); - amqpAdmin.deleteQueue(userQueueName); - cleared = true; - - } - - /** - * - * @return true if associated AMQP listeners are still running - */ - public boolean checkActive() { - return cardListener == null || cardListener.isRunning(); - } - - - /** - * Create a {@link MessageListenerContainer} for the specified queue - * @param queueName AMQP queue name - * @return listener container for the specified queue - */ - public MessageListenerContainer createMessageListenerContainer(String queueName) { - - SimpleMessageListenerContainer mlc = new SimpleMessageListenerContainer(connectionFactory); - mlc.addQueueNames(queueName); - mlc.setAcknowledgeMode(AcknowledgeMode.AUTO); - return mlc; } diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java index aaf9e330bf..9623eafb09 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java @@ -10,11 +10,10 @@ package org.opfab.cards.consultation.services; import lombok.extern.slf4j.Slf4j; + + import org.opfab.springtools.configuration.oauth.UserServiceCache; import org.opfab.users.model.CurrentUserWithPerimeters; -import org.springframework.amqp.core.AmqpAdmin; -import org.springframework.amqp.core.FanoutExchange; -import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -28,39 +27,17 @@ @Slf4j public class CardSubscriptionService { - - private final FanoutExchange cardExchange; - private final FanoutExchange processExchange; - private final FanoutExchange userExchange; - private final AmqpAdmin amqpAdmin; private final long heartbeatDelay; - private final ConnectionFactory connectionFactory; private Map cache = new ConcurrentHashMap<>(); @Autowired protected UserServiceCache userServiceCache; - @Value("${operatorfabric.amqp.connectionRetries:10}") - private int retries; - - @Value("${operatorfabric.amqp.connectionRetryInterval:5000}") - private long retryInterval; - - @Autowired public CardSubscriptionService( - FanoutExchange cardExchange, - FanoutExchange processExchange, - FanoutExchange userExchange, - ConnectionFactory connectionFactory, - AmqpAdmin amqpAdmin, @Value("${operatorfabric.heartbeat.delay:10000}") long heartbeatDelay) { - this.cardExchange = cardExchange; - this.processExchange = processExchange; - this.userExchange = userExchange; - this.amqpAdmin = amqpAdmin; - this.connectionFactory = connectionFactory; + this.heartbeatDelay = heartbeatDelay; Thread heartbeat = new Thread(){ @Override @@ -90,23 +67,16 @@ private void sendHeartbeatMessageInAllSubscriptions() log.debug("Send heartbeat to all subscription"); cache.keySet().forEach(key -> { CardSubscription sub = cache.get(key); - if (key!=null) // subscription can be null if it has been evict during the process of going throw the keys + if (sub!=null) // subscription can be null if it has been evict during the process of going throw the keys { - if (!sub.isClosed()) { log.debug("Send heartbeat to {}",key); - sendHeartbeat(sub); - } + sub.publishDataIntoSubscription("HEARTBEAT"); } }); } } - private void sendHeartbeat(CardSubscription subscription) - { - if (subscription!=null) subscription.publishDataIntoSubscription("HEARTBEAT"); - } - /** *

Generates a {@link CardSubscription} or retrieve it from a local {@link CardSubscription} cache.

*/ @@ -119,12 +89,7 @@ public CardSubscription subscribe( if (cardSubscription == null) { CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder = CardSubscription.builder() .currentUserWithPerimeters(currentUserWithPerimeters) - .clientId(clientId) - .amqpAdmin(amqpAdmin) - .cardExchange(this.cardExchange) - .processExchange(this.processExchange) - .userExchange(this.userExchange) - .connectionFactory(this.connectionFactory); + .clientId(clientId); cardSubscription = buildSubscription(subId, cardSubscriptionBuilder); } return cardSubscription; @@ -133,7 +98,7 @@ public CardSubscription subscribe( private CardSubscription buildSubscription(String subId, CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder) { CardSubscription cardSubscription; cardSubscription = cardSubscriptionBuilder.build(); - cardSubscription.initSubscription(retries, retryInterval, () -> evictSubscription(subId)); + cardSubscription.initSubscription( () -> evictSubscription(subId)); cache.put(subId, cardSubscription); log.info("Subscription created with id {} for user {} ", cardSubscription.getId(),cardSubscription.getUserLogin()); cardSubscription.userServiceCache = this.userServiceCache; @@ -141,20 +106,14 @@ private CardSubscription buildSubscription(String subId, CardSubscription.CardSu } - public void evictSubscription(String subId) { - log.info("Trying to evict subscription with id {}", subId); CardSubscription sub = cache.get(subId); if (sub==null) { - log.info("Subscription {} is not existing anymore ", subId); + log.info("Subscription with id {} already evicted , as it is not existing anymore ", subId); return; } - // remove first in cache to avoid the user getting a close subscription - // if it happens it is not really an issue as the ui will reopen it as it will see - // it is closed cache.remove(subId); - sub.clearSubscription(); log.info("Subscription with id {} evicted (user {})", subId , sub.getUserLogin()); } @@ -176,7 +135,6 @@ public CardSubscription findSubscription(CurrentUserWithPerimeters currentUserWi // only use for testing purpose public void clearSubscriptions() { - this.cache.forEach((k,v)->v.clearSubscription()); this.cache.clear(); } @@ -184,4 +142,24 @@ public Collection getSubscriptions() { return cache.values(); } + + + public void onMessage(String queueName, String message) { + switch (queueName) { + case "process": + cache.values().forEach(subscription -> subscription.publishDataIntoSubscription(message)); + break; + case "user": + cache.values().forEach(subscription -> { + if (message.equals(subscription.getUserLogin())) + subscription.publishDataIntoSubscription("USER_CONFIG_CHANGE"); + }); + break; + case "card": + cache.values().forEach(subscription -> subscription.processNewCard(message)); + break; + default: + log.info("unrecognized queue {}" , queueName); + } + } } diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/RabbitMQEntryPoint.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/RabbitMQEntryPoint.java new file mode 100644 index 0000000000..af1aa5e00d --- /dev/null +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/RabbitMQEntryPoint.java @@ -0,0 +1,104 @@ +/* Copyright (c) 2021, RTE (http://www.rte-france.com) + * See AUTHORS.txt + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + * This file is part of the OperatorFabric project. + */ + +package org.opfab.cards.consultation.services; + +import javax.annotation.PreDestroy; + +import org.opfab.utilities.AmqpUtils; +import org.springframework.amqp.core.AmqpAdmin; +import org.springframework.amqp.core.FanoutExchange; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +public class RabbitMQEntryPoint { + + private static final String CARD_QUEUE_NAME = "card"; + private static final String PROCESS_QUEUE_NAME = "process"; + private static final String USER_QUEUE_NAME = "user"; + + @Value("${operatorfabric.amqp.connectionRetries:30}") + private int retries; + + @Value("${operatorfabric.amqp.connectionRetryInterval:5000}") + private long retryInterval; + + private AmqpAdmin amqpAdmin; + private FanoutExchange cardExchange; + private FanoutExchange processExchange; + private FanoutExchange userExchange; + private ConnectionFactory connectionFactory; + private CardSubscriptionService cardSubscriptionService; + + private SimpleMessageListenerContainer cardListener; + private SimpleMessageListenerContainer userListener; + private SimpleMessageListenerContainer processListener; + + + @Autowired + public RabbitMQEntryPoint(AmqpAdmin amqpAdmin, + FanoutExchange cardExchange, + FanoutExchange processExchange, + FanoutExchange userExchange, + ConnectionFactory connectionFactory, + CardSubscriptionService cardSubscriptionService) { + this.amqpAdmin = amqpAdmin; + this.cardExchange = cardExchange; + this.processExchange = processExchange; + this.userExchange = userExchange; + this.connectionFactory = connectionFactory; + this.cardSubscriptionService = cardSubscriptionService; + + log.info("Starting rabbitMQ queues"); + createQueues(); + + } + + private void createQueues() { + AmqpUtils.createQueue(amqpAdmin, CARD_QUEUE_NAME, cardExchange, retries, retryInterval); + AmqpUtils.createQueue(amqpAdmin, PROCESS_QUEUE_NAME, processExchange, retries, retryInterval); + AmqpUtils.createQueue(amqpAdmin, USER_QUEUE_NAME, userExchange, retries, retryInterval); + cardListener = startListener(CARD_QUEUE_NAME); + userListener = startListener(USER_QUEUE_NAME); + processListener = startListener(PROCESS_QUEUE_NAME); + } + + private SimpleMessageListenerContainer startListener(String queueName) { + SimpleMessageListenerContainer mlc = new SimpleMessageListenerContainer(connectionFactory); + mlc.addQueueNames(queueName); + mlc.setAcknowledgeMode(AcknowledgeMode.AUTO); + mlc.setupMessageListener( + message -> cardSubscriptionService.onMessage(queueName, new String(message.getBody()))); + mlc.start(); + return mlc; + } + + @PreDestroy + public void destroy() { + + cardListener.stop(); + userListener.stop(); + processListener.stop(); + + // we just stop the listener but + // we do not delete queue via amqpAdmin.deleteQueue(...) + // as we want to keep the message received while the service is down + + log.debug("******* Rabbit Listener stopped "); + + } +} diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/routes/ConnectionRoutesShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/routes/ConnectionRoutesShould.java index b43f24508f..a0e8b09e8a 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/routes/ConnectionRoutesShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/routes/ConnectionRoutesShould.java @@ -11,23 +11,17 @@ import lombok.extern.slf4j.Slf4j; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.AfterEach; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.opfab.cards.consultation.application.IntegrationTestApplication; import org.opfab.cards.consultation.configuration.webflux.ConnectionRoutesConfig; -import org.opfab.cards.consultation.model.ArchivedCardConsultationData; -import org.opfab.cards.consultation.model.ConnectionData; -import org.opfab.cards.consultation.repositories.ArchivedCardRepository; import org.opfab.cards.consultation.services.CardSubscription; import org.opfab.cards.consultation.services.CardSubscriptionService; import org.opfab.springtools.configuration.test.UserServiceCacheTestApplication; import org.opfab.springtools.configuration.test.WithMockOpFabUserReactive; -import org.opfab.test.EmptyListComparator; import org.opfab.users.model.CurrentUserWithPerimeters; import org.opfab.users.model.User; import org.springframework.beans.factory.annotation.Autowired; @@ -38,13 +32,8 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; -import reactor.test.StepVerifier; - -import java.lang.reflect.Array; -import java.time.Instant; - import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.is; + @ExtendWith(SpringExtension.class) @SpringBootTest(classes = { IntegrationTestApplication.class, ConnectionRoutesConfig.class, @@ -100,7 +89,6 @@ void respondWithNoUSerConnectedIsEmpty() { void respondWithOneUserConnected() { CardSubscription subscription = service.subscribe(currentUserWithPerimeters, "test"); subscription.getPublisher().subscribe(log::info); - Assertions.assertThat(subscription.checkActive()).isTrue(); webTestClient.get().uri("/connections").exchange().expectStatus().isOk() .expectBody() .jsonPath("$[0].login").isEqualTo("testuser"); diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java index 41003e3538..87b5ec3ccd 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java @@ -11,10 +11,10 @@ import lombok.extern.slf4j.Slf4j; import org.assertj.core.api.Assertions; -import org.awaitility.core.ConditionTimeoutException; import net.minidev.json.JSONObject; import net.minidev.json.parser.JSONParser; import net.minidev.json.parser.ParseException; + import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,23 +24,17 @@ import org.opfab.users.model.CurrentUserWithPerimeters; import org.opfab.users.model.RightsEnum; import org.opfab.users.model.User; -import org.springframework.amqp.core.FanoutExchange; -import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit.jupiter.SpringExtension; -import reactor.test.StepVerifier; import java.util.ArrayList; import java.util.Arrays; -import java.util.Date; import java.util.List; -import java.util.concurrent.TimeUnit; -import static org.awaitility.Awaitility.await; + /** *

@@ -58,17 +52,11 @@ public class CardSubscriptionServiceShould { private static String TEST_ID = "testClient"; - @Autowired - private RabbitTemplate rabbitTemplate; - @Autowired - private FanoutExchange cardExchange; + @Autowired private CardSubscriptionService service; - @Autowired - private ThreadPoolTaskScheduler taskScheduler; - private CurrentUserWithPerimeters currentUserWithPerimeters; - private static String rabbitTestMessage = "{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"Process1\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"State1\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"testgroup1\"],\"type\":\"ADD\"}"; + private CurrentUserWithPerimeters currentUserWithPerimeters; public CardSubscriptionServiceShould(){ User user = new User(); @@ -96,38 +84,6 @@ public CardSubscriptionServiceShould(){ currentUserWithPerimeters.setComputedPerimeters(Arrays.asList(perimeter)); } - @Test - void createAndDeleteSubscription(){ - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - subscription.getPublisher().subscribe(log::info); - Assertions.assertThat(subscription.checkActive()).isTrue(); - service.evictSubscription(subscription.getId()); - Assertions.assertThat(subscription.isCleared()).isTrue(); - Assertions.assertThat(subscription.checkActive()).isFalse(); - } - - - - @Test - void receiveCards(){ - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - StepVerifier.FirstStep verifier = StepVerifier.create(subscription.getPublisher().filter(m -> !m.equals("HEARTBEAT") && !m.equals("BUSINESS_CONFIG_CHANGE") && !m.equals("USER_CONFIG_CHANGE") )); - taskScheduler.schedule(createSendMessageTask(),new Date(System.currentTimeMillis() + 1000)); - verifier - .expectNext("INIT") - .expectNext(rabbitTestMessage) - .expectNext(rabbitTestMessage) - .thenCancel() - .verify(); - } - - private Runnable createSendMessageTask() { - return () ->{ - - rabbitTemplate.convertAndSend(cardExchange.getName(), currentUserWithPerimeters.getUserData().getLogin(),rabbitTestMessage); - rabbitTemplate.convertAndSend(cardExchange.getName(), currentUserWithPerimeters.getUserData().getLogin(),rabbitTestMessage); - }; - } private JSONObject createJSONObjectFromString(String jsonString) { From 476a39adce115418c272ec5aa5c7c4a5531fb40b Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Tue, 28 Dec 2021 17:12:20 +0100 Subject: [PATCH 48/73] Permit graceful shutdown for java service using exec to have PID = 1 (#2293) See : - https://www.ctl.io/developers/blog/post/gracefully-stopping-docker-containers/ - https://unix.stackexchange.com/questions/146756/forward-sigterm-to-child-in-bash Signed-off-by: freddidierRTE --- src/main/docker/java-config-docker-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/docker/java-config-docker-entrypoint.sh b/src/main/docker/java-config-docker-entrypoint.sh index cbd8b515ae..e649c2f397 100755 --- a/src/main/docker/java-config-docker-entrypoint.sh +++ b/src/main/docker/java-config-docker-entrypoint.sh @@ -13,5 +13,5 @@ cp $JAVA_HOME/jre/lib/security/cacerts /tmp chmod u+w /tmp/cacerts ./add-certificates.sh /certificates_to_add /tmp/cacerts -java -agentlib:jdwp=transport=dt_socket,address=5005,server=y,suspend=n -Djavax.net.ssl.trustStore=/tmp/cacerts -Djava.security.egd=file:/dev/./urandom $JAVA_OPTIONS -jar /app.jar +exec java -agentlib:jdwp=transport=dt_socket,address=5005,server=y,suspend=n -Djavax.net.ssl.trustStore=/tmp/cacerts -Djava.security.egd=file:/dev/./urandom $JAVA_OPTIONS -jar /app.jar From ad310f80e029c6eaa33e4a0f314758601499285c Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Tue, 28 Dec 2021 17:24:21 +0100 Subject: [PATCH 49/73] Isolate code regarding routing rules (#2293) Signed-off-by: freddidierRTE --- .../controllers/CardOperationsController.java | 2 +- .../services/CardRoutingUtilities.java | 143 ++++++++++++++ .../services/CardSubscription.java | 164 +--------------- .../services/CardSubscriptionService.java | 68 ++++--- .../services/CardRoutingUtilitiesShould.java | 179 ++++++++++++++++++ .../CardSubscriptionServiceShould.java | 168 ---------------- 6 files changed, 371 insertions(+), 353 deletions(-) create mode 100644 services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardRoutingUtilities.java create mode 100644 services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java delete mode 100644 services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java index 7c76234558..ac49b4cac8 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java @@ -107,7 +107,7 @@ public Mono updateSubscriptionAndPublish(Mono fetchOldCards(CardSubscription subscription,Instant publishFrom,Instant start,Instant end) { - subscription.updateCurrentUserWithPerimeters(); + return fetchOldCards0(publishFrom, start, end, subscription.getCurrentUserWithPerimeters()); } diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardRoutingUtilities.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardRoutingUtilities.java new file mode 100644 index 0000000000..216925d3a9 --- /dev/null +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardRoutingUtilities.java @@ -0,0 +1,143 @@ +/* Copyright (c) 2021, RTE (http://www.rte-france.com) + * See AUTHORS.txt + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + * This file is part of the OperatorFabric project. + */ + +package org.opfab.cards.consultation.services; + + +import lombok.extern.slf4j.Slf4j; +import net.minidev.json.JSONArray; +import net.minidev.json.JSONObject; +import org.opfab.users.model.CurrentUserWithPerimeters; +import org.opfab.users.model.RightsEnum; + + +import java.util.*; + +@Slf4j +public class CardRoutingUtilities { + + public static final String GROUPS_SUFFIX = "Groups"; + public static final String DELETE_OPERATION = "DELETE"; + public static final String ERROR_MESSAGE_PARSING = "ERROR during received message parsing"; + + + private CardRoutingUtilities(){ + } + + public static String createDeleteCardMessageForUserNotRecipient(JSONObject cardOperation,String userLogin) { + + String typeOperation = (cardOperation.get("type") != null) ? (String) cardOperation.get("type") : ""; + + if (typeOperation.equals("ADD") || typeOperation.equals("UPDATE")) { + JSONObject cardObj = (JSONObject) cardOperation.get("card"); + + String idCard = (cardObj != null) ? (String) cardObj.get("id") : ""; + + log.debug("Send delete card with id {} for user {}", idCard, userLogin); + cardOperation.replace("type", DELETE_OPERATION); + cardOperation.put("cardId", idCard); + + return cardOperation.toJSONString(); + } + + return ""; + } + + + public static boolean checkIfUserMustBeNotifiedForThisProcessState(String process, String state, CurrentUserWithPerimeters currentUserWithPerimeters) { + Map> processesStatesNotNotified = currentUserWithPerimeters.getProcessesStatesNotNotified(); + return ! ((processesStatesNotNotified != null) && (processesStatesNotNotified.get(process) != null) && + (processesStatesNotNotified.get(process).contains(state))); + } + + private static Map loadUserRightsPerProcessAndState(CurrentUserWithPerimeters currentUserWithPerimeters) { + Map userRightsPerProcessAndState = new HashMap<>(); + if (currentUserWithPerimeters.getComputedPerimeters() != null) + currentUserWithPerimeters.getComputedPerimeters() + .forEach(perimeter -> userRightsPerProcessAndState.put(perimeter.getProcess() + "." + perimeter.getState(), perimeter.getRights())); + return userRightsPerProcessAndState; + } + + private static boolean isReceiveRightsForProcessAndState(String processId, String stateId, Map userRightsPerProcessAndState) { + final RightsEnum rights = userRightsPerProcessAndState.get(processId + '.' + stateId); + return rights == RightsEnum.RECEIVE || rights == RightsEnum.RECEIVEANDWRITE; + } + + /** Rules for receiving cards : + 1) If the card is sent to user1, the card is received and visible for user1 if he has the receive right for the + corresponding process/state (Receive or ReceiveAndWrite) + 2) If the card is sent to GROUP1 (or ENTITY1), the card is received and visible for user if all of the following is true : + - he's a member of GROUP1 (or ENTITY1) + - he has the receive right for the corresponding process/state (Receive or ReceiveAndWrite) + 3) If the card is sent to ENTITY1 and GROUP1, the card is received and visible for user if all of the following is true : + - he's a member of ENTITY1 (either directly or through one of its children entities) + - he's a member of GROUP1 + - he has the receive right for the corresponding process/state (Receive or ReceiveAndWrite) + **/ + public static boolean checkIfUserMustReceiveTheCard(JSONObject cardOperation, CurrentUserWithPerimeters currentUserWithPerimeters) { + Map userRightsPerProcessAndState = loadUserRightsPerProcessAndState(currentUserWithPerimeters); + + JSONArray groupRecipientsIdsArray = (JSONArray) cardOperation.get("groupRecipientsIds"); + JSONArray entityRecipientsIdsArray = (JSONArray) cardOperation.get("entityRecipientsIds"); + JSONArray userRecipientsIdsArray = (JSONArray) cardOperation.get("userRecipientsIds"); + JSONObject cardObj = (JSONObject) cardOperation.get("card"); + String typeOperation = (cardOperation.get("type") != null) ? (String) cardOperation.get("type") : ""; + + String idCard = null; + String process = ""; + String state = ""; + if (cardObj != null) { + idCard = (cardObj.get("id") != null) ? (String) cardObj.get("id") : ""; + process = (String) cardObj.get("process"); + state = (String) cardObj.get("state"); + } + + if (!checkIfUserMustBeNotifiedForThisProcessState(process, state, currentUserWithPerimeters)) + return false; + + String processStateKey = process + "." + state; + List userGroups = currentUserWithPerimeters.getUserData().getGroups(); + List userEntities = currentUserWithPerimeters.getUserData().getEntities(); + + log.debug("Check if user {} shall receive card {} for processStateKey {}", currentUserWithPerimeters.getUserData().getLogin(), idCard, processStateKey); + + // First, we check if the user has the right for receiving this card (Receive or ReceiveAndWrite) + if ((!typeOperation.equals(DELETE_OPERATION)) && (!isReceiveRightsForProcessAndState(process, state, userRightsPerProcessAndState))) + return false; + + // Now, we check if the user is member of the group and/or entity (or the recipient himself) + if (checkInCaseOfCardSentToUserDirectly(userRecipientsIdsArray,currentUserWithPerimeters.getUserData().getLogin())) { // user only + log.debug("User {} is in user recipients and shall receive card {}", currentUserWithPerimeters.getUserData().getLogin(), idCard); + return true; + } + + if (checkInCaseOfCardSentToGroupOrEntityOrBoth(userGroups, groupRecipientsIdsArray, userEntities, entityRecipientsIdsArray)) { + log.debug("User {} is member of a group or/and entity that shall receive card {}", currentUserWithPerimeters.getUserData().getLogin(), idCard); + return true; + } + return false; + } + + + private static boolean checkInCaseOfCardSentToUserDirectly(JSONArray userRecipientsIdsArray,String userLogin) { + return (userRecipientsIdsArray != null && !Collections.disjoint(Arrays.asList(userLogin), userRecipientsIdsArray)); + } + + private static boolean checkInCaseOfCardSentToGroupOrEntityOrBoth(List userGroups, JSONArray groupRecipientsIdsArray, + List userEntities, JSONArray entityRecipientsIdsArray) { + if ((groupRecipientsIdsArray != null) && (!groupRecipientsIdsArray.isEmpty()) + && (Collections.disjoint(userGroups, groupRecipientsIdsArray))) + return false; + if ((entityRecipientsIdsArray != null) && (!entityRecipientsIdsArray.isEmpty()) + && (Collections.disjoint(userEntities, entityRecipientsIdsArray))) + return false; + return ! ((groupRecipientsIdsArray == null || groupRecipientsIdsArray.isEmpty()) && + (entityRecipientsIdsArray == null || entityRecipientsIdsArray.isEmpty())); + } +} diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java index b464d0f35e..59ff7bb1fc 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscription.java @@ -15,49 +15,27 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import net.minidev.json.JSONArray; -import net.minidev.json.JSONObject; -import net.minidev.json.parser.JSONParser; -import net.minidev.json.parser.ParseException; import org.opfab.springtools.configuration.oauth.UserServiceCache; import org.opfab.users.model.CurrentUserWithPerimeters; -import org.opfab.users.model.RightsEnum; - import reactor.core.publisher.Flux; import reactor.core.publisher.FluxSink; -import java.util.*; -/** - *

This object manages subscription to AMQP exchange

- * - *

Two exchanges are used, {@link #cardExchange} and {@link #userExchange}. - * See amqp.xml resource file ([project]/services/cards-publication/src/main/resources/amqp.xml) - * for their exact configuration

- * - * - */ @Slf4j @EqualsAndHashCode public class CardSubscription { - public static final String GROUPS_SUFFIX = "Groups"; - public static final String DELETE_OPERATION = "DELETE"; - public static final String ERROR_MESSAGE_PARSING = "ERROR during received message parsing"; - private long current = 0; - @Getter + private CurrentUserWithPerimeters currentUserWithPerimeters; @Getter private String id; @Getter private Flux publisher; - private Flux amqpPublisher; private FluxSink messageSink; - private final String clientId; private String userLogin; - protected UserServiceCache userServiceCache; + /** * Constructs a card subscription and init access to AMQP exchanges */ @@ -68,7 +46,7 @@ public CardSubscription(CurrentUserWithPerimeters currentUserWithPerimeters, userLogin = currentUserWithPerimeters.getUserData().getLogin(); this.id = computeSubscriptionId(userLogin, clientId); this.currentUserWithPerimeters = currentUserWithPerimeters; - this.clientId = clientId; + } public String getUserLogin() @@ -76,7 +54,7 @@ public String getUserLogin() return userLogin; } - public void updateCurrentUserWithPerimeters() { + public CurrentUserWithPerimeters getCurrentUserWithPerimeters() { if (userServiceCache != null) try { currentUserWithPerimeters = userServiceCache.fetchCurrentUserWithPerimetersFromCacheOrProxy(userLogin); @@ -92,8 +70,9 @@ public void updateCurrentUserWithPerimeters() { // log.info("Cannot get new perimeter for user {} , use old one ", userLogin); } + return currentUserWithPerimeters; } - + public static String computeSubscriptionId(String prefix, String clientId) { return prefix + "#" + clientId; } @@ -113,148 +92,19 @@ public void initSubscription(Runnable doOnCancel) { } - public void processNewCard(String message) { - JSONObject card; - try - { - card = (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(message); - } - catch(ParseException e){ log.error(ERROR_MESSAGE_PARSING, e); return;} - - if (checkIfUserMustReceiveTheCard(card)){ - publishDataIntoSubscription(message); - } - // In case of ADD or UPDATE, we send a delete card operation (to delete the card from the feed, more information in OC-297) - else { - String deleteMessage = createDeleteCardMessageForUserNotRecipient(card); - if (! deleteMessage.isEmpty()) publishDataIntoSubscription(deleteMessage); - } - } - public void publishDataIntoSubscription(String message) { if (this.messageSink!=null) this.messageSink.next(message); } - public void publishDataFluxIntoSubscription(Flux messageFlux) { messageFlux.subscribe(next -> this.messageSink.next(next)); } - public String createDeleteCardMessageForUserNotRecipient(JSONObject cardOperation) { - - String typeOperation = (cardOperation.get("type") != null) ? (String) cardOperation.get("type") : ""; - - if (typeOperation.equals("ADD") || typeOperation.equals("UPDATE")) { - JSONObject cardObj = (JSONObject) cardOperation.get("card"); - - String idCard = (cardObj != null) ? (String) cardObj.get("id") : ""; - - log.debug("Send delete card with id {} for user {}", idCard, userLogin); - cardOperation.replace("type", DELETE_OPERATION); - cardOperation.put("cardId", idCard); - - return cardOperation.toJSONString(); - } - - return ""; - } - - public boolean checkIfUserMustReceiveTheCard(JSONObject cardOperation) { - updateCurrentUserWithPerimeters(); - return checkIfUserMustReceiveTheCard(cardOperation, currentUserWithPerimeters); - } - - public boolean checkIfUserMustBeNotifiedForThisProcessState(String process, String state, CurrentUserWithPerimeters currentUserWithPerimeters) { - Map> processesStatesNotNotified = currentUserWithPerimeters.getProcessesStatesNotNotified(); - return ! ((processesStatesNotNotified != null) && (processesStatesNotNotified.get(process) != null) && - (((List)processesStatesNotNotified.get(process)).contains(state))); - } - - private Map loadUserRightsPerProcessAndState() { - Map userRightsPerProcessAndState = new HashMap<>(); - if (currentUserWithPerimeters.getComputedPerimeters() != null) - currentUserWithPerimeters.getComputedPerimeters() - .forEach(perimeter -> userRightsPerProcessAndState.put(perimeter.getProcess() + "." + perimeter.getState(), perimeter.getRights())); - return userRightsPerProcessAndState; - } - - private boolean isReceiveRightsForProcessAndState(String processId, String stateId, Map userRightsPerProcessAndState) { - final RightsEnum rights = userRightsPerProcessAndState.get(processId + '.' + stateId); - return rights == RightsEnum.RECEIVE || rights == RightsEnum.RECEIVEANDWRITE; - } - - /** Rules for receiving cards : - 1) If the card is sent to user1, the card is received and visible for user1 if he has the receive right for the - corresponding process/state (Receive or ReceiveAndWrite) - 2) If the card is sent to GROUP1 (or ENTITY1), the card is received and visible for user if all of the following is true : - - he's a member of GROUP1 (or ENTITY1) - - he has the receive right for the corresponding process/state (Receive or ReceiveAndWrite) - 3) If the card is sent to ENTITY1 and GROUP1, the card is received and visible for user if all of the following is true : - - he's a member of ENTITY1 (either directly or through one of its children entities) - - he's a member of GROUP1 - - he has the receive right for the corresponding process/state (Receive or ReceiveAndWrite) - **/ - public boolean checkIfUserMustReceiveTheCard(JSONObject cardOperation, CurrentUserWithPerimeters currentUserWithPerimeters) { - Map userRightsPerProcessAndState = loadUserRightsPerProcessAndState(); - - JSONArray groupRecipientsIdsArray = (JSONArray) cardOperation.get("groupRecipientsIds"); - JSONArray entityRecipientsIdsArray = (JSONArray) cardOperation.get("entityRecipientsIds"); - JSONArray userRecipientsIdsArray = (JSONArray) cardOperation.get("userRecipientsIds"); - JSONObject cardObj = (JSONObject) cardOperation.get("card"); - String typeOperation = (cardOperation.get("type") != null) ? (String) cardOperation.get("type") : ""; - String idCard = null; - String process = ""; - String state = ""; - if (cardObj != null) { - idCard = (cardObj.get("id") != null) ? (String) cardObj.get("id") : ""; - process = (String) cardObj.get("process"); - state = (String) cardObj.get("state"); - } - if (!checkIfUserMustBeNotifiedForThisProcessState(process, state, currentUserWithPerimeters)) - return false; + - String processStateKey = process + "." + state; - List userGroups = currentUserWithPerimeters.getUserData().getGroups(); - List userEntities = currentUserWithPerimeters.getUserData().getEntities(); - - log.debug("Check if user {} shall receive card {} for processStateKey {}", userLogin, idCard, processStateKey); - - // First, we check if the user has the right for receiving this card (Receive or ReceiveAndWrite) - if ((!typeOperation.equals(DELETE_OPERATION)) && (!isReceiveRightsForProcessAndState(process, state, userRightsPerProcessAndState))) - return false; - - // Now, we check if the user is member of the group and/or entity (or the recipient himself) - if (checkInCaseOfCardSentToUserDirectly(userRecipientsIdsArray)) { // user only - log.debug("User {} is in user recipients and shall receive card {}", userLogin, idCard); - return true; - } - - if (checkInCaseOfCardSentToGroupOrEntityOrBoth(userGroups, groupRecipientsIdsArray, userEntities, entityRecipientsIdsArray)) { - log.debug("User {} is member of a group or/and entity that shall receive card {}", userLogin, idCard); - return true; - } - return false; - } - - - boolean checkInCaseOfCardSentToUserDirectly(JSONArray userRecipientsIdsArray) { - return (userRecipientsIdsArray != null && !Collections.disjoint(Arrays.asList(userLogin), userRecipientsIdsArray)); - } - - private boolean checkInCaseOfCardSentToGroupOrEntityOrBoth(List userGroups, JSONArray groupRecipientsIdsArray, - List userEntities, JSONArray entityRecipientsIdsArray) { - if ((groupRecipientsIdsArray != null) && (!groupRecipientsIdsArray.isEmpty()) - && (Collections.disjoint(userGroups, groupRecipientsIdsArray))) - return false; - if ((entityRecipientsIdsArray != null) && (!entityRecipientsIdsArray.isEmpty()) - && (Collections.disjoint(userEntities, entityRecipientsIdsArray))) - return false; - return ! ((groupRecipientsIdsArray == null || groupRecipientsIdsArray.isEmpty()) && - (entityRecipientsIdsArray == null || entityRecipientsIdsArray.isEmpty())); - } } diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java index 9623eafb09..fa9f8c5b84 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/services/CardSubscriptionService.java @@ -10,7 +10,7 @@ package org.opfab.cards.consultation.services; import lombok.extern.slf4j.Slf4j; - +import net.minidev.json.parser.JSONParser; import org.opfab.springtools.configuration.oauth.UserServiceCache; import org.opfab.users.model.CurrentUserWithPerimeters; @@ -20,13 +20,15 @@ import java.util.Collection; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; - - +import net.minidev.json.JSONObject; +import net.minidev.json.parser.ParseException; @Service @Slf4j public class CardSubscriptionService { + + private static final String ERROR_MESSAGE_PARSING = "ERROR during received message parsing"; private final long heartbeatDelay; private Map cache = new ConcurrentHashMap<>(); @@ -77,35 +79,22 @@ private void sendHeartbeatMessageInAllSubscriptions() } } - /** - *

Generates a {@link CardSubscription} or retrieve it from a local {@link CardSubscription} cache.

- */ - public CardSubscription subscribe( - CurrentUserWithPerimeters currentUserWithPerimeters, - String clientId) { - String subId = CardSubscription.computeSubscriptionId(currentUserWithPerimeters.getUserData().getLogin(), clientId); - CardSubscription cardSubscription = cache.get(subId); - - if (cardSubscription == null) { - CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder = CardSubscription.builder() - .currentUserWithPerimeters(currentUserWithPerimeters) - .clientId(clientId); - cardSubscription = buildSubscription(subId, cardSubscriptionBuilder); - } - return cardSubscription; - } + + public CardSubscription subscribe( CurrentUserWithPerimeters currentUserWithPerimeters,String clientId) { - private CardSubscription buildSubscription(String subId, CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder) { + String subId = CardSubscription.computeSubscriptionId(currentUserWithPerimeters.getUserData().getLogin(),clientId); + CardSubscription.CardSubscriptionBuilder cardSubscriptionBuilder = CardSubscription.builder() + .currentUserWithPerimeters(currentUserWithPerimeters) + .clientId(clientId); CardSubscription cardSubscription; cardSubscription = cardSubscriptionBuilder.build(); - cardSubscription.initSubscription( () -> evictSubscription(subId)); + cardSubscription.initSubscription(() -> evictSubscription(subId)); cache.put(subId, cardSubscription); - log.info("Subscription created with id {} for user {} ", cardSubscription.getId(),cardSubscription.getUserLogin()); + log.info("Subscription created with id {} for user {} ", cardSubscription.getId(), cardSubscription.getUserLogin()); cardSubscription.userServiceCache = this.userServiceCache; return cardSubscription; } - public void evictSubscription(String subId) { CardSubscription sub = cache.get(subId); @@ -138,13 +127,14 @@ public void clearSubscriptions() { this.cache.clear(); } - public Collection getSubscriptions() - { + public Collection getSubscriptions() { return cache.values(); } public void onMessage(String queueName, String message) { + + log.debug("receive from rabbit queue {} message {}",queueName,message); switch (queueName) { case "process": cache.values().forEach(subscription -> subscription.publishDataIntoSubscription(message)); @@ -156,10 +146,34 @@ public void onMessage(String queueName, String message) { }); break; case "card": - cache.values().forEach(subscription -> subscription.processNewCard(message)); + cache.values().forEach(subscription -> processNewCard(message,subscription)); break; default: log.info("unrecognized queue {}" , queueName); } } + + private void processNewCard(String cardAsString, CardSubscription subscription) { + JSONObject card; + try { + card = (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(cardAsString); + } catch (ParseException e) { + log.error(ERROR_MESSAGE_PARSING, e); + return; + } + + if (CardRoutingUtilities.checkIfUserMustReceiveTheCard(card, + subscription.getCurrentUserWithPerimeters())) { + subscription.publishDataIntoSubscription(cardAsString); + } + // In case of ADD or UPDATE, we send a delete card operation (to delete the card + // from the feed, more information in OC-297) + else { + String deleteMessage = CardRoutingUtilities.createDeleteCardMessageForUserNotRecipient(card, + subscription.getUserLogin()); + if (!deleteMessage.isEmpty()) + subscription.publishDataIntoSubscription(deleteMessage); + } + } + } diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java new file mode 100644 index 0000000000..472729d666 --- /dev/null +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java @@ -0,0 +1,179 @@ +/* Copyright (c) 2018-2021, RTE (http://www.rte-france.com) + * See AUTHORS.txt + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + * This file is part of the OperatorFabric project. + */ + +package org.opfab.cards.consultation.services; + +import lombok.extern.slf4j.Slf4j; +import org.assertj.core.api.Assertions; +import net.minidev.json.JSONObject; +import net.minidev.json.parser.JSONParser; +import net.minidev.json.parser.ParseException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.opfab.cards.consultation.application.IntegrationTestApplication; +import org.opfab.users.model.ComputedPerimeter; +import org.opfab.users.model.CurrentUserWithPerimeters; +import org.opfab.users.model.RightsEnum; +import org.opfab.users.model.User; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + + + +@ExtendWith(SpringExtension.class) +@SpringBootTest(classes = {IntegrationTestApplication.class}) +@Slf4j +@ActiveProfiles("test") +public class CardRoutingUtilitiesShould { + + + private CurrentUserWithPerimeters currentUserWithPerimeters; + private String processStateInPerimeter = "\"card\":{\"process\":\"Process1\", \"state\":\"State1\"}"; + private String processStateNotInPerimeter = "\"card\":{\"process\":\"Process1\", \"state\":\"State2\"}"; + + public CardRoutingUtilitiesShould(){ + User user = new User(); + user.setLogin("testuser"); + user.setFirstName("Test"); + user.setLastName("User"); + + List groups = new ArrayList<>(); + groups.add("testgroup1"); + groups.add("testgroup2"); + user.setGroups(groups); + + List entities = new ArrayList<>(); + entities.add("testentity1"); + entities.add("testentity2"); + user.setEntities(entities); + + ComputedPerimeter perimeter = new ComputedPerimeter(); + perimeter.setProcess("Process1"); + perimeter.setState("State1"); + perimeter.setRights(RightsEnum.RECEIVE); + + currentUserWithPerimeters = new CurrentUserWithPerimeters(); + currentUserWithPerimeters.setUserData(user); + currentUserWithPerimeters.setComputedPerimeters(Arrays.asList(perimeter)); + } + + + private JSONObject createJSONObjectFromString(String jsonString) + { + try + { + return (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(jsonString); + } + catch(ParseException e){ log.error("Error parsing", e); return null;} + } + + @Test + void checkIfUserMustReceiveTheCardUsingGroupsOnly() { + + JSONObject messageBodyWithGroupOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"]}"); //true + JSONObject messageBodyWithGroupOfTheUserButStateNotInPerimeter = createJSONObjectFromString("{" + processStateNotInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"]}"); //true + JSONObject messageBodyWithNoGroupOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"]}"); //false + JSONObject messageBodyWithGroupOfTheUserAndEmptyEntitiesList = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[]}"); //true + JSONObject messageBodyWithNoGroupOfTheUserAndEmptyEntitiessList = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[]}"); //false + + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithGroupOfTheUser, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithGroupOfTheUserButStateNotInPerimeter, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoGroupOfTheUser, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithGroupOfTheUserAndEmptyEntitiesList, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoGroupOfTheUserAndEmptyEntitiessList, currentUserWithPerimeters)).isFalse(); + + } + + @Test + void checkIfUserMustReceiveTheCardUsingEntitiesOnly() { + + + JSONObject messageBodyWithEntityOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); + JSONObject messageBodyWithEntityOfTheUserButStateNotInPerimeter = createJSONObjectFromString("{" + processStateNotInPerimeter + ", \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); + JSONObject messageBodyWithNoEntityOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); + JSONObject messageBodyWithEntityOfTheUserAndEmptyGroupsList = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); + JSONObject messageBodyWithNoEntityOfTheUserAndEmptyGroupsList = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); + + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityOfTheUser, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityOfTheUserButStateNotInPerimeter, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoEntityOfTheUser, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityOfTheUserAndEmptyGroupsList, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoEntityOfTheUserAndEmptyGroupsList, currentUserWithPerimeters)).isFalse(); + + } + + + @Test + void checkIfUserMustReceiveTheCardUsingGroupsAndEntities() { + + JSONObject messageBodyWithEntityAndGroupOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //true + JSONObject messageBodyWithEntityAndGroupOfTheUser2 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup2\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity2\", \"testentity4\"]}"); //true + JSONObject messageBodyWithEntityAndGroupOfTheUserButStateNotInPerimeter = createJSONObjectFromString("{" + processStateNotInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //true + JSONObject messageBodyWithGroupOfTheUserButNotEntity = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false (in group but not in entity) + JSONObject messageBodyWithEntityOfTheUserButNotGroup = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //false (in entity but not in group) + JSONObject messageBodyWithNoGroupAndNoEntityOfTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false (not in group and not in entity) + + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityAndGroupOfTheUser, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityAndGroupOfTheUser2, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityAndGroupOfTheUserButStateNotInPerimeter, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithGroupOfTheUserButNotEntity, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEntityOfTheUserButNotGroup, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoGroupAndNoEntityOfTheUser,currentUserWithPerimeters)).isFalse(); + + } + + @Test + void checkIfUserMustReceiveTheCardUsingNoGroupsAndNoEntities() { + + JSONObject messageBodyWithEmptyRecipientAndGroup = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[]}"); //false + JSONObject messageBodyWithNoRecipients = createJSONObjectFromString("{" + processStateInPerimeter + "}"); //false + + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithEmptyRecipientAndGroup, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithNoRecipients, currentUserWithPerimeters)).isFalse(); + + } + + + @Test + void checkIfUserMustReceiveTheCardUsingUserOnly() { + + JSONObject messageBodyWithTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"testuser\", \"noexistantuser2\"]}"); + JSONObject messageBodyWithTheUserAndEntity = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"testuser\", \"noexistantuser2\"],\"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); + JSONObject messageBodyWithTheUserAndGroup = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"testuser\", \"noexistantuser2\"], \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"]}"); + JSONObject messageBodyWithTheUserButStateNotInPerimeter = createJSONObjectFromString("{" + processStateNotInPerimeter + ",\"userRecipientsIds\":[\"testuser\", \"noexistantuser2\"]}"); + JSONObject messageBodyWithoutTheUser = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"noexistantuser1\", \"noexistantuser2\"]}"); + + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithTheUser, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithTheUserAndEntity, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithTheUserAndGroup, currentUserWithPerimeters)).isTrue(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithTheUserButStateNotInPerimeter, currentUserWithPerimeters)).isFalse(); + Assertions.assertThat(CardRoutingUtilities.checkIfUserMustReceiveTheCard(messageBodyWithoutTheUser, currentUserWithPerimeters)).isFalse(); + } + + + @Test + void testCreateDeleteCardMessageForUserNotRecipient(){ + JSONObject cardAdd = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"ADD\"}"); + JSONObject cardAddWantedOutput = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\",\"cardId\":\"api_test_process5b\"}"); + JSONObject cardAddOutput = createJSONObjectFromString(CardRoutingUtilities.createDeleteCardMessageForUserNotRecipient(cardAdd,"test")); + Assertions.assertThat(cardAddOutput).isEqualTo(cardAddWantedOutput); + + String messageBodyDelete = "{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5d\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\"}"; + JSONObject inputDelete = createJSONObjectFromString(messageBodyDelete); + String outputDelete = CardRoutingUtilities.createDeleteCardMessageForUserNotRecipient(inputDelete,"test"); + Assertions.assertThat(outputDelete).isEmpty(); + } +} diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java deleted file mode 100644 index 87b5ec3ccd..0000000000 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardSubscriptionServiceShould.java +++ /dev/null @@ -1,168 +0,0 @@ -/* Copyright (c) 2018-2021, RTE (http://www.rte-france.com) - * See AUTHORS.txt - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * SPDX-License-Identifier: MPL-2.0 - * This file is part of the OperatorFabric project. - */ - -package org.opfab.cards.consultation.services; - -import lombok.extern.slf4j.Slf4j; -import org.assertj.core.api.Assertions; -import net.minidev.json.JSONObject; -import net.minidev.json.parser.JSONParser; -import net.minidev.json.parser.ParseException; - -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.opfab.cards.consultation.application.IntegrationTestApplication; -import org.opfab.springtools.configuration.test.UserServiceCacheTestApplication; -import org.opfab.users.model.ComputedPerimeter; -import org.opfab.users.model.CurrentUserWithPerimeters; -import org.opfab.users.model.RightsEnum; -import org.opfab.users.model.User; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; - - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - - - -/** - *

- * Created on 29/10/18 - * - */ -@ExtendWith(SpringExtension.class) -@SpringBootTest(classes = {IntegrationTestApplication.class,CardSubscriptionService.class, - UserServiceCacheTestApplication.class}) -@Slf4j -@ActiveProfiles("test") -@Tag("end-to-end") -@Tag("amqp") -public class CardSubscriptionServiceShould { - - private static String TEST_ID = "testClient"; - - - @Autowired - private CardSubscriptionService service; - - private CurrentUserWithPerimeters currentUserWithPerimeters; - - public CardSubscriptionServiceShould(){ - User user = new User(); - user.setLogin("testuser"); - user.setFirstName("Test"); - user.setLastName("User"); - - List groups = new ArrayList<>(); - groups.add("testgroup1"); - groups.add("testgroup2"); - user.setGroups(groups); - - List entities = new ArrayList<>(); - entities.add("testentity1"); - entities.add("testentity2"); - user.setEntities(entities); - - ComputedPerimeter perimeter = new ComputedPerimeter(); - perimeter.setProcess("Process1"); - perimeter.setState("State1"); - perimeter.setRights(RightsEnum.RECEIVE); - - currentUserWithPerimeters = new CurrentUserWithPerimeters(); - currentUserWithPerimeters.setUserData(user); - currentUserWithPerimeters.setComputedPerimeters(Arrays.asList(perimeter)); - } - - - private JSONObject createJSONObjectFromString(String jsonString) - { - try - { - return (JSONObject) (new JSONParser(JSONParser.MODE_PERMISSIVE)).parse(jsonString); - } - catch(ParseException e){ log.error("Error parsing", e); return null;} - } - - @Test - void testCheckIfUserMustReceiveTheCard() { - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - - //groups only - String processStateInPerimeter = "\"card\":{\"process\":\"Process1\", \"state\":\"State1\"}"; - JSONObject messageBody1 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"]}"); //true - JSONObject messageBody2 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"]}"); //false - JSONObject messageBody3 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[]}"); //true - JSONObject messageBody4 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[]}"); //false - - //entities only - JSONObject messageBody5 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //true - JSONObject messageBody6 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false - JSONObject messageBody7 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //true - JSONObject messageBody8 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false - - //groups and entities - JSONObject messageBody9 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //true - JSONObject messageBody10 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup2\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity2\", \"testentity4\"]}"); //true - JSONObject messageBody11 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup1\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false (in group but not in entity) - JSONObject messageBody12 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity1\", \"testentity4\"]}"); //false (in entity but not in group) - JSONObject messageBody13 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[\"testgroup3\", \"testgroup4\"], \"entityRecipientsIds\":[\"testentity3\", \"testentity4\"]}"); //false (not in group and not in entity) - - //no groups and no entities - JSONObject messageBody14 = createJSONObjectFromString("{" + processStateInPerimeter + ", \"groupRecipientsIds\":[], \"entityRecipientsIds\":[]}"); //false - JSONObject messageBody15 = createJSONObjectFromString("{" + processStateInPerimeter + "}"); //false - - // users only - JSONObject messageBody16 = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"testuser\", \"noexistantuser2\"]}"); //true - JSONObject messageBody17 = createJSONObjectFromString("{" + processStateInPerimeter + ",\"userRecipientsIds\":[\"noexistantuser1\", \"noexistantuser2\"]}"); //false - - - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody1, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody2, currentUserWithPerimeters)).isFalse(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody3, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody4, currentUserWithPerimeters)).isFalse(); - - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody5, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody6, currentUserWithPerimeters)).isFalse(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody7, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody8, currentUserWithPerimeters)).isFalse(); - - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody9, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody10, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody11, currentUserWithPerimeters)).isFalse(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody12, currentUserWithPerimeters)).isFalse(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody13,currentUserWithPerimeters)).isFalse(); - - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody14, currentUserWithPerimeters)).isFalse(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody15, currentUserWithPerimeters)).isFalse(); - - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody16, currentUserWithPerimeters)).isTrue(); - Assertions.assertThat(subscription.checkIfUserMustReceiveTheCard(messageBody17, currentUserWithPerimeters)).isFalse(); - } - - - @Test - void testCreateDeleteCardMessageForUserNotRecipient(){ - CardSubscription subscription = service.subscribe(currentUserWithPerimeters, TEST_ID); - - JSONObject cardAdd = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"ADD\"}"); - JSONObject cardAddWantedOutput = createJSONObjectFromString("{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5b\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\",\"cardId\":\"api_test_process5b\"}"); - JSONObject cardAddOutput = createJSONObjectFromString(subscription.createDeleteCardMessageForUserNotRecipient(cardAdd)); - Assertions.assertThat(cardAddOutput).isEqualTo(cardAddWantedOutput); - - String messageBodyDelete = "{\"card\":{\"severity\":\"ALARM\",\"summary\":{\"parameters\":{},\"key\":\"defaultProcess.summary\"},\"process\":\"defaultProcess\",\"publishDate\":1592389043000,\"title\":{\"parameters\":{},\"key\":\"defaultProcess.title\"},\"uid\":\"db914230-a5aa-42f2-aa29-f5348700fa55\",\"publisherVersion\":\"1\",\"processInstanceId\":\"process5b\",\"publisher\":\"api_test\",\"id\":\"api_test_process5d\",\"state\":\"messageState\",\"startDate\":1592396243446},\"publishDate\":1592389043000,\"groupRecipientsIds\":[\"Dispatcher\"],\"type\":\"DELETE\"}"; - JSONObject inputDelete = createJSONObjectFromString(messageBodyDelete); - String outputDelete = subscription.createDeleteCardMessageForUserNotRecipient(inputDelete); - Assertions.assertThat(outputDelete).isEmpty(); - } -} From b8aed6e69614449d404618f56ccbd5df75c33e63 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Tue, 28 Dec 2021 21:43:23 +0100 Subject: [PATCH 50/73] Close subscription when logout (#2293) Signed-off-by: freddidierRTE --- ui/main/src/app/services/card.service.ts | 7 ++++++- ui/main/src/app/store/effects/authentication.effects.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/main/src/app/services/card.service.ts b/ui/main/src/app/services/card.service.ts index ff0a7dfcb4..1326893e29 100644 --- a/ui/main/src/app/services/card.service.ts +++ b/ui/main/src/app/services/card.service.ts @@ -22,7 +22,7 @@ import {Page} from '@ofModel/page.model'; import {AppState} from '@ofStore/index'; import {Store} from '@ngrx/store'; import {CardSubscriptionClosed, CardSubscriptionOpen} from '@ofActions/cards-subscription.actions'; -import {catchError} from 'rxjs/operators'; +import {catchError, takeUntil} from 'rxjs/operators'; import {RemoveLightCard} from '@ofActions/light-card.actions'; import {BusinessConfigChangeAction} from '@ofStore/actions/processes.actions'; import {UserConfigChangeAction} from '@ofStore/actions/user.actions'; @@ -46,6 +46,7 @@ export class CardService { private lastHeardBeatDate: number; private firstSubscriptionInitDone = false; public initSubscription = new Subject(); + private unsubscribe$: Subject = new Subject(); private startOfAlreadyLoadedPeriod: number; private endOfAlreadyLoadedPeriod: number; @@ -81,6 +82,7 @@ export class CardService { public initCardSubscription() { this.getCardSubscription() + .pipe(takeUntil(this.unsubscribe$)) .subscribe( { next: operation => { switch (operation.type) { @@ -121,6 +123,9 @@ export class CardService { } + public closeSubscription() { + this.unsubscribe$.next(); + } private getCardSubscription(): Observable { // security header needed here as SSE request are not intercepted by our header interceptor diff --git a/ui/main/src/app/store/effects/authentication.effects.ts b/ui/main/src/app/store/effects/authentication.effects.ts index 385a617dd4..af81a216e6 100644 --- a/ui/main/src/app/store/effects/authentication.effects.ts +++ b/ui/main/src/app/store/effects/authentication.effects.ts @@ -275,6 +275,7 @@ export class AuthenticationEffects { private resetState() { this.authService.clearAuthenticationInformation(); + this.cardService.closeSubscription(); window.location.href = this.configService.getConfigValue('security.logout-url','https://opfab.github.io'); } From 5d1ca5a5c0527d508dd524a7ab07e0624473a5e4 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Tue, 28 Dec 2021 21:59:19 +0100 Subject: [PATCH 51/73] Reload process and user config after connection loss (#2293) Signed-off-by: freddidierRTE --- ui/main/src/app/services/card.service.ts | 14 ++++++++++---- ui/main/src/app/store/effects/processes.effects.ts | 2 +- ui/main/src/app/store/effects/user.effects.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/main/src/app/services/card.service.ts b/ui/main/src/app/services/card.service.ts index 1326893e29..445766a8a6 100644 --- a/ui/main/src/app/services/card.service.ts +++ b/ui/main/src/app/services/card.service.ts @@ -150,7 +150,13 @@ export class CardService { console.log(new Date().toISOString(), `CardService - Card subscription initialized`); this.initSubscription.next(); this.initSubscription.complete(); - if (this.firstSubscriptionInitDone) this.recoverAnyLostCardWhenConnectionHasBeenReset(); + if (this.firstSubscriptionInitDone) { + this.recoverAnyLostCardWhenConnectionHasBeenReset(); + // process or user config may have change during connection loss + // so reload both configuration + this.store.dispatch(new BusinessConfigChangeAction()); + this.store.dispatch(new UserConfigChangeAction()); + } else this.firstSubscriptionInitDone = true; break; case 'HEARTBEAT': @@ -162,9 +168,9 @@ export class CardService { console.log(new Date().toISOString(), `CardService - BUSINESS_CONFIG_CHANGE received`); break; case 'USER_CONFIG_CHANGE': - this.store.dispatch(new UserConfigChangeAction()); - console.log(new Date().toISOString(), `CardService - USER_CONFIG_CHANGE received`); - break; + this.store.dispatch(new UserConfigChangeAction()); + console.log(new Date().toISOString(), `CardService - USER_CONFIG_CHANGE received`); + break; default : return observer.next(JSON.parse(message.data, CardOperation.convertTypeIntoEnum)); } diff --git a/ui/main/src/app/store/effects/processes.effects.ts b/ui/main/src/app/store/effects/processes.effects.ts index e92af30b89..5c85822417 100644 --- a/ui/main/src/app/store/effects/processes.effects.ts +++ b/ui/main/src/app/store/effects/processes.effects.ts @@ -25,7 +25,7 @@ export class ProcessesEffects { updateBusinessConfig: Observable = createEffect(() => this.actions$ .pipe( ofType(ProcessesActionTypes.BusinessConfigChange), - debounce(() => timer(10000)), + debounce(() => timer(5000 + Math.floor(Math.random() * 5000))), // use a random part to avoid all UI to access at the same time the server map(() => { this.templateService.clearCache(); this.service.loadAllProcesses().subscribe(); diff --git a/ui/main/src/app/store/effects/user.effects.ts b/ui/main/src/app/store/effects/user.effects.ts index d7546aa826..bf54ca8230 100644 --- a/ui/main/src/app/store/effects/user.effects.ts +++ b/ui/main/src/app/store/effects/user.effects.ts @@ -73,7 +73,7 @@ export class UserEffects { updateUserConfig: Observable = createEffect(() => this.actions$ .pipe( ofType(UserActionsTypes.UserConfigChange), - debounce(() => timer(10000)), + debounce(() => timer(5000 + Math.floor(Math.random() * 5000))), // use a random part to avoid all UI to access at the same time the server map(() => { this.userService.loadUserWithPerimetersData().subscribe(); }), From ad463a899765783d10b96ea84d83ce5691491ffb Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 30 Dec 2021 17:20:09 +0100 Subject: [PATCH 52/73] Adapt RealtimeUser cypress test to new subscription mechanism (#2293) Signed-off-by: freddidierRTE --- .../cypress/cypress/integration/1-RealTimeUsers.spec.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/test/cypress/cypress/integration/1-RealTimeUsers.spec.js b/src/test/cypress/cypress/integration/1-RealTimeUsers.spec.js index 48165251db..510caefd5b 100644 --- a/src/test/cypress/cypress/integration/1-RealTimeUsers.spec.js +++ b/src/test/cypress/cypress/integration/1-RealTimeUsers.spec.js @@ -17,12 +17,8 @@ describe ('RealTimeUsersPage',()=>{ }) - it('Connection of operator1', ()=>{ - cy.loginOpFab('operator1','test'); - }) - - it('Connection of admin and check of Real time users screen', ()=> { - cy.loginOpFab('admin', 'test'); + it('Connection of operator3 and check of Real time users screen', ()=> { + cy.loginOpFab('operator3', 'test'); //click on user menu (top right of the screen) cy.get('#opfab-navbar-drop_user_menu').click(); From f8d3d4734c9a9c62db4002ec716ffab4198eda44 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 30 Dec 2021 18:10:50 +0100 Subject: [PATCH 53/73] Deal with connection loss before first heartbeat (#2293) Signed-off-by: freddidierRTE --- ui/main/src/app/services/card.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/main/src/app/services/card.service.ts b/ui/main/src/app/services/card.service.ts index 445766a8a6..132b09c172 100644 --- a/ui/main/src/app/services/card.service.ts +++ b/ui/main/src/app/services/card.service.ts @@ -43,7 +43,7 @@ export class CardService { readonly cardsPubUrl: string; readonly userCardReadUrl: string; readonly userCardUrl: string; - private lastHeardBeatDate: number; + private lastHeardBeatDate: number = 0; private firstSubscriptionInitDone = false; public initSubscription = new Subject(); private unsubscribe$: Subject = new Subject(); @@ -157,7 +157,10 @@ export class CardService { this.store.dispatch(new BusinessConfigChangeAction()); this.store.dispatch(new UserConfigChangeAction()); } - else this.firstSubscriptionInitDone = true; + else { + this.firstSubscriptionInitDone = true; + this.lastHeardBeatDate = new Date().valueOf(); + } break; case 'HEARTBEAT': this.lastHeardBeatDate = new Date().valueOf(); From 7d481fc75de7655a642f6e259cc4bb505ac85e39 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 30 Dec 2021 18:27:05 +0100 Subject: [PATCH 54/73] Add publish date value in return object when updating subscription (#2293) Signed-off-by: freddidierRTE --- .../consultation/controllers/CardOperationsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java index ac49b4cac8..e90daa0b0a 100644 --- a/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java +++ b/services/cards-consultation/src/main/java/org/opfab/cards/consultation/controllers/CardOperationsController.java @@ -90,7 +90,7 @@ public Mono updateSubscriptionAndPublish(Mono Date: Thu, 30 Dec 2021 20:14:55 +0100 Subject: [PATCH 55/73] Add resilience test for subscription mechanism (#2293) Signed-off-by: freddidierRTE --- config/dev/docker-compose.yml | 3 +- config/docker/docker-compose.yml | 14 +++ .../cypress/integration/Resilience.specs.js | 108 ++++++++++++++++++ src/test/cypress/cypress/support/commands.js | 4 + ui/main/src/app/app.component.html | 2 +- 5 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 src/test/cypress/cypress/integration/Resilience.specs.js diff --git a/config/dev/docker-compose.yml b/config/dev/docker-compose.yml index a36ba3b3a9..6e47ccd471 100755 --- a/config/dev/docker-compose.yml +++ b/config/dev/docker-compose.yml @@ -1,6 +1,7 @@ version: '2.1' services: rabbitmq: + container_name: rabbit image: rabbitmq:3-management ports: - "5672:5672" @@ -26,8 +27,8 @@ services: - "89:8080" - "90:9990" web-ui: + container_name: web-ui image: "lfeoperatorfabric/of-web-ui:SNAPSHOT" - #user: ${USER_ID}:${USER_GID} ports: - "2002:80" volumes: diff --git a/config/docker/docker-compose.yml b/config/docker/docker-compose.yml index f55e93d8c1..97e26aa300 100755 --- a/config/docker/docker-compose.yml +++ b/config/docker/docker-compose.yml @@ -8,7 +8,20 @@ services: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: password rabbitmq: + container_name: rabbit image: rabbitmq:3-management + hostname: rabbit + +# If you want to persist queue after rabbit docker container removal mount +# a volume to store persistance data +# WARNING : On Kubernetes , as pod are destroy , it is +# necessary to persist queue . Otherwise in case of restart of +# the rabbit pod, the connection with cards-consultation will be lost +# +# volumes: +# - "./rabbit-persistance:/var/lib/rabbitmq/mnesia/" + + # Port closed for security reasons , be careful if you open it # ports: # - "5672:5672" @@ -99,6 +112,7 @@ services: - "./cards-consultation-docker.yml:/config/application-docker.yml" - ${CONFIG_PATH}:/external-config web-ui: + container_name: web-ui image: "lfeoperatorfabric/of-web-ui:SNAPSHOT" ports: - "2002:80" diff --git a/src/test/cypress/cypress/integration/Resilience.specs.js b/src/test/cypress/cypress/integration/Resilience.specs.js new file mode 100644 index 0000000000..e3cc7b10f8 --- /dev/null +++ b/src/test/cypress/cypress/integration/Resilience.specs.js @@ -0,0 +1,108 @@ +/* Copyright (c) 2021, RTE (http://www.rte-france.com) + * See AUTHORS.txt + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + * This file is part of the OperatorFabric project. + */ + + + +describe ('Resilience tests',function () { + + before('Set up configuration', function () { + + // This can stay in a `before` block rather than `beforeEach` as long as the test does not change configuration + cy.resetUIConfigurationFiles(); + + cy.loadTestConf(); + + // Clean up existing cards + cy.deleteAllCards(); + + }); + + + + it('Check card reception after nginx restart ', function () { + + cy.loginOpFab('operator1','test'); + + cy.get('of-light-card').should('have.length',0); + + // Stop nginx + cy.exec('docker stop web-ui'); + + cy.wait(15000); + + // Check loading spinner is present + cy.get('#opfab-connecting-spinner'); + + // Start Nginx + cy.exec('docker start web-ui'); + + // Wait for subscription to be fully restored + cy.wait(20000); + + cy.send6TestCards(); + cy.get('of-light-card').should('have.length',6); + + // Check loading spinner is not present anymore + cy.get('#opfab-connecting-spinner').should('not.exist'); + + + }); + + it('Check card reception after rabbit restart ', function () { + + cy.loginOpFab('operator1','test'); + + cy.delete6TestCards(); + cy.get('of-light-card').should('have.length',0); + + // Restart rabbitMQ + cy.exec('docker restart rabbit'); + + cy.wait(10000); // Wait for rabbitMQ to be fully up + + cy.send6TestCards(); + cy.get('of-light-card').should('have.length',6); + + }); + + // the following test will only be relevant if using docker mode + // in dev mode it will execute but the cards-consultation services will not be restart + it('Check card reception when cards-consultation is restarted ', function () { + + cy.loginOpFab('operator1', 'test'); + + cy.delete6TestCards(); + cy.get('of-light-card').should('have.length', 0); + + // wait for subscription to be fully working + cy.wait(5000); + + cy.exec('docker stop cards-consultation',{failOnNonZeroExit: false}).then((result) => { + // only if docker stop works, so it will not be executed in dev mode + if (result.code === 0) { + + // Send 6 cards when cards-consultation servcie is down + cy.send6TestCards(); + + cy.exec('docker start cards-consultation'); + + cy.waitForOpfabToStart(); + + // wait for subscription to be fully restored + cy.wait(20000); + + cy.get('of-light-card').should('have.length', 6); + } + }) + + + }); + + +}) diff --git a/src/test/cypress/cypress/support/commands.js b/src/test/cypress/cypress/support/commands.js index 2f4aa1e870..b4ede73e69 100644 --- a/src/test/cypress/cypress/support/commands.js +++ b/src/test/cypress/cypress/support/commands.js @@ -123,4 +123,8 @@ Cypress.Commands.add('deleteAllCards', () => { Cypress.Commands.add('deleteAllSettings', () => { cy.exec('cd .. && ./resources/deleteAllSettings.sh '+Cypress.env('host')); +}) + +Cypress.Commands.add('waitForOpfabToStart', () => { + cy.exec('cd ../../.. && ./bin/waitForOpfabToStart.sh '); }) \ No newline at end of file diff --git a/ui/main/src/app/app.component.html b/ui/main/src/app/app.component.html index 8b3c0ffa52..8ebe4bd3f2 100644 --- a/ui/main/src/app/app.component.html +++ b/ui/main/src/app/app.component.html @@ -23,7 +23,7 @@
-
+
From 8ccee61f14032ad854453d1c73557a89e0ab1a12 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Mon, 3 Jan 2022 15:47:16 +0100 Subject: [PATCH 56/73] Resolve sonar issue (#2293) Signed-off-by: freddidierRTE --- .../consultation/services/CardRoutingUtilitiesShould.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java index 472729d666..e2fad9cd5b 100644 --- a/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java +++ b/services/cards-consultation/src/test/java/org/opfab/cards/consultation/services/CardRoutingUtilitiesShould.java @@ -37,8 +37,7 @@ @SpringBootTest(classes = {IntegrationTestApplication.class}) @Slf4j @ActiveProfiles("test") -public class CardRoutingUtilitiesShould { - +class CardRoutingUtilitiesShould { private CurrentUserWithPerimeters currentUserWithPerimeters; private String processStateInPerimeter = "\"card\":{\"process\":\"Process1\", \"state\":\"State1\"}"; From efd8c9aa12d43976a03b5840aba3cc1644c3525b Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 4 Jan 2022 23:11:03 +0000 Subject: [PATCH 57/73] Update dependency @types/node to v16.11.19 Signed-off-by: Renovate Bot --- ui/main/package-lock.json | 6 +++--- ui/main/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/main/package-lock.json b/ui/main/package-lock.json index 5e188877fb..296c0cf021 100644 --- a/ui/main/package-lock.json +++ b/ui/main/package-lock.json @@ -2280,9 +2280,9 @@ } }, "@types/node": { - "version": "16.11.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.18.tgz", - "integrity": "sha512-7N8AOYWWYuw0g+K+GKCmIwfU1VMHcexYNpLPYzFZ4Uq2W6C/ptfeC7XhXgy/4pcwhz/9KoS5yijMfnYQ0u0Udw==", + "version": "16.11.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.19.tgz", + "integrity": "sha512-BPAcfDPoHlRQNKktbsbnpACGdypPFBuX4xQlsWDE7B8XXcfII+SpOLay3/qZmCLb39kV5S1RTYwXdkx2lwLYng==", "dev": true }, "@types/parse-json": { diff --git a/ui/main/package.json b/ui/main/package.json index 96d82b0c8e..0f69d16545 100755 --- a/ui/main/package.json +++ b/ui/main/package.json @@ -74,7 +74,7 @@ "@types/jasminewd2": "2.0.10", "@types/lodash": "4.14.178", "@types/moment": "2.13.0", - "@types/node": "16.11.18", + "@types/node": "16.11.19", "codelyzer": "6.0.2", "jasmine-core": "4.0.0", "jasmine-marbles": "0.8.4", From e934e3f1c655eb6abf84b3aacba52d4b51987c06 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Sun, 2 Jan 2022 23:19:21 +0100 Subject: [PATCH 58/73] Avoid ExpressionChangedAfterItHasBeenCheckedError (#2335) Signed-off-by: freddidierRTE --- ui/main/src/app/modules/monitoring/monitoring.component.ts | 3 ++- .../app/services/lightcards/lightcards-feed-filter.service.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/main/src/app/modules/monitoring/monitoring.component.ts b/ui/main/src/app/modules/monitoring/monitoring.component.ts index 0f97b98538..919c3cfcf8 100644 --- a/ui/main/src/app/modules/monitoring/monitoring.component.ts +++ b/ui/main/src/app/modules/monitoring/monitoring.component.ts @@ -10,7 +10,7 @@ import {Component, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {combineLatest, Observable, of, Subject} from 'rxjs'; import {LineOfMonitoringResult} from '@ofModel/line-of-monitoring-result.model'; -import {catchError, filter, map, takeUntil} from 'rxjs/operators'; +import {catchError, debounceTime, filter, map, takeUntil} from 'rxjs/operators'; import {LightCard} from '@ofModel/light-card.model'; import * as moment from 'moment'; import {I18n} from '@ofModel/i18n.model'; @@ -76,6 +76,7 @@ export class MonitoringComponent implements OnInit, OnDestroy { this.lightCardsStoreService.getLightCards() ] ).pipe( + debounceTime(0), // Add this to avoid ExpressionChangedAfterItHasBeenCheckedError so it waits for component init before processing takeUntil(this.unsubscribe$), // the filters are set by the monitoring filter and by the time line // so it generates two events , we need to wait until every filter is set diff --git a/ui/main/src/app/services/lightcards/lightcards-feed-filter.service.ts b/ui/main/src/app/services/lightcards/lightcards-feed-filter.service.ts index 749360f282..3ae990c844 100644 --- a/ui/main/src/app/services/lightcards/lightcards-feed-filter.service.ts +++ b/ui/main/src/app/services/lightcards/lightcards-feed-filter.service.ts @@ -9,7 +9,7 @@ import {Injectable} from '@angular/core'; -import {map} from 'rxjs/operators'; +import {debounceTime, map} from 'rxjs/operators'; import {combineLatest, Observable, Subject, } from 'rxjs'; import {LightCard} from '@ofModel/light-card.model'; import {LightCardsStoreService} from './lightcards-store.service'; @@ -65,6 +65,8 @@ export class LightCardsFeedFilterService { this.onlyBusinessFilterForTimeLine.asObservable(), ] ).pipe( + debounceTime(50), // When resetting components it can happen that we have more than one filter change + // with debounceTime, we avoid processing intermediate states map(results => { const lightCards = results[1]; const onlyBusinessFitlerForTimeLine = results[2]; From 79c116d6357b45427a8e41907d41ac1e8baadb2e Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Tue, 21 Dec 2021 15:39:27 +0100 Subject: [PATCH 59/73] Hide "Play sound on external device" setting if none is configured for the user (#2273) Signed-off-by: vlo-rte --- .../src/app/model/external-devices.model.ts | 7 +++++++ .../settings/settings.component.html | 2 +- .../components/settings/settings.component.ts | 19 ++++++++++++++++++- .../app/services/external-devices.service.ts | 8 +++++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/ui/main/src/app/model/external-devices.model.ts b/ui/main/src/app/model/external-devices.model.ts index 74de1866d3..e7e4289d1a 100644 --- a/ui/main/src/app/model/external-devices.model.ts +++ b/ui/main/src/app/model/external-devices.model.ts @@ -12,4 +12,11 @@ export class Notification { constructor( readonly opfabSignalId: string) { } +} + +export class UserConfiguration { + public constructor( + readonly userLogin: string, + readonly externalDeviceId: string + ) {} } \ No newline at end of file diff --git a/ui/main/src/app/modules/settings/components/settings/settings.component.html b/ui/main/src/app/modules/settings/components/settings/settings.component.html index 7f9ed89a48..e54aa12798 100644 --- a/ui/main/src/app/modules/settings/components/settings/settings.component.html +++ b/ui/main/src/app/modules/settings/components/settings/settings.component.html @@ -21,7 +21,7 @@
- +
diff --git a/ui/main/src/app/modules/settings/components/settings/settings.component.ts b/ui/main/src/app/modules/settings/components/settings/settings.component.ts index 80646dca00..139c7b32d9 100644 --- a/ui/main/src/app/modules/settings/components/settings/settings.component.ts +++ b/ui/main/src/app/modules/settings/components/settings/settings.component.ts @@ -13,6 +13,9 @@ import {Component, OnInit} from '@angular/core'; import {Store} from '@ngrx/store'; import {AppState} from '@ofStore/index'; import {ConfigService} from '@ofServices/config.service'; +import {ExternalDevicesService} from "@ofServices/external-devices.service"; +import {UserService} from "@ofServices/user.service"; +import {UserConfiguration} from "@ofModel/external-devices.model"; @Component({ selector: 'of-settings', @@ -31,7 +34,12 @@ export class SettingsComponent implements OnInit { replayEnabledDefaultValue: boolean; replayIntervalDefaultValue: number; - constructor(private store: Store,private configService: ConfigService) { } + userConfiguration: UserConfiguration; + + constructor(private store: Store, + private configService: ConfigService, + private userService: UserService, + private externalDevicesService: ExternalDevicesService) { } ngOnInit() { this.locales = this.configService.getConfigValue('i18n.supported.locales'); @@ -44,6 +52,15 @@ export class SettingsComponent implements OnInit { this.playSoundForInformationDefaultValue = !!this.configService.getConfigValue('settings.playSoundForInformation') ? this.configService.getConfigValue('settings.playSoundForInformation') : false; this.replayEnabledDefaultValue = !!this.configService.getConfigValue('settings.replayEnabled') ? this.configService.getConfigValue('settings.replayEnabled') : false; this.replayIntervalDefaultValue = !!this.configService.getConfigValue('settings.replayInterval') ? this.configService.getConfigValue('settings.replayInterval') : 5; + + const userLogin = this.userService.getCurrentUserWithPerimeters().userData.login; + this.externalDevicesService.fetchUserConfiguration(userLogin).subscribe(result => { + this.userConfiguration = result; + }); + } + + isExternalDeviceConfiguredForUser() : boolean { + return (!!this.userConfiguration && !!this.userConfiguration.externalDeviceId); } } diff --git a/ui/main/src/app/services/external-devices.service.ts b/ui/main/src/app/services/external-devices.service.ts index ac8fddf010..07f72e9978 100644 --- a/ui/main/src/app/services/external-devices.service.ts +++ b/ui/main/src/app/services/external-devices.service.ts @@ -11,7 +11,7 @@ import {environment} from '@env/environment'; import {HttpClient} from '@angular/common/http'; import {catchError} from 'rxjs/operators'; import {Observable, Subject} from 'rxjs'; -import {Notification} from "@ofModel/external-devices.model"; +import {Notification, UserConfiguration} from "@ofModel/external-devices.model"; import {Injectable} from '@angular/core'; import {ErrorService} from "@ofServices/error-service"; @@ -22,6 +22,7 @@ export class ExternalDevicesService extends ErrorService { readonly externalDevicesUrl: string; readonly notificationsUrl: string; + readonly configurationsUrl: string; private ngUnsubscribe$ = new Subject(); /** * @constructor @@ -31,6 +32,7 @@ export class ExternalDevicesService extends ErrorService { super(); this.externalDevicesUrl = `${environment.urls.externalDevices}`; this.notificationsUrl = this.externalDevicesUrl+'/notifications'; + this.configurationsUrl = this.externalDevicesUrl+'/configurations'; } sendNotification(notification: Notification): Observable { @@ -39,4 +41,8 @@ export class ExternalDevicesService extends ErrorService { ); } + fetchUserConfiguration(login: string): Observable { + return this.httpClient.get(`${this.configurationsUrl}/users/${login}`); + } + } From 9b69709b92d429b9e6fbe85995aa4d5e959f9f0f Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Mon, 27 Dec 2021 11:51:26 +0100 Subject: [PATCH 60/73] Allowing users to make a GET request on externalDevices/configurations/users/{login} endpoint (#2273) Signed-off-by: vlo-rte --- .../configuration/oauth2/WebSecurityConfiguration.java | 2 ++ .../api/karate/externaldevices/fetchUserConfig.feature | 2 +- .../settings/components/settings/settings.component.html | 2 +- .../settings/components/settings/settings.component.ts | 8 +++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java index 60a2d76e6d..c325ca83dd 100644 --- a/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java +++ b/services/external-devices/src/main/java/org/opfab/externaldevices/configuration/oauth2/WebSecurityConfiguration.java @@ -32,6 +32,7 @@ public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String PROMETHEUS_PATH ="/actuator/prometheus**"; public static final String LOGGERS_PATH ="/actuator/loggers/**"; public static final String CONFIGURATIONS_ROOT_PATH = "/configurations/"; + public static final String CONFIGURATIONS_USERS_PATH = CONFIGURATIONS_ROOT_PATH + "users/{login}"; public static final String DEVICES_ROOT_PATH = "/devices/"; public static final String NOTIFICATIONS_ROOT_PATH = "/notifications"; @@ -61,6 +62,7 @@ public static void configureCommon(final HttpSecurity http) throws Exception { .antMatchers(HttpMethod.GET,PROMETHEUS_PATH).permitAll() .antMatchers(LOGGERS_PATH).hasRole(ADMIN_ROLE) .antMatchers(HttpMethod.POST,NOTIFICATIONS_ROOT_PATH).access(AUTH_AND_IP_ALLOWED) + .antMatchers(HttpMethod.GET, CONFIGURATIONS_USERS_PATH).access(AUTH_AND_IP_ALLOWED) .antMatchers(CONFIGURATIONS_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) .antMatchers(DEVICES_ROOT_PATH+"**").access(ADMIN_AND_IP_ALLOWED) .anyRequest().access(AUTH_AND_IP_ALLOWED); diff --git a/src/test/api/karate/externaldevices/fetchUserConfig.feature b/src/test/api/karate/externaldevices/fetchUserConfig.feature index 11c0199957..ed4a7da827 100644 --- a/src/test/api/karate/externaldevices/fetchUserConfig.feature +++ b/src/test/api/karate/externaldevices/fetchUserConfig.feature @@ -58,6 +58,6 @@ Feature: User Configuration Management (Fetch) Given url opfabUrl + userConfigEndpoint + '/operator1' And header Authorization = 'Bearer ' + authTokenAsTSO When method GET - Then status 403 + Then status 200 diff --git a/ui/main/src/app/modules/settings/components/settings/settings.component.html b/ui/main/src/app/modules/settings/components/settings/settings.component.html index e54aa12798..d2a981cc1c 100644 --- a/ui/main/src/app/modules/settings/components/settings/settings.component.html +++ b/ui/main/src/app/modules/settings/components/settings/settings.component.html @@ -21,7 +21,7 @@
- +
diff --git a/ui/main/src/app/modules/settings/components/settings/settings.component.ts b/ui/main/src/app/modules/settings/components/settings/settings.component.ts index 139c7b32d9..1eceb9f687 100644 --- a/ui/main/src/app/modules/settings/components/settings/settings.component.ts +++ b/ui/main/src/app/modules/settings/components/settings/settings.component.ts @@ -54,9 +54,11 @@ export class SettingsComponent implements OnInit { this.replayIntervalDefaultValue = !!this.configService.getConfigValue('settings.replayInterval') ? this.configService.getConfigValue('settings.replayInterval') : 5; const userLogin = this.userService.getCurrentUserWithPerimeters().userData.login; - this.externalDevicesService.fetchUserConfiguration(userLogin).subscribe(result => { - this.userConfiguration = result; - }); + + if (this.externalDevicesEnabled) + this.externalDevicesService.fetchUserConfiguration(userLogin).subscribe(result => { + this.userConfiguration = result; + }); } isExternalDeviceConfiguredForUser() : boolean { From cde9c55aff41f9f5fc723bb0ab47e207bd3306c1 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 4 Jan 2022 12:27:03 +0000 Subject: [PATCH 61/73] Update confluent to v7.0.1 Signed-off-by: Renovate Bot --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 6d26d28aa1..1845d579ab 100755 --- a/gradle.properties +++ b/gradle.properties @@ -17,8 +17,8 @@ lombok=1.18.22 feign=11.8 jacksonAnnotations=2.13.1 jacksonDatabind=2.13.1 -kavroSchemaRegistryClient=7.0.0 -kavroAvroSerializer=7.0.0 +kavroSchemaRegistryClient=7.0.1 +kavroAvroSerializer=7.0.1 springKafka=2.8.1 micrometer=1.8.1 avro=1.11.0 From c5edd078d4fcce07323b5c4e8df95b549d6158bd Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Wed, 5 Jan 2022 11:53:00 +0100 Subject: [PATCH 62/73] Add sound when user session end (#2337) Signed-off-by: freddidierRTE --- .../cypress/integration/SessionEnded.spec.js | 96 +++++++++++++ .../integration/SoundNotification.spec.js | 33 ++--- src/test/cypress/cypress/support/commands.js | 19 ++- ui/main/src/app/app.component.html | 12 +- ui/main/src/app/app.component.ts | 23 ++- .../authentication/authentication.service.ts | 8 +- .../services/sound-notification.service.ts | 135 +++++++++++------- .../store/actions/authentication.actions.ts | 8 +- .../store/effects/authentication.effects.ts | 32 +---- ui/main/src/assets/i18n/en.json | 3 +- ui/main/src/assets/i18n/fr.json | 3 +- 11 files changed, 257 insertions(+), 115 deletions(-) create mode 100644 src/test/cypress/cypress/integration/SessionEnded.spec.js diff --git a/src/test/cypress/cypress/integration/SessionEnded.spec.js b/src/test/cypress/cypress/integration/SessionEnded.spec.js new file mode 100644 index 0000000000..7d8a6468bd --- /dev/null +++ b/src/test/cypress/cypress/integration/SessionEnded.spec.js @@ -0,0 +1,96 @@ +/* Copyright (c) 2022, RTE (http://www.rte-france.com) + * See AUTHORS.txt + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + * This file is part of the OperatorFabric project. + */ + + +describe('Session ended test', function () { + + const user = 'operator1'; + + // Do not use the generic login feature as we + // need to launch cy.clock after cy.visit('') + const loginWithClock = function () { + + cy.visit('') + cy.clock(new Date()); + cy.get('#opfab-login').type('operator1') + cy.get('#opfab-password').type('test') + cy.get('#opfab-login-btn-submit').click(); + + //Wait for the app to finish initializing + cy.get('#opfab-cypress-loaded-check', {timeout: 15000}).should('have.text', 'true'); + + } + + before('Reset UI configuration file ', function () { + cy.resetUIConfigurationFiles(); + cy.deleteAllCards(); + cy.deleteAllSettings(); + }) + + + it('Checking session end after 10 hours ', () => { + + loginWithClock(); + cy.stubPlaySound(); + // go 1 hour in the future + cy.tick(1*60*60*1000); + + // The session is active + cy.get('#opfab-sessionEnd').should('not.exist'); + + // go 10 hour in the future + cy.tick(10*60*60*1000); + + // Session is closed + // check session end message + cy.get('#opfab-sessionEnd'); + + // no sound configured , sound shall not be activated + cy.get('@playSound').its('callCount').should('eq', 0); + + }) + + it('Checking sound when session end ', () => { + + cy.loginOpFab('operator1', 'test'); + cy.openOpfabSettings(); + + // set severity alarm to be notified by sound + cy.get('#opfab-checkbox-setting-form-alarm').click(); + cy.waitDefaultTime(); + // set no replay for sound + cy.get('#opfab-checkbox-setting-form-replay').click(); + cy.waitDefaultTime(); + + cy.logoutOpFab(); + loginWithClock(); + cy.stubPlaySound(); + cy.waitDefaultTime(); // wait for configuration load end (in SoundNotificationService.ts) + + // go 1 hour in the future + cy.tick(1*60*60*1000); + + // The session is active + cy.get('#opfab-sessionEnd').should('not.exist'); + + // go 10 hour in the future + cy.tick(10*60*60*1000); + + // Session is closed + // check session end message + cy.get('#opfab-sessionEnd'); + + //As one sound is configured , sound shall be activated + cy.get('@playSound').its('callCount').should('eq', 1); + + + }) + + +}) diff --git a/src/test/cypress/cypress/integration/SoundNotification.spec.js b/src/test/cypress/cypress/integration/SoundNotification.spec.js index c846054b28..3a5a2cc222 100644 --- a/src/test/cypress/cypress/integration/SoundNotification.spec.js +++ b/src/test/cypress/cypress/integration/SoundNotification.spec.js @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, RTE (http://www.rte-france.com) +/* Copyright (c) 2021-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -12,19 +12,6 @@ describe('Sound notification test', function () { const user = 'operator1'; - const openSettings = function () { - cy.get('#opfab-navbar-drop_user_menu').click(); - cy.get("#opfab-navbar-right-menu-settings").click({force: true}); - } - - // Stub playSound method to catch when opfab send a sound - const stubPlaySound = function () { - cy.window() - .its('soundNotificationService') - .then((soundNotificationService) => { - cy.stub(soundNotificationService, 'playSound').as('playSound') - }) - } before('Reset UI configuration file ', function () { //cy.loadTestConf(); Avoid to launch it as it is time consuming @@ -36,8 +23,8 @@ describe('Sound notification test', function () { describe('Checking sound when receiving notification ', function () { it('Sound when receiving card ', () => { cy.loginOpFab(user, 'test'); - stubPlaySound(); - openSettings(); + cy.stubPlaySound(); + cy.openOpfabSettings(); // set severity alarm to be notified by sound cy.get('#opfab-checkbox-setting-form-alarm').click(); @@ -59,7 +46,7 @@ describe('Sound notification test', function () { // no new sound cy.get('@playSound').its('callCount').should('eq', 1); - openSettings(); + cy.openOpfabSettings(); // set severity alarm to NOT be notified by sound cy.get('#opfab-checkbox-setting-form-alarm').click(); @@ -73,7 +60,7 @@ describe('Sound notification test', function () { // No new sound cy.get('@playSound').its('callCount').should('eq', 1); - openSettings(); + cy.openOpfabSettings(); // set severity action to be notified by sound cy.get('#opfab-checkbox-setting-form-action').click(); @@ -88,7 +75,7 @@ describe('Sound notification test', function () { // New sound cy.get('@playSound').its('callCount').should('eq', 2); - openSettings(); + cy.openOpfabSettings(); // set severity information to be notified by sound cy.get('#opfab-checkbox-setting-form-information').click(); @@ -108,10 +95,10 @@ describe('Sound notification test', function () { it('Repeating sound when receiving card with default repeating interval ', () => { cy.delete6TestCards(); cy.loginOpFab(user, 'test'); - stubPlaySound(); + cy.stubPlaySound(); // Activate repeating sound (no need to click the checkbox because it is already checked, because of the default value set to true in web-ui.json) - openSettings(); + cy.openOpfabSettings(); cy.waitDefaultTime(); // Open the feed and send card @@ -149,10 +136,10 @@ describe('Sound notification test', function () { it('Repeating sound when receiving card with custom repeating interval ', () => { cy.delete6TestCards(); cy.loginOpFab(user, 'test'); - stubPlaySound(); + cy.stubPlaySound(); // Set repeating interval to 20 seconds - openSettings(); + cy.openOpfabSettings(); cy.get('#opfab-setting-replayInterval').clear(); cy.get('#opfab-setting-replayInterval').type('20'); cy.waitDefaultTime(); diff --git a/src/test/cypress/cypress/support/commands.js b/src/test/cypress/cypress/support/commands.js index b4ede73e69..55504f856b 100644 --- a/src/test/cypress/cypress/support/commands.js +++ b/src/test/cypress/cypress/support/commands.js @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, RTE (http://www.rte-france.com) +/* Copyright (c) 2021-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -127,4 +127,19 @@ Cypress.Commands.add('deleteAllSettings', () => { Cypress.Commands.add('waitForOpfabToStart', () => { cy.exec('cd ../../.. && ./bin/waitForOpfabToStart.sh '); -}) \ No newline at end of file +}) + +Cypress.Commands.add('openOpfabSettings', () => { + cy.get('#opfab-navbar-drop_user_menu').click(); + cy.get("#opfab-navbar-right-menu-settings").click({force: true}); +}) + + // Stub playSound method to catch when opfab send a sound +Cypress.Commands.add('stubPlaySound', () => { + cy.window() + .its('soundNotificationService') + .then((soundNotificationService) => { + cy.stub(soundNotificationService, 'playSound').as('playSound') + }) +}) + diff --git a/ui/main/src/app/app.component.html b/ui/main/src/app/app.component.html index 8ebe4bd3f2..c1896958e0 100644 --- a/ui/main/src/app/app.component.html +++ b/ui/main/src/app/app.component.html @@ -1,4 +1,4 @@ - + @@ -54,3 +54,13 @@
+ + + + + \ No newline at end of file diff --git a/ui/main/src/app/app.component.ts b/ui/main/src/app/app.component.ts index 4f2cb7fbc8..d3772e23af 100644 --- a/ui/main/src/app/app.component.ts +++ b/ui/main/src/app/app.component.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2018-2021, RTE (http://www.rte-france.com) +/* Copyright (c) 2018-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -10,7 +10,7 @@ import {Component, HostListener, OnInit, TemplateRef, ViewChild} from '@angular/core'; import {Title} from '@angular/platform-browser'; -import {Store} from '@ngrx/store'; +import {Action, Store} from '@ngrx/store'; import {AppState} from '@ofStore/index'; import {AuthenticationService} from '@ofServices/authentication/authentication.service'; import {LoadConfigSuccess} from '@ofActions/config.actions'; @@ -32,6 +32,7 @@ import {Message, MessageLevel} from '@ofModel/message.model'; import {GroupsService} from '@ofServices/groups.service'; import {SoundNotificationService} from "@ofServices/sound-notification.service"; import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {AuthenticationActionTypes, TryToLogOut} from '@ofStore/actions/authentication.actions'; class Alert { @@ -58,6 +59,7 @@ export class AppComponent implements OnInit { private modalRef: NgbModalRef; @ViewChild('noSound') noSoundPopupRef: TemplateRef; + @ViewChild('sessionEnd') sessionEndPopupRef: TemplateRef; /** * NB: I18nService is injected to trigger its constructor at application startup @@ -88,7 +90,7 @@ export class AppComponent implements OnInit { @HostListener('document:click', ['$event.target']) public onPageClick() { - this.soundNotificationService.clearOutstandingNotifications(); + this.soundNotificationService.clearOutstandingNotifications(); } @@ -164,6 +166,7 @@ export class AppComponent implements OnInit { this.loaded = true; this.reminderService.startService(identifier); this.activateSoundIfNotActivated(); + this.subscribeToSessionEnd(); }, error: catchError((err, caught) => { console.error('Error in application initialization', err); @@ -220,6 +223,20 @@ export class AppComponent implements OnInit { }); } + private subscribeToSessionEnd() { + this.actions$.pipe( + ofType(AuthenticationActionTypes.SessionExpired)).subscribe( () => { + this.soundNotificationService.handleSessionEnd(); + this.modalRef = this.modalService.open(this.sessionEndPopupRef, {centered: true, backdrop: 'static'}); + } + ); + } + + public logout() { + this.modalRef.close(); + this.store.dispatch(new TryToLogOut()); + } + private displayAlert(message: Message) { let className = 'opfab-alert-info'; switch (message.level) { diff --git a/ui/main/src/app/services/authentication/authentication.service.ts b/ui/main/src/app/services/authentication/authentication.service.ts index ef2a295b78..77d5b7039e 100644 --- a/ui/main/src/app/services/authentication/authentication.service.ts +++ b/ui/main/src/app/services/authentication/authentication.service.ts @@ -1,5 +1,5 @@ /* Copyright (c) 2020, RTEi (http://www.rte-international.com) - * Copyright (c) 2021, RTE (http://www.rte-france.com) + * Copyright (c) 2021-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -20,7 +20,7 @@ import { InitAuthStatus, PayloadForSuccessfulAuthentication, RejectLogIn, - UnableToRefreshOrGetToken, + SessionExpired, UnAuthenticationFromImplicitFlow } from '@ofActions/authentication.actions'; import {environment} from '@env/environment'; @@ -145,7 +145,7 @@ export class AuthenticationService { this.regularCheckTokenValidity(); }, MILLIS_TO_WAIT_BETWEEN_TOKEN_EXPIRATION_DATE_CONTROLS); } else {// Will send Logout if token is expired - this.store.dispatch(new UnableToRefreshOrGetToken()); + this.store.dispatch(new SessionExpired()); } } @@ -528,7 +528,7 @@ export class ImplicitAuthenticationHandler implements AuthenticationModeHandler // This case arise for example when using a SSO and the session is not valid anymore (session timeout) case ('token_error'): case('token_refresh_error'): - this.store.dispatch(new UnableToRefreshOrGetToken()); + this.store.dispatch(new SessionExpired()); break; case('logout'): { this.store.dispatch(new UnAuthenticationFromImplicitFlow()); diff --git a/ui/main/src/app/services/sound-notification.service.ts b/ui/main/src/app/services/sound-notification.service.ts index 6c79117d8b..eef257a264 100644 --- a/ui/main/src/app/services/sound-notification.service.ts +++ b/ui/main/src/app/services/sound-notification.service.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2018-2021, RTE (http://www.rte-france.com) +/* Copyright (c) 2018-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -9,23 +9,23 @@ import {Injectable, OnDestroy} from '@angular/core'; -import {PlatformLocation} from "@angular/common"; -import {LightCard, Severity} from "@ofModel/light-card.model"; -import {Notification} from "@ofModel/external-devices.model"; -import {Store} from "@ngrx/store"; -import {AppState} from "@ofStore/index"; -import {buildSettingsOrConfigSelector} from "@ofSelectors/settings.x.config.selectors"; +import {PlatformLocation} from '@angular/common'; +import {LightCard, Severity} from '@ofModel/light-card.model'; +import {Notification} from '@ofModel/external-devices.model'; +import {Store} from '@ngrx/store'; +import {AppState} from '@ofStore/index'; +import {buildSettingsOrConfigSelector} from '@ofSelectors/settings.x.config.selectors'; import {LightCardsFeedFilterService} from './lightcards/lightcards-feed-filter.service'; import {LightCardsStoreService} from './lightcards/lightcards-store.service'; -import {EMPTY, iif, merge, of, Subject, timer} from "rxjs"; import {filter, map, switchMap, takeUntil} from "rxjs/operators"; -import {ExternalDevicesService} from "@ofServices/external-devices.service"; -import {ConfigService} from "@ofServices/config.service"; +import {EMPTY, iif, merge, of, Subject, timer} from 'rxjs'; import {filter, map, switchMap, takeUntil} from 'rxjs/operators'; +import {ExternalDevicesService} from '@ofServices/external-devices.service'; +import {ConfigService} from '@ofServices/config.service'; @Injectable() -export class SoundNotificationService implements OnDestroy{ +export class SoundNotificationService implements OnDestroy { - private static RECENT_THRESHOLD: number = 4000; // in milliseconds + private static RECENT_THRESHOLD = 4000; // in milliseconds /* The subscription used by the front end to get cards to display in the feed from the backend doesn't distinguish * between old cards loaded from the database and new cards arriving through the notification broker. * In addition, the getCardOperation observable on which this sound notification is hooked will also emit events @@ -33,18 +33,20 @@ export class SoundNotificationService implements OnDestroy{ * once (and only new cards), sounds will only be played for a given card if the elapsed time since its publishDate * is below this threshold. */ - private static DEFAULT_REPLAY_INTERVAL: number = 5; // in seconds - private static SECONDS_TO_MILLISECONDS: number = 1000; + private static DEFAULT_REPLAY_INTERVAL = 5; // in seconds + private static SECONDS_TO_MILLISECONDS = 1000; private replayInterval: number; private soundConfigBySeverity: Map; private soundEnabled: Map; private playSoundOnExternalDevice: boolean; private replayEnabled: boolean; + private playSoundWhenSessionEnd = false; private readonly soundFileBasePath: string; private incomingCardOrReminder = new Subject(); + private sessionEnd = new Subject(); private clearSignal = new Subject(); private ngUnsubscribe$ = new Subject(); private lastSentCardId: string; @@ -66,26 +68,37 @@ export class SoundNotificationService implements OnDestroy{ this.soundConfigBySeverity.set(Severity.COMPLIANT, {soundFileName: 'compliant.mp3', soundEnabledSetting: 'playSoundForCompliant'}); this.soundConfigBySeverity.set(Severity.INFORMATION, {soundFileName: 'information.mp3', soundEnabledSetting: 'playSoundForInformation'}); - let baseHref = platformLocation.getBaseHrefFromDOM(); - this.soundFileBasePath = (baseHref ? baseHref : '/') + 'assets/sounds/' + const baseHref = platformLocation.getBaseHrefFromDOM(); + this.soundFileBasePath = (baseHref ? baseHref : '/') + 'assets/sounds/'; this.soundEnabled = new Map(); this.soundConfigBySeverity.forEach((soundConfig, severity) => { store.select(buildSettingsOrConfigSelector(soundConfig.soundEnabledSetting, false)).subscribe(x => { this.soundEnabled.set(severity, x); + this.setSoundForSessionEndWhenAtLeastOneSoundForASeverityIsActivated(); + }); }); - }) - - store.select(buildSettingsOrConfigSelector('playSoundOnExternalDevice',false)).subscribe(x => { this.playSoundOnExternalDevice = x;}) - store.select(buildSettingsOrConfigSelector('replayEnabled',false)).subscribe(x => { this.replayEnabled = x;}) - store.select(buildSettingsOrConfigSelector('replayInterval',SoundNotificationService.DEFAULT_REPLAY_INTERVAL)).subscribe(x => { this.replayInterval = x;}) + store.select(buildSettingsOrConfigSelector('playSoundOnExternalDevice', false)) + .subscribe(x => { this.playSoundOnExternalDevice = x; }); + store.select(buildSettingsOrConfigSelector('replayEnabled', false)) + .subscribe(x => { this.replayEnabled = x; }); + store.select(buildSettingsOrConfigSelector('replayInterval', SoundNotificationService.DEFAULT_REPLAY_INTERVAL)) + .subscribe(x => { this.replayInterval = x; }); - for (let severity of Object.values(Severity)) this.initSoundPlayingForSeverity(severity); + for (const severity of Object.values(Severity)) this.initSoundPlayingForSeverity(severity); + this.initSoundPlayingForSessionEnd(); this.listenForCardUpdate(); } + private setSoundForSessionEndWhenAtLeastOneSoundForASeverityIsActivated() { + this.playSoundWhenSessionEnd = false; + for (const soundEnabled of this.soundEnabled.values()) { + if (soundEnabled) this.playSoundWhenSessionEnd = true; + } + } + private listenForCardUpdate(){ this.lightCardsStoreService.getNewLightCards().subscribe( (card) => this.handleLoadedCard(card) @@ -103,7 +116,7 @@ export class SoundNotificationService implements OnDestroy{ } public handleRemindCard(card: LightCard ) { - if(this.lightCardsFeedFilterService.isCardVisibleInFeed(card)) this.incomingCardOrReminder.next(card); + if (this.lightCardsFeedFilterService.isCardVisibleInFeed(card)) this.incomingCardOrReminder.next(card); } public handleLoadedCard(card: LightCard) { @@ -114,33 +127,41 @@ export class SoundNotificationService implements OnDestroy{ } } + public handleSessionEnd() { + if (this.playSoundWhenSessionEnd) { + this.sessionEnd.next(null); + } + } + public lastSentCard(cardId: string) { this.lastSentCardId = cardId; } - private checkCardIsRecent (card: LightCard) : boolean { + private checkCardIsRecent (card: LightCard): boolean { return ((new Date().getTime() - card.publishDate) <= SoundNotificationService.RECENT_THRESHOLD); } private getSoundForSeverity(severity: Severity) : HTMLAudioElement { - return new Audio(this.soundFileBasePath+this.soundConfigBySeverity.get(severity).soundFileName); + return new Audio(this.soundFileBasePath + this.soundConfigBySeverity.get(severity).soundFileName); } - private playSoundForSeverity(severity : Severity) { + private playSoundForSeverityEnabled(severity: Severity) { - if(this.soundEnabled.get(severity)) { - if(this.configService.getConfigValue('externalDevicesEnabled') && this.playSoundOnExternalDevice) { - console.debug("External devices enabled. Sending notification for "+severity+"."); - let notification = new Notification(severity.toString()); - this.externalDevicesService.sendNotification(notification).subscribe(); - } else { - this.playSound(this.getSoundForSeverity(severity)); - } + if (this.soundEnabled.get(severity)) this.playSound(severity); + else console.debug('No sound was played for ' + severity + ' as sound is disabled for this severity'); + } + + private playSound(severity: Severity) { + if (this.configService.getConfigValue('externalDevicesEnabled') && this.playSoundOnExternalDevice) { + console.debug('External devices enabled. Sending notification for ' + severity + '.'); + const notification = new Notification(severity.toString()); + this.externalDevicesService.sendNotification(notification).subscribe(); } else { - console.debug("No sound was played for "+severity+" as sound is disabled for this severity"); + this.playSoundOnBrowser(this.getSoundForSeverity(severity)); } } + private initSoundPlayingForSeverity(severity: Severity) { merge( this.incomingCardOrReminder.pipe( @@ -154,28 +175,44 @@ export class SoundNotificationService implements OnDestroy{ // at the specified interval. In the case of SignalType.CLEAR, it creates an observable that completes immediately. // Because of the switchMap, any new observable cancels the previous one, so that a click or a new card/reminder // resets the replay timer. - .pipe(switchMap((x : SignalType) => { - if(x === SignalType.CLEAR) { - return EMPTY; - } else { - return iif(() => this.replayEnabled, - timer(0,this.replayInterval * SoundNotificationService.SECONDS_TO_MILLISECONDS), - of(null) - ); - } - }), - takeUntil(this.ngUnsubscribe$)) + .pipe(this.processSignal(),takeUntil(this.ngUnsubscribe$)) .subscribe((x ) => { console.log(new Date().toISOString() , ' Play sound'); - this.playSoundForSeverity(severity); + this.playSoundForSeverityEnabled(severity); }); } + private initSoundPlayingForSessionEnd() { + merge( + this.sessionEnd.pipe(map( x => SignalType.NOTIFICATION )), + this.clearSignal.pipe(map(x => SignalType.CLEAR)) + ) + .pipe(this.processSignal(),takeUntil(this.ngUnsubscribe$)) + .subscribe((x ) => { + console.log(new Date().toISOString() , ' Play sound for session end'); + this.playSound(Severity.ALARM); + }); + } + + private processSignal() { + return switchMap((x: SignalType) => { + if (x === SignalType.CLEAR) { + return EMPTY; + } else { + return iif(() => this.replayEnabled, + timer(0, this.replayInterval * SoundNotificationService.SECONDS_TO_MILLISECONDS), + of(null) + ); + } + }) + } + + /* There is no need to limit the frequency of calls to playSound because if a given sound XXXX is already * playing when XXXX.play() is called, nothing happens. * */ - private playSound(sound: HTMLAudioElement) { + private playSoundOnBrowser(sound: HTMLAudioElement) { sound.play().catch(error => { console.log(new Date().toISOString(), `Notification sound wasn't played because the user hasn't interacted with the app yet (autoplay policy).`); @@ -195,7 +232,7 @@ export class SoundNotificationService implements OnDestroy{ export class SoundConfig { soundFileName: string; - soundEnabledSetting:string; + soundEnabledSetting: string; } diff --git a/ui/main/src/app/store/actions/authentication.actions.ts b/ui/main/src/app/store/actions/authentication.actions.ts index ad4d11790f..39075adac7 100644 --- a/ui/main/src/app/store/actions/authentication.actions.ts +++ b/ui/main/src/app/store/actions/authentication.actions.ts @@ -25,7 +25,7 @@ export enum AuthenticationActionTypes { , UselessAuthAction = '[Authentication] Test purpose action' , ImplicitlyAuthenticated = '[Authentication] User is authentication using Implicit Flow' , UnAuthenticationFromImplicitFlow = '[Authentication] User is log out by implicit Flow internal management' - , UnableToRefreshOrGetToken = '[Authentication] The token can not be refresh or we cannot get a token' + , SessionExpired = '[Authentication] The token can not be refresh or is expired' } /** @@ -143,9 +143,9 @@ export class UnAuthenticationFromImplicitFlow implements Action { readonly type = AuthenticationActionTypes.UnAuthenticationFromImplicitFlow; } -export class UnableToRefreshOrGetToken implements Action { +export class SessionExpired implements Action { /* istanbul ignore next */ - readonly type = AuthenticationActionTypes.UnableToRefreshOrGetToken; + readonly type = AuthenticationActionTypes.SessionExpired; } @@ -162,4 +162,4 @@ export type AuthenticationActions = | UselessAuthAction | ImplicitlyAuthenticated | UnAuthenticationFromImplicitFlow - | UnableToRefreshOrGetToken; + | SessionExpired; diff --git a/ui/main/src/app/store/effects/authentication.effects.ts b/ui/main/src/app/store/effects/authentication.effects.ts index af81a216e6..0d3b788835 100644 --- a/ui/main/src/app/store/effects/authentication.effects.ts +++ b/ui/main/src/app/store/effects/authentication.effects.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2018-2021, RTE (http://www.rte-france.com) +/* Copyright (c) 2018-2022, RTE (http://www.rte-france.com) * See AUTHORS.txt * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -19,8 +19,7 @@ import { AuthenticationActions, AuthenticationActionTypes, RejectLogIn, - TryToLogIn, - TryToLogOut + TryToLogIn } from '@ofActions/authentication.actions'; import {AuthenticationService} from '@ofServices/authentication/authentication.service'; import {catchError, map, switchMap, tap, withLatestFrom} from 'rxjs/operators'; @@ -41,16 +40,7 @@ import {TranslateService} from "@ngx-translate/core"; @Injectable() export class AuthenticationEffects { - /** - * @constructorCheckImplicitFlowAuthenticationStatus - * @param store - {Store} state manager - * @param actions$ - {Action} {Observable} of Action of the Application - * @param authService - service implementing the authentication business rules - * @param cardService - service handling request of cards - * @param router - router service to redirect user accordingly to the user authentication status or variation of it. - * @param translate - object to get translation - * - * istanbul ignore next */ + constructor(private store: Store, private actions$: Actions, private authService: AuthenticationService, @@ -170,7 +160,7 @@ export class AuthenticationEffects { * @typedef {Observable} * */ - + CheckAuthentication: Observable = createEffect(() => this.actions$ .pipe( @@ -235,16 +225,6 @@ export class AuthenticationEffects { )); - - UnableToRefreshToken: Observable = createEffect(() => - this.actions$.pipe( - ofType(AuthenticationActionTypes.UnableToRefreshOrGetToken), - switchMap(() => { - window.alert(this.translate.instant("login.error.disconnected")); - return of(new TryToLogOut()); - }) - )); - handleErrorOnTokenGeneration(errorResponse, category: string) { let message, key; const params = new Map(); @@ -270,14 +250,12 @@ export class AuthenticationEffects { handleRejectedLogin(errorMsg: Message): AuthenticationActions { this.authService.clearAuthenticationInformation(); return new RejectLogIn({error: errorMsg}); - } - + private resetState() { this.authService.clearAuthenticationInformation(); this.cardService.closeSubscription(); window.location.href = this.configService.getConfigValue('security.logout-url','https://opfab.github.io'); - } } diff --git a/ui/main/src/assets/i18n/en.json b/ui/main/src/assets/i18n/en.json index d18184f5ca..7880a36f49 100755 --- a/ui/main/src/assets/i18n/en.json +++ b/ui/main/src/assets/i18n/en.json @@ -58,7 +58,8 @@ "connectionLost":"Connection lost", "tryToReconnect":"Try to reconnect ...", "activateSoundText":"Sound not activated, click to activate", - "activateSoundButton":"Activate sound" + "activateSoundButton":"Activate sound", + "sessionExpiredText":"Your session has expired" }, "menu": { "feed": "Card Feed", diff --git a/ui/main/src/assets/i18n/fr.json b/ui/main/src/assets/i18n/fr.json index 7c4fce8c61..6b3788a668 100755 --- a/ui/main/src/assets/i18n/fr.json +++ b/ui/main/src/assets/i18n/fr.json @@ -58,7 +58,8 @@ "connectionLost":"Connexion perdue", "tryToReconnect":"Tentative de reconnexion ...", "activateSoundText":"Son non activé, cliquer pour activer", - "activateSoundButton":"Activer le son" + "activateSoundButton":"Activer le son", + "sessionExpiredText":"Votre session a expiré" }, "menu": { "feed": "Flux de cartes", From 9f934dc3e81bca6f44a78f85ce01faadbddbe4f9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 4 Jan 2022 23:11:23 +0000 Subject: [PATCH 63/73] Update plugin com.palantir.docker to v0.32.0 Signed-off-by: Renovate Bot --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6c3223961f..468377d04e 100755 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { id "io.spring.dependency-management" version "1.0.11.RELEASE" apply false id 'org.sonarqube' version '3.3' id "com.github.davidmc24.gradle.plugin.avro" version "1.3.0" apply false - id "com.palantir.docker" version "0.31.0" apply false + id "com.palantir.docker" version "0.32.0" apply false id "org.hidetake.swagger.generator" version "2.19.1" apply false } From 1e874651dba85ddd88e12795bce931c33865c32a Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 6 Jan 2022 08:10:37 +0100 Subject: [PATCH 64/73] Adapt conf after updated plugin com.palantir.docker to v0.32.0 Signed-off-by: freddidierRTE --- services/external-devices/build.gradle | 3 --- services/services.gradle | 3 --- src/test/dummyModbusDevice/dummyModbusDevice.gradle | 3 --- src/test/externalApp/externalApp.gradle | 4 +--- web-ui/web-ui.gradle | 3 --- 5 files changed, 1 insertion(+), 15 deletions(-) diff --git a/services/external-devices/build.gradle b/services/external-devices/build.gradle index 6aad1f324b..604a5d48e0 100755 --- a/services/external-devices/build.gradle +++ b/services/external-devices/build.gradle @@ -76,10 +76,7 @@ tasks.named("processResources") { } docker { - /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) - * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ name "lfeoperatorfabric/of-${project.name}:${project.version}" - tag "${project.version}", "lfeoperatorfabric/of-${project.name}:${project.version}" if (!project.version.equals("SNAPSHOT")) tag "latest", "latest" labels (['project':"${project.group}"]) diff --git a/services/services.gradle b/services/services.gradle index a0b8a741b8..4d8a3cdba0 100755 --- a/services/services.gradle +++ b/services/services.gradle @@ -111,10 +111,7 @@ subprojects { } docker { - /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) - * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ name "lfeoperatorfabric/of-${project.name}:${project.version}" - tag "${project.version}", "lfeoperatorfabric/of-${project.name}:${project.version}" if (!project.version.equals("SNAPSHOT")) tag "latest", "latest" labels (['project':"${project.group}"]) diff --git a/src/test/dummyModbusDevice/dummyModbusDevice.gradle b/src/test/dummyModbusDevice/dummyModbusDevice.gradle index e93917f496..9a788bb343 100755 --- a/src/test/dummyModbusDevice/dummyModbusDevice.gradle +++ b/src/test/dummyModbusDevice/dummyModbusDevice.gradle @@ -24,10 +24,7 @@ bootJar { docker { - /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) - * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ name "lfeoperatorfabric/of-dummy-modbus-device:${project.version}" - tag "${project.version}", "lfeoperatorfabric/of-dummy-modbus-device:${project.version}" if (!project.version.equals("SNAPSHOT")) tag "latest", "latest" labels (['project':"${project.group}"]) diff --git a/src/test/externalApp/externalApp.gradle b/src/test/externalApp/externalApp.gradle index f3377458a2..a4e74ec75b 100755 --- a/src/test/externalApp/externalApp.gradle +++ b/src/test/externalApp/externalApp.gradle @@ -28,10 +28,8 @@ bootJar { docker { - /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) - * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ name "lfeoperatorfabric/of-external-app:${project.version}" - tag "${project.version}", "lfeoperatorfabric/of-external-app:${project.version}" + if (!project.version.equals("SNAPSHOT")) tag "latest", "latest" labels (['project':"${project.group}"]) diff --git a/web-ui/web-ui.gradle b/web-ui/web-ui.gradle index 17d1f62fc4..aa6f3e25b5 100755 --- a/web-ui/web-ui.gradle +++ b/web-ui/web-ui.gradle @@ -4,10 +4,7 @@ plugins { } docker { - /* We need to specify the version in the name because if empty, it is tagged latest (https://vsupalov.com/docker-latest-tag/) - * but we also need to add a "tag" property otherwise the corresponding tasks (dockerTagXXX, dockerPushXXX) are not created */ name "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version}" - tag "${project.version}", "lfeoperatorfabric/of-${project.name.toLowerCase()}:${project.version}" if (!project.version.equals("SNAPSHOT")) tag "latest", "latest" labels(['project': "${project.group}"]) From 959851ed3005c8f4c7c77b83ca98cf3e52819582 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 6 Jan 2022 16:57:55 +0100 Subject: [PATCH 65/73] Secure docker push (authorized a limited list of user to push) Signed-off-by: freddidierRTE --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93ca8e97d0..492d5c7ef5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -145,6 +145,7 @@ jobs: GH_DOC_TOKEN: ${{ secrets.GH_DOC_TOKEN}} - name : Push images to dockerhub + environment: publishVersion if: ${{ github.event.inputs.dockerPush == 'true' || github.event_name == 'schedule' || github.ref_name == 'master' }} run: | echo ${{ secrets.DOCKER_TOKEN }} | docker login --username opfabtravis --password-stdin @@ -154,6 +155,7 @@ jobs: done - name : Push images latest to dockerhub + environment: publishVersion if: ${{ github.event.inputs.dockerPushLatest == 'true' || github.ref_name == 'master' }} run: | echo ${{ secrets.DOCKER_TOKEN }} | docker login --username opfabtravis --password-stdin From bf0ed6c8a486ca046087647d6123a4ed6f8ee5ec Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 6 Jan 2022 17:10:01 +0100 Subject: [PATCH 66/73] revert previous commit Signed-off-by: freddidierRTE --- .github/workflows/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 492d5c7ef5..93ca8e97d0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -145,7 +145,6 @@ jobs: GH_DOC_TOKEN: ${{ secrets.GH_DOC_TOKEN}} - name : Push images to dockerhub - environment: publishVersion if: ${{ github.event.inputs.dockerPush == 'true' || github.event_name == 'schedule' || github.ref_name == 'master' }} run: | echo ${{ secrets.DOCKER_TOKEN }} | docker login --username opfabtravis --password-stdin @@ -155,7 +154,6 @@ jobs: done - name : Push images latest to dockerhub - environment: publishVersion if: ${{ github.event.inputs.dockerPushLatest == 'true' || github.ref_name == 'master' }} run: | echo ${{ secrets.DOCKER_TOKEN }} | docker login --username opfabtravis --password-stdin From 67c63b9360e90f3722e70273fb2ebd815147c4c3 Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Thu, 6 Jan 2022 17:44:53 +0100 Subject: [PATCH 67/73] Secure docker & documentation push (authorize a limited list of user via github action environment) Signed-off-by: freddidierRTE --- .github/workflows/main.yml | 85 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93ca8e97d0..d6b5d0f3b0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,6 +48,7 @@ on: jobs: build: runs-on: ubuntu-latest + if: ${{ github.event.inputs.dockerPush != 'true' && github.event.inputs.dockerPushLatest != 'true' && github.event.inputs.doc != 'true' && github.event.inputs.docLatest != 'true' && github.event_name != 'schedule' && github.ref_name != 'master' }} steps: - uses: actions/checkout@v2 @@ -128,6 +129,90 @@ jobs: cd config/docker docker-compose down + + publish: + runs-on: ubuntu-latest + environment: publishVersion + if: ${{ github.event.inputs.dockerPush == 'true' || github.event.inputs.dockerPushLatest == 'true' || github.event.inputs.doc == 'true' || github.event.inputs.docLatest == 'true' || github.event_name == 'schedule' || github.ref_name == 'master' }} + steps: + - uses: actions/checkout@v2 + + - name: Job status + run: | + export OF_VERSION=$( $HOME/.sdkman/etc/config ; + echo sdkman_auto_selfupdate=true >> $HOME/.sdkman/etc/config ; + source $HOME/.sdkman/bin/sdkman-init.sh; + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm + [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion + source ./bin/load_environment_light.sh; + sudo apt-get install jq + echo "npm version $(npm -version)" + echo "node version $(node --version)" + sdk version + javac -version + git config --global user.email "opfabtech@gmail.com" + git config --global user.name "OpfabTech" + + - name: Build + if: ${{ github.event.inputs.build == 'true' || github.event_name == 'schedule' || github.event_name == 'pull_request' || github.event_name == 'push'}} + run: | + export OF_VERSION=$( Date: Thu, 6 Jan 2022 21:44:52 +0100 Subject: [PATCH 68/73] Remove docker login for build Signed-off-by: freddidierRTE --- .github/workflows/main.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d6b5d0f3b0..e4ce63993a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -95,7 +95,6 @@ jobs: if: ${{ github.event.inputs.build == 'true' || github.event_name == 'schedule' || github.event_name == 'pull_request' || github.event_name == 'push'}} run: | export OF_VERSION=$( Date: Thu, 6 Jan 2022 22:08:56 +0100 Subject: [PATCH 69/73] Create anchore-analysis.yml --- .github/workflows/anchore-analysis.yml | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/anchore-analysis.yml diff --git a/.github/workflows/anchore-analysis.yml b/.github/workflows/anchore-analysis.yml new file mode 100644 index 0000000000..c58d609a54 --- /dev/null +++ b/.github/workflows/anchore-analysis.yml @@ -0,0 +1,35 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow checks out code, builds an image, performs a container image +# vulnerability scan with Anchore's Grype tool, and integrates the results with GitHub Advanced Security +# code scanning feature. For more information on the Anchore scan action usage +# and parameters, see https://github.com/anchore/scan-action. For more +# information on Anchore's container image scanning tool Grype, see +# https://github.com/anchore/grype +name: Anchore Container Scan + +on: + push: + branches: [ develop] + workflow_dispatch: + +jobs: + Anchore-Build-Scan: + runs-on: ubuntu-latest + steps: + - name: Checkout the code + uses: actions/checkout@v2 + - name: Build the Docker image + run: docker pull lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT + - name: Run the Anchore scan action itself with GitHub Advanced Security code scanning integration enabled + uses: anchore/scan-action@b08527d5ae7f7dc76f9621edb6e49eaf47933ccd + with: + image: "lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT" + acs-report-enable: true + - name: Upload Anchore Scan Report + uses: github/codeql-action/upload-sarif@v1 + with: + sarif_file: results.sarif From 2c314b8e4d5265d34850415c0a75a810e975b9ce Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Fri, 7 Jan 2022 11:24:20 +0100 Subject: [PATCH 70/73] Adapt anchor analysis Signed-off-by: freddidierRTE --- .github/workflows/anchore-analysis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/anchore-analysis.yml b/.github/workflows/anchore-analysis.yml index c58d609a54..4e32308090 100644 --- a/.github/workflows/anchore-analysis.yml +++ b/.github/workflows/anchore-analysis.yml @@ -25,10 +25,11 @@ jobs: - name: Build the Docker image run: docker pull lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT - name: Run the Anchore scan action itself with GitHub Advanced Security code scanning integration enabled - uses: anchore/scan-action@b08527d5ae7f7dc76f9621edb6e49eaf47933ccd + uses: anchore/scan-action@v3 with: image: "lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT" acs-report-enable: true + fail-build: false - name: Upload Anchore Scan Report uses: github/codeql-action/upload-sarif@v1 with: From b96d3131d81d85f42afe99439898f8c18ecc2b6d Mon Sep 17 00:00:00 2001 From: freddidierRTE Date: Fri, 7 Jan 2022 13:41:53 +0100 Subject: [PATCH 71/73] test anchor tool Signed-off-by: freddidierRTE --- .github/workflows/anchore-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/anchore-analysis.yml b/.github/workflows/anchore-analysis.yml index 4e32308090..4dd5be97f8 100644 --- a/.github/workflows/anchore-analysis.yml +++ b/.github/workflows/anchore-analysis.yml @@ -23,11 +23,11 @@ jobs: - name: Checkout the code uses: actions/checkout@v2 - name: Build the Docker image - run: docker pull lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT + run: docker pull lfeoperatorfabric/of-cards-consultation-business-service:3.2.0.RELEASE - name: Run the Anchore scan action itself with GitHub Advanced Security code scanning integration enabled uses: anchore/scan-action@v3 with: - image: "lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT" + image: "lfeoperatorfabric/of-cards-consultation-business-service:3.2.0.RELEASE" acs-report-enable: true fail-build: false - name: Upload Anchore Scan Report From b6b2df02151da03b51b81fb84ba224ad4d7d8431 Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Fri, 7 Jan 2022 14:56:00 +0100 Subject: [PATCH 72/73] Remove s in french translation for notification configuration screen (#2348) Signed-off-by: vlo-rte --- ui/main/src/assets/i18n/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/main/src/assets/i18n/fr.json b/ui/main/src/assets/i18n/fr.json index 6b3788a668..43e933dc77 100755 --- a/ui/main/src/assets/i18n/fr.json +++ b/ui/main/src/assets/i18n/fr.json @@ -225,7 +225,7 @@ "search": "RECHERCHER" }, "feedConfiguration": { - "title": "CONFIGURATIONS DES NOTIFICATIONS", + "title": "CONFIGURATION DES NOTIFICATIONS", "confirmSettings": "ENREGISTRER LA CONFIGURATION", "popup": { "title": "CONFIRMATION", From d1675fb37376d8f63110074b50a88190ca070bee Mon Sep 17 00:00:00 2001 From: vlo-rte Date: Mon, 10 Jan 2022 11:48:05 +0100 Subject: [PATCH 73/73] [RELEASE] 3.3.0.RELEASE Signed-off-by: vlo-rte --- VERSION | 2 +- config/dev/docker-compose.yml | 4 ++-- config/docker/docker-compose.yml | 18 +++++++++--------- config/docker/ui-config/web-ui.json | 4 ++-- .../src/main/modeling/swagger.yaml | 2 +- .../src/main/modeling/swagger.yaml | 2 +- .../src/main/modeling/swagger.yaml | 2 +- services/users/src/main/modeling/swagger.yaml | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/VERSION b/VERSION index fc1dd57365..24d79dbd1a 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -SNAPSHOT +3.3.0.RELEASE diff --git a/config/dev/docker-compose.yml b/config/dev/docker-compose.yml index 6e47ccd471..98db96f08c 100755 --- a/config/dev/docker-compose.yml +++ b/config/dev/docker-compose.yml @@ -28,7 +28,7 @@ services: - "90:9990" web-ui: container_name: web-ui - image: "lfeoperatorfabric/of-web-ui:SNAPSHOT" + image: "lfeoperatorfabric/of-web-ui:3.3.0.RELEASE" ports: - "2002:80" volumes: @@ -37,6 +37,6 @@ services: - "./nginx.conf:/etc/nginx/conf.d/default.conf" - "./loggingResults:/etc/nginx/html/logging" ext-app: - image: "lfeoperatorfabric/of-external-app:SNAPSHOT" + image: "lfeoperatorfabric/of-external-app:3.3.0.RELEASE" ports: - "8090:8090" diff --git a/config/docker/docker-compose.yml b/config/docker/docker-compose.yml index 97e26aa300..51e830b8a4 100755 --- a/config/docker/docker-compose.yml +++ b/config/docker/docker-compose.yml @@ -41,7 +41,7 @@ services: # - "90:9990" users: container_name: users - image: "lfeoperatorfabric/of-users-business-service:SNAPSHOT" + image: "lfeoperatorfabric/of-users-business-service:3.3.0.RELEASE" depends_on: - mongodb - rabbitmq @@ -59,7 +59,7 @@ services: - ${CONFIG_PATH}:/external-config businessconfig: container_name: businessconfig - image: "lfeoperatorfabric/of-businessconfig-business-service:SNAPSHOT" + image: "lfeoperatorfabric/of-businessconfig-business-service:3.3.0.RELEASE" depends_on: - mongodb user: ${USER_ID}:${USER_GID} @@ -77,7 +77,7 @@ services: - ${CONFIG_PATH}:/external-config cards-publication: container_name: cards-publication - image: "lfeoperatorfabric/of-cards-publication-business-service:SNAPSHOT" + image: "lfeoperatorfabric/of-cards-publication-business-service:3.3.0.RELEASE" depends_on: - mongodb - rabbitmq @@ -95,7 +95,7 @@ services: - ${CONFIG_PATH}:/external-config cards-consultation: container_name: cards-consultation - image: "lfeoperatorfabric/of-cards-consultation-business-service:SNAPSHOT" + image: "lfeoperatorfabric/of-cards-consultation-business-service:3.3.0.RELEASE" depends_on: - mongodb - rabbitmq @@ -113,7 +113,7 @@ services: - ${CONFIG_PATH}:/external-config web-ui: container_name: web-ui - image: "lfeoperatorfabric/of-web-ui:SNAPSHOT" + image: "lfeoperatorfabric/of-web-ui:3.3.0.RELEASE" ports: - "2002:80" depends_on: @@ -127,7 +127,7 @@ services: # - "./custom-sounds:/usr/share/nginx/html/assets/sounds" external-devices: container_name: external-devices - image: "lfeoperatorfabric/of-external-devices-service:SNAPSHOT" + image: "lfeoperatorfabric/of-external-devices-service:3.3.0.RELEASE" depends_on: - mongodb - users @@ -148,13 +148,13 @@ services: # External application example ext-app: - image: "lfeoperatorfabric/of-external-app:SNAPSHOT" + image: "lfeoperatorfabric/of-external-app:3.3.0.RELEASE" ports: - "8090:8090" # Dummy external devices using Modbus Protocol dummy-modbus-device_1: container_name: dummy-modbus-device_1 - image: "lfeoperatorfabric/of-dummy-modbus-device:SNAPSHOT" + image: "lfeoperatorfabric/of-dummy-modbus-device:3.3.0.RELEASE" user: ${USER_ID}:${USER_GID} ports: - "4031:4030" @@ -167,7 +167,7 @@ services: - ${CONFIG_PATH}:/external-config dummy-modbus-device_2: container_name: dummy-modbus-device_2 - image: "lfeoperatorfabric/of-dummy-modbus-device:SNAPSHOT" + image: "lfeoperatorfabric/of-dummy-modbus-device:3.3.0.RELEASE" user: ${USER_ID}:${USER_GID} ports: - "4032:4030" diff --git a/config/docker/ui-config/web-ui.json b/config/docker/ui-config/web-ui.json index 2a45cd4c0e..3f6186480f 100644 --- a/config/docker/ui-config/web-ui.json +++ b/config/docker/ui-config/web-ui.json @@ -47,7 +47,7 @@ }, "hideAckFilter": false, "hideResponseFilter": false, - "hideApplyFiltersToTimeLineChoice":false, + "hideApplyFiltersToTimeLineChoice": false, "secondsBeforeLttdForClockDisplay": 3700, "hideReadSort": false, "hideSeveritySort": false, @@ -133,7 +133,7 @@ "operatorfabric": { "name": "OperatorFabric", "rank": 0, - "version": "SNAPSHOT" + "version": "3.3.0.RELEASE" } }, "infos": { diff --git a/services/businessconfig/src/main/modeling/swagger.yaml b/services/businessconfig/src/main/modeling/swagger.yaml index d758cc11b8..7ffa470775 100755 --- a/services/businessconfig/src/main/modeling/swagger.yaml +++ b/services/businessconfig/src/main/modeling/swagger.yaml @@ -1,7 +1,7 @@ swagger: '2.0' info: description: IMPORTANT - The Try it Out button will generate curl requests for examples, but executing them through the UI will not work as authentication has not been set up. This page is for documentation only. - version: SNAPSHOT + version: 3.3.0.RELEASE title: BusinessConfig Management termsOfService: '' contact: diff --git a/services/cards-publication/src/main/modeling/swagger.yaml b/services/cards-publication/src/main/modeling/swagger.yaml index ad4815c065..a8ca0e436c 100755 --- a/services/cards-publication/src/main/modeling/swagger.yaml +++ b/services/cards-publication/src/main/modeling/swagger.yaml @@ -1,7 +1,7 @@ swagger: '2.0' info: description: IMPORTANT - The Try it Out button will generate curl requests for examples, but executing them through the UI will not work as authentication has not been set up. This page is for documentation only. - version: SNAPSHOT + version: 3.3.0.RELEASE title: Card Management API termsOfService: '' contact: diff --git a/services/external-devices/src/main/modeling/swagger.yaml b/services/external-devices/src/main/modeling/swagger.yaml index 8b06a76a88..29debf2467 100755 --- a/services/external-devices/src/main/modeling/swagger.yaml +++ b/services/external-devices/src/main/modeling/swagger.yaml @@ -1,7 +1,7 @@ swagger: '2.0' info: description: IMPORTANT - The Try it Out button will generate curl requests for examples, but executing them through the UI will not work as authentication has not been set up. This page is for documentation only. - version: SNAPSHOT + version: 3.3.0.RELEASE title: External Devices Management termsOfService: '' contact: diff --git a/services/users/src/main/modeling/swagger.yaml b/services/users/src/main/modeling/swagger.yaml index 9c0db79562..79ba0c387f 100755 --- a/services/users/src/main/modeling/swagger.yaml +++ b/services/users/src/main/modeling/swagger.yaml @@ -1,7 +1,7 @@ swagger: '2.0' info: description: IMPORTANT - The Try it Out button will generate curl requests for examples, but executing them through the UI will not work as authentication has not been set up. This page is for documentation only. - version: SNAPSHOT + version: 3.3.0.RELEASE title: User Management termsOfService: '' contact: