feat: Allow unsubscribing from all notification channels

This commit is contained in:
Joachim Van Herwegen 2023-04-21 14:11:21 +02:00
parent f7e05ca31e
commit e9463483f4
21 changed files with 165 additions and 149 deletions

View File

@ -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"
], ],

View File

@ -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",

View File

@ -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" }
}
} }
] ]
} }

View File

@ -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"
} }
] ]
} }

View File

@ -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",

View File

@ -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",

View File

@ -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" }
] ]
} }

View File

@ -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",

View File

@ -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`.

View File

@ -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';

View File

@ -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;
}); });
} }

View File

@ -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>;
} }

View 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();
}
}

View File

@ -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();
}
}

View File

@ -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();
} }
} }

View File

@ -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
}); });
}); });

View File

@ -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
});
}); });

View File

@ -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);
}); });
}); });

View File

@ -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');
});
});

View File

@ -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');
});
});

View File

@ -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,