From c36f15e2da9d0bb54409e220803b3abf95fe17ea Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Fri, 27 Jan 2023 11:53:30 +0100 Subject: [PATCH] feat: Generalize and extend notification channel type behaviour --- .componentsignore | 1 + .../http/notifications/webhooks/handler.json | 2 +- .../notifications/webhooks/subscription.json | 5 +- .../notifications/websockets/handler.json | 2 +- .../websockets/subscription.json | 5 +- .../architecture/features/notifications.md | 45 +-- package-lock.json | 320 +++++++++++------- package.json | 5 +- src/index.ts | 1 + src/server/notifications/BaseChannelType.ts | 241 +++++++++++++ .../notifications/KeyValueChannelStorage.ts | 17 +- .../notifications/NotificationChannel.ts | 93 ++--- .../NotificationChannelStorage.ts | 12 +- .../notifications/NotificationChannelType.ts | 43 ++- .../notifications/NotificationSubscriber.ts | 65 ++-- .../WebHookSubscription2021.ts | 93 +++-- .../WebSocketSubscription2021.ts | 66 ++-- src/util/StringUtil.ts | 35 ++ src/util/Vocabularies.ts | 5 + templates/contexts/shacl.jsonld | 177 ++++++++++ .../WebHookSubscription2021.test.ts | 9 +- .../WebSocketSubscription2021.test.ts | 10 +- .../notifications/BaseChannelType.test.ts | 190 +++++++++++ .../KeyValueChannelStorage.test.ts | 17 +- .../notifications/NotificationChannel.test.ts | 78 ----- .../NotificationSubscriber.test.ts | 147 +++++--- .../WebHookEmitter.test.ts | 3 +- .../WebHookSubscription2021.test.ts | 118 +++---- .../WebHookUnsubscriber.test.ts | 3 +- .../WebSocketSubscription2021.test.ts | 84 ++--- test/unit/util/StringUtil.test.ts | 19 +- test/util/NotificationUtil.ts | 4 +- 32 files changed, 1291 insertions(+), 624 deletions(-) create mode 100644 src/server/notifications/BaseChannelType.ts create mode 100644 templates/contexts/shacl.jsonld create mode 100644 test/unit/server/notifications/BaseChannelType.test.ts delete mode 100644 test/unit/server/notifications/NotificationChannel.test.ts diff --git a/.componentsignore b/.componentsignore index a31248878..74a204187 100644 --- a/.componentsignore +++ b/.componentsignore @@ -3,6 +3,7 @@ "Adapter", "AlgJwk", "BaseActivityEmitter", + "BaseChannelType", "BaseHttpError", "BaseRouterHandler", "BasicConditions", diff --git a/config/http/notifications/webhooks/handler.json b/config/http/notifications/webhooks/handler.json index ffdefa19e..51e74e10a 100644 --- a/config/http/notifications/webhooks/handler.json +++ b/config/http/notifications/webhooks/handler.json @@ -5,7 +5,7 @@ "comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.", "@id": "urn:solid-server:default:WebHookNotificationHandler", "@type": "TypedNotificationHandler", - "type": "WebHookSubscription2021", + "type": "http://www.w3.org/ns/solid/notifications#WebHookSubscription2021", "source": { "@type": "ComposedNotificationHandler", "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, diff --git a/config/http/notifications/webhooks/subscription.json b/config/http/notifications/webhooks/subscription.json index f9c9a0e0b..9ef66f530 100644 --- a/config/http/notifications/webhooks/subscription.json +++ b/config/http/notifications/webhooks/subscription.json @@ -11,16 +11,17 @@ "handler": { "@type": "NotificationSubscriber", "channelType": { "@id": "urn:solid-server:default:WebHookSubscription2021" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, "permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, - "authorizer": { "@id": "urn:solid-server:default:Authorizer" } + "authorizer": { "@id": "urn:solid-server:default:Authorizer" }, + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" } } }, { "comment": "Contains all the metadata relevant for a WebHookSubscription2021.", "@id": "urn:solid-server:default:WebHookSubscription2021", "@type": "WebHookSubscription2021", - "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }, "unsubscribeRoute": { "@id": "urn:solid-server:default:WebHookUnsubscribeRoute" }, "stateHandler": { "@type": "BaseStateHandler", diff --git a/config/http/notifications/websockets/handler.json b/config/http/notifications/websockets/handler.json index 611a2c3c5..63014ca43 100644 --- a/config/http/notifications/websockets/handler.json +++ b/config/http/notifications/websockets/handler.json @@ -5,7 +5,7 @@ "comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.", "@id": "urn:solid-server:default:WebSocket2021NotificationHandler", "@type": "TypedNotificationHandler", - "type": "WebSocketSubscription2021", + "type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021", "source": { "@type": "ComposedNotificationHandler", "generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" }, diff --git a/config/http/notifications/websockets/subscription.json b/config/http/notifications/websockets/subscription.json index 069b984c7..c98eb2003 100644 --- a/config/http/notifications/websockets/subscription.json +++ b/config/http/notifications/websockets/subscription.json @@ -11,9 +11,11 @@ "handler": { "@type": "NotificationSubscriber", "channelType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" }, + "converter": { "@id": "urn:solid-server:default:RepresentationConverter" }, "credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" }, "permissionReader": { "@id": "urn:solid-server:default:PermissionReader" }, - "authorizer": { "@id": "urn:solid-server:default:Authorizer" } + "authorizer": { "@id": "urn:solid-server:default:Authorizer" }, + "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" } } }, { @@ -26,7 +28,6 @@ "comment": "Contains all the metadata relevant for a WebSocketSubscription2021.", "@id": "urn:solid-server:default:WebSocketSubscription2021", "@type": "WebSocketSubscription2021", - "storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }, "route": { "@id": "urn:solid-server:default:WebSocket2021Route" } }, diff --git a/documentation/markdown/architecture/features/notifications.md b/documentation/markdown/architecture/features/notifications.md index d0ebb7fb9..78031c8c8 100644 --- a/documentation/markdown/architecture/features/notifications.md +++ b/documentation/markdown/architecture/features/notifications.md @@ -1,9 +1,9 @@ # Notifications -This section covers the architecture used to support Notifications protocol -as described in . +This section covers the architecture used to support the Notifications protocol +as described in . -There are 3 core architectural components to this that each have separate entry points: +There are three core architectural components, that have distinct entry points: * Exposing metadata to allow discovery of the subscription type. * Handling subscriptions targeting a resource. @@ -19,12 +19,13 @@ as the notification subscription URL is always located in the root of the server flowchart LR StorageDescriptionHandler("
StorageDescriptionHandler") StorageDescriptionHandler --> StorageDescriber("StorageDescriber
ArrayUnionHandler") - StorageDescriber --> StorageDescriberArgs + StorageDescriber --> NotificationDescriber("NotificationDescriber
NotificationDescriber") + NotificationDescriber --> NotificationDescriberArgs - subgraph StorageDescriberArgs[" "] + subgraph NotificationDescriberArgs[" "] direction LR - NotificationDescriber("
NotificationDescriber") - NotificationDescriber2("
NotificationDescriber") + NotificationChannelType("
NotificationChannelType") + NotificationChannelType2("
NotificationChannelType") end ``` @@ -33,14 +34,16 @@ and to handle content negotiation. To generate the data we have multiple `StorageDescriber`s, whose results get merged together in an `ArrayUnionHandler`. +A `NotificationChannelType` contains the specific details of a specification notification channel type, +including a JSON-LD representation of the corresponding subscription resource. One specific instance of a `StorageDescriber` is a `NotificationSubcriber`, -that contains all the necessary presets to describe a notification subscription type. +which merges those JSON-LD descriptions into a single set of RDF quads. When adding a new subscription type, a new instance of such a class should be added to the `urn:solid-server:default:StorageDescriber`. ## NotificationChannel -To subscribe, a client has to send a specific JSON-LD request to the URl found during discovery. +To subscribe, a client has to send a specific JSON-LD request to the URL found during discovery. ```mermaid flowchart LR @@ -50,9 +53,9 @@ flowchart LR subgraph NotificationTypeHandlerArgs[" "] direction LR OperationRouterHandler("
OperationRouterHandler") --> NotificationSubscriber("
NotificationSubscriber") - NotificationSubscriber --> SubscriptionType("
SubscriptionType") + NotificationChannelType --> NotificationChannelType("
NotificationChannelType") OperationRouterHandler2("
OperationRouterHandler") --> NotificationSubscriber2("
NotificationSubscriber") - NotificationSubscriber2 --> SubscriptionType2("
SubscriptionType") + NotificationChannelType2 --> NotificationChannelType2("
NotificationChannelType") end ``` @@ -60,7 +63,7 @@ Every subscription type should have a subscription URL relative to the root noti which in our configs is set to `/.notifications/`. For every type there is then a `OperationRouterHandler` that accepts requests to that specific URL, after which a `NotificationSubscriber` handles all checks related to subscribing, -for which it uses a `SubscriptionType` that contains all the information necessary for a specific type. +for which it uses a `NotificationChannelType`. If the subscription is valid and has authorization, the results will be saved in a `NotificationChannelStorage`. ## Activity @@ -99,13 +102,13 @@ To add support for [WebSocketSubscription2021](https://solidproject.org/TR/2022/ notifications, components were added as described in the documentation above. -For discovery a `NotificationDescriber` was added with the corresponding settings. +For discovery, a `NotificationDescriber` was added with the corresponding settings. -As `SubscriptionType` there is a specific `WebSocketSubscription2021` that contains all the necessary information. +As `SubscriptionType`, there is a specific `WebSocketSubscription2021` that contains all the necessary information. ### Handling notifications -As `NotificationHandler` the following architecture is used: +As `NotificationHandler`, the following architecture is used: ```mermaid flowchart TB @@ -137,8 +140,8 @@ and also caches the result so it can be reused by multiple subscriptions. `urn:solid-server:default:BaseNotificationSerializer` converts the Notification to a JSON-LD representation and handles any necessary content negotiation based on the `accept` notification feature. -A `WebSocket2021Emitter` is a specific emitter that checks the current open WebSockets -if they correspond to the subscription. +A `WebSocket2021Emitter` is a specific emitter that checks +whether the current open WebSockets correspond to the subscription. ### WebSockets @@ -163,12 +166,12 @@ flowchart TB ``` To detect and store WebSocket connections, the `WebSocket2021Listener` is added as a listener to the HTTP server. -For all WebSocket connections that get opened, it verifies if they correspond to an existing subscription. -If yes the information gets sent out to its stored `WebSocket2021Handler`. +For all WebSocket connections that get opened, it verifies whether they correspond to an existing subscription. +If yes, the information gets sent out to its stored `WebSocket2021Handler`. -In this case this is a `SequenceHandler` which contains a `WebSocket2021Storer` and a `BaseStateHandler`. +In this case, this is a `SequenceHandler`, which contains a `WebSocket2021Storer` and a `BaseStateHandler`. The `WebSocket2021Storer` will store the WebSocket in the same map used by the `WebSocket2021Emitter`, -so that class can later on emit events as mentioned above. +so that class can emit events later on, as mentioned above. The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request, as defined in the notification specification. diff --git a/package-lock.json b/package-lock.json index c8e6a7970..3ab547916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/proper-lockfile": "^4.1.2", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", + "@types/rdf-validate-shacl": "^0.4.1", "@types/sparqljs": "^3.1.3", "@types/url-join": "^4.0.1", "@types/uuid": "^9.0.0", @@ -65,6 +66,7 @@ "rdf-serialize": "^2.0.0", "rdf-string": "^1.6.1", "rdf-terms": "^1.9.0", + "rdf-validate-shacl": "^0.4.5", "sparqlalgebrajs": "^4.0.3", "sparqljs": "^3.5.2", "url-join": "^4.0.1", @@ -72,8 +74,7 @@ "winston": "^3.8.1", "winston-transport": "^4.5.0", "ws": "^8.8.1", - "yargs": "^17.5.1", - "yup": "^0.32.11" + "yargs": "^17.5.1" }, "bin": { "community-solid-server": "bin/server.js" @@ -666,17 +667,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz", - "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==", - "dependencies": { - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", @@ -3804,6 +3794,52 @@ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, + "node_modules/@rdfjs/data-model": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz", + "integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==", + "dependencies": { + "@rdfjs/types": ">=1.0.1" + }, + "bin": { + "rdfjs-data-model-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/dataset": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz", + "integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==", + "dependencies": { + "@rdfjs/data-model": "^1.2.0" + }, + "bin": { + "rdfjs-dataset-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/namespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz", + "integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==", + "dependencies": { + "@rdfjs/data-model": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@rdfjs/term-set": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz", + "integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==", + "dependencies": { + "@rdfjs/to-ntriples": "^2.0.0" + } + }, + "node_modules/@rdfjs/to-ntriples": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz", + "integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q==" + }, "node_modules/@rdfjs/types": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz", @@ -3990,6 +4026,14 @@ "@types/node": "*" } }, + "node_modules/@types/clownface": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz", + "integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==", + "dependencies": { + "rdf-js": "^4.0.2" + } + }, "node_modules/@types/connect": { "version": "3.4.34", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", @@ -4312,13 +4356,13 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" }, - "node_modules/@types/rdf-js": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz", - "integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==", - "deprecated": "This is a stub types definition. rdf-js provides its own type definitions, so you do not need this installed.", + "node_modules/@types/rdf-validate-shacl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz", + "integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==", "dependencies": { - "rdf-js": "*" + "@types/clownface": "*", + "rdf-js": "^4.0.2" } }, "node_modules/@types/readable-stream": { @@ -5878,6 +5922,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clownface": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz", + "integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==", + "dependencies": { + "@rdfjs/data-model": "^1.1.0", + "@rdfjs/namespace": "^1.0.0" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", @@ -11372,12 +11425,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash.clonedeep": { "version": "4.5.0", @@ -11931,11 +11980,6 @@ "node": ">=8.0" } }, - "node_modules/nanoclone": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", - "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" - }, "node_modules/nanoid": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", @@ -12850,11 +12894,6 @@ "signal-exit": "^3.0.2" } }, - "node_modules/property-expr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", - "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -13049,12 +13088,12 @@ } }, "node_modules/rdf-literal": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz", - "integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz", + "integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==", "dependencies": { - "@types/rdf-js": "*", - "rdf-data-factory": "^1.0.1" + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" } }, "node_modules/rdf-object": { @@ -13163,6 +13202,32 @@ "rdf-string": "^1.6.0" } }, + "node_modules/rdf-validate-datatype": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz", + "integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==", + "dependencies": { + "@rdfjs/namespace": "^1.1.0", + "@rdfjs/to-ntriples": "^2.0.0" + }, + "engines": { + "node": ">=10.4" + } + }, + "node_modules/rdf-validate-shacl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz", + "integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==", + "dependencies": { + "@rdfjs/dataset": "^1.1.1", + "@rdfjs/namespace": "^1.0.0", + "@rdfjs/term-set": "^1.1.0", + "clownface": "^1.4.0", + "debug": "^4.3.2", + "rdf-literal": "^1.3.0", + "rdf-validate-datatype": "^0.1.5" + } + }, "node_modules/rdfa-streaming-parser": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz", @@ -13335,11 +13400,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, "node_modules/regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -14419,11 +14479,6 @@ "node": ">=0.6" } }, - "node_modules/toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" - }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -15232,23 +15287,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/yup": { - "version": "0.32.11", - "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", - "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/lodash": "^4.14.175", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "nanoclone": "^0.2.1", - "property-expr": "^2.0.4", - "toposort": "^2.0.2" - }, - "engines": { - "node": ">=10" - } } }, "dependencies": { @@ -15656,14 +15694,6 @@ "@babel/helper-plugin-utils": "^7.18.6" } }, - "@babel/runtime": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz", - "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==", - "requires": { - "regenerator-runtime": "^0.13.4" - } - }, "@babel/template": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", @@ -18583,6 +18613,43 @@ } } }, + "@rdfjs/data-model": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz", + "integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==", + "requires": { + "@rdfjs/types": ">=1.0.1" + } + }, + "@rdfjs/dataset": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz", + "integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==", + "requires": { + "@rdfjs/data-model": "^1.2.0" + } + }, + "@rdfjs/namespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz", + "integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==", + "requires": { + "@rdfjs/data-model": "^1.1.0" + } + }, + "@rdfjs/term-set": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz", + "integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==", + "requires": { + "@rdfjs/to-ntriples": "^2.0.0" + } + }, + "@rdfjs/to-ntriples": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz", + "integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q==" + }, "@rdfjs/types": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.0.tgz", @@ -18757,6 +18824,14 @@ "@types/node": "*" } }, + "@types/clownface": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-1.5.1.tgz", + "integrity": "sha512-jYRGdXZu5BD6gp+Rfml9eAYovhj0Sf2ovufleMS9PEg8Un9Mc+ZbdbHt6nlutsuSk3QEqluTSzkYr1lno2FnHw==", + "requires": { + "rdf-js": "^4.0.2" + } + }, "@types/connect": { "version": "3.4.34", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz", @@ -19079,12 +19154,13 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==" }, - "@types/rdf-js": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/rdf-js/-/rdf-js-4.0.2.tgz", - "integrity": "sha512-soR/+RMogGiDU1lrpuQl5ZL55/L1eq/JlR2dWx052Uh/RYs9okh3XZHFlIJXHZqjqyjEn4WdbOMfBj7vvc2WVQ==", + "@types/rdf-validate-shacl": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.1.tgz", + "integrity": "sha512-ol9l4scrPhYgOVNiylIGjdk9H5EzIOMV6ecue10T5IKGNlEE2ySFDEgxPPTVslmiyVO+3vV32GSQvsf+aQ0hKw==", "requires": { - "rdf-js": "*" + "@types/clownface": "*", + "rdf-js": "^4.0.2" } }, "@types/readable-stream": { @@ -20188,6 +20264,15 @@ "mimic-response": "^1.0.0" } }, + "clownface": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz", + "integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==", + "requires": { + "@rdfjs/data-model": "^1.1.0", + "@rdfjs/namespace": "^1.0.0" + } + }, "cluster-key-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz", @@ -24367,12 +24452,8 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash.clonedeep": { "version": "4.5.0", @@ -24801,11 +24882,6 @@ "readable-stream": "^3.6.0" } }, - "nanoclone": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", - "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" - }, "nanoid": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.0.tgz", @@ -25476,11 +25552,6 @@ "signal-exit": "^3.0.2" } }, - "property-expr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", - "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" - }, "psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -25635,12 +25706,12 @@ } }, "rdf-literal": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.2.0.tgz", - "integrity": "sha512-N7nyfp/xzoiUuJt0xZ80BvBGkCPwWejgVDkCxWDSuooXKSows4ToW+KouYkMHLcoFzGg1Rlw2lk6btjMJg5aSA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.1.tgz", + "integrity": "sha512-+o/PGOfJchyay9Rjrvi/oveRJACnt2WFO3LhEvtPlsRD1tFmwVUCMU+s33FtQprMo+z1ohFrv/yfEQ6Eym4KgQ==", "requires": { - "@types/rdf-js": "*", - "rdf-data-factory": "^1.0.1" + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" } }, "rdf-object": { @@ -25749,6 +25820,29 @@ "rdf-string": "^1.6.0" } }, + "rdf-validate-datatype": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz", + "integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==", + "requires": { + "@rdfjs/namespace": "^1.1.0", + "@rdfjs/to-ntriples": "^2.0.0" + } + }, + "rdf-validate-shacl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz", + "integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==", + "requires": { + "@rdfjs/dataset": "^1.1.1", + "@rdfjs/namespace": "^1.0.0", + "@rdfjs/term-set": "^1.1.0", + "clownface": "^1.4.0", + "debug": "^4.3.2", + "rdf-literal": "^1.3.0", + "rdf-validate-datatype": "^0.1.5" + } + }, "rdfa-streaming-parser": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-1.5.0.tgz", @@ -25881,11 +25975,6 @@ "redis-errors": "^1.0.0" } }, - "regenerator-runtime": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" - }, "regexp-tree": { "version": "0.1.24", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.24.tgz", @@ -26733,11 +26822,6 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" }, - "toposort": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" - }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -27342,20 +27426,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true - }, - "yup": { - "version": "0.32.11", - "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", - "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", - "requires": { - "@babel/runtime": "^7.15.4", - "@types/lodash": "^4.14.175", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "nanoclone": "^0.2.1", - "property-expr": "^2.0.4", - "toposort": "^2.0.2" - } } } } diff --git a/package.json b/package.json index 59c43d131..6bc251f29 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "@types/proper-lockfile": "^4.1.2", "@types/pump": "^1.1.1", "@types/punycode": "^2.1.0", + "@types/rdf-validate-shacl": "^0.4.1", "@types/sparqljs": "^3.1.3", "@types/url-join": "^4.0.1", "@types/uuid": "^9.0.0", @@ -155,6 +156,7 @@ "rdf-serialize": "^2.0.0", "rdf-string": "^1.6.1", "rdf-terms": "^1.9.0", + "rdf-validate-shacl": "^0.4.5", "sparqlalgebrajs": "^4.0.3", "sparqljs": "^3.5.2", "url-join": "^4.0.1", @@ -162,8 +164,7 @@ "winston": "^3.8.1", "winston-transport": "^4.5.0", "ws": "^8.8.1", - "yargs": "^17.5.1", - "yup": "^0.32.11" + "yargs": "^17.5.1" }, "devDependencies": { "@commitlint/cli": "^17.0.3", diff --git a/src/index.ts b/src/index.ts index f4de19ec1..ba3bf7ec6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -338,6 +338,7 @@ export * from './server/notifications/WebSocketSubscription2021/WebSocketSubscri // Server/Notifications export * from './server/notifications/ActivityEmitter'; +export * from './server/notifications/BaseChannelType'; export * from './server/notifications/BaseStateHandler'; export * from './server/notifications/ComposedNotificationHandler'; export * from './server/notifications/KeyValueChannelStorage'; diff --git a/src/server/notifications/BaseChannelType.ts b/src/server/notifications/BaseChannelType.ts new file mode 100644 index 000000000..618592014 --- /dev/null +++ b/src/server/notifications/BaseChannelType.ts @@ -0,0 +1,241 @@ +import { Readable } from 'stream'; +import { KeysRdfParseJsonLd } from '@comunica/context-entries'; +import { parse, toSeconds } from 'iso8601-duration'; +import type { Store } from 'n3'; +import type { NamedNode, Term } from 'rdf-js'; +import rdfParser from 'rdf-parse'; +import SHACLValidator from 'rdf-validate-shacl'; +import { v4 } from 'uuid'; +import type { Credentials } from '../../authentication/Credentials'; +import type { AccessMap } from '../../authorization/permissions/Permissions'; +import { AccessMode } from '../../authorization/permissions/Permissions'; +import { ContextDocumentLoader } from '../../storage/conversion/ConversionUtil'; +import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError'; +import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap'; +import { readableToQuads } from '../../util/StreamUtil'; +import { msToDuration } from '../../util/StringUtil'; +import { NOTIFY, RDF, XSD } from '../../util/Vocabularies'; +import { CONTEXT_NOTIFICATION } from './Notification'; +import type { NotificationChannel } from './NotificationChannel'; +import type { NotificationChannelType } from './NotificationChannelType'; +import { DEFAULT_NOTIFICATION_FEATURES } from './NotificationDescriber'; + +/** + * Helper type used to store information about the default features. + */ +type Feature = { + predicate: NamedNode; + key: keyof NotificationChannel; + dataType: string; +}; + +/** + * All the necessary fields of the default features that are possible for all Notification Channels. + */ +const featureDefinitions: Feature[] = [ + { predicate: NOTIFY.terms.accept, key: 'accept', dataType: XSD.string }, + { predicate: NOTIFY.terms.endAt, key: 'endAt', dataType: XSD.dateTime }, + { predicate: NOTIFY.terms.rate, key: 'rate', dataType: XSD.duration }, + { predicate: NOTIFY.terms.startAt, key: 'startAt', dataType: XSD.dateTime }, + { predicate: NOTIFY.terms.state, key: 'state', dataType: XSD.string }, +]; + +// This context is slightly outdated but seems to be the only "official" source for a SHACL context. +const CONTEXT_SHACL = 'https://w3c.github.io/shacl/shacl-jsonld-context/shacl.context.ld.json'; +/** + * The SHACL shape for the minimum requirements on a notification channel subscription request. + */ +export const DEFAULT_SUBSCRIPTION_SHACL = { + '@context': [ CONTEXT_SHACL ], + '@type': 'sh:NodeShape', + // Use the topic predicate to find the focus node + targetSubjectsOf: NOTIFY.topic, + closed: true, + property: [ + { path: RDF.type, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' }, + { path: NOTIFY.topic, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' }, + ...featureDefinitions.map((feat): unknown => + ({ path: feat.predicate.value, maxCount: 1, datatype: feat.dataType })), + ], +} as const; + +/** + * A {@link NotificationChannelType} that handles the base case of parsing and serializing a notification channel. + * Note that the `extractModes` call always requires Read permissions on the target resource. + * + * Uses SHACL to validate the incoming data in `initChannel`. + * Classes extending this can pass extra SHACL properties in the constructor to extend the validation check. + * + * The `completeChannel` implementation is an empty function. + */ +export abstract class BaseChannelType implements NotificationChannelType { + protected readonly type: NamedNode; + protected readonly shacl: unknown; + protected shaclQuads?: Store; + + /** + * @param type - The URI of the notification channel type. + * This will be added to the SHACL shape to validate incoming subscription data. + * @param additionalShaclProperties - Any additional properties that need to be added to the default SHACL shape. + */ + protected constructor(type: NamedNode, additionalShaclProperties: unknown[] = []) { + this.type = type; + + // Inject requested properties into default SHACL shape + this.shacl = { + ...DEFAULT_SUBSCRIPTION_SHACL, + property: [ + ...DEFAULT_SUBSCRIPTION_SHACL.property, + // Add type check + { path: RDF.type, hasValue: { '@id': type.value }}, + ...additionalShaclProperties, + ], + }; + } + + /** + * Initiates the channel by first calling {@link validateSubscription} followed by {@link quadsToChannel}. + * Subclasses can override either function safely to impact the result of the function. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async initChannel(data: Store, credentials: Credentials): Promise { + const subject = await this.validateSubscription(data); + return this.quadsToChannel(data, subject); + } + + /** + * Returns an N3.js {@link Store} containing quads corresponding to the stored SHACL representation. + * Caches this result so the conversion from JSON-LD to quads only has to happen once. + */ + protected async getShaclQuads(): Promise { + if (!this.shaclQuads) { + const shaclStream = rdfParser.parse( + Readable.from(JSON.stringify(this.shacl)), + { + contentType: 'application/ld+json', + // Make sure our internal version of the context gets used + [KeysRdfParseJsonLd.documentLoader.name]: new ContextDocumentLoader({ + [CONTEXT_SHACL]: '@css:templates/contexts/shacl.jsonld', + }), + }, + ); + this.shaclQuads = await readableToQuads(shaclStream); + } + return this.shaclQuads; + } + + /** + * Validates whether the given data conforms to the stored SHACL shape. + * Will throw an {@link UnprocessableEntityHttpError} if validation fails. + * Along with the SHACL check, this also makes sure there is only one matching entry in the dataset. + * + * @param data - The data to validate. + * + * @returns The focus node that corresponds to the subject of the found notification channel description. + */ + protected async validateSubscription(data: Store): Promise { + // Need to make sure there is exactly one matching entry, which can't be done with SHACL. + // The predicate used here must be the same as is used for `targetSubjectsOf` in the SHACL shape. + const focusNodes = data.getSubjects(NOTIFY.terms.topic, null, null); + if (focusNodes.length === 0) { + throw new UnprocessableEntityHttpError('Missing topic value.'); + } + if (focusNodes.length > 1) { + throw new UnprocessableEntityHttpError('Only one subscription can be done at the same time.'); + } + + const validator = new SHACLValidator(await this.getShaclQuads()); + const report = validator.validate(data); + + if (!report.conforms) { + // Use the first error to generate error message + const result = report.results[0]; + const message = result.message[0]; + throw new UnprocessableEntityHttpError(`${message.value} - ${result.path?.value}`); + } + + // From this point on, we can assume the subject corresponds to a valid subscription request + return focusNodes[0] as NamedNode; + } + + /** + * Converts a set of quads to a {@link NotificationChannel}. + * Assumes the data is valid, so this should be called after {@link validateSubscription} + * + * The values of the default features will be added to the resulting channel, + * subclasses with additional features that need to be added are responsible for parsing those quads. + * + * @param data - Data to convert. + * @param subject - The identifier of the notification channel description in the dataset. + * + * @returns The generated {@link NotificationChannel}. + */ + protected async quadsToChannel(data: Store, subject: Term): Promise { + const topic = data.getObjects(subject, NOTIFY.terms.topic, null)[0] as NamedNode; + const type = data.getObjects(subject, RDF.terms.type, null)[0] as NamedNode; + + const channel: NotificationChannel = { + id: `${v4()}:${topic.value}`, + type: type.value, + topic: topic.value, + }; + + // Apply the values for all present features that are enabled + for (const feature of DEFAULT_NOTIFICATION_FEATURES) { + const objects = data.getObjects(subject, feature, null); + if (objects.length === 1) { + // Will always succeed since we are iterating over a list which was built using `featureDefinitions` + const { dataType, key } = featureDefinitions.find((feat): boolean => feat.predicate.value === feature)!; + let val: string | number = objects[0].value; + if (dataType === XSD.dateTime) { + val = Date.parse(val); + } else if (dataType === XSD.duration) { + val = toSeconds(parse(val)) * 1000; + } + // Need to convince TS that we can assign `string | number` to this key + (channel as Record)[key] = val; + } + } + + return channel; + } + + /** + * Converts the given channel to a JSON-LD description. + * All fields found in the channel, except `lastEmit`, will be part of the result subject, + * so subclasses should remove any fields that should not be exposed. + */ + public async toJsonLd(channel: NotificationChannel): Promise> { + const result: Record = { + '@context': [ + CONTEXT_NOTIFICATION, + ], + ...channel, + }; + // No need to expose this field + delete result.lastEmit; + + // Convert all the epoch values back to the expected date/rate format + for (const { key, dataType } of featureDefinitions) { + const value = channel[key]; + if (value) { + if (dataType === XSD.dateTime) { + result[key] = new Date(value).toISOString(); + } else if (dataType === XSD.duration) { + result[key] = msToDuration(value as number); + } + } + } + + return result; + } + + public async extractModes(channel: NotificationChannel): Promise { + return new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async completeChannel(channel: NotificationChannel): Promise { + // Do nothing + } +} diff --git a/src/server/notifications/KeyValueChannelStorage.ts b/src/server/notifications/KeyValueChannelStorage.ts index 843aa299e..bb11fb82e 100644 --- a/src/server/notifications/KeyValueChannelStorage.ts +++ b/src/server/notifications/KeyValueChannelStorage.ts @@ -1,10 +1,9 @@ -import { v4 } from 'uuid'; import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../../logging/LogUtil'; import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage'; import { InternalServerError } from '../../util/errors/InternalServerError'; import type { ReadWriteLocker } from '../../util/locking/ReadWriteLocker'; -import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel'; +import type { NotificationChannel } from './NotificationChannel'; import type { NotificationChannelStorage } from './NotificationChannelStorage'; type StorageValue = string | string[] | NotificationChannel; @@ -25,20 +24,6 @@ export class KeyValueChannelStorage implements NotificationChannelStorage { this.locker = locker; } - public create(channel: NotificationChannelJson, features: Record): NotificationChannel { - return { - id: `${channel.type}:${v4()}:${channel.topic}`, - topic: channel.topic, - type: channel.type, - startAt: channel.startAt, - endAt: channel.endAt, - accept: channel.accept, - rate: channel.rate, - state: channel.state, - ...features, - }; - } - public async get(id: string): Promise { const channel = await this.storage.get(id); if (channel && this.isChannel(channel)) { diff --git a/src/server/notifications/NotificationChannel.ts b/src/server/notifications/NotificationChannel.ts index 63d78766d..743b649aa 100644 --- a/src/server/notifications/NotificationChannel.ts +++ b/src/server/notifications/NotificationChannel.ts @@ -1,46 +1,59 @@ -import { parse, toSeconds } from 'iso8601-duration'; -import type { InferType } from 'yup'; -import { array, number, object, string } from 'yup'; -import { CONTEXT_NOTIFICATION } from './Notification'; - /** - * A JSON parsing schema that can be used to parse a notification channel sent during subscription. - * Specific notification channels can extend this schema with their own custom keys. + * Internal representation of a notification channel. + * Most of the fields are those defined in + * https://solid.github.io/notifications/protocol#notification-channel-data-model + * + * We only support notification channels with a single topic. */ -export const NOTIFICATION_CHANNEL_SCHEMA = object({ - '@context': array(string()).ensure().required().test({ - name: 'RequireNotificationContext', - message: `The ${CONTEXT_NOTIFICATION} context is required in the notification channel JSON-LD body.`, - test: (context): boolean => Boolean(context?.includes(CONTEXT_NOTIFICATION)), - }), - type: string().required(), - topic: string().required(), - state: string().optional(), - startAt: number().transform((value, original): number | undefined => - // Convert the date string to milliseconds - Date.parse(original)).optional(), - endAt: number().transform((value, original): number | undefined => - // Convert the date string to milliseconds - Date.parse(original)).optional(), - rate: number().transform((value, original): number | undefined => - // Convert the rate string to milliseconds - toSeconds(parse(original)) * 1000).optional(), - accept: string().optional(), -}); -export type NotificationChannelJson = InferType; - -/** - * The info provided for a notification channel during a subscription. - * `features` can contain custom values relevant for a specific channel type. - */ -export type NotificationChannel = { +export interface NotificationChannel { + /** + * The unique identifier of the channel. + */ id: string; - topic: string; + /** + * The channel type. + */ type: string; - startAt?: number; - endAt?: number; - accept?: string; - rate?: number; + /** + * The resource this channel sends notifications about. + */ + topic: string; + /** + * The state parameter sent by the receiver. + * This is used to send a notification when the channel is established and the topic resource has a different state. + */ state?: string; + /** + * When the channel should start sending notifications, in milliseconds since epoch. + */ + startAt?: number; + /** + * When the channel should stop existing, in milliseconds since epoch. + */ + endAt?: number; + /** + * The minimal time required between notifications, in milliseconds. + */ + rate?: number; + /** + * The media type in which the receiver expects the notifications. + */ + accept?: string; + /** + * The resource receivers can use to establish a connection and receive notifications. + */ + receiveFrom?: string; + /** + * The resource on the receiver where notifications can be sent. + */ + sendTo?: string; + /** + * Can be used to identify the sender. + */ + sender?: string; + + /** + * Internal value that we use to track when this channel last sent a notification. + */ lastEmit?: number; -}; +} diff --git a/src/server/notifications/NotificationChannelStorage.ts b/src/server/notifications/NotificationChannelStorage.ts index a3e5f020c..01a5dbd50 100644 --- a/src/server/notifications/NotificationChannelStorage.ts +++ b/src/server/notifications/NotificationChannelStorage.ts @@ -1,5 +1,5 @@ import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier'; -import type { NotificationChannel, NotificationChannelJson } from './NotificationChannel'; +import type { NotificationChannel } from './NotificationChannel'; /** * Stores all the information necessary to keep track of notification channels. @@ -9,15 +9,7 @@ import type { NotificationChannel, NotificationChannelJson } from './Notificatio */ export interface NotificationChannelStorage { /** - * Creates channel corresponding to the given channel and features. - * This does not store the generated channel in the storage. - * @param channel - Notification channel to generate channel of. - * @param features - Features to add to the channel - */ - create: (channel: NotificationChannelJson, features: Record) => NotificationChannel; - - /** - * Returns the channel for the requested notification channel. + * Returns the requested channel. * `undefined` if no match was found or if the notification channel expired. * @param id - The identifier of the notification channel. */ diff --git a/src/server/notifications/NotificationChannelType.ts b/src/server/notifications/NotificationChannelType.ts index 11fc15382..ac7900569 100644 --- a/src/server/notifications/NotificationChannelType.ts +++ b/src/server/notifications/NotificationChannelType.ts @@ -1,42 +1,41 @@ -import type { InferType } from 'yup'; +import type { Store } from 'n3'; import type { Credentials } from '../../authentication/Credentials'; import type { AccessMap } from '../../authorization/permissions/Permissions'; -import type { Representation } from '../../http/representation/Representation'; -import type { NOTIFICATION_CHANNEL_SCHEMA, NotificationChannel } from './NotificationChannel'; - -export interface NotificationChannelResponse { - response: Representation; - channel: NotificationChannel; -} +import type { NotificationChannel } from './NotificationChannel'; /** * A specific channel type as defined at * https://solidproject.org/TR/2022/notifications-protocol-20221231#notification-channel-types. + * + * All functions that take a {@link NotificationChannel} as input + * only need to support channels generated by an `initChannel` on the same class. */ -export interface NotificationChannelType< - TSub extends typeof NOTIFICATION_CHANNEL_SCHEMA = typeof NOTIFICATION_CHANNEL_SCHEMA> { +export interface NotificationChannelType { /** - * The expected type value in the JSON-LD body of requests subscribing for this notification channel type. + * Validate and convert the input quads into a {@link NotificationChannel}. + * @param data - The input quads. + * @param credentials - The credentials of the agent doing the request. */ - readonly type: string; + initChannel: (data: Store, credentials: Credentials) => Promise; + /** - * An extension of {@link NOTIFICATION_CHANNEL_SCHEMA} - * that can be used to parse and validate an incoming subscription request with a notification channel body. + * Converts a {@link NotificationChannel} to a serialized JSON-LD representation. + * @param channel - The notification channel to serialize. */ - readonly schema: TSub; + toJsonLd: (channel: NotificationChannel) => Promise>; + /** * Determines which modes are required to allow the given notification channel. * @param channel - The notification channel to verify. * * @returns The required modes. */ - extractModes: (json: InferType) => Promise; + extractModes: (channel: NotificationChannel) => Promise; + /** - * Registers the given notification channel. - * @param channel - The notification channel to register. - * @param credentials - The credentials of the client trying to subscribe. - * - * @returns A {@link Representation} to return as a response and the generated {@link NotificationChannel}. + * This function will be called after the serialized channel is sent back as a response, + * allowing for any final actions that need to happen. + * @param channel - The notification channel that is completed. */ - subscribe: (json: InferType, credentials: Credentials) => Promise; + completeChannel: (channel: NotificationChannel) => Promise; } diff --git a/src/server/notifications/NotificationSubscriber.ts b/src/server/notifications/NotificationSubscriber.ts index 45cd707aa..1afd5aa1a 100644 --- a/src/server/notifications/NotificationSubscriber.ts +++ b/src/server/notifications/NotificationSubscriber.ts @@ -4,16 +4,17 @@ import type { Authorizer } from '../../authorization/Authorizer'; import type { PermissionReader } from '../../authorization/PermissionReader'; import { OkResponseDescription } from '../../http/output/response/OkResponseDescription'; import type { ResponseDescription } from '../../http/output/response/ResponseDescription'; +import { BasicRepresentation } from '../../http/representation/BasicRepresentation'; import { getLoggerFor } from '../../logging/LogUtil'; -import { APPLICATION_LD_JSON } from '../../util/ContentTypes'; +import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter'; +import { APPLICATION_LD_JSON, INTERNAL_QUADS } from '../../util/ContentTypes'; import { createErrorMessage } from '../../util/errors/ErrorUtil'; import { UnprocessableEntityHttpError } from '../../util/errors/UnprocessableEntityHttpError'; -import { UnsupportedMediaTypeHttpError } from '../../util/errors/UnsupportedMediaTypeHttpError'; -import { readableToString } from '../../util/StreamUtil'; -import type { HttpRequest } from '../HttpRequest'; +import { endOfStream, readableToQuads } from '../../util/StreamUtil'; import type { OperationHttpHandlerInput } from '../OperationHttpHandler'; import { OperationHttpHandler } from '../OperationHttpHandler'; -import type { NotificationChannelJson } from './NotificationChannel'; +import type { NotificationChannel } from './NotificationChannel'; +import type { NotificationChannelStorage } from './NotificationChannelStorage'; import type { NotificationChannelType } from './NotificationChannelType'; export interface NotificationSubscriberArgs { @@ -21,6 +22,10 @@ export interface NotificationSubscriberArgs { * The {@link NotificationChannelType} with all the necessary information. */ channelType: NotificationChannelType; + /** + * {@link RepresentationConverter} used to convert input data into RDF. + */ + converter: RepresentationConverter; /** * Used to extract the credentials from the request. */ @@ -33,6 +38,10 @@ export interface NotificationSubscriberArgs { * Used to determine if the request has the necessary permissions. */ authorizer: Authorizer; + /** + * Storage used to store the channels. + */ + storage: NotificationChannelStorage; /** * Overrides the expiration feature of channels, by making sure they always expire after the `maxDuration` value. * If the expiration of the channel is shorter than `maxDuration`, the original value will be kept. @@ -46,34 +55,42 @@ export interface NotificationSubscriberArgs { * * Uses the information from the provided {@link NotificationChannelType} to validate the input * and verify the request has the required permissions available. + * If successful the generated channel will be stored in a {@link NotificationChannelStorage}. */ export class NotificationSubscriber extends OperationHttpHandler { protected logger = getLoggerFor(this); private readonly channelType: NotificationChannelType; + private readonly converter: RepresentationConverter; private readonly credentialsExtractor: CredentialsExtractor; private readonly permissionReader: PermissionReader; private readonly authorizer: Authorizer; + private readonly storage: NotificationChannelStorage; private readonly maxDuration: number; public constructor(args: NotificationSubscriberArgs) { super(); this.channelType = args.channelType; + this.converter = args.converter; this.credentialsExtractor = args.credentialsExtractor; this.permissionReader = args.permissionReader; this.authorizer = args.authorizer; + this.storage = args.storage; this.maxDuration = (args.maxDuration ?? 0) * 60 * 1000; } public async handle({ operation, request }: OperationHttpHandlerInput): Promise { - if (operation.body.metadata.contentType !== APPLICATION_LD_JSON) { - throw new UnsupportedMediaTypeHttpError('Subscribe bodies need to be application/ld+json.'); - } + const credentials = await this.credentialsExtractor.handleSafe(request); + this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`); - let channel: NotificationChannelJson; + let channel: NotificationChannel; try { - const json = JSON.parse(await readableToString(operation.body.data)); - channel = await this.channelType.schema.validate(json); + const quadStream = await this.converter.handleSafe({ + identifier: operation.target, + representation: operation.body, + preferences: { type: { [INTERNAL_QUADS]: 1 }}, + }); + channel = await this.channelType.initChannel(await readableToQuads(quadStream.data), credentials); } catch (error: unknown) { throw new UnprocessableEntityHttpError(`Unable to process notification channel: ${createErrorMessage(error)}`); } @@ -86,17 +103,27 @@ export class NotificationSubscriber extends OperationHttpHandler { } // Verify if the client is allowed to subscribe - const credentials = await this.authorize(request, channel); + await this.authorize(credentials, channel); - const { response } = await this.channelType.subscribe(channel, credentials); + // Store the channel once it has been authorized + await this.storage.add(channel); + + // Generate the response JSON-LD + const jsonld = await this.channelType.toJsonLd(channel); + const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON); + + // Complete the channel once the response has been sent out + endOfStream(response.data) + .then((): Promise => this.channelType.completeChannel(channel)) + .catch((error): void => { + this.logger.error(`There was an issue completing notification channel ${channel.id}: ${ + createErrorMessage(error)}`); + }); return new OkResponseDescription(response.metadata, response.data); } - private async authorize(request: HttpRequest, channel: NotificationChannelJson): Promise { - const credentials = await this.credentialsExtractor.handleSafe(request); - this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`); - + private async authorize(credentials: Credentials, channel: NotificationChannel): Promise { const requestedModes = await this.channelType.extractModes(channel); this.logger.debug(`Retrieved required modes: ${[ ...requestedModes.entrySets() ]}`); @@ -104,8 +131,6 @@ export class NotificationSubscriber extends OperationHttpHandler { this.logger.debug(`Available permissions are ${[ ...availablePermissions.entries() ]}`); await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions }); - this.logger.verbose(`Authorization succeeded, creating notification channel`); - - return credentials; + this.logger.debug(`Authorization succeeded, creating notification channel`); } } diff --git a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts index 22d56d4c4..30b09b9ce 100644 --- a/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts +++ b/src/server/notifications/WebHookSubscription2021/WebHookSubscription2021.ts @@ -1,31 +1,15 @@ -import type { InferType } from 'yup'; -import { string } from 'yup'; +import type { Store } from 'n3'; import type { Credentials } from '../../../authentication/Credentials'; -import type { AccessMap } from '../../../authorization/permissions/Permissions'; -import { AccessMode } from '../../../authorization/permissions/Permissions'; -import { BasicRepresentation } from '../../../http/representation/BasicRepresentation'; import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; import { getLoggerFor } from '../../../logging/LogUtil'; -import { APPLICATION_LD_JSON } from '../../../util/ContentTypes'; import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError'; import { createErrorMessage } from '../../../util/errors/ErrorUtil'; -import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap'; -import { endOfStream } from '../../../util/StreamUtil'; -import { CONTEXT_NOTIFICATION } from '../Notification'; +import { NOTIFY } from '../../../util/Vocabularies'; +import { BaseChannelType } from '../BaseChannelType'; import type { NotificationChannel } from '../NotificationChannel'; -import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel'; -import type { NotificationChannelStorage } from '../NotificationChannelStorage'; -import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType'; import type { StateHandler } from '../StateHandler'; import { generateWebHookUnsubscribeUrl } from './WebHook2021Util'; -const type = 'WebHookSubscription2021'; -const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({ - type: string().required().oneOf([ type ]), - // Not using `.url()` validator since it does not support localhost URLs - target: string().required(), -}); - /** * A {@link NotificationChannel} containing the necessary fields for a WebHookSubscription2021 channel. */ @@ -33,7 +17,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel { /** * The "WebHookSubscription2021" type. */ - type: typeof type; + type: typeof NOTIFY.WebHookSubscription2021; /** * Where the notifications have to be sent. */ @@ -50,7 +34,7 @@ export interface WebHookSubscription2021Channel extends NotificationChannel { } export function isWebHook2021Channel(channel: NotificationChannel): channel is WebHookSubscription2021Channel { - return channel.type === type; + return channel.type === NOTIFY.WebHookSubscription2021; } /** @@ -61,29 +45,27 @@ export function isWebHook2021Channel(channel: NotificationChannel): channel is W * * Also handles the `state` feature if present. */ -export class WebHookSubscription2021 implements NotificationChannelType { +export class WebHookSubscription2021 extends BaseChannelType { protected readonly logger = getLoggerFor(this); - private readonly storage: NotificationChannelStorage; private readonly unsubscribePath: string; private readonly stateHandler: StateHandler; - public readonly type = type; - public readonly schema = schema; - - public constructor(storage: NotificationChannelStorage, unsubscribeRoute: InteractionRoute, - stateHandler: StateHandler) { - this.storage = storage; + public constructor(unsubscribeRoute: InteractionRoute, stateHandler: StateHandler) { + super(NOTIFY.terms.WebHookSubscription2021, + // Need to remember to remove `target` from the vocabulary again once this is updated to webhooks 2023, + // as it is not actually part of the vocabulary. + // Technically we should also require that this node is a named node, + // but that would require clients to send `target: { '@id': 'http://example.com/target' }`, + // which would make this more annoying so we are lenient here. + // Could change in the future once this field is updated and part of the context. + [{ path: NOTIFY.target, minCount: 1, maxCount: 1 }]); this.unsubscribePath = unsubscribeRoute.getPath(); this.stateHandler = stateHandler; } - public async extractModes(json: InferType): Promise { - return new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]); - } - - public async subscribe(json: InferType, credentials: Credentials): - Promise { + public async initChannel(data: Store, credentials: Credentials): Promise { + // The WebID is used to verify who can unsubscribe const webId = credentials.agent?.webId; if (!webId) { @@ -92,27 +74,36 @@ export class WebHookSubscription2021 implements NotificationChannelType => this.stateHandler.handleSafe({ channel })) - .catch((error): void => { - this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`); - }); + public async toJsonLd(channel: NotificationChannel): Promise> { + const json = await super.toJsonLd(channel); - return { response, channel }; + // We don't want to expose the WebID that initialized the notification channel. + // This is not really specified either way in the spec so this might change in the future. + delete json.webId; + + return json; + } + + public async completeChannel(channel: NotificationChannel): Promise { + try { + // Send the state notification, if there is one + await this.stateHandler.handleSafe({ channel }); + } catch (error: unknown) { + this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`); + } } } diff --git a/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts index ba6e61bbf..ee3d9a85b 100644 --- a/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts +++ b/src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.ts @@ -1,22 +1,29 @@ -import { string } from 'yup'; -import type { AccessMap } from '../../../authorization/permissions/Permissions'; -import { AccessMode } from '../../../authorization/permissions/Permissions'; -import { BasicRepresentation } from '../../../http/representation/BasicRepresentation'; +import type { Store } from 'n3'; +import type { Credentials } from '../../../authentication/Credentials'; import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute'; import { getLoggerFor } from '../../../logging/LogUtil'; -import { APPLICATION_LD_JSON } from '../../../util/ContentTypes'; -import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap'; -import { CONTEXT_NOTIFICATION } from '../Notification'; -import type { NotificationChannelJson } from '../NotificationChannel'; -import { NOTIFICATION_CHANNEL_SCHEMA } from '../NotificationChannel'; -import type { NotificationChannelStorage } from '../NotificationChannelStorage'; -import type { NotificationChannelResponse, NotificationChannelType } from '../NotificationChannelType'; +import { NOTIFY } from '../../../util/Vocabularies'; +import { BaseChannelType } from '../BaseChannelType'; +import type { NotificationChannel } from '../NotificationChannel'; import { generateWebSocketUrl } from './WebSocket2021Util'; -const type = 'WebSocketSubscription2021'; -const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({ - type: string().required().oneOf([ type ]), -}); +/** + * A {@link NotificationChannel} containing the necessary fields for a WebSocketSubscription2021 channel. + */ +export interface WebSocketSubscription2021Channel extends NotificationChannel { + /** + * The "notify:WebSocketSubscription2021" type. + */ + type: typeof NOTIFY.WebSocketSubscription2021; + /** + * The WebSocket through which the channel will send notifications. + */ + source: string; +} + +export function isWebSocket2021Channel(channel: NotificationChannel): channel is WebSocketSubscription2021Channel { + return channel.type === NOTIFY.WebSocketSubscription2021; +} /** * The notification channel type WebSocketSubscription2021 as described in @@ -24,35 +31,22 @@ const schema = NOTIFICATION_CHANNEL_SCHEMA.shape({ * * Requires read permissions on a resource to be able to receive notifications. */ -export class WebSocketSubscription2021 implements NotificationChannelType { +export class WebSocketSubscription2021 extends BaseChannelType { protected readonly logger = getLoggerFor(this); - private readonly storage: NotificationChannelStorage; private readonly path: string; - public readonly type = type; - public readonly schema = schema; - - public constructor(storage: NotificationChannelStorage, route: InteractionRoute) { - this.storage = storage; + public constructor(route: InteractionRoute) { + super(NOTIFY.terms.WebSocketSubscription2021); this.path = route.getPath(); } - public async extractModes(json: NotificationChannelJson): Promise { - return new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]); - } - - public async subscribe(json: NotificationChannelJson): Promise { - const channel = this.storage.create(json, {}); - await this.storage.add(channel); - - const jsonld = { - '@context': [ CONTEXT_NOTIFICATION ], - type: this.type, + public async initChannel(data: Store, credentials: Credentials): Promise { + const channel = await super.initChannel(data, credentials); + return { + ...channel, + type: NOTIFY.WebSocketSubscription2021, source: generateWebSocketUrl(this.path, channel.id), }; - const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON); - - return { response, channel }; } } diff --git a/src/util/StringUtil.ts b/src/util/StringUtil.ts index 2fbcd71ee..a51722750 100644 --- a/src/util/StringUtil.ts +++ b/src/util/StringUtil.ts @@ -28,3 +28,38 @@ export function sanitizeUrlPart(urlPart: string): string { export function isValidFileName(name: string): boolean { return /^[\w.-]+$/u.test(name); } + +/** + * Converts milliseconds to an ISO 8601 duration string. + * The only categories used are days, hours, minutes, and seconds, + * because months have no fixed size in milliseconds. + * @param ms - The duration in ms to convert. + */ +export function msToDuration(ms: number): string { + let totalSeconds = ms / 1000; + const days = Math.floor(totalSeconds / (60 * 60 * 24)); + totalSeconds -= days * 60 * 60 * 24; + const hours = Math.floor(totalSeconds / (60 * 60)); + totalSeconds -= hours * 60 * 60; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds - (minutes * 60); + + const stringParts: string[] = [ 'P' ]; + if (days > 0) { + stringParts.push(`${days}D`); + } + if (hours > 0 || minutes > 0 || seconds > 0) { + stringParts.push('T'); + } + if (hours > 0) { + stringParts.push(`${hours}H`); + } + if (minutes > 0) { + stringParts.push(`${minutes}M`); + } + if (seconds > 0) { + stringParts.push(`${seconds}S`); + } + + return stringParts.join(''); +} diff --git a/src/util/Vocabularies.ts b/src/util/Vocabularies.ts index e6b342f9c..95b045c2c 100644 --- a/src/util/Vocabularies.ts +++ b/src/util/Vocabularies.ts @@ -200,10 +200,13 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications 'startAt', 'state', 'subscription', + 'target', + 'topic', 'webhookAuth', 'webid', 'WebHookSubscription2021', + 'WebSocketSubscription2021', ); export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#', @@ -276,7 +279,9 @@ export const VCARD = createVocabulary('http://www.w3.org/2006/vcard/ns#', export const XSD = createVocabulary('http://www.w3.org/2001/XMLSchema#', 'dateTime', + 'duration', 'integer', + 'string', ); // Alias for commonly used types diff --git a/templates/contexts/shacl.jsonld b/templates/contexts/shacl.jsonld new file mode 100644 index 000000000..62b322651 --- /dev/null +++ b/templates/contexts/shacl.jsonld @@ -0,0 +1,177 @@ +{ + "@context": { + "@vocab": "http://www.w3.org/ns/shacl#", + "sh": "http://www.w3.org/ns/shacl#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "comment": "rdfs:comment", + "target": { + "@type": "@id" + }, + "targetClass": { + "@type": "@id" + }, + "targetObjectsOf": { + "@type": "@id" + }, + "targetSubjectsOf": { + "@type": "@id" + }, + "severity": { + "@type": "@id" + }, + "result": { + "@type": "@id" + }, + "detail": { + "@type": "@id" + }, + "resultPath": { + "@type": "@id" + }, + "resultSeverity": { + "@type": "@id" + }, + "sourceShape": { + "@type": "@id" + }, + "sourceConstraintComponent": { + "@type": "@id" + }, + "prefixes": { + "@type": "@id" + }, + "declare": { + "@type": "@id" + }, + "namespace": { + "@type": "xsd:anyURI" + }, + "shapesGraph": { + "@type": "@id" + }, + "path": { + "@type": "@id" + }, + "inversePath": { + "@type": "@id" + }, + "alternativePath": { + "@type": "@id", + "@container": "@list" + }, + "zeroOrMorePath": { + "@type": "@id" + }, + "oneOrMorePath": { + "@type": "@id" + }, + "zeroOrOnePath": { + "@type": "@id" + }, + "parameter": { + "@type": "@id" + }, + "validator": { + "@type": "@id" + }, + "nodeValidator": { + "@type": "@id" + }, + "propertyValidator": { + "@type": "@id" + }, + "and": { + "@type": "@id", + "@container": "@list" + }, + "class": { + "@type": "@id" + }, + "ignoredProperties": { + "@type": "@id", + "@container": "@list" + }, + "datatype": { + "@type": "@id" + }, + "disjoint": { + "@type": "@id" + }, + "equals": { + "@type": "@id" + }, + "in": { + "@container": "@list" + }, + "languageIn": { + "@container": "@list" + }, + "lessThan": { + "@type": "@id" + }, + "lessThanOrEquals": { + "@type": "@id" + }, + "node": { + "@type": "@id" + }, + "nodeKind": { + "@type": "@id" + }, + "not": { + "@type": "@id" + }, + "or": { + "@type": "@id", + "@container": "@list" + }, + "property": { + "@type": "@id" + }, + "qualifiedValueShape": { + "@type": "@id" + }, + "xone": { + "@type": "@id", + "@container": "@list" + }, + "sparql": { + "@type": "@id" + }, + "derivedValues": { + "@type": "@id" + }, + "group": { + "@type": "@id" + }, + "returnType": { + "@type": "@id" + }, + "resultAnnotation": { + "@type": "@id" + }, + "annotationProperty": { + "@type": "@id" + }, + "maxCount": { + "@type": "xsd:integer" + }, + "maxLength": { + "@type": "xsd:integer" + }, + "minCount": { + "@type": "xsd:integer" + }, + "minLength": { + "@type": "xsd:integer" + }, + "qualifiedMaxCount": { + "@type": "xsd:integer" + }, + "qualifiedMinCount": { + "@type": "xsd:integer" + } + } +} diff --git a/test/integration/WebHookSubscription2021.test.ts b/test/integration/WebHookSubscription2021.test.ts index 6c802bb9b..0e927988c 100644 --- a/test/integration/WebHookSubscription2021.test.ts +++ b/test/integration/WebHookSubscription2021.test.ts @@ -16,7 +16,8 @@ import { getPresetConfigPath, getTestConfigPath, getTestFolder, - instantiateFromConfig, removeFolder, + instantiateFromConfig, + removeFolder, } from './Config'; import quad = DataFactory.quad; import namedNode = DataFactory.namedNode; @@ -26,7 +27,7 @@ const baseUrl = `http://localhost:${port}/`; const clientPort = getPort('WebHookSubscription2021-client'); const target = `http://localhost:${clientPort}/`; const webId = 'http://example.com/card/#me'; -const notificationType = 'WebHookSubscription2021'; +const notificationType = NOTIFY.WebHookSubscription2021; const rootFilePath = getTestFolder('WebHookSubscription2021'); const stores: [string, any][] = [ @@ -109,7 +110,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n }); it('supports subscribing.', async(): Promise => { - await subscribe(notificationType, webId, subscriptionUrl, topic, { target }); + await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target }); }); it('emits Created events.', async(): Promise => { @@ -168,7 +169,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n }); }); - await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' }); + await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target, state: 'abc' }); // Will resolve even though the resource did not change since subscribing const { request, response } = await clientPromise; diff --git a/test/integration/WebSocketSubscription2021.test.ts b/test/integration/WebSocketSubscription2021.test.ts index ff828a79c..11945bd70 100644 --- a/test/integration/WebSocketSubscription2021.test.ts +++ b/test/integration/WebSocketSubscription2021.test.ts @@ -14,14 +14,15 @@ import { getPresetConfigPath, getTestConfigPath, getTestFolder, - instantiateFromConfig, removeFolder, + instantiateFromConfig, + removeFolder, } from './Config'; import quad = DataFactory.quad; import namedNode = DataFactory.namedNode; const port = getPort('WebSocketSubscription2021'); const baseUrl = `http://localhost:${port}/`; -const notificationType = 'WebSocketSubscription2021'; +const notificationType = NOTIFY.WebSocketSubscription2021; const rootFilePath = getTestFolder('WebSocketSubscription2021'); const stores: [string, any][] = [ @@ -166,7 +167,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', const channel = { '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebSocketSubscription2021', + type: NOTIFY.WebSocketSubscription2021, topic: restricted, }; @@ -212,7 +213,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', }); it('removes expired channels.', async(): Promise => { - const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: 1 }) as any; + const { source } = + await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: '1988-03-09T14:48:00.000Z' }) as any; const socket = new WebSocket(source); const messagePromise = new Promise((resolve): any => socket.on('message', resolve)); diff --git a/test/unit/server/notifications/BaseChannelType.test.ts b/test/unit/server/notifications/BaseChannelType.test.ts new file mode 100644 index 000000000..657cfdc99 --- /dev/null +++ b/test/unit/server/notifications/BaseChannelType.test.ts @@ -0,0 +1,190 @@ +import { DataFactory, Store } from 'n3'; +import type { Credentials } from '../../../../src/authentication/Credentials'; +import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; +import { BaseChannelType } from '../../../../src/server/notifications/BaseChannelType'; +import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel'; +import { DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/NotificationDescriber'; +import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; +import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; +import { NOTIFY, RDF, XSD } from '../../../../src/util/Vocabularies'; +import namedNode = DataFactory.namedNode; +import quad = DataFactory.quad; +import blankNode = DataFactory.blankNode; +import literal = DataFactory.literal; + +jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); + +const dummyType = namedNode('http://example.com/DummyType'); +class DummyChannelType extends BaseChannelType { + public constructor(properties?: unknown[]) { + super( + dummyType, + properties, + ); + } +} + +describe('A BaseChannelType', (): void => { + const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource'; + const credentials: Credentials = {}; + const channelType = new DummyChannelType(); + + describe('#initChannel', (): void => { + let data: Store; + const subject = blankNode(); + beforeEach(async(): Promise => { + data = new Store(); + data.addQuad(quad(subject, RDF.terms.type, dummyType)); + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource'))); + }); + + it('converts the quads to a channel with an identifier.', async(): Promise => { + await expect(channelType.initChannel(data, credentials)).resolves.toEqual({ + id, + type: dummyType.value, + topic: 'https://storage.example/resource', + }); + }); + + it('requires exactly 1 topic.', async(): Promise => { + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource2'))); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + + data.removeQuads(data.getQuads(subject, NOTIFY.terms.topic, null, null)); + expect(data.size).toBe(1); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + + // Data is correct again now + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource'))); + await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined(); + + // Also make sure we can't have 2 different subjects with 1 topic each + data.addQuad(quad(blankNode(), NOTIFY.terms.topic, namedNode('https://storage.example/resource2'))); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + }); + + it('requires the correct type.', async(): Promise => { + data = new Store(); + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource'))); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + + data.addQuad(quad(subject, RDF.terms.type, namedNode('http://example.com/wrongType'))); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + + data.addQuad(quad(subject, RDF.terms.type, dummyType)); + await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + + data.removeQuads(data.getQuads(subject, RDF.terms.type, namedNode('http://example.com/wrongType'), null)); + data.addQuad(quad(subject, RDF.terms.type, dummyType)); + await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined(); + }); + + it('converts the start date to a number.', async(): Promise => { + const date = '1988-03-09T14:48:00.000Z'; + const ms = Date.parse(date); + + data.addQuad(quad(subject, NOTIFY.terms.startAt, literal(date, XSD.terms.dateTime))); + await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({ + startAt: ms, + })); + }); + + it('converts the end date to a number.', async(): Promise => { + const date = '1988-03-09T14:48:00.000Z'; + const ms = Date.parse(date); + + data.addQuad(quad(subject, NOTIFY.terms.endAt, literal(date, XSD.terms.dateTime))); + await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({ + endAt: ms, + })); + }); + + it('converts the rate to a number.', async(): Promise => { + data.addQuad(quad(subject, NOTIFY.terms.rate, literal('PT10S', XSD.terms.duration))); + await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({ + rate: 10 * 1000, + })); + }); + + it('requires correct datatypes on the features.', async(): Promise => { + for (const feature of DEFAULT_NOTIFICATION_FEATURES) { + const badData = new Store(data.getQuads(null, null, null, null)); + // No feature accepts an integer + badData.addQuad(quad(subject, namedNode(feature), literal(123456, XSD.terms.integer))); + await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + } + }); + + it('requires that features occur at most once.', async(): Promise => { + const values = { + [NOTIFY.startAt]: [ + literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime), + literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime), + ], + [NOTIFY.endAt]: [ + literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime), + literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime), + ], + [NOTIFY.rate]: [ literal('PT10S', XSD.terms.duration), literal('PT11S', XSD.terms.duration) ], + [NOTIFY.accept]: [ literal('text/turtle'), literal('application/ld+json') ], + [NOTIFY.state]: [ literal('123456'), literal('654321') ], + }; + + for (const [ predicate, objects ] of Object.entries(values)) { + const badData = new Store(data.getQuads(null, null, null, null)); + badData.addQuad(quad(subject, namedNode(predicate), objects[0])); + // One entry is fine + await expect(channelType.initChannel(badData, credentials)).resolves.toBeDefined(); + badData.addQuad(quad(subject, namedNode(predicate), objects[1])); + await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError); + } + }); + }); + + it('can convert a notification channel to a JSON-LD representation.', async(): Promise => { + const startDate = '1988-03-09T14:48:00.000Z'; + const endDate = '2022-03-09T14:48:00.000Z'; + const channel: NotificationChannel = { + id, + type: 'DummyType', + topic: 'https://storage.example/resource', + state: 'state', + startAt: Date.parse(startDate), + endAt: Date.parse(endDate), + rate: 10 * 1000, + accept: 'text/turtle', + lastEmit: 123456789, + }; + + await expect(channelType.toJsonLd(channel)).resolves.toEqual({ + '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], + id: channel.id, + type: channel.type, + topic: channel.topic, + state: channel.state, + startAt: startDate, + endAt: endDate, + rate: 'PT10S', + accept: channel.accept, + }); + }); + + it('requires read permissions on the topic.', async(): Promise => { + const channel: NotificationChannel = { + id, + type: 'DummyType', + topic: 'https://storage.example/resource', + }; + await expect(channelType.extractModes(channel)).resolves + .toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]])); + }); + + it('does nothing when completing the channel.', async(): Promise => { + const channel: NotificationChannel = { + id, + type: 'DummyType', + topic: 'https://storage.example/resource', + }; + await expect(channelType.completeChannel(channel)).resolves.toBeUndefined(); + }); +}); diff --git a/test/unit/server/notifications/KeyValueChannelStorage.test.ts b/test/unit/server/notifications/KeyValueChannelStorage.test.ts index 007534b28..8d7fc7465 100644 --- a/test/unit/server/notifications/KeyValueChannelStorage.test.ts +++ b/test/unit/server/notifications/KeyValueChannelStorage.test.ts @@ -3,10 +3,7 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res import type { Logger } from '../../../../src/logging/Logger'; import { getLoggerFor } from '../../../../src/logging/LogUtil'; import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage'; -import type { - NotificationChannel, - NotificationChannelJson, -} from '../../../../src/server/notifications/NotificationChannel'; +import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel'; import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage'; import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker'; import resetAllMocks = jest.resetAllMocks; @@ -21,12 +18,6 @@ describe('A KeyValueChannelStorage', (): void => { const logger = getLoggerFor('mock'); const topic = 'http://example.com/foo'; const identifier = { path: topic }; - const json = { - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebSocketSubscription2021', - topic, - } as NotificationChannelJson; - const features = {}; let channel: NotificationChannel; let internalMap: Map; let internalStorage: KeyValueStorage; @@ -53,12 +44,6 @@ describe('A KeyValueChannelStorage', (): void => { storage = new KeyValueChannelStorage(internalStorage, locker); }); - describe('#create', (): void => { - it('creates channel based on a notification channel.', async(): Promise => { - expect(storage.create(json, features)).toEqual(channel); - }); - }); - describe('#get', (): void => { it('returns undefined if there is no match.', async(): Promise => { await expect(storage.get('notexists')).resolves.toBeUndefined(); diff --git a/test/unit/server/notifications/NotificationChannel.test.ts b/test/unit/server/notifications/NotificationChannel.test.ts deleted file mode 100644 index b29c392eb..000000000 --- a/test/unit/server/notifications/NotificationChannel.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel'; - -describe('A NotificationChannel', (): void => { - const validChannel = { - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'NotificationChannelType', - topic: 'http://example.com/foo', - }; - - it('requires a minimal set of values.', async(): Promise => { - await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(validChannel)).resolves.toBe(true); - }); - - it('requires the notification context header to be present.', async(): Promise => { - let channel: unknown = { - type: 'NotificationChannelType', - topic: 'http://example.com/foo', - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false); - - channel = { - '@context': [ 'wrongContext' ], - type: 'NotificationChannelType', - topic: 'http://example.com/foo', - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false); - - channel = { - '@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ], - type: 'NotificationChannelType', - topic: 'http://example.com/foo', - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true); - - channel = { - '@context': 'https://www.w3.org/ns/solid/notification/v1', - type: 'NotificationChannelType', - topic: 'http://example.com/foo', - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true); - }); - - it('converts the start date to a number.', async(): Promise => { - const date = '1988-03-09T14:48:00.000Z'; - const ms = Date.parse(date); - - const channel: unknown = { - ...validChannel, - startAt: date, - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({ - startAt: ms, - })); - }); - - it('converts the end date to a number.', async(): Promise => { - const date = '1988-03-09T14:48:00.000Z'; - const ms = Date.parse(date); - - const channel: unknown = { - ...validChannel, - endAt: date, - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({ - endAt: ms, - })); - }); - - it('converts the rate to a number.', async(): Promise => { - const channel: unknown = { - ...validChannel, - rate: 'PT10S', - }; - await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({ - rate: 10 * 1000, - })); - }); -}); diff --git a/test/unit/server/notifications/NotificationSubscriber.test.ts b/test/unit/server/notifications/NotificationSubscriber.test.ts index 2df45f250..62b47c229 100644 --- a/test/unit/server/notifications/NotificationSubscriber.test.ts +++ b/test/unit/server/notifications/NotificationSubscriber.test.ts @@ -6,50 +6,67 @@ import { AccessMode } from '../../../../src/authorization/permissions/Permission import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; +import type { Logger } from '../../../../src/logging/Logger'; +import { getLoggerFor } from '../../../../src/logging/LogUtil'; import type { HttpRequest } from '../../../../src/server/HttpRequest'; import type { HttpResponse } from '../../../../src/server/HttpResponse'; -import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel'; +import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel'; +import type { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage'; import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType'; import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber'; +import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter'; +import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes'; import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError'; -import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError'; import { IdentifierMap, IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; -import { guardedStreamFrom } from '../../../../src/util/StreamUtil'; +import { readableToString } from '../../../../src/util/StreamUtil'; +import { flushPromises } from '../../../util/Util'; + +jest.mock('../../../../src/logging/LogUtil', (): any => { + const logger: Logger = + { debug: jest.fn(), error: jest.fn() } as any; + return { getLoggerFor: (): Logger => logger }; +}); describe('A NotificationSubscriber', (): void => { - let channel: any; const request: HttpRequest = {} as any; const response: HttpResponse = {} as any; let operation: Operation; const topic: ResourceIdentifier = { path: 'http://example.com/foo' }; + let channel: NotificationChannel; let channelType: jest.Mocked; + let converter: jest.Mocked; let credentialsExtractor: jest.Mocked; let permissionReader: jest.Mocked; let authorizer: jest.Mocked; + let storage: jest.Mocked; let subscriber: NotificationSubscriber; beforeEach(async(): Promise => { - channel = { - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'NotificationChannelType', - topic: topic.path, - }; - operation = { method: 'POST', target: { path: 'http://example.com/.notifications/websockets/' }, - body: new BasicRepresentation(JSON.stringify(channel), 'application/ld+json'), + body: new BasicRepresentation(), preferences: {}, }; - channelType = { + channel = { type: 'NotificationChannelType', - schema: NOTIFICATION_CHANNEL_SCHEMA, + topic: topic.path, + id: '123456', + }; + + channelType = { + initChannel: jest.fn().mockResolvedValue(channel), + toJsonLd: jest.fn().mockResolvedValue({}), extractModes: jest.fn(async(subscription): Promise => new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap), - subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), channel: {}}), + completeChannel: jest.fn(), }; + converter = { + handleSafe: jest.fn().mockResolvedValue(new BasicRepresentation([], INTERNAL_QUADS)), + } as any; + credentialsExtractor = { handleSafe: jest.fn().mockResolvedValue({ public: {}}), } as any; @@ -62,38 +79,40 @@ describe('A NotificationSubscriber', (): void => { handleSafe: jest.fn(), } as any; - subscriber = new NotificationSubscriber({ channelType, credentialsExtractor, permissionReader, authorizer }); - }); + storage = { + add: jest.fn(), + } as any; - it('requires the request to be JSON-LD.', async(): Promise => { - operation.body.metadata.contentType = 'text/turtle'; - - await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnsupportedMediaTypeHttpError); + subscriber = new NotificationSubscriber( + { channelType, converter, credentialsExtractor, permissionReader, authorizer, storage }, + ); }); it('errors if the request can not be parsed correctly.', async(): Promise => { - operation.body.data = guardedStreamFrom('not json'); - await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError); - - // Type is missing - operation.body.data = guardedStreamFrom(JSON.stringify({ - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - topic, - })); - await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError); + converter.handleSafe.mockRejectedValueOnce(new Error('bad data')); + await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data'); + expect(storage.add).toHaveBeenCalledTimes(0); }); - it('returns the representation generated by the subscribe call.', async(): Promise => { + it('errors if the channel type rejects the input.', async(): Promise => { + channelType.initChannel.mockRejectedValueOnce(new Error('bad data')); + await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError); + expect(storage.add).toHaveBeenCalledTimes(0); + }); + + it('returns the JSON generated by the channel type.', async(): Promise => { const description = await subscriber.handle({ operation, request, response }); expect(description.statusCode).toBe(200); - const subscribeResult = await channelType.subscribe.mock.results[0].value; - expect(description.data).toBe(subscribeResult.response.data); - expect(description.metadata).toBe(subscribeResult.response.metadata); + expect(JSON.parse(await readableToString(description.data!))).toEqual({}); + expect(description.metadata?.contentType).toBe('application/ld+json'); + expect(storage.add).toHaveBeenCalledTimes(1); + expect(storage.add).toHaveBeenLastCalledWith(channel); }); it('errors on requests the Authorizer rejects.', async(): Promise => { authorizer.handleSafe.mockRejectedValue(new Error('not allowed')); await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('not allowed'); + expect(storage.add).toHaveBeenCalledTimes(0); }); it('updates the channel expiration if a max is defined.', async(): Promise => { @@ -102,35 +121,65 @@ describe('A NotificationSubscriber', (): void => { subscriber = new NotificationSubscriber({ channelType, + converter, credentialsExtractor, permissionReader, authorizer, + storage, maxDuration: 60, }); await subscriber.handle({ operation, request, response }); - expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ - endAt: Date.now() + (60 * 60 * 1000), - }), { public: {}}); - - operation.body.data = guardedStreamFrom(JSON.stringify({ + expect(storage.add).toHaveBeenCalledTimes(1); + expect(storage.add).toHaveBeenLastCalledWith({ ...channel, - endAt: new Date(Date.now() + 99999999999999).toISOString(), - })); - await subscriber.handle({ operation, request, response }); - expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ endAt: Date.now() + (60 * 60 * 1000), - }), { public: {}}); + }); - operation.body.data = guardedStreamFrom(JSON.stringify({ - ...channel, - endAt: new Date(Date.now() + 5).toISOString(), - })); + converter.handleSafe.mockResolvedValue(new BasicRepresentation()); + channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 99999999999999 }); await subscriber.handle({ operation, request, response }); - expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({ + expect(storage.add).toHaveBeenCalledTimes(2); + expect(storage.add).toHaveBeenLastCalledWith({ + ...channel, + endAt: Date.now() + (60 * 60 * 1000), + }); + + converter.handleSafe.mockResolvedValue(new BasicRepresentation()); + channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 5 }); + await subscriber.handle({ operation, request, response }); + expect(storage.add).toHaveBeenCalledTimes(3); + expect(storage.add).toHaveBeenLastCalledWith({ + ...channel, endAt: Date.now() + 5, - }), { public: {}}); + }); jest.useRealTimers(); }); + + it('calls the completeChannel function after sending the response.', async(): Promise => { + const description = await subscriber.handle({ operation, request, response }); + + // Read out data to end stream correctly + await readableToString(description.data!); + await flushPromises(); + + expect(channelType.completeChannel).toHaveBeenCalledTimes(1); + }); + + it('logs an error if the completeChannel functions throws.', async(): Promise => { + const logger = getLoggerFor('mock'); + channelType.completeChannel.mockRejectedValue(new Error('notification error')); + + const description = await subscriber.handle({ operation, request, response }); + + // Read out data to end stream correctly + await readableToString(description.data!); + await flushPromises(); + + expect(channelType.completeChannel).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error) + .toHaveBeenLastCalledWith(`There was an issue completing notification channel ${channel.id}: notification error`); + }); }); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts index 0f734dbb8..4e6c4c15b 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookEmitter.test.ts @@ -16,6 +16,7 @@ import type { import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError'; import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil'; import { trimTrailingSlashes } from '../../../../../src/util/PathUtil'; +import { NOTIFY } from '../../../../../src/util/Vocabularies'; jest.mock('cross-fetch'); @@ -43,7 +44,7 @@ describe('A WebHookEmitter', (): void => { const channel: WebHookSubscription2021Channel = { id: 'id', topic: 'http://example.com/foo', - type: 'WebHookSubscription2021', + type: NOTIFY.WebHookSubscription2021, target: 'http://example.org/somewhere-else', webId: webIdRoute.getPath(), // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts index e65877ed5..2d9e7a785 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookSubscription2021.test.ts @@ -1,24 +1,25 @@ -import type { InferType } from 'yup'; +import { DataFactory, Store } from 'n3'; import type { Credentials } from '../../../../../src/authentication/Credentials'; -import { AccessMode } from '../../../../../src/authorization/permissions/Permissions'; import { AbsolutePathInteractionRoute, } from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; import type { Logger } from '../../../../../src/logging/Logger'; import { getLoggerFor } from '../../../../../src/logging/LogUtil'; +import { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/Notification'; import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; -import type { - NotificationChannelStorage, -} from '../../../../../src/server/notifications/NotificationChannelStorage'; import type { StateHandler } from '../../../../../src/server/notifications/StateHandler'; +import type { + WebHookSubscription2021Channel, +} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; import { isWebHook2021Channel, WebHookSubscription2021, } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021'; -import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap'; import { joinUrl } from '../../../../../src/util/PathUtil'; -import { readableToString, readJsonStream } from '../../../../../src/util/StreamUtil'; -import { flushPromises } from '../../../../util/Util'; +import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies'; +import quad = DataFactory.quad; +import blankNode = DataFactory.blankNode; +import namedNode = DataFactory.namedNode; jest.mock('../../../../../src/logging/LogUtil', (): any => { const logger: Logger = @@ -26,93 +27,75 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => { return { getLoggerFor: (): Logger => logger }; }); +jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); + describe('A WebHookSubscription2021', (): void => { const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }}; const target = 'http://example.org/somewhere-else'; - let json: InferType; + const topic = 'https://storage.example/resource'; + const subject = blankNode(); + let data: Store; + let channel: WebHookSubscription2021Channel; const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe'); - let storage: jest.Mocked; let stateHandler: jest.Mocked; let channelType: WebHookSubscription2021; beforeEach(async(): Promise => { - json = { - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebHookSubscription2021', + data = new Store(); + data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookSubscription2021)); + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic))); + data.addQuad(quad(subject, NOTIFY.terms.target, namedNode(target))); + + const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource'; + channel = { + id, + type: NOTIFY.WebHookSubscription2021, topic: 'https://storage.example/resource', target, - state: undefined, - startAt: undefined, - endAt: undefined, - accept: undefined, - rate: undefined, + webId: 'http://example.org/alice', + // eslint-disable-next-line @typescript-eslint/naming-convention + unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), encodeURIComponent(id)), }; - storage = { - create: jest.fn((features: Record): NotificationChannel => ({ - id: '123', - topic: 'http://example.com/foo', - type: 'WebHookSubscription2021', - ...features, - })), - add: jest.fn(), - } as any; - stateHandler = { handleSafe: jest.fn(), } as any; - channelType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler); + channelType = new WebHookSubscription2021(unsubscribeRoute, stateHandler); }); it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise => { - const channel = storage.create(json, {}); expect(isWebHook2021Channel(channel)).toBe(true); - channel.type = 'something else'; + (channel as NotificationChannel).type = 'something else'; expect(isWebHook2021Channel(channel)).toBe(false); }); - it('has the correct type.', async(): Promise => { - expect(channelType.type).toBe('WebHookSubscription2021'); - }); - it('correctly parses notification channel bodies.', async(): Promise => { - await expect(channelType.schema.isValid(json)).resolves.toBe(true); - - json.type = 'something else'; - await expect(channelType.schema.isValid(json)).resolves.toBe(false); - }); - - it('requires Read permissions on the topic.', async(): Promise => { - await expect(channelType.extractModes(json)).resolves - .toEqual(new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]])); - }); - - it('stores the channel and returns a valid response when subscribing.', async(): Promise => { - const { response } = await channelType.subscribe(json, credentials); - expect(response.metadata.contentType).toBe('application/ld+json'); - await expect(readJsonStream(response.data)).resolves.toEqual({ - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebHookSubscription2021', - target, - // eslint-disable-next-line @typescript-eslint/naming-convention - unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), '123'), - }); + await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel); }); it('errors if the credentials do not contain a WebID.', async(): Promise => { - await expect(channelType.subscribe(json, {})).rejects + await expect(channelType.initChannel(data, {})).rejects .toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.'); }); - it('calls the state handler once the response has been read.', async(): Promise => { - const { response, channel } = await channelType.subscribe(json, credentials); - expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0); - - // Read out data to end stream correctly - await readableToString(response.data); + it('removes the WebID when converting back to JSON-LD.', async(): Promise => { + await expect(channelType.toJsonLd(channel)).resolves.toEqual({ + '@context': [ + CONTEXT_NOTIFICATION, + ], + id: channel.id, + type: NOTIFY.WebHookSubscription2021, + target, + topic, + // eslint-disable-next-line @typescript-eslint/naming-convention + unsubscribe_endpoint: channel.unsubscribe_endpoint, + }); + }); + it('calls the state handler once the channel is completed.', async(): Promise => { + await channelType.completeChannel(channel); expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1); expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel }); }); @@ -121,14 +104,7 @@ describe('A WebHookSubscription2021', (): void => { const logger = getLoggerFor('mock'); stateHandler.handleSafe.mockRejectedValue(new Error('notification error')); - const { response } = await channelType.subscribe(json, credentials); - expect(logger.error).toHaveBeenCalledTimes(0); - - // Read out data to end stream correctly - await readableToString(response.data); - - await flushPromises(); - + await channelType.completeChannel(channel); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error'); }); diff --git a/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts b/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts index 7611f3c18..eb11ee95b 100644 --- a/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts +++ b/test/unit/server/notifications/WebHookSubscription2021/WebHookUnsubscriber.test.ts @@ -11,6 +11,7 @@ import { } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber'; import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError'; import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError'; +import { NOTIFY } from '../../../../../src/util/Vocabularies'; describe('A WebHookUnsubscriber', (): void => { const request: HttpRequest = {} as any; @@ -34,7 +35,7 @@ describe('A WebHookUnsubscriber', (): void => { } as any; storage = { - get: jest.fn().mockResolvedValue({ type: 'WebHookSubscription2021', webId }), + get: jest.fn().mockResolvedValue({ type: NOTIFY.WebHookSubscription2021, webId }), delete: jest.fn(), } as any; diff --git a/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts b/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts index ab8349bfc..5c9b6992c 100644 --- a/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts +++ b/test/unit/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021.test.ts @@ -1,70 +1,58 @@ -import { AccessMode } from '../../../../../src/authorization/permissions/Permissions'; +import { DataFactory, Store } from 'n3'; import { AbsolutePathInteractionRoute, } from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute'; -import type { NotificationChannelJson } from '../../../../../src/server/notifications/NotificationChannel'; -import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage'; +import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel'; + import { + generateWebSocketUrl, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Util'; +import type { + WebSocketSubscription2021Channel, +} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021'; +import { + isWebSocket2021Channel, WebSocketSubscription2021, } from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021'; -import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap'; -import { readJsonStream } from '../../../../../src/util/StreamUtil'; +import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies'; +import quad = DataFactory.quad; +import blankNode = DataFactory.blankNode; +import namedNode = DataFactory.namedNode; + +jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' })); describe('A WebSocketSubscription2021', (): void => { - let channel: NotificationChannelJson; - let storage: jest.Mocked; + let data: Store; + let channel: WebSocketSubscription2021Channel; + const subject = blankNode(); + const topic = 'https://storage.example/resource'; const route = new AbsolutePathInteractionRoute('http://example.com/foo'); let channelType: WebSocketSubscription2021; beforeEach(async(): Promise => { + data = new Store(); + data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebSocketSubscription2021)); + data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic))); + + const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource'; channel = { - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebSocketSubscription2021', - topic: 'https://storage.example/resource', - state: undefined, - startAt: undefined, - endAt: undefined, - accept: undefined, - rate: undefined, + id, + type: NOTIFY.WebSocketSubscription2021, + topic, + source: generateWebSocketUrl(route.getPath(), id), }; - storage = { - create: jest.fn().mockReturnValue({ - id: '123', - topic: 'http://example.com/foo', - type: 'WebSocketSubscription2021', - lastEmit: 0, - features: {}, - }), - add: jest.fn(), - } as any; - - channelType = new WebSocketSubscription2021(storage, route); + channelType = new WebSocketSubscription2021(route); }); - it('has the correct type.', async(): Promise => { - expect(channelType.type).toBe('WebSocketSubscription2021'); + it('exposes a utility function to verify if a channel is a websocket channel.', async(): Promise => { + expect(isWebSocket2021Channel(channel)).toBe(true); + + (channel as NotificationChannel).type = 'something else'; + expect(isWebSocket2021Channel(channel)).toBe(false); }); it('correctly parses notification channel bodies.', async(): Promise => { - await expect(channelType.schema.isValid(channel)).resolves.toBe(true); - - channel.type = 'something else'; - await expect(channelType.schema.isValid(channel)).resolves.toBe(false); - }); - - it('requires Read permissions on the topic.', async(): Promise => { - await expect(channelType.extractModes(channel)).resolves - .toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]])); - }); - - it('stores the channel and returns a valid response when subscribing.', async(): Promise => { - const { response } = await channelType.subscribe(channel); - expect(response.metadata.contentType).toBe('application/ld+json'); - await expect(readJsonStream(response.data)).resolves.toEqual({ - '@context': [ 'https://www.w3.org/ns/solid/notification/v1' ], - type: 'WebSocketSubscription2021', - source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u), - }); + await expect(channelType.initChannel(data, {})).resolves.toEqual(channel); }); }); diff --git a/test/unit/util/StringUtil.test.ts b/test/unit/util/StringUtil.test.ts index d5b3f9641..2a7122c95 100644 --- a/test/unit/util/StringUtil.test.ts +++ b/test/unit/util/StringUtil.test.ts @@ -1,7 +1,7 @@ import { sanitizeUrlPart, splitCommaSeparated, - isValidFileName, + isValidFileName, msToDuration, } from '../../../src/util/StringUtil'; describe('HeaderUtil', (): void => { @@ -31,4 +31,21 @@ describe('HeaderUtil', (): void => { expect(isValidFileName('$%^*')).toBeFalsy(); }); }); + + describe('#msToDuration', (): void => { + it('converts ms to a duration string.', async(): Promise => { + const ms = ((2 * 24 * 60 * 60) + (10 * 60 * 60) + (5 * 60) + 50.25) * 1000; + expect(msToDuration(ms)).toBe('P2DT10H5M50.25S'); + }); + + it('ignores 0 values.', async(): Promise => { + const ms = ((2 * 24 * 60 * 60) + 50.25) * 1000; + expect(msToDuration(ms)).toBe('P2DT50.25S'); + }); + + it('excludes the T if there is no time segment.', async(): Promise => { + const ms = ((2 * 24 * 60 * 60)) * 1000; + expect(msToDuration(ms)).toBe('P2D'); + }); + }); }); diff --git a/test/util/NotificationUtil.ts b/test/util/NotificationUtil.ts index 5bbce325c..3bcbe2e0e 100644 --- a/test/util/NotificationUtil.ts +++ b/test/util/NotificationUtil.ts @@ -2,9 +2,9 @@ import { fetch } from 'cross-fetch'; /** * Subscribes to a notification channel. - * @param type - The type of the notification channel. E.g. "WebSocketSubscription2021". + * @param type - The type of the notification channel, e.g., "NOTIFY.WebHookSubscription2021". * @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import. - * @param subscriptionUrl - The subscription URL where the request needs to be sent to. + * @param subscriptionUrl - The subscription URL to which the request needs to be sent. * @param topic - The topic to subscribe to. * @param features - Any extra fields that need to be added to the subscription body. */