mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for WebHookSubscription2021
This commit is contained in:
parent
cb619415fa
commit
f54c34d1e0
@ -7,6 +7,7 @@
|
|||||||
- The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL.
|
- The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL.
|
||||||
`config/file-acp.json` is an example of a configuration that uses this authorization scheme instead.
|
`config/file-acp.json` is an example of a configuration that uses this authorization scheme instead.
|
||||||
- Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021)
|
- Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021)
|
||||||
|
and the [WebHook Notification protocol draft](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md)
|
||||||
was added.
|
was added.
|
||||||
|
|
||||||
### Data migration
|
### Data migration
|
||||||
@ -27,7 +28,7 @@ The following changes pertain to the imports in the default configs:
|
|||||||
- All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports.
|
- All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports.
|
||||||
- A new `http/notifications` set of import options have been added
|
- A new `http/notifications` set of import options have been added
|
||||||
to determine which notification specification a CSS instance should use.
|
to determine which notification specification a CSS instance should use.
|
||||||
All default configurations have been updated to use `http/notifications/websockets.json`.
|
Most default configurations have been updated to use `http/notifications/websockets.json`.
|
||||||
|
|
||||||
The following changes are relevant for v5 custom configs that replaced certain features.
|
The following changes are relevant for v5 custom configs that replaced certain features.
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"css:config/app/variables/default.json",
|
"css:config/app/variables/default.json",
|
||||||
"css:config/http/handler/default.json",
|
"css:config/http/handler/default.json",
|
||||||
"css:config/http/middleware/default.json",
|
"css:config/http/middleware/default.json",
|
||||||
"css:config/http/notifications/websockets.json",
|
"css:config/http/notifications/all.json",
|
||||||
"css:config/http/server-factory/http.json",
|
"css:config/http/server-factory/http.json",
|
||||||
"css:config/http/static/default.json",
|
"css:config/http/static/default.json",
|
||||||
"css:config/identity/access/public.json",
|
"css:config/identity/access/public.json",
|
||||||
|
@ -20,9 +20,14 @@ and then pass the request along.
|
|||||||
|
|
||||||
Determines how notifications should be sent out from the server when resources change.
|
Determines how notifications should be sent out from the server when resources change.
|
||||||
|
|
||||||
|
* *all*: Supports all available notification types of the Solid Notifications protocol
|
||||||
|
[specification](https://solidproject.org/TR/notifications-protocol).
|
||||||
|
Currently, this includes WebHookSubscription2021 and WebSocketSubscription2021.
|
||||||
* *disabled*: No notifications are sent out.
|
* *disabled*: No notifications are sent out.
|
||||||
* *legacy-websocket*: Follows the legacy Solid WebSocket
|
* *legacy-websocket*: Follows the legacy Solid WebSocket
|
||||||
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
|
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
|
||||||
|
* *webhooks*: Follows the WebHookSubscription2021
|
||||||
|
[specification](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md) draft.
|
||||||
* *websockets*: Follows the WebSocketSubscription2021
|
* *websockets*: Follows the WebSocketSubscription2021
|
||||||
[specification](https://solidproject.org/TR/websocket-subscription-2021).
|
[specification](https://solidproject.org/TR/websocket-subscription-2021).
|
||||||
|
|
||||||
|
22
config/http/notifications/all.json
Normal file
22
config/http/notifications/all.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"import": [
|
||||||
|
"css:config/http/notifications/base/description.json",
|
||||||
|
"css:config/http/notifications/base/handler.json",
|
||||||
|
"css:config/http/notifications/base/listener.json",
|
||||||
|
"css:config/http/notifications/base/storage.json",
|
||||||
|
"css:config/http/notifications/websockets/description.json",
|
||||||
|
"css:config/http/notifications/websockets/handler.json",
|
||||||
|
"css:config/http/notifications/websockets/http.json",
|
||||||
|
"css:config/http/notifications/websockets/subscription.json",
|
||||||
|
"css:config/http/notifications/webhooks/description.json",
|
||||||
|
"css:config/http/notifications/webhooks/handler.json",
|
||||||
|
"css:config/http/notifications/webhooks/routes.json",
|
||||||
|
"css:config/http/notifications/webhooks/subscription.json"
|
||||||
|
],
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "All the relevant components are made in the specific imports seen above."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
18
config/http/notifications/webhooks.json
Normal file
18
config/http/notifications/webhooks.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"import": [
|
||||||
|
"css:config/http/notifications/base/description.json",
|
||||||
|
"css:config/http/notifications/base/handler.json",
|
||||||
|
"css:config/http/notifications/base/listener.json",
|
||||||
|
"css:config/http/notifications/base/storage.json",
|
||||||
|
"css:config/http/notifications/webhooks/description.json",
|
||||||
|
"css:config/http/notifications/webhooks/handler.json",
|
||||||
|
"css:config/http/notifications/webhooks/routes.json",
|
||||||
|
"css:config/http/notifications/webhooks/subscription.json"
|
||||||
|
],
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "All the relevant components are made in the specific imports seen above."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
18
config/http/notifications/webhooks/description.json
Normal file
18
config/http/notifications/webhooks/description.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:StorageDescriber",
|
||||||
|
"@type": "ArrayUnionHandler",
|
||||||
|
"handlers": [
|
||||||
|
{
|
||||||
|
"comment": "Handles the storage description triples used for discovery of a WebHookSubscription2021 endpoint.",
|
||||||
|
"@type": "WebHookDescriber",
|
||||||
|
"route": { "@id": "urn:solid-server:default:WebHookRoute" },
|
||||||
|
"relative": "#webhookNotification",
|
||||||
|
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
33
config/http/notifications/webhooks/handler.json
Normal file
33
config/http/notifications/webhooks/handler.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "Handles the generation and serialization of notifications for WebHookSubscription2021.",
|
||||||
|
"@id": "urn:solid-server:default:WebHookNotificationHandler",
|
||||||
|
"@type": "TypedNotificationHandler",
|
||||||
|
"type": "WebHookSubscription2021",
|
||||||
|
"source": {
|
||||||
|
"@type": "ComposedNotificationHandler",
|
||||||
|
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
|
||||||
|
"serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" },
|
||||||
|
"emitter": { "@id": "urn:solid-server:default:WebHookEmitter" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "Emits serialized notifications through HTTP requests to the WebHook.",
|
||||||
|
"@id": "urn:solid-server:default:WebHookEmitter",
|
||||||
|
"@type": "WebHookEmitter",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"webIdRoute": { "@id": "urn:solid-server:default:WebHookWebIdRoute" },
|
||||||
|
"jwkGenerator": { "@id": "urn:solid-server:default:JwkGenerator" }
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:NotificationHandler",
|
||||||
|
"@type": "WaterfallHandler",
|
||||||
|
"handlers": [
|
||||||
|
{ "@id": "urn:solid-server:default:WebHookNotificationHandler" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
59
config/http/notifications/webhooks/routes.json
Normal file
59
config/http/notifications/webhooks/routes.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:WebHookRoute",
|
||||||
|
"@type": "RelativePathInteractionRoute",
|
||||||
|
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
|
||||||
|
"relativePath": "/WebHookSubscription2021/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:WebHookUnsubscribeRoute",
|
||||||
|
"@type": "RelativePathInteractionRoute",
|
||||||
|
"base": { "@id": "urn:solid-server:default:WebHookRoute" },
|
||||||
|
"relativePath": "/unsubscribe/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:WebHookWebIdRoute",
|
||||||
|
"@type": "RelativePathInteractionRoute",
|
||||||
|
"base": { "@id": "urn:solid-server:default:WebHookRoute" },
|
||||||
|
"relativePath": "/webId"
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
"comment": "Handles unsubscribing from a WebHookSubscription2021.",
|
||||||
|
"@id": "urn:solid-server:default:WebHookUnsubscriber",
|
||||||
|
"@type": "OperationRouterHandler",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"allowedMethods": [ "DELETE" ],
|
||||||
|
"allowedPathNames": [ "/WebHookSubscription2021/unsubscribe/" ],
|
||||||
|
"handler": {
|
||||||
|
"@type": "WebHookUnsubscriber",
|
||||||
|
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
|
||||||
|
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"comment": "Handles the WebHookSubscription2021 WebID.",
|
||||||
|
"@id": "urn:solid-server:default:WebHookWebId",
|
||||||
|
"@type": "OperationRouterHandler",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"allowedPathNames": [ "/WebHookSubscription2021/webId" ],
|
||||||
|
"handler": {
|
||||||
|
"@type": "WebHookWebId",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"@id": "urn:solid-server:default:NotificationTypeHandler",
|
||||||
|
"@type": "WaterfallHandler",
|
||||||
|
"handlers": [
|
||||||
|
{ "@id": "urn:solid-server:default:WebHookSubscriber" },
|
||||||
|
{ "@id": "urn:solid-server:default:WebHookUnsubscriber" },
|
||||||
|
{ "@id": "urn:solid-server:default:WebHookWebId" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
32
config/http/notifications/webhooks/subscription.json
Normal file
32
config/http/notifications/webhooks/subscription.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "Handles the subscriptions targeting a WebHookSubscription2021.",
|
||||||
|
"@id": "urn:solid-server:default:WebHookSubscriber",
|
||||||
|
"@type": "OperationRouterHandler",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"allowedMethods": [ "POST" ],
|
||||||
|
"allowedPathNames": [ "/WebHookSubscription2021/$" ],
|
||||||
|
"handler": {
|
||||||
|
"@type": "NotificationSubscriber",
|
||||||
|
"subscriptionType": { "@id": "urn:solid-server:default:WebHookSubscription2021" },
|
||||||
|
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
|
||||||
|
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
|
||||||
|
"authorizer": { "@id": "urn:solid-server:default:Authorizer" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"handler": { "@id": "urn:solid-server:default:WebHookNotificationHandler" },
|
||||||
|
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -95,7 +95,8 @@ that handles notifications for the specific type.
|
|||||||
|
|
||||||
## WebSocketSubscription2021
|
## WebSocketSubscription2021
|
||||||
|
|
||||||
To add support for WebSocketSubscription2021 notifications,
|
To add support for [WebSocketSubscription2021](https://solidproject.org/TR/2022/websocket-subscription-2021-20220509)
|
||||||
|
notifications,
|
||||||
components were added as described in the documentation above.
|
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.
|
||||||
@ -170,3 +171,14 @@ The `WebSocket2021Storer` will store the WebSocket in the same map used by the `
|
|||||||
so that class can later on emit events as mentioned above.
|
so that class can later on emit events as mentioned above.
|
||||||
The state handler will make sure that a notification gets sent out if the subscription has a `state` feature request,
|
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.
|
as defined in the notification specification.
|
||||||
|
|
||||||
|
## WebHookSubscription2021
|
||||||
|
|
||||||
|
The additions required to support
|
||||||
|
[WebHookSubscription2021](https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md)
|
||||||
|
are quite similar to those needed for WebSocketSubscription2021:
|
||||||
|
|
||||||
|
* For discovery, there is a `WebHookDescriber`, which is an extension of a `NotificationDescriber`.
|
||||||
|
* The `WebHookSubscription2021` class contains all the necessary typing information.
|
||||||
|
* `WebHookEmitter` is the `NotificationEmitter` that sends the request.
|
||||||
|
* `WebHookUnsubscriber` and `WebHookWebId` are additional utility classes to support the spec requirements.
|
||||||
|
@ -320,6 +320,13 @@ export * from './server/notifications/serialize/ConvertingNotificationSerializer
|
|||||||
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
|
||||||
export * from './server/notifications/serialize/NotificationSerializer';
|
export * from './server/notifications/serialize/NotificationSerializer';
|
||||||
|
|
||||||
|
// Server/Notifications/WebHookSubscription2021
|
||||||
|
export * from './server/notifications/WebHookSubscription2021/WebHookDescriber';
|
||||||
|
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||||
|
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||||
|
export * from './server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
|
||||||
|
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
|
||||||
|
|
||||||
// Server/Notifications/WebSocketSubscription2021
|
// Server/Notifications/WebSocketSubscription2021
|
||||||
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
|
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
|
||||||
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler';
|
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler';
|
||||||
|
@ -6,7 +6,7 @@ import { NOTIFY, RDF } from '../../util/Vocabularies';
|
|||||||
import { StorageDescriber } from '../description/StorageDescriber';
|
import { StorageDescriber } from '../description/StorageDescriber';
|
||||||
const { namedNode, quad } = DataFactory;
|
const { namedNode, quad } = DataFactory;
|
||||||
|
|
||||||
const DEFAULT_FEATURES = [
|
export const DEFAULT_NOTIFICATION_FEATURES = [
|
||||||
NOTIFY.accept,
|
NOTIFY.accept,
|
||||||
NOTIFY.expiration,
|
NOTIFY.expiration,
|
||||||
NOTIFY.rate,
|
NOTIFY.rate,
|
||||||
@ -14,7 +14,7 @@ const DEFAULT_FEATURES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outputs quads describing how to access a specific Notificaion Subscription type and its features,
|
* Outputs quads describing how to access a specific Notification Subscription type and its features,
|
||||||
* as described in https://solidproject.org/TR/notifications-protocol#discovery.
|
* as described in https://solidproject.org/TR/notifications-protocol#discovery.
|
||||||
*/
|
*/
|
||||||
export class NotificationDescriber extends StorageDescriber {
|
export class NotificationDescriber extends StorageDescriber {
|
||||||
@ -30,7 +30,8 @@ export class NotificationDescriber extends StorageDescriber {
|
|||||||
* @param type - The rdf:type of the subscription type.
|
* @param type - The rdf:type of the subscription type.
|
||||||
* @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state.
|
* @param features - Which features are enabled for this subscription type. Defaults to accept/expiration/rate/state.
|
||||||
*/
|
*/
|
||||||
public constructor(route: InteractionRoute, relative: string, type: string, features: string[] = DEFAULT_FEATURES) {
|
public constructor(route: InteractionRoute, relative: string, type: string,
|
||||||
|
features: string[] = DEFAULT_NOTIFICATION_FEATURES) {
|
||||||
super();
|
super();
|
||||||
this.path = namedNode(route.getPath());
|
this.path = namedNode(route.getPath());
|
||||||
this.relative = relative;
|
this.relative = relative;
|
||||||
|
@ -2,12 +2,13 @@ import type { Representation } from '../../http/representation/Representation';
|
|||||||
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
import { AsyncHandler } from '../../util/handlers/AsyncHandler';
|
||||||
import type { SubscriptionInfo } from './SubscriptionStorage';
|
import type { SubscriptionInfo } from './SubscriptionStorage';
|
||||||
|
|
||||||
export interface NotificationEmitterInput {
|
export interface NotificationEmitterInput<T = Record<string, unknown>> {
|
||||||
representation: Representation;
|
representation: Representation;
|
||||||
info: SubscriptionInfo;
|
info: SubscriptionInfo<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emits a serialized Notification to the subscription defined by the info.
|
* Emits a serialized Notification to the subscription defined by the info.
|
||||||
*/
|
*/
|
||||||
export abstract class NotificationEmitter extends AsyncHandler<NotificationEmitterInput> {}
|
export abstract class NotificationEmitter<T = Record<string, unknown>>
|
||||||
|
extends AsyncHandler<NotificationEmitterInput<T>> {}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { Credentials } from '../../authentication/Credentials';
|
||||||
import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor';
|
import type { CredentialsExtractor } from '../../authentication/CredentialsExtractor';
|
||||||
import type { Authorizer } from '../../authorization/Authorizer';
|
import type { Authorizer } from '../../authorization/Authorizer';
|
||||||
import type { PermissionReader } from '../../authorization/PermissionReader';
|
import type { PermissionReader } from '../../authorization/PermissionReader';
|
||||||
@ -85,14 +86,14 @@ export class NotificationSubscriber extends OperationHttpHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify if the client is allowed to subscribe
|
// Verify if the client is allowed to subscribe
|
||||||
await this.authorize(request, subscription);
|
const credentials = await this.authorize(request, subscription);
|
||||||
|
|
||||||
const { response } = await this.subscriptionType.subscribe(subscription);
|
const { response } = await this.subscriptionType.subscribe(subscription, credentials);
|
||||||
|
|
||||||
return new OkResponseDescription(response.metadata, response.data);
|
return new OkResponseDescription(response.metadata, response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authorize(request: HttpRequest, subscription: Subscription): Promise<void> {
|
private async authorize(request: HttpRequest, subscription: Subscription): Promise<Credentials> {
|
||||||
const credentials = await this.credentialsExtractor.handleSafe(request);
|
const credentials = await this.credentialsExtractor.handleSafe(request);
|
||||||
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
|
this.logger.debug(`Extracted credentials: ${JSON.stringify(credentials)}`);
|
||||||
|
|
||||||
@ -104,5 +105,7 @@ export class NotificationSubscriber extends OperationHttpHandler {
|
|||||||
|
|
||||||
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
|
await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
|
||||||
this.logger.verbose(`Authorization succeeded, creating subscription`);
|
this.logger.verbose(`Authorization succeeded, creating subscription`);
|
||||||
|
|
||||||
|
return credentials;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { InferType } from 'yup';
|
import type { InferType } from 'yup';
|
||||||
|
import type { Credentials } from '../../authentication/Credentials';
|
||||||
import type { AccessMap } from '../../authorization/permissions/Permissions';
|
import type { AccessMap } from '../../authorization/permissions/Permissions';
|
||||||
import type { Representation } from '../../http/representation/Representation';
|
import type { Representation } from '../../http/representation/Representation';
|
||||||
import type { SUBSCRIBE_SCHEMA } from './Subscription';
|
import type { SUBSCRIBE_SCHEMA } from './Subscription';
|
||||||
@ -32,8 +33,9 @@ export interface SubscriptionType<TSub extends typeof SUBSCRIBE_SCHEMA = typeof
|
|||||||
/**
|
/**
|
||||||
* Registers the given subscription.
|
* Registers the given subscription.
|
||||||
* @param subscription - The subscription to register.
|
* @param subscription - The subscription 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 SubscriptionInfo}.
|
* @returns A {@link Representation} to return as a response and the generated {@link SubscriptionInfo}.
|
||||||
*/
|
*/
|
||||||
subscribe: (subscription: InferType<TSub>) => Promise<SubscriptionResponse<TFeat>>;
|
subscribe: (subscription: InferType<TSub>, credentials: Credentials) => Promise<SubscriptionResponse<TFeat>>;
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
import { joinUrl } from '../../../util/PathUtil';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a specific unsubscribe URL for a WebHookSubscription2021
|
||||||
|
* by combining the default unsubscribe URL with the given identifier.
|
||||||
|
* @param url - The default unsubscribe URL.
|
||||||
|
* @param id - The identifier.
|
||||||
|
*/
|
||||||
|
export function generateWebHookUnsubscribeUrl(url: string, id: string): string {
|
||||||
|
return joinUrl(url, encodeURIComponent(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a WebHookSubscription2021 unsubscribe URL to extract the identifier.
|
||||||
|
* @param url - The unsubscribe URL that is being called.
|
||||||
|
*/
|
||||||
|
export function parseWebHookUnsubscribeUrl(url: string): string {
|
||||||
|
// Split always returns an array of at least length 1 so result can not be undefined
|
||||||
|
return decodeURIComponent(url.split(/\//u).pop()!);
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
import type { NamedNode } from '@rdfjs/types';
|
||||||
|
import { DataFactory } from 'n3';
|
||||||
|
import type { Quad } from 'rdf-js';
|
||||||
|
import type { ResourceIdentifier } from '../../../http/representation/ResourceIdentifier';
|
||||||
|
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
|
||||||
|
import { NOTIFY, RDF } from '../../../util/Vocabularies';
|
||||||
|
import { DEFAULT_NOTIFICATION_FEATURES, NotificationDescriber } from '../NotificationDescriber';
|
||||||
|
const { namedNode, quad } = DataFactory;
|
||||||
|
|
||||||
|
export interface WebHookStorageDescriberArgs {
|
||||||
|
route: InteractionRoute;
|
||||||
|
relative: string;
|
||||||
|
webIdRoute: InteractionRoute;
|
||||||
|
features?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the necessary triples for describing a WebHookSubcription2021 notification channel.
|
||||||
|
*
|
||||||
|
* Extends {@link NotificationDescriber} by adding the necessary `notify:webid` and `notify:webhookAuth` triples.
|
||||||
|
*/
|
||||||
|
export class WebHookDescriber extends NotificationDescriber {
|
||||||
|
private readonly webId: NamedNode;
|
||||||
|
|
||||||
|
public constructor(args: WebHookStorageDescriberArgs) {
|
||||||
|
const features = args.features ?? [ ...DEFAULT_NOTIFICATION_FEATURES ];
|
||||||
|
features.push(NOTIFY.webhookAuth);
|
||||||
|
super(args.route, args.relative, NOTIFY.WebHookSubscription2021, features);
|
||||||
|
|
||||||
|
this.webId = namedNode(args.webIdRoute.getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle(input: ResourceIdentifier): Promise<Quad[]> {
|
||||||
|
const quads = await super.handle(input);
|
||||||
|
|
||||||
|
// Find the notification channel subject
|
||||||
|
const typeQuad = quads.find((entry): boolean => entry.predicate.equals(RDF.terms.type) &&
|
||||||
|
entry.object.equals(NOTIFY.terms.WebHookSubscription2021));
|
||||||
|
quads.push(quad(typeQuad!.subject, NOTIFY.terms.webid, this.webId));
|
||||||
|
|
||||||
|
return quads;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
import fetch from 'cross-fetch';
|
||||||
|
import { calculateJwkThumbprint, importJWK, SignJWT } from 'jose';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
import type { JwkGenerator } from '../../../identity/configuration/JwkGenerator';
|
||||||
|
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
|
||||||
|
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||||
|
import { trimTrailingSlashes } from '../../../util/PathUtil';
|
||||||
|
import { readableToString } from '../../../util/StreamUtil';
|
||||||
|
import type { NotificationEmitterInput } from '../NotificationEmitter';
|
||||||
|
import { NotificationEmitter } from '../NotificationEmitter';
|
||||||
|
import type { WebHookFeatures } from './WebHookSubscription2021';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits a notification representation using the WebHookSubscription2021 specification.
|
||||||
|
*
|
||||||
|
* Generates a DPoP token and proof, and adds those to the HTTP request that is sent to the target.
|
||||||
|
*
|
||||||
|
* The `expiration` input parameter is how long the generated token should be valid in minutes.
|
||||||
|
* Default is 20.
|
||||||
|
*/
|
||||||
|
export class WebHookEmitter extends NotificationEmitter<WebHookFeatures> {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly issuer: string;
|
||||||
|
private readonly webId: string;
|
||||||
|
private readonly jwkGenerator: JwkGenerator;
|
||||||
|
private readonly expiration: number;
|
||||||
|
|
||||||
|
public constructor(baseUrl: string, webIdRoute: InteractionRoute, jwkGenerator: JwkGenerator, expiration = 20) {
|
||||||
|
super();
|
||||||
|
this.issuer = trimTrailingSlashes(baseUrl);
|
||||||
|
this.webId = webIdRoute.getPath();
|
||||||
|
this.jwkGenerator = jwkGenerator;
|
||||||
|
this.expiration = expiration * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle({ info, representation }: NotificationEmitterInput<WebHookFeatures>): Promise<void> {
|
||||||
|
this.logger.debug(`Emitting WebHook notification with target ${info.features.target}`);
|
||||||
|
|
||||||
|
const privateKey = await this.jwkGenerator.getPrivateKey();
|
||||||
|
const publicKey = await this.jwkGenerator.getPublicKey();
|
||||||
|
|
||||||
|
const privateKeyObject = await importJWK(privateKey);
|
||||||
|
|
||||||
|
// Make sure both header and proof have the same timestamp
|
||||||
|
const time = Date.now();
|
||||||
|
|
||||||
|
// The spec is not completely clear on which fields actually need to be present in the token,
|
||||||
|
// only that it needs to contain the WebID somehow.
|
||||||
|
// The format used here has been chosen to be similar
|
||||||
|
// to how ID tokens are described in the Solid-OIDC specification for consistency.
|
||||||
|
const dpopToken = await new SignJWT({
|
||||||
|
webid: this.webId,
|
||||||
|
azp: this.webId,
|
||||||
|
sub: this.webId,
|
||||||
|
cnf: {
|
||||||
|
jkt: await calculateJwkThumbprint(publicKey, 'sha256'),
|
||||||
|
},
|
||||||
|
}).setProtectedHeader({ alg: privateKey.alg })
|
||||||
|
.setIssuedAt(time)
|
||||||
|
.setExpirationTime(time + this.expiration)
|
||||||
|
.setAudience([ this.webId, 'solid' ])
|
||||||
|
.setIssuer(this.issuer)
|
||||||
|
.setJti(v4())
|
||||||
|
.sign(privateKeyObject);
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.2
|
||||||
|
const dpopProof = await new SignJWT({
|
||||||
|
htu: info.features.target,
|
||||||
|
htm: 'POST',
|
||||||
|
}).setProtectedHeader({ alg: privateKey.alg, jwk: publicKey, typ: 'dpop+jwt' })
|
||||||
|
.setIssuedAt(time)
|
||||||
|
.setJti(v4())
|
||||||
|
.sign(privateKeyObject);
|
||||||
|
|
||||||
|
const response = await fetch(info.features.target, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': representation.metadata.contentType!,
|
||||||
|
authorization: `DPoP ${dpopToken}`,
|
||||||
|
dpop: dpopProof,
|
||||||
|
},
|
||||||
|
body: await readableToString(representation.data),
|
||||||
|
});
|
||||||
|
if (response.status >= 400) {
|
||||||
|
this.logger.error(`There was an issue emitting a WebHook notification with target ${info.features.target}: ${
|
||||||
|
await response.text()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
import type { InferType } from 'yup';
|
||||||
|
import { string } from 'yup';
|
||||||
|
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 type { StateHandler } from '../StateHandler';
|
||||||
|
import { SUBSCRIBE_SCHEMA } from '../Subscription';
|
||||||
|
import type { SubscriptionStorage } from '../SubscriptionStorage';
|
||||||
|
import type { SubscriptionResponse, SubscriptionType } from '../SubscriptionType';
|
||||||
|
import { generateWebHookUnsubscribeUrl } from './WebHook2021Util';
|
||||||
|
|
||||||
|
const type = 'WebHookSubscription2021';
|
||||||
|
const schema = SUBSCRIBE_SCHEMA.shape({
|
||||||
|
type: string().required().oneOf([ type ]),
|
||||||
|
// Not using `.url()` validator since it does not support localhost URLs
|
||||||
|
target: string().required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WebHookFeatures = { target: string; webId: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification subscription type WebHookSubscription2021 as described in
|
||||||
|
* https://github.com/solid/notifications/blob/main/webhook-subscription-2021.md
|
||||||
|
*
|
||||||
|
* Requires read permissions on a resource to be able to receive notifications.
|
||||||
|
*
|
||||||
|
* Also handles the `state` feature if present.
|
||||||
|
*/
|
||||||
|
export class WebHookSubscription2021 implements SubscriptionType<typeof schema, WebHookFeatures> {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly storage: SubscriptionStorage<WebHookFeatures>;
|
||||||
|
private readonly unsubscribePath: string;
|
||||||
|
private readonly stateHandler: StateHandler;
|
||||||
|
|
||||||
|
public readonly type = type;
|
||||||
|
public readonly schema = schema;
|
||||||
|
|
||||||
|
public constructor(storage: SubscriptionStorage<WebHookFeatures>, unsubscribeRoute: InteractionRoute,
|
||||||
|
stateHandler: StateHandler) {
|
||||||
|
this.storage = storage;
|
||||||
|
this.unsubscribePath = unsubscribeRoute.getPath();
|
||||||
|
this.stateHandler = stateHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async extractModes(subscription: InferType<typeof schema>): Promise<AccessMap> {
|
||||||
|
return new IdentifierSetMultiMap<AccessMode>([[{ path: subscription.topic }, AccessMode.read ]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribe(subscription: InferType<typeof schema>, credentials: Credentials):
|
||||||
|
Promise<SubscriptionResponse<WebHookFeatures>> {
|
||||||
|
const webId = credentials.agent?.webId;
|
||||||
|
|
||||||
|
if (!webId) {
|
||||||
|
throw new BadRequestHttpError(
|
||||||
|
'A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = this.storage.create(subscription, { target: subscription.target, webId });
|
||||||
|
await this.storage.add(info);
|
||||||
|
|
||||||
|
const jsonld = {
|
||||||
|
'@context': [ CONTEXT_NOTIFICATION ],
|
||||||
|
type: this.type,
|
||||||
|
target: subscription.target,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
unsubscribe_endpoint: generateWebHookUnsubscribeUrl(this.unsubscribePath, info.id),
|
||||||
|
};
|
||||||
|
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
|
||||||
|
|
||||||
|
// We want to send the state notification, if there is one,
|
||||||
|
// right after we send the response for subscribing.
|
||||||
|
// We do this by waiting for the response to be closed.
|
||||||
|
endOfStream(response.data)
|
||||||
|
.then((): Promise<void> => this.stateHandler.handleSafe({ info }))
|
||||||
|
.catch((error): void => {
|
||||||
|
this.logger.error(`Error emitting state notification: ${createErrorMessage(error)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response, info };
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
import type { CredentialsExtractor } from '../../../authentication/CredentialsExtractor';
|
||||||
|
import { ResetResponseDescription } from '../../../http/output/response/ResetResponseDescription';
|
||||||
|
import type { ResponseDescription } from '../../../http/output/response/ResponseDescription';
|
||||||
|
import { getLoggerFor } from '../../../logging/LogUtil';
|
||||||
|
import { ForbiddenHttpError } from '../../../util/errors/ForbiddenHttpError';
|
||||||
|
import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
|
||||||
|
import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
|
||||||
|
import { OperationHttpHandler } from '../../OperationHttpHandler';
|
||||||
|
import type { SubscriptionStorage } from '../SubscriptionStorage';
|
||||||
|
import { parseWebHookUnsubscribeUrl } from './WebHook2021Util';
|
||||||
|
import type { WebHookFeatures } from './WebHookSubscription2021';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows clients to unsubscribe from a WebHookSubscription2021.
|
||||||
|
* Should be wrapped in a route handler that only allows `DELETE` operations.
|
||||||
|
*/
|
||||||
|
export class WebHookUnsubscriber extends OperationHttpHandler {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly credentialsExtractor: CredentialsExtractor;
|
||||||
|
private readonly storage: SubscriptionStorage<WebHookFeatures>;
|
||||||
|
|
||||||
|
public constructor(credentialsExtractor: CredentialsExtractor, storage: SubscriptionStorage<WebHookFeatures>) {
|
||||||
|
super();
|
||||||
|
this.credentialsExtractor = credentialsExtractor;
|
||||||
|
this.storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
||||||
|
const id = parseWebHookUnsubscribeUrl(operation.target.path);
|
||||||
|
const info = await this.storage.get(id);
|
||||||
|
if (!info) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = await this.credentialsExtractor.handleSafe(request);
|
||||||
|
if (info.features.webId !== credentials.agent?.webId) {
|
||||||
|
throw new ForbiddenHttpError();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(`Deleting WebHook subscription ${id}`);
|
||||||
|
await this.storage.delete(id);
|
||||||
|
|
||||||
|
return new ResetResponseDescription();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { Parser } from 'n3';
|
||||||
|
import { OkResponseDescription } from '../../../http/output/response/OkResponseDescription';
|
||||||
|
import type { ResponseDescription } from '../../../http/output/response/ResponseDescription';
|
||||||
|
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
|
||||||
|
import { TEXT_TURTLE } from '../../../util/ContentTypes';
|
||||||
|
import { createErrorMessage } from '../../../util/errors/ErrorUtil';
|
||||||
|
import { trimTrailingSlashes } from '../../../util/PathUtil';
|
||||||
|
import type { OperationHttpHandlerInput } from '../../OperationHttpHandler';
|
||||||
|
import { OperationHttpHandler } from '../../OperationHttpHandler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebHookSubscription2021 requires the server to have a WebID
|
||||||
|
* that is used during the generation of the DPoP headers.
|
||||||
|
* There are no real specifications about what this should contain or look like,
|
||||||
|
* so we just return a Turtle document that contains a solid:oidcIssuer triple for now.
|
||||||
|
* This way we confirm that our server was allowed to sign the token.
|
||||||
|
*/
|
||||||
|
export class WebHookWebId extends OperationHttpHandler {
|
||||||
|
private readonly turtle: string;
|
||||||
|
|
||||||
|
public constructor(baseUrl: string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.turtle = `@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
||||||
|
<> solid:oidcIssuer <${trimTrailingSlashes(baseUrl)}>.`;
|
||||||
|
|
||||||
|
// This will throw an error if something is wrong with the issuer URL
|
||||||
|
const parser = new Parser();
|
||||||
|
try {
|
||||||
|
parser.parse(this.turtle);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
throw new Error(`Invalid issuer URL: ${createErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle(input: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
||||||
|
const representation = new BasicRepresentation(this.turtle, input.operation.target, TEXT_TURTLE);
|
||||||
|
return new OkResponseDescription(representation.metadata, representation.data);
|
||||||
|
}
|
||||||
|
}
|
@ -199,6 +199,10 @@ export const NOTIFY = createVocabulary('http://www.w3.org/ns/solid/notifications
|
|||||||
'rate',
|
'rate',
|
||||||
'state',
|
'state',
|
||||||
'subscription',
|
'subscription',
|
||||||
|
'webhookAuth',
|
||||||
|
'webid',
|
||||||
|
|
||||||
|
'WebHookSubscription2021',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',
|
export const OIDC = createVocabulary('http://www.w3.org/ns/solid/oidc#',
|
||||||
|
185
test/integration/WebHookSubscription2021.test.ts
Normal file
185
test/integration/WebHookSubscription2021.test.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import { createServer } from 'http';
|
||||||
|
import type { Server, IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { fetch } from 'cross-fetch';
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
import type { NamedNode } from 'n3';
|
||||||
|
import { DataFactory, Parser, Store } from 'n3';
|
||||||
|
import type { App } from '../../src/init/App';
|
||||||
|
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
|
||||||
|
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
|
||||||
|
import { readJsonStream } from '../../src/util/StreamUtil';
|
||||||
|
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
|
||||||
|
import { expectNotification, subscribe } from '../util/NotificationUtil';
|
||||||
|
import { getPort } from '../util/Util';
|
||||||
|
import {
|
||||||
|
getDefaultVariables,
|
||||||
|
getPresetConfigPath,
|
||||||
|
getTestConfigPath,
|
||||||
|
getTestFolder,
|
||||||
|
instantiateFromConfig, removeFolder,
|
||||||
|
} from './Config';
|
||||||
|
import quad = DataFactory.quad;
|
||||||
|
import namedNode = DataFactory.namedNode;
|
||||||
|
|
||||||
|
const port = getPort('WebHookSubscription2021');
|
||||||
|
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 rootFilePath = getTestFolder('WebHookSubscription2021');
|
||||||
|
const stores: [string, any][] = [
|
||||||
|
[ 'in-memory storage', {
|
||||||
|
configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ],
|
||||||
|
teardown: jest.fn(),
|
||||||
|
}],
|
||||||
|
[ 'on-disk storage', {
|
||||||
|
// Switch to file locker after https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1452
|
||||||
|
configs: [ 'storage/backend/file.json', 'util/resource-locker/memory.json' ],
|
||||||
|
teardown: async(): Promise<void> => removeFolder(rootFilePath),
|
||||||
|
}],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (name, { configs, teardown }): void => {
|
||||||
|
let app: App;
|
||||||
|
const topic = joinUrl(baseUrl, '/foo');
|
||||||
|
let storageDescriptionUrl: string;
|
||||||
|
let subscriptionUrl: string;
|
||||||
|
let clientServer: Server;
|
||||||
|
let serverWebId: string;
|
||||||
|
|
||||||
|
beforeAll(async(): Promise<void> => {
|
||||||
|
const variables = {
|
||||||
|
...getDefaultVariables(port, baseUrl),
|
||||||
|
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create and start the server
|
||||||
|
const instances = await instantiateFromConfig(
|
||||||
|
'urn:solid-server:test:Instances',
|
||||||
|
[
|
||||||
|
...configs.map(getPresetConfigPath),
|
||||||
|
getTestConfigPath('webhook-notifications.json'),
|
||||||
|
],
|
||||||
|
variables,
|
||||||
|
) as Record<string, any>;
|
||||||
|
({ app } = instances);
|
||||||
|
|
||||||
|
await app.start();
|
||||||
|
|
||||||
|
// Start client server
|
||||||
|
clientServer = createServer();
|
||||||
|
clientServer.listen(clientPort);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async(): Promise<void> => {
|
||||||
|
clientServer.close();
|
||||||
|
await app.stop();
|
||||||
|
await teardown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to the storage description.', async(): Promise<void> => {
|
||||||
|
const response = await fetch(baseUrl);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const linkHeader = response.headers.get('link');
|
||||||
|
expect(linkHeader).not.toBeNull();
|
||||||
|
const match = /<([^>]+)>; rel="http:\/\/www\.w3\.org\/ns\/solid\/terms#storageDescription"/u.exec(linkHeader!);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
storageDescriptionUrl = match![1];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes metadata on how to subscribe in the storage description.', async(): Promise<void> => {
|
||||||
|
const response = await fetch(storageDescriptionUrl, { headers: { accept: 'text/turtle' }});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const quads = new Store(new Parser().parse(await response.text()));
|
||||||
|
|
||||||
|
// Find the notification channel for websockets
|
||||||
|
const channels = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.notificationChannel, null);
|
||||||
|
const webHookChannels = channels.filter((channel): boolean => quads.has(
|
||||||
|
quad(channel as NamedNode, RDF.terms.type, namedNode(`${NOTIFY.namespace}WebHookSubscription2021`)),
|
||||||
|
));
|
||||||
|
expect(webHookChannels).toHaveLength(1);
|
||||||
|
const subscriptionUrls = quads.getObjects(webHookChannels[0], NOTIFY.terms.subscription, null);
|
||||||
|
expect(subscriptionUrls).toHaveLength(1);
|
||||||
|
subscriptionUrl = subscriptionUrls[0].value;
|
||||||
|
|
||||||
|
// It should also link to the server WebID
|
||||||
|
const webIds = quads.getObjects(webHookChannels[0], NOTIFY.terms.webid, null);
|
||||||
|
expect(webIds).toHaveLength(1);
|
||||||
|
serverWebId = webIds[0].value;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports subscribing.', async(): Promise<void> => {
|
||||||
|
await subscribe(notificationType, webId, subscriptionUrl, topic, { target });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits Created events.', async(): Promise<void> => {
|
||||||
|
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
|
||||||
|
clientServer.on('request', (request, response): void => {
|
||||||
|
resolve({ request, response });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = await fetch(topic, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
body: 'abc',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const { request, response } = await clientPromise;
|
||||||
|
expect(request.headers['content-type']).toBe('application/ld+json');
|
||||||
|
const notification = await readJsonStream(request);
|
||||||
|
|
||||||
|
expectNotification(notification, topic, 'Create');
|
||||||
|
|
||||||
|
// Find the JWKS of the server
|
||||||
|
res = await fetch(joinUrl(baseUrl, '.well-known/openid-configuration'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('content-type')).toContain('application/json');
|
||||||
|
const resJson = await res.json();
|
||||||
|
expect(typeof resJson.jwks_uri).toBe('string');
|
||||||
|
const jwks = createRemoteJWKSet(new URL(resJson.jwks_uri));
|
||||||
|
|
||||||
|
// Check validity of DPoP headers
|
||||||
|
// Note that this is not a comprehensive validation of the headers,
|
||||||
|
// only some of the basics are checked.
|
||||||
|
const { authorization, dpop } = request.headers;
|
||||||
|
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
|
||||||
|
const encodedDpopToken = authorization!.slice('dpop '.length);
|
||||||
|
// These will throw if they can not be decoded with the JWKS from the server
|
||||||
|
const decodedDpopToken = await jwtVerify(encodedDpopToken, jwks, { issuer: trimTrailingSlashes(baseUrl) });
|
||||||
|
expect(decodedDpopToken.payload).toMatchObject({
|
||||||
|
webid: serverWebId,
|
||||||
|
});
|
||||||
|
const decodedDpopProof = await jwtVerify(dpop as string, jwks);
|
||||||
|
expect(decodedDpopProof.payload).toMatchObject({
|
||||||
|
htu: target,
|
||||||
|
htm: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the connection so the server can shut down
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a notification if a state value was sent along.', async(): Promise<void> => {
|
||||||
|
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
|
||||||
|
clientServer.on('request', (request, response): void => {
|
||||||
|
resolve({ request, response });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' });
|
||||||
|
|
||||||
|
// Will resolve even though the resource did not change since subscribing
|
||||||
|
const { request, response } = await clientPromise;
|
||||||
|
expect(request.headers['content-type']).toBe('application/ld+json');
|
||||||
|
const notification = await readJsonStream(request);
|
||||||
|
|
||||||
|
expectNotification(notification, topic, 'Update');
|
||||||
|
|
||||||
|
// Close the connection so the server can shut down
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
});
|
@ -7,6 +7,7 @@ import type { App } from '../../src/init/App';
|
|||||||
import type { ResourceStore } from '../../src/storage/ResourceStore';
|
import type { ResourceStore } from '../../src/storage/ResourceStore';
|
||||||
import { joinUrl } from '../../src/util/PathUtil';
|
import { joinUrl } from '../../src/util/PathUtil';
|
||||||
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
|
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
|
||||||
|
import { expectNotification, subscribe } from '../util/NotificationUtil';
|
||||||
import { getPort } from '../util/Util';
|
import { getPort } from '../util/Util';
|
||||||
import {
|
import {
|
||||||
getDefaultVariables,
|
getDefaultVariables,
|
||||||
@ -20,6 +21,7 @@ import namedNode = DataFactory.namedNode;
|
|||||||
|
|
||||||
const port = getPort('WebSocketSubscription2021');
|
const port = getPort('WebSocketSubscription2021');
|
||||||
const baseUrl = `http://localhost:${port}/`;
|
const baseUrl = `http://localhost:${port}/`;
|
||||||
|
const notificationType = 'WebSocketSubscription2021';
|
||||||
|
|
||||||
const rootFilePath = getTestFolder('WebSocketSubscription2021');
|
const rootFilePath = getTestFolder('WebSocketSubscription2021');
|
||||||
const stores: [string, any][] = [
|
const stores: [string, any][] = [
|
||||||
@ -34,51 +36,6 @@ const stores: [string, any][] = [
|
|||||||
}],
|
}],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Send the subscribe request and check the response
|
|
||||||
async function subscribe(subscriptionUrl: string, topic: string, features: Record<string, unknown> = {}):
|
|
||||||
Promise<string> {
|
|
||||||
const subscription = {
|
|
||||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
|
||||||
type: 'WebSocketSubscription2021',
|
|
||||||
topic,
|
|
||||||
...features,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(subscriptionUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/ld+json' },
|
|
||||||
body: JSON.stringify(subscription),
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get('content-type')).toBe('application/ld+json');
|
|
||||||
const { type, source } = await response.json();
|
|
||||||
expect(type).toBe('WebSocketSubscription2021');
|
|
||||||
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a notification has the correct format
|
|
||||||
function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void {
|
|
||||||
const expected: any = {
|
|
||||||
'@context': [
|
|
||||||
'https://www.w3.org/ns/activitystreams',
|
|
||||||
'https://www.w3.org/ns/solid/notification/v1',
|
|
||||||
],
|
|
||||||
id: expect.stringContaining(topic),
|
|
||||||
type: [ type ],
|
|
||||||
object: {
|
|
||||||
id: topic,
|
|
||||||
type: [],
|
|
||||||
},
|
|
||||||
published: expect.anything(),
|
|
||||||
};
|
|
||||||
if (type !== 'Delete') {
|
|
||||||
expected.state = expect.anything();
|
|
||||||
expected.object.type.push('http://www.w3.org/ns/ldp#Resource');
|
|
||||||
}
|
|
||||||
expect(notification).toEqual(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => {
|
describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => {
|
||||||
let app: App;
|
let app: App;
|
||||||
let store: ResourceStore;
|
let store: ResourceStore;
|
||||||
@ -140,7 +97,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('supports subscribing.', async(): Promise<void> => {
|
it('supports subscribing.', async(): Promise<void> => {
|
||||||
webSocketUrl = await subscribe(subscriptionUrl, topic);
|
const response = await subscribe(notificationType, webId, subscriptionUrl, topic);
|
||||||
|
webSocketUrl = (response as any).source;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits Created events.', async(): Promise<void> => {
|
it('emits Created events.', async(): Promise<void> => {
|
||||||
@ -242,7 +200,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
|
|||||||
});
|
});
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
|
|
||||||
const source = await subscribe(subscriptionUrl, topic, { state: 'abc' });
|
const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { state: 'abc' }) as any;
|
||||||
|
|
||||||
const socket = new WebSocket(source);
|
const socket = new WebSocket(source);
|
||||||
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
|
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
|
||||||
@ -256,7 +214,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes expired subscriptions.', async(): Promise<void> => {
|
it('removes expired subscriptions.', async(): Promise<void> => {
|
||||||
const source = await subscribe(subscriptionUrl, topic, { expiration: 1 });
|
const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { expiration: 1 }) as any;
|
||||||
|
|
||||||
const socket = new WebSocket(source);
|
const socket = new WebSocket(source);
|
||||||
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
|
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
|
||||||
|
52
test/integration/config/webhook-notifications.json
Normal file
52
test/integration/config/webhook-notifications.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||||
|
"import": [
|
||||||
|
"css:config/app/main/default.json",
|
||||||
|
"css:config/app/init/initialize-root.json",
|
||||||
|
"css:config/app/setup/disabled.json",
|
||||||
|
"css:config/http/handler/default.json",
|
||||||
|
"css:config/http/middleware/default.json",
|
||||||
|
"css:config/http/notifications/webhooks.json",
|
||||||
|
"css:config/http/server-factory/http.json",
|
||||||
|
"css:config/http/static/default.json",
|
||||||
|
"css:config/identity/access/public.json",
|
||||||
|
"css:config/identity/email/default.json",
|
||||||
|
"css:config/identity/handler/default.json",
|
||||||
|
"css:config/identity/ownership/token.json",
|
||||||
|
"css:config/identity/pod/static.json",
|
||||||
|
"css:config/identity/registration/enabled.json",
|
||||||
|
"css:config/ldp/authentication/debug-auth-header.json",
|
||||||
|
"css:config/ldp/authorization/webacl.json",
|
||||||
|
"css:config/ldp/handler/default.json",
|
||||||
|
"css:config/ldp/metadata-parser/default.json",
|
||||||
|
"css:config/ldp/metadata-writer/default.json",
|
||||||
|
"css:config/ldp/modes/default.json",
|
||||||
|
|
||||||
|
"css:config/storage/key-value/resource-store.json",
|
||||||
|
"css:config/storage/middleware/default.json",
|
||||||
|
"css:config/util/auxiliary/acl.json",
|
||||||
|
"css:config/util/identifiers/suffix.json",
|
||||||
|
"css:config/util/index/default.json",
|
||||||
|
"css:config/util/logging/winston.json",
|
||||||
|
"css:config/util/representation-conversion/default.json",
|
||||||
|
|
||||||
|
"css:config/util/variables/default.json"
|
||||||
|
],
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "WebSocket notifications with debug authentication.",
|
||||||
|
"@id": "urn:solid-server:test:Instances",
|
||||||
|
"@type": "RecordObject",
|
||||||
|
"record": [
|
||||||
|
{
|
||||||
|
"RecordObject:_record_key": "app",
|
||||||
|
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RecordObject:_record_key": "store",
|
||||||
|
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@ -111,7 +111,7 @@ describe('A NotificationSubscriber', (): void => {
|
|||||||
await subscriber.handle({ operation, request, response });
|
await subscriber.handle({ operation, request, response });
|
||||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
expiration: Date.now() + (60 * 60 * 1000),
|
expiration: Date.now() + (60 * 60 * 1000),
|
||||||
}));
|
}), { public: {}});
|
||||||
|
|
||||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||||
...subscriptionBody,
|
...subscriptionBody,
|
||||||
@ -120,7 +120,7 @@ describe('A NotificationSubscriber', (): void => {
|
|||||||
await subscriber.handle({ operation, request, response });
|
await subscriber.handle({ operation, request, response });
|
||||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
expiration: Date.now() + (60 * 60 * 1000),
|
expiration: Date.now() + (60 * 60 * 1000),
|
||||||
}));
|
}), { public: {}});
|
||||||
|
|
||||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||||
...subscriptionBody,
|
...subscriptionBody,
|
||||||
@ -129,7 +129,7 @@ describe('A NotificationSubscriber', (): void => {
|
|||||||
await subscriber.handle({ operation, request, response });
|
await subscriber.handle({ operation, request, response });
|
||||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
expiration: Date.now() + 5,
|
expiration: Date.now() + 5,
|
||||||
}));
|
}), { public: {}});
|
||||||
|
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
generateWebHookUnsubscribeUrl, parseWebHookUnsubscribeUrl,
|
||||||
|
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHook2021Util';
|
||||||
|
|
||||||
|
describe('WebHook2021Util', (): void => {
|
||||||
|
describe('#generateWebHookUnsubscribeUrl', (): void => {
|
||||||
|
it('generates the URL with the identifier.', async(): Promise<void> => {
|
||||||
|
expect(generateWebHookUnsubscribeUrl('http://example.com/unsubscribe', '123$456'))
|
||||||
|
.toBe('http://example.com/unsubscribe/123%24456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#parseWebHookUnsubscribeUrl', (): void => {
|
||||||
|
it('returns the parsed identifier from the URL.', async(): Promise<void> => {
|
||||||
|
expect(parseWebHookUnsubscribeUrl('http://example.com/unsubscribe/123%24456')).toBe('123$456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,38 @@
|
|||||||
|
import 'jest-rdf';
|
||||||
|
import { DataFactory } from 'n3';
|
||||||
|
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
|
||||||
|
import {
|
||||||
|
AbsolutePathInteractionRoute,
|
||||||
|
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||||
|
import { WebHookDescriber } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookDescriber';
|
||||||
|
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
||||||
|
const { namedNode, quad } = DataFactory;
|
||||||
|
|
||||||
|
describe('A WebHookDescriber', (): void => {
|
||||||
|
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||||
|
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/');
|
||||||
|
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/webId');
|
||||||
|
const relative = '#webhookNotification';
|
||||||
|
const type = 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021';
|
||||||
|
let describer: WebHookDescriber;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
describer = new WebHookDescriber({ route, webIdRoute, relative });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outputs the expected quads.', async(): Promise<void> => {
|
||||||
|
const subscription = namedNode('http://example.com/foo#webhookNotification');
|
||||||
|
const quads = await describer.handle(identifier);
|
||||||
|
expect(quads).toBeRdfIsomorphic([
|
||||||
|
quad(namedNode(identifier.path), NOTIFY.terms.notificationChannel, subscription),
|
||||||
|
quad(subscription, RDF.terms.type, namedNode(type)),
|
||||||
|
quad(subscription, NOTIFY.terms.subscription, namedNode('http://example.com/.notifications/webhooks/')),
|
||||||
|
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
|
||||||
|
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.expiration),
|
||||||
|
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
|
||||||
|
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
|
||||||
|
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.webhookAuth),
|
||||||
|
quad(subscription, NOTIFY.terms.webid, namedNode(webIdRoute.getPath())),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,142 @@
|
|||||||
|
import fetch from 'cross-fetch';
|
||||||
|
import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, jwtVerify } from 'jose';
|
||||||
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
|
import type { Representation } from '../../../../../src/http/representation/Representation';
|
||||||
|
import type { AlgJwk, JwkGenerator } from '../../../../../src/identity/configuration/JwkGenerator';
|
||||||
|
import {
|
||||||
|
AbsolutePathInteractionRoute,
|
||||||
|
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||||
|
import type { Logger } from '../../../../../src/logging/Logger';
|
||||||
|
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||||
|
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||||
|
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||||
|
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||||
|
import type {
|
||||||
|
WebHookFeatures,
|
||||||
|
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||||
|
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
|
||||||
|
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
|
||||||
|
|
||||||
|
jest.mock('cross-fetch');
|
||||||
|
|
||||||
|
jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||||
|
const logger: Logger =
|
||||||
|
{ error: jest.fn(), debug: jest.fn() } as any;
|
||||||
|
return { getLoggerFor: (): Logger => logger };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A WebHookEmitter', (): void => {
|
||||||
|
const fetchMock: jest.Mock = fetch as any;
|
||||||
|
const baseUrl = 'http://example.com/';
|
||||||
|
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifcations/webhooks/webid');
|
||||||
|
const notification: Notification = {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://www.w3.org/ns/solid/notification/v1',
|
||||||
|
],
|
||||||
|
id: `urn:123:http://example.com/foo`,
|
||||||
|
type: [ 'Update' ],
|
||||||
|
object: {
|
||||||
|
id: 'http://example.com/foo',
|
||||||
|
type: [],
|
||||||
|
},
|
||||||
|
published: '123',
|
||||||
|
};
|
||||||
|
let representation: Representation;
|
||||||
|
const info: SubscriptionInfo<WebHookFeatures> = {
|
||||||
|
id: 'id',
|
||||||
|
topic: 'http://example.com/foo',
|
||||||
|
type: 'type',
|
||||||
|
features: {
|
||||||
|
target: 'http://example.org/somewhere-else',
|
||||||
|
webId: webIdRoute.getPath(),
|
||||||
|
},
|
||||||
|
lastEmit: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let privateJwk: AlgJwk;
|
||||||
|
let publicJwk: AlgJwk;
|
||||||
|
let jwkGenerator: jest.Mocked<JwkGenerator>;
|
||||||
|
let emitter: WebHookEmitter;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
fetchMock.mockResolvedValue({ status: 200 });
|
||||||
|
|
||||||
|
representation = new BasicRepresentation(JSON.stringify(notification), 'application/ld+json');
|
||||||
|
|
||||||
|
const { privateKey, publicKey } = await generateKeyPair('ES256');
|
||||||
|
|
||||||
|
privateJwk = { ...await exportJWK(privateKey), alg: 'ES256' };
|
||||||
|
publicJwk = { ...await exportJWK(publicKey), alg: 'ES256' };
|
||||||
|
|
||||||
|
jwkGenerator = {
|
||||||
|
alg: 'ES256',
|
||||||
|
getPrivateKey: jest.fn().mockResolvedValue(privateJwk),
|
||||||
|
getPublicKey: jest.fn().mockResolvedValue(publicJwk),
|
||||||
|
};
|
||||||
|
|
||||||
|
emitter = new WebHookEmitter(baseUrl, webIdRoute, jwkGenerator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends out the necessary data and headers.', async(): Promise<void> => {
|
||||||
|
const now = Date.now();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(now);
|
||||||
|
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = fetchMock.mock.calls[0];
|
||||||
|
expect(call[0]).toBe('http://example.org/somewhere-else');
|
||||||
|
const { authorization, dpop, 'content-type': contentType } = call[1].headers;
|
||||||
|
expect(contentType).toBe('application/ld+json');
|
||||||
|
|
||||||
|
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
|
||||||
|
const encodedDpopToken = authorization.slice('DPoP '.length);
|
||||||
|
|
||||||
|
const publicObject = await importJWK(publicJwk);
|
||||||
|
|
||||||
|
// Check all the DPoP token fields
|
||||||
|
const decodedDpopToken = await jwtVerify(encodedDpopToken, publicObject, { issuer: trimTrailingSlashes(baseUrl) });
|
||||||
|
expect(decodedDpopToken.payload).toMatchObject({
|
||||||
|
webid: info.features.webId,
|
||||||
|
azp: info.features.webId,
|
||||||
|
sub: info.features.webId,
|
||||||
|
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
|
||||||
|
iat: now,
|
||||||
|
exp: now + (20 * 60 * 1000),
|
||||||
|
aud: [ info.features.webId, 'solid' ],
|
||||||
|
jti: expect.stringContaining('-'),
|
||||||
|
});
|
||||||
|
expect(decodedDpopToken.protectedHeader).toMatchObject({
|
||||||
|
alg: 'ES256',
|
||||||
|
});
|
||||||
|
|
||||||
|
// CHeck the DPoP proof
|
||||||
|
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
||||||
|
expect(decodedDpopProof.payload).toMatchObject({
|
||||||
|
htu: info.features.target,
|
||||||
|
htm: 'POST',
|
||||||
|
iat: now,
|
||||||
|
jti: expect.stringContaining('-'),
|
||||||
|
});
|
||||||
|
expect(decodedDpopProof.protectedHeader).toMatchObject({
|
||||||
|
alg: 'ES256',
|
||||||
|
typ: 'dpop+jwt',
|
||||||
|
jwk: publicJwk,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs an error if the fetch request receives an invalid status code.', async(): Promise<void> => {
|
||||||
|
const logger = getLoggerFor('mock');
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue({ status: 400, text: async(): Promise<string> => 'invalid request' });
|
||||||
|
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.error).toHaveBeenLastCalledWith(
|
||||||
|
`There was an issue emitting a WebHook notification with target ${info.features.target}: invalid request`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,129 @@
|
|||||||
|
import type { InferType } from 'yup';
|
||||||
|
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 type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
||||||
|
import type {
|
||||||
|
SubscriptionInfo,
|
||||||
|
SubscriptionStorage,
|
||||||
|
} from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||||
|
import type {
|
||||||
|
WebHookFeatures,
|
||||||
|
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||||
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
|
jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||||
|
const logger: Logger =
|
||||||
|
{ error: jest.fn() } as any;
|
||||||
|
return { getLoggerFor: (): Logger => logger };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A WebHookSubscription2021', (): void => {
|
||||||
|
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
||||||
|
const target = 'http://example.org/somewhere-else';
|
||||||
|
let subscription: InferType<WebHookSubscription2021['schema']>;
|
||||||
|
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
|
||||||
|
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
|
||||||
|
let stateHandler: jest.Mocked<StateHandler>;
|
||||||
|
let subscriptionType: WebHookSubscription2021;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
subscription = {
|
||||||
|
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||||
|
type: 'WebHookSubscription2021',
|
||||||
|
topic: 'https://storage.example/resource',
|
||||||
|
target,
|
||||||
|
state: undefined,
|
||||||
|
expiration: undefined,
|
||||||
|
accept: undefined,
|
||||||
|
rate: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
storage = {
|
||||||
|
create: jest.fn((features: WebHookFeatures): SubscriptionInfo<WebHookFeatures> => ({
|
||||||
|
id: '123',
|
||||||
|
topic: 'http://example.com/foo',
|
||||||
|
type: 'WebHookSubscription2021',
|
||||||
|
lastEmit: 0,
|
||||||
|
features,
|
||||||
|
})),
|
||||||
|
add: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
stateHandler = {
|
||||||
|
handleSafe: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
subscriptionType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the correct type.', async(): Promise<void> => {
|
||||||
|
expect(subscriptionType.type).toBe('WebHookSubscription2021');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly parses subscriptions.', async(): Promise<void> => {
|
||||||
|
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(true);
|
||||||
|
|
||||||
|
subscription.type = 'something else';
|
||||||
|
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
||||||
|
await expect(subscriptionType.extractModes(subscription)).resolves
|
||||||
|
.toEqual(new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||||
|
const { response } = await subscriptionType.subscribe(subscription, 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'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
||||||
|
await expect(subscriptionType.subscribe(subscription, {})).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<void> => {
|
||||||
|
const { response, info } = await subscriptionType.subscribe(subscription, credentials);
|
||||||
|
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Read out data to end stream correctly
|
||||||
|
await readableToString(response.data);
|
||||||
|
|
||||||
|
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ info });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs an error if something went wrong emitting the state notification.', async(): Promise<void> => {
|
||||||
|
const logger = getLoggerFor('mock');
|
||||||
|
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
|
||||||
|
|
||||||
|
const { response } = await subscriptionType.subscribe(subscription, credentials);
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Read out data to end stream correctly
|
||||||
|
await readableToString(response.data);
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,64 @@
|
|||||||
|
import type { CredentialsExtractor } from '../../../../../src/authentication/CredentialsExtractor';
|
||||||
|
import type { Operation } from '../../../../../src/http/Operation';
|
||||||
|
import { ResetResponseDescription } from '../../../../../src/http/output/response/ResetResponseDescription';
|
||||||
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
|
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||||
|
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
||||||
|
import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||||
|
import type {
|
||||||
|
WebHookFeatures,
|
||||||
|
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||||
|
import {
|
||||||
|
WebHookUnsubscriber,
|
||||||
|
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
|
||||||
|
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
|
||||||
|
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
|
||||||
|
|
||||||
|
describe('A WebHookUnsubscriber', (): void => {
|
||||||
|
const request: HttpRequest = {} as any;
|
||||||
|
const response: HttpResponse = {} as any;
|
||||||
|
let operation: Operation;
|
||||||
|
const webId = 'http://example.com/alice';
|
||||||
|
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
|
||||||
|
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
|
||||||
|
let unsubscriber: WebHookUnsubscriber;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
operation = {
|
||||||
|
method: 'DELETE',
|
||||||
|
target: { path: 'http://example.com/.notifications/webhooks/unsubscribe/134' },
|
||||||
|
preferences: {},
|
||||||
|
body: new BasicRepresentation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
credentialsExtractor = {
|
||||||
|
handleSafe: jest.fn().mockResolvedValue({ agent: { webId }}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
storage = {
|
||||||
|
get: jest.fn().mockResolvedValue({ features: { webId }}),
|
||||||
|
delete: jest.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if the id does not match any stored info.', async(): Promise<void> => {
|
||||||
|
storage.get.mockResolvedValue(undefined);
|
||||||
|
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
|
||||||
|
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if credentials are wrong.', async(): Promise<void> => {
|
||||||
|
credentialsExtractor.handleSafe.mockResolvedValue({ agent: { webId: 'http://example.com/bob' }});
|
||||||
|
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(ForbiddenHttpError);
|
||||||
|
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the corresponding info.', async(): Promise<void> => {
|
||||||
|
await expect(unsubscriber.handle({ operation, request, response }))
|
||||||
|
.resolves.toEqual(new ResetResponseDescription());
|
||||||
|
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage.delete).toHaveBeenLastCalledWith('134');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,47 @@
|
|||||||
|
import { DataFactory, Parser } from 'n3';
|
||||||
|
import type { Operation } from '../../../../../src/http/Operation';
|
||||||
|
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||||
|
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||||
|
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
||||||
|
import { WebHookWebId } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookWebId';
|
||||||
|
import { readableToString } from '../../../../../src/util/StreamUtil';
|
||||||
|
import { SOLID } from '../../../../../src/util/Vocabularies';
|
||||||
|
const { namedNode, quad } = DataFactory;
|
||||||
|
|
||||||
|
describe('A WebHookWebId', (): void => {
|
||||||
|
const request: HttpRequest = {} as any;
|
||||||
|
const response: HttpResponse = {} as any;
|
||||||
|
let operation: Operation;
|
||||||
|
const baseUrl = 'http://example.com/';
|
||||||
|
let webIdHandler: WebHookWebId;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
operation = {
|
||||||
|
method: 'GET',
|
||||||
|
target: { path: 'http://example.com/.notifications/webhooks/webid' },
|
||||||
|
preferences: {},
|
||||||
|
body: new BasicRepresentation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
webIdHandler = new WebHookWebId(baseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a solid:oidcIssuer triple.', async(): Promise<void> => {
|
||||||
|
const turtle = await webIdHandler.handle({ operation, request, response });
|
||||||
|
expect(turtle.statusCode).toBe(200);
|
||||||
|
expect(turtle.metadata?.contentType).toBe('text/turtle');
|
||||||
|
expect(turtle.data).toBeDefined();
|
||||||
|
const quads = new Parser({ baseIRI: operation.target.path }).parse(await readableToString(turtle.data!));
|
||||||
|
expect(quads).toHaveLength(1);
|
||||||
|
expect(quads).toEqual([ quad(
|
||||||
|
namedNode('http://example.com/.notifications/webhooks/webid'),
|
||||||
|
SOLID.terms.oidcIssuer,
|
||||||
|
namedNode('http://example.com'),
|
||||||
|
) ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('errors if the base URL is invalid.', async(): Promise<void> => {
|
||||||
|
expect((): any => new WebHookWebId('very invalid URL'))
|
||||||
|
.toThrow('Invalid issuer URL: Unexpected "<very" on line 2.');
|
||||||
|
});
|
||||||
|
});
|
57
test/util/NotificationUtil.ts
Normal file
57
test/util/NotificationUtil.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { fetch } from 'cross-fetch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to a notification channel.
|
||||||
|
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
|
||||||
|
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
|
||||||
|
* @param subscriptionUrl - The URL where the subscription request needs to be sent to.
|
||||||
|
* @param topic - The topic to subscribe to.
|
||||||
|
* @param features - Any extra fields that need to be added to the subscription body.
|
||||||
|
*/
|
||||||
|
export async function subscribe(type: string, webId: string, subscriptionUrl: string, topic: string,
|
||||||
|
features: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
|
const subscription = {
|
||||||
|
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||||
|
type,
|
||||||
|
topic,
|
||||||
|
...features,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(subscriptionUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `WebID ${webId}`, 'content-type': 'application/ld+json' },
|
||||||
|
body: JSON.stringify(subscription),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toBe('application/ld+json');
|
||||||
|
const jsonResponse = await response.json();
|
||||||
|
expect(jsonResponse.type).toBe(type);
|
||||||
|
return jsonResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies if a notification has the expected format.
|
||||||
|
* @param notification - The (parsed) notification.
|
||||||
|
* @param topic - The topic of the notification.
|
||||||
|
* @param type - What type of notification is expected.
|
||||||
|
*/
|
||||||
|
export function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void {
|
||||||
|
const expected: any = {
|
||||||
|
'@context': [
|
||||||
|
'https://www.w3.org/ns/activitystreams',
|
||||||
|
'https://www.w3.org/ns/solid/notification/v1',
|
||||||
|
],
|
||||||
|
id: expect.stringContaining(topic),
|
||||||
|
type: [ type ],
|
||||||
|
object: {
|
||||||
|
id: topic,
|
||||||
|
type: [],
|
||||||
|
},
|
||||||
|
published: expect.anything(),
|
||||||
|
};
|
||||||
|
if (type !== 'Delete') {
|
||||||
|
expected.state = expect.anything();
|
||||||
|
expected.object.type.push('http://www.w3.org/ns/ldp#Resource');
|
||||||
|
}
|
||||||
|
expect(notification).toEqual(expected);
|
||||||
|
}
|
@ -29,6 +29,8 @@ const portNames = [
|
|||||||
'SetupMemory',
|
'SetupMemory',
|
||||||
'SparqlStorage',
|
'SparqlStorage',
|
||||||
'Subdomains',
|
'Subdomains',
|
||||||
|
'WebHookSubscription2021',
|
||||||
|
'WebHookSubscription2021-client',
|
||||||
'WebSocketSubscription2021',
|
'WebSocketSubscription2021',
|
||||||
|
|
||||||
// Unit
|
// Unit
|
||||||
|
Loading…
x
Reference in New Issue
Block a user