mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Allow unsubscribing from all notification channels
This commit is contained in:
parent
f7e05ca31e
commit
e9463483f4
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||||
"import": [
|
"import": [
|
||||||
"css:config/http/handler/handlers/notifications.json",
|
|
||||||
"css:config/http/handler/handlers/oidc.json",
|
"css:config/http/handler/handlers/oidc.json",
|
||||||
"css:config/http/handler/handlers/storage-description.json"
|
"css:config/http/handler/handlers/storage-description.json"
|
||||||
],
|
],
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"import": [
|
"import": [
|
||||||
"css:config/http/notifications/base/description.json",
|
"css:config/http/notifications/base/description.json",
|
||||||
"css:config/http/notifications/base/handler.json",
|
"css:config/http/notifications/base/handler.json",
|
||||||
|
"css:config/http/notifications/base/http.json",
|
||||||
"css:config/http/notifications/base/listener.json",
|
"css:config/http/notifications/base/listener.json",
|
||||||
"css:config/http/notifications/base/storage.json",
|
"css:config/http/notifications/base/storage.json",
|
||||||
"css:config/http/notifications/websockets/handler.json",
|
"css:config/http/notifications/websockets/handler.json",
|
||||||
|
@ -16,6 +16,21 @@
|
|||||||
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
"errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
|
||||||
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
"responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
|
||||||
"operationHandler": {
|
"operationHandler": {
|
||||||
|
"@type": "WaterfallHandler",
|
||||||
|
"handlers": [
|
||||||
|
{ "@id": "urn:solid-server:default:NotificationReadWriteHandler" },
|
||||||
|
{ "@id": "urn:solid-server:default:NotificationDeleteHandler" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"comment": "Handles reading and writing notification subscriptions and channels.",
|
||||||
|
"@id": "urn:solid-server:default:NotificationReadWriteHandler",
|
||||||
|
"@type": "OperationRouterHandler",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"allowedMethods": [ "HEAD", "GET", "POST" ],
|
||||||
|
"handler": {
|
||||||
"comment": "Converts outgoing responses based on the user preferences",
|
"comment": "Converts outgoing responses based on the user preferences",
|
||||||
"@type": "ConvertingOperationHttpHandler",
|
"@type": "ConvertingOperationHttpHandler",
|
||||||
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
|
||||||
@ -26,6 +41,18 @@
|
|||||||
"handlers": [ ]
|
"handlers": [ ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"comment": "Handles deleting notification channels.",
|
||||||
|
"@id": "urn:solid-server:default:NotificationDeleteHandler",
|
||||||
|
"@type": "OperationRouterHandler",
|
||||||
|
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
|
||||||
|
"allowedMethods": [ "DELETE" ],
|
||||||
|
"handler": {
|
||||||
|
"@type": "NotificationUnsubscriber",
|
||||||
|
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -2,7 +2,9 @@
|
|||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
{
|
{
|
||||||
"comment": "Disable notifications by not attaching a notification listener."
|
"comment": "Disables notification routing.",
|
||||||
|
"@id": "urn:solid-server:default:NotificationHttpHandler",
|
||||||
|
"@type": "UnsupportedAsyncHandler"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^6.0.0/components/context.jsonld",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
|
{
|
||||||
|
"comment": "Disables notification routing.",
|
||||||
|
"@id": "urn:solid-server:default:NotificationHttpHandler",
|
||||||
|
"@type": "UnsupportedAsyncHandler"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"@id": "urn:solid-server:default:ServerConfigurator",
|
"@id": "urn:solid-server:default:ServerConfigurator",
|
||||||
"@type": "ParallelHandler",
|
"@type": "ParallelHandler",
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"import": [
|
"import": [
|
||||||
"css:config/http/notifications/base/description.json",
|
"css:config/http/notifications/base/description.json",
|
||||||
"css:config/http/notifications/base/handler.json",
|
"css:config/http/notifications/base/handler.json",
|
||||||
|
"css:config/http/notifications/base/http.json",
|
||||||
"css:config/http/notifications/base/listener.json",
|
"css:config/http/notifications/base/listener.json",
|
||||||
"css:config/http/notifications/base/storage.json",
|
"css:config/http/notifications/base/storage.json",
|
||||||
"css:config/http/notifications/webhooks/handler.json",
|
"css:config/http/notifications/webhooks/handler.json",
|
||||||
|
@ -14,20 +14,6 @@
|
|||||||
"relativePath": "/webId"
|
"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/" ],
|
|
||||||
"handler": {
|
|
||||||
"@type": "WebHookUnsubscriber",
|
|
||||||
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
|
|
||||||
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"comment": "Handles the WebHookSubscription2021 WebID.",
|
"comment": "Handles the WebHookSubscription2021 WebID.",
|
||||||
"@id": "urn:solid-server:default:WebHookWebId",
|
"@id": "urn:solid-server:default:WebHookWebId",
|
||||||
@ -45,7 +31,6 @@
|
|||||||
"@type": "WaterfallHandler",
|
"@type": "WaterfallHandler",
|
||||||
"handlers": [
|
"handlers": [
|
||||||
{ "@id": "urn:solid-server:default:WebHookRouter" },
|
{ "@id": "urn:solid-server:default:WebHookRouter" },
|
||||||
{ "@id": "urn:solid-server:default:WebHookUnsubscriber" },
|
|
||||||
{ "@id": "urn:solid-server:default:WebHookWebId" }
|
{ "@id": "urn:solid-server:default:WebHookWebId" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
"import": [
|
"import": [
|
||||||
"css:config/http/notifications/base/description.json",
|
"css:config/http/notifications/base/description.json",
|
||||||
"css:config/http/notifications/base/handler.json",
|
"css:config/http/notifications/base/handler.json",
|
||||||
|
"css:config/http/notifications/base/http.json",
|
||||||
"css:config/http/notifications/base/listener.json",
|
"css:config/http/notifications/base/listener.json",
|
||||||
"css:config/http/notifications/base/storage.json",
|
"css:config/http/notifications/base/storage.json",
|
||||||
"css:config/http/notifications/websockets/handler.json",
|
"css:config/http/notifications/websockets/handler.json",
|
||||||
|
@ -135,6 +135,19 @@ The `unsubscribe_endpoint` field is new here.
|
|||||||
Once created, the notification channel can be removed and notifications stopped
|
Once created, the notification channel can be removed and notifications stopped
|
||||||
by sending a `DELETE` request to the URL found in that field.
|
by sending a `DELETE` request to the URL found in that field.
|
||||||
|
|
||||||
|
## Unsubscribing from a notification channel
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
This feature is not part of the Solid Notification v0.2 specification
|
||||||
|
so might be changed or removed in the future.
|
||||||
|
|
||||||
|
If you no longer want to receive notifications on the channel you created,
|
||||||
|
you can send a `DELETE` request to the channel to remove it.
|
||||||
|
Use the value found in the `id` field of the subscription response.
|
||||||
|
There is no way to retrieve this identifier later on,
|
||||||
|
so make sure to keep track of it just in case you want to unsubscribe at some point.
|
||||||
|
No authorization is needed for this request.
|
||||||
|
|
||||||
## Notification format
|
## Notification format
|
||||||
|
|
||||||
Below is an example notification that would be sent when a resource changes:
|
Below is an example notification that would be sent when a resource changes:
|
||||||
@ -203,13 +216,13 @@ The available fields are:
|
|||||||
|
|
||||||
## Important note for server owners
|
## Important note for server owners
|
||||||
|
|
||||||
There is not much restriction on who can create a new notification channel,
|
There is not much restriction on who can create a new notification channel;
|
||||||
only `Read` permissions on the target resource are required.
|
only `Read` permissions on the target resource are required.
|
||||||
It is therefore possible for the server to accumulate created channels.
|
It is therefore possible for the server to accumulate created channels.
|
||||||
As these channels still get used every time their corresponding resource changes,
|
As these channels still get used every time their corresponding resource changes,
|
||||||
this could degrade server performance.
|
this could degrade server performance.
|
||||||
|
|
||||||
For this reason, the server is by default configured to always remove notification channels after 2 weeks.
|
For this reason, the default server configuration removes notification channels after two weeks (20160 minutes).
|
||||||
You can modify this behaviour by adding the following block to your configuration:
|
You can modify this behaviour by adding the following block to your configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -222,5 +235,5 @@ You can modify this behaviour by adding the following block to your configuratio
|
|||||||
|
|
||||||
`maxDuration` defines after how many minutes every channel will be removed.
|
`maxDuration` defines after how many minutes every channel will be removed.
|
||||||
Setting this value to 0 will allow channels to exist forever.
|
Setting this value to 0 will allow channels to exist forever.
|
||||||
Similarly, for changing the maximum duration of webhook channels you can use the identifier
|
Similarly, to change the maximum duration of webhook channels you can use the identifier
|
||||||
`urn:solid-server:default:WebHookSubscriber`.
|
`urn:solid-server:default:WebHookSubscriber`.
|
||||||
|
@ -324,7 +324,6 @@ export * from './server/notifications/serialize/NotificationSerializer';
|
|||||||
// Server/Notifications/WebHookSubscription2021
|
// Server/Notifications/WebHookSubscription2021
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
|
export * from './server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
export * from './server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
|
|
||||||
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
|
export * from './server/notifications/WebHookSubscription2021/WebHookWebId';
|
||||||
|
|
||||||
// Server/Notifications/WebSocketChannel2023
|
// Server/Notifications/WebSocketChannel2023
|
||||||
@ -350,6 +349,7 @@ export * from './server/notifications/NotificationDescriber';
|
|||||||
export * from './server/notifications/NotificationEmitter';
|
export * from './server/notifications/NotificationEmitter';
|
||||||
export * from './server/notifications/NotificationHandler';
|
export * from './server/notifications/NotificationHandler';
|
||||||
export * from './server/notifications/NotificationSubscriber';
|
export * from './server/notifications/NotificationSubscriber';
|
||||||
|
export * from './server/notifications/NotificationUnsubscriber';
|
||||||
export * from './server/notifications/StateHandler';
|
export * from './server/notifications/StateHandler';
|
||||||
export * from './server/notifications/TypedNotificationHandler';
|
export * from './server/notifications/TypedNotificationHandler';
|
||||||
|
|
||||||
|
@ -74,13 +74,14 @@ export class KeyValueChannelStorage implements NotificationChannelStorage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(id: string): Promise<void> {
|
public async delete(id: string): Promise<boolean> {
|
||||||
return this.locker.withWriteLock(this.getLockKey(id), async(): Promise<void> => {
|
return this.locker.withWriteLock(this.getLockKey(id), async(): Promise<boolean> => {
|
||||||
const channel = await this.get(id);
|
const channel = await this.get(id);
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
await this.deleteChannel(channel);
|
await this.deleteChannel(channel);
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +37,8 @@ export interface NotificationChannelStorage {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the given notification channel from the storage.
|
* Deletes the given notification channel from the storage.
|
||||||
|
* Returns true if the channel existed.
|
||||||
* @param id - The identifier of the notification channel
|
* @param id - The identifier of the notification channel
|
||||||
*/
|
*/
|
||||||
delete: (id: string) => Promise<void>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
34
src/server/notifications/NotificationUnsubscriber.ts
Normal file
34
src/server/notifications/NotificationUnsubscriber.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { ResetResponseDescription } from '../../http/output/response/ResetResponseDescription';
|
||||||
|
import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
|
||||||
|
import { getLoggerFor } from '../../logging/LogUtil';
|
||||||
|
import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
|
||||||
|
import type { OperationHttpHandlerInput } from '../OperationHttpHandler';
|
||||||
|
import { OperationHttpHandler } from '../OperationHttpHandler';
|
||||||
|
import type { NotificationChannelStorage } from './NotificationChannelStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows clients to unsubscribe from notification channels.
|
||||||
|
* Should be wrapped in a route handler that only allows `DELETE` operations.
|
||||||
|
*/
|
||||||
|
export class NotificationUnsubscriber extends OperationHttpHandler {
|
||||||
|
protected readonly logger = getLoggerFor(this);
|
||||||
|
|
||||||
|
private readonly storage: NotificationChannelStorage;
|
||||||
|
|
||||||
|
public constructor(storage: NotificationChannelStorage) {
|
||||||
|
super();
|
||||||
|
this.storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handle({ operation }: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
||||||
|
const id = operation.target.path;
|
||||||
|
|
||||||
|
const existed = await this.storage.delete(id);
|
||||||
|
if (!existed) {
|
||||||
|
throw new NotFoundHttpError();
|
||||||
|
}
|
||||||
|
this.logger.debug(`Deleted notification channel ${id}`);
|
||||||
|
|
||||||
|
return new ResetResponseDescription();
|
||||||
|
}
|
||||||
|
}
|
@ -1,45 +0,0 @@
|
|||||||
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 { NotificationChannelStorage } from '../NotificationChannelStorage';
|
|
||||||
import { isWebHook2021Channel } 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: NotificationChannelStorage;
|
|
||||||
|
|
||||||
public constructor(credentialsExtractor: CredentialsExtractor, storage: NotificationChannelStorage) {
|
|
||||||
super();
|
|
||||||
this.credentialsExtractor = credentialsExtractor;
|
|
||||||
this.storage = storage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handle({ operation, request }: OperationHttpHandlerInput): Promise<ResponseDescription> {
|
|
||||||
const id = operation.target.path;
|
|
||||||
const channel = await this.storage.get(id);
|
|
||||||
if (!channel || !isWebHook2021Channel(channel)) {
|
|
||||||
throw new NotFoundHttpError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = await this.credentialsExtractor.handleSafe(request);
|
|
||||||
if (channel.webId !== credentials.agent?.webId) {
|
|
||||||
throw new ForbiddenHttpError();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Deleting WebHook notification channel ${id}`);
|
|
||||||
await this.storage.delete(id);
|
|
||||||
|
|
||||||
return new ResetResponseDescription();
|
|
||||||
}
|
|
||||||
}
|
|
@ -49,7 +49,8 @@ export class WebSocket2023Storer extends WebSocket2023Handler {
|
|||||||
const result = await this.storage.get(id);
|
const result = await this.storage.get(id);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
for (const socket of sockets) {
|
for (const socket of sockets) {
|
||||||
// Due to the attached listener this also deletes the entries
|
// Due to the attached listener, this also deletes the entries in the `socketMap`
|
||||||
|
socket.send(`Notification channel has expired`);
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,7 @@ import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|||||||
import type { NamedNode } from 'n3';
|
import type { NamedNode } from 'n3';
|
||||||
import { DataFactory, Parser, Store } from 'n3';
|
import { DataFactory, Parser, Store } from 'n3';
|
||||||
import type { App } from '../../src/init/App';
|
import type { App } from '../../src/init/App';
|
||||||
import type {
|
|
||||||
WebHookSubscription2021Channel,
|
|
||||||
} from '../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
|
||||||
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
|
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
|
||||||
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
|
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
|
||||||
import { readJsonStream } from '../../src/util/StreamUtil';
|
import { readJsonStream } from '../../src/util/StreamUtil';
|
||||||
@ -185,14 +183,12 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
|
|||||||
response.end();
|
response.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows a user to unsubscribe.', async(): Promise<void> => {
|
it('can remove notification channels.', async(): Promise<void> => {
|
||||||
const json = await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target });
|
const { id } = await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target }) as any;
|
||||||
const unsubscribeUrl = (json as WebHookSubscription2021Channel).unsubscribe_endpoint;
|
|
||||||
let response = await fetch(unsubscribeUrl, { method: 'DELETE' });
|
const response = await fetch(id, { method: 'DELETE' });
|
||||||
expect(response.status).toBe(403);
|
|
||||||
response = await fetch(unsubscribeUrl, { method: 'DELETE', headers: { authorization: `WebID ${webId}` }});
|
|
||||||
expect(response.status).toBe(205);
|
expect(response.status).toBe(205);
|
||||||
response = await fetch(joinUrl(subscriptionUrl, 'abc'), { method: 'DELETE' });
|
|
||||||
expect(response.status).toBe(404);
|
// Expired WebSockets only get removed every hour so not feasible to test in integration test
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -308,4 +308,13 @@ describe.each(stores)('A server supporting WebSocketChannel2023 using %s', (name
|
|||||||
expect(quads.getObjects(null, NOTIFY.terms.topic, null)).toEqual([ namedNode(topic) ]);
|
expect(quads.getObjects(null, NOTIFY.terms.topic, null)).toEqual([ namedNode(topic) ]);
|
||||||
expect(quads.countQuads(null, NOTIFY.terms.receiveFrom, null, null)).toBe(1);
|
expect(quads.countQuads(null, NOTIFY.terms.receiveFrom, null, null)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can remove notification channels.', async(): Promise<void> => {
|
||||||
|
const { id } = await subscribe(notificationType, webId, subscriptionUrl, topic) as any;
|
||||||
|
|
||||||
|
const response = await fetch(id, { method: 'DELETE' });
|
||||||
|
expect(response.status).toBe(205);
|
||||||
|
|
||||||
|
// Expired WebSockets only get removed every hour so not feasible to test in integration test
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -130,7 +130,7 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
await storage.add(channel);
|
await storage.add(channel);
|
||||||
await storage.add(channel2);
|
await storage.add(channel2);
|
||||||
expect(internalMap.size).toBe(3);
|
expect(internalMap.size).toBe(3);
|
||||||
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBe(true);
|
||||||
expect(internalMap.size).toBe(2);
|
expect(internalMap.size).toBe(2);
|
||||||
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
|
||||||
[ channel2.id ],
|
[ channel2.id ],
|
||||||
@ -140,12 +140,12 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
|
|
||||||
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
|
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
|
||||||
await storage.add(channel);
|
await storage.add(channel);
|
||||||
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBe(true);
|
||||||
expect(internalMap.size).toBe(0);
|
expect(internalMap.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does nothing if the target does not exist.', async(): Promise<void> => {
|
it('does nothing if the target does not exist.', async(): Promise<void> => {
|
||||||
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
|
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
|
||||||
@ -153,7 +153,7 @@ describe('A KeyValueChannelStorage', (): void => {
|
|||||||
// Looking for the key so this test doesn't depend on the internal keys used
|
// Looking for the key so this test doesn't depend on the internal keys used
|
||||||
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
|
||||||
internalMap.set(id, []);
|
internalMap.set(id, []);
|
||||||
await expect(storage.delete(channel.id)).resolves.toBeUndefined();
|
await expect(storage.delete(channel.id)).resolves.toBe(true);
|
||||||
expect(logger.error).toHaveBeenCalledTimes(2);
|
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
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 { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage';
|
||||||
|
import { NotificationUnsubscriber } from '../../../../src/server/notifications/NotificationUnsubscriber';
|
||||||
|
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
|
||||||
|
|
||||||
|
describe('A NotificationUnsubscriber', (): void => {
|
||||||
|
const request: HttpRequest = {} as any;
|
||||||
|
const response: HttpResponse = {} as any;
|
||||||
|
let operation: Operation;
|
||||||
|
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||||
|
let unsubscriber: NotificationUnsubscriber;
|
||||||
|
|
||||||
|
beforeEach(async(): Promise<void> => {
|
||||||
|
operation = {
|
||||||
|
method: 'DELETE',
|
||||||
|
target: { path: 'http://example.com/.notifications/channeltype/134' },
|
||||||
|
preferences: {},
|
||||||
|
body: new BasicRepresentation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
storage = {
|
||||||
|
delete: jest.fn().mockResolvedValue(true),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
unsubscriber = new NotificationUnsubscriber(storage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if the id does not match any stored channel.', async(): Promise<void> => {
|
||||||
|
storage.delete.mockResolvedValue(false);
|
||||||
|
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
|
||||||
|
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage.delete).toHaveBeenLastCalledWith('http://example.com/.notifications/channeltype/134');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the corresponding channel.', async(): Promise<void> => {
|
||||||
|
await expect(unsubscriber.handle({ operation, request, response }))
|
||||||
|
.resolves.toEqual(new ResetResponseDescription());
|
||||||
|
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage.delete).toHaveBeenLastCalledWith('http://example.com/.notifications/channeltype/134');
|
||||||
|
});
|
||||||
|
});
|
@ -1,63 +0,0 @@
|
|||||||
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 { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
|
||||||
|
|
||||||
import {
|
|
||||||
WebHookUnsubscriber,
|
|
||||||
} 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;
|
|
||||||
const response: HttpResponse = {} as any;
|
|
||||||
let operation: Operation;
|
|
||||||
const webId = 'http://example.com/alice';
|
|
||||||
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
|
|
||||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
|
||||||
let unsubscriber: WebHookUnsubscriber;
|
|
||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
|
||||||
operation = {
|
|
||||||
method: 'DELETE',
|
|
||||||
target: { path: 'http://example.com/.notifications/webhooks/134' },
|
|
||||||
preferences: {},
|
|
||||||
body: new BasicRepresentation(),
|
|
||||||
};
|
|
||||||
|
|
||||||
credentialsExtractor = {
|
|
||||||
handleSafe: jest.fn().mockResolvedValue({ agent: { webId }}),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
storage = {
|
|
||||||
get: jest.fn().mockResolvedValue({ type: NOTIFY.WebHookSubscription2021, webId }),
|
|
||||||
delete: jest.fn(),
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects if the id does not match any stored channel.', 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 channel.', async(): Promise<void> => {
|
|
||||||
await expect(unsubscriber.handle({ operation, request, response }))
|
|
||||||
.resolves.toEqual(new ResetResponseDescription());
|
|
||||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
|
||||||
expect(storage.delete).toHaveBeenLastCalledWith('http://example.com/.notifications/webhooks/134');
|
|
||||||
});
|
|
||||||
});
|
|
@ -25,6 +25,7 @@ describe('A WebSocket2023Storer', (): void => {
|
|||||||
|
|
||||||
beforeEach(async(): Promise<void> => {
|
beforeEach(async(): Promise<void> => {
|
||||||
webSocket = new EventEmitter() as any;
|
webSocket = new EventEmitter() as any;
|
||||||
|
webSocket.send = jest.fn();
|
||||||
webSocket.close = jest.fn();
|
webSocket.close = jest.fn();
|
||||||
|
|
||||||
storage = {
|
storage = {
|
||||||
@ -63,8 +64,10 @@ describe('A WebSocket2023Storer', (): void => {
|
|||||||
storer = new WebSocket2023Storer(storage, socketMap);
|
storer = new WebSocket2023Storer(storage, socketMap);
|
||||||
|
|
||||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||||
|
webSocket2.send = jest.fn();
|
||||||
webSocket2.close = jest.fn();
|
webSocket2.close = jest.fn();
|
||||||
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||||
|
webSocketOther.send = jest.fn();
|
||||||
webSocketOther.close = jest.fn();
|
webSocketOther.close = jest.fn();
|
||||||
const channelOther: NotificationChannel = {
|
const channelOther: NotificationChannel = {
|
||||||
...channel,
|
...channel,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user