feat: Add support for WebHookSubscription2021

This commit is contained in:
Joachim Van Herwegen
2022-10-13 10:15:05 +02:00
parent cb619415fa
commit f54c34d1e0
34 changed files with 1305 additions and 64 deletions

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

View File

@@ -7,6 +7,7 @@ import type { App } from '../../src/init/App';
import type { ResourceStore } from '../../src/storage/ResourceStore';
import { joinUrl } from '../../src/util/PathUtil';
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
import { expectNotification, subscribe } from '../util/NotificationUtil';
import { getPort } from '../util/Util';
import {
getDefaultVariables,
@@ -20,6 +21,7 @@ import namedNode = DataFactory.namedNode;
const port = getPort('WebSocketSubscription2021');
const baseUrl = `http://localhost:${port}/`;
const notificationType = 'WebSocketSubscription2021';
const rootFilePath = getTestFolder('WebSocketSubscription2021');
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 => {
let app: App;
let store: ResourceStore;
@@ -140,7 +97,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
});
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> => {
@@ -242,7 +200,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
});
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 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> => {
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 messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));

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

View File

@@ -111,7 +111,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000),
}));
}), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody,
@@ -120,7 +120,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000),
}));
}), { public: {}});
operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody,
@@ -129,7 +129,7 @@ describe('A NotificationSubscriber', (): void => {
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + 5,
}));
}), { public: {}});
jest.useRealTimers();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -29,6 +29,8 @@ const portNames = [
'SetupMemory',
'SparqlStorage',
'Subdomains',
'WebHookSubscription2021',
'WebHookSubscription2021-client',
'WebSocketSubscription2021',
// Unit