mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for WebHookSubscription2021
This commit is contained in:
185
test/integration/WebHookSubscription2021.test.ts
Normal file
185
test/integration/WebHookSubscription2021.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { createServer } from 'http';
|
||||
import type { Server, IncomingMessage, ServerResponse } from 'http';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import type { NamedNode } from 'n3';
|
||||
import { DataFactory, Parser, Store } from 'n3';
|
||||
import type { App } from '../../src/init/App';
|
||||
import { matchesAuthorizationScheme } from '../../src/util/HeaderUtil';
|
||||
import { joinUrl, trimTrailingSlashes } from '../../src/util/PathUtil';
|
||||
import { readJsonStream } from '../../src/util/StreamUtil';
|
||||
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
|
||||
import { expectNotification, subscribe } from '../util/NotificationUtil';
|
||||
import { getPort } from '../util/Util';
|
||||
import {
|
||||
getDefaultVariables,
|
||||
getPresetConfigPath,
|
||||
getTestConfigPath,
|
||||
getTestFolder,
|
||||
instantiateFromConfig, removeFolder,
|
||||
} from './Config';
|
||||
import quad = DataFactory.quad;
|
||||
import namedNode = DataFactory.namedNode;
|
||||
|
||||
const port = getPort('WebHookSubscription2021');
|
||||
const baseUrl = `http://localhost:${port}/`;
|
||||
const clientPort = getPort('WebHookSubscription2021-client');
|
||||
const target = `http://localhost:${clientPort}/`;
|
||||
const webId = 'http://example.com/card/#me';
|
||||
const notificationType = 'WebHookSubscription2021';
|
||||
|
||||
const rootFilePath = getTestFolder('WebHookSubscription2021');
|
||||
const stores: [string, any][] = [
|
||||
[ 'in-memory storage', {
|
||||
configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ],
|
||||
teardown: jest.fn(),
|
||||
}],
|
||||
[ 'on-disk storage', {
|
||||
// Switch to file locker after https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1452
|
||||
configs: [ 'storage/backend/file.json', 'util/resource-locker/memory.json' ],
|
||||
teardown: async(): Promise<void> => removeFolder(rootFilePath),
|
||||
}],
|
||||
];
|
||||
|
||||
describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (name, { configs, teardown }): void => {
|
||||
let app: App;
|
||||
const topic = joinUrl(baseUrl, '/foo');
|
||||
let storageDescriptionUrl: string;
|
||||
let subscriptionUrl: string;
|
||||
let clientServer: Server;
|
||||
let serverWebId: string;
|
||||
|
||||
beforeAll(async(): Promise<void> => {
|
||||
const variables = {
|
||||
...getDefaultVariables(port, baseUrl),
|
||||
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
|
||||
};
|
||||
|
||||
// Create and start the server
|
||||
const instances = await instantiateFromConfig(
|
||||
'urn:solid-server:test:Instances',
|
||||
[
|
||||
...configs.map(getPresetConfigPath),
|
||||
getTestConfigPath('webhook-notifications.json'),
|
||||
],
|
||||
variables,
|
||||
) as Record<string, any>;
|
||||
({ app } = instances);
|
||||
|
||||
await app.start();
|
||||
|
||||
// Start client server
|
||||
clientServer = createServer();
|
||||
clientServer.listen(clientPort);
|
||||
});
|
||||
|
||||
afterAll(async(): Promise<void> => {
|
||||
clientServer.close();
|
||||
await app.stop();
|
||||
await teardown();
|
||||
});
|
||||
|
||||
it('links to the storage description.', async(): Promise<void> => {
|
||||
const response = await fetch(baseUrl);
|
||||
expect(response.status).toBe(200);
|
||||
const linkHeader = response.headers.get('link');
|
||||
expect(linkHeader).not.toBeNull();
|
||||
const match = /<([^>]+)>; rel="http:\/\/www\.w3\.org\/ns\/solid\/terms#storageDescription"/u.exec(linkHeader!);
|
||||
expect(match).not.toBeNull();
|
||||
storageDescriptionUrl = match![1];
|
||||
});
|
||||
|
||||
it('exposes metadata on how to subscribe in the storage description.', async(): Promise<void> => {
|
||||
const response = await fetch(storageDescriptionUrl, { headers: { accept: 'text/turtle' }});
|
||||
expect(response.status).toBe(200);
|
||||
const quads = new Store(new Parser().parse(await response.text()));
|
||||
|
||||
// Find the notification channel for websockets
|
||||
const channels = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.notificationChannel, null);
|
||||
const webHookChannels = channels.filter((channel): boolean => quads.has(
|
||||
quad(channel as NamedNode, RDF.terms.type, namedNode(`${NOTIFY.namespace}WebHookSubscription2021`)),
|
||||
));
|
||||
expect(webHookChannels).toHaveLength(1);
|
||||
const subscriptionUrls = quads.getObjects(webHookChannels[0], NOTIFY.terms.subscription, null);
|
||||
expect(subscriptionUrls).toHaveLength(1);
|
||||
subscriptionUrl = subscriptionUrls[0].value;
|
||||
|
||||
// It should also link to the server WebID
|
||||
const webIds = quads.getObjects(webHookChannels[0], NOTIFY.terms.webid, null);
|
||||
expect(webIds).toHaveLength(1);
|
||||
serverWebId = webIds[0].value;
|
||||
});
|
||||
|
||||
it('supports subscribing.', async(): Promise<void> => {
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { target });
|
||||
});
|
||||
|
||||
it('emits Created events.', async(): Promise<void> => {
|
||||
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
|
||||
clientServer.on('request', (request, response): void => {
|
||||
resolve({ request, response });
|
||||
});
|
||||
});
|
||||
|
||||
let res = await fetch(topic, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: 'abc',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const { request, response } = await clientPromise;
|
||||
expect(request.headers['content-type']).toBe('application/ld+json');
|
||||
const notification = await readJsonStream(request);
|
||||
|
||||
expectNotification(notification, topic, 'Create');
|
||||
|
||||
// Find the JWKS of the server
|
||||
res = await fetch(joinUrl(baseUrl, '.well-known/openid-configuration'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('application/json');
|
||||
const resJson = await res.json();
|
||||
expect(typeof resJson.jwks_uri).toBe('string');
|
||||
const jwks = createRemoteJWKSet(new URL(resJson.jwks_uri));
|
||||
|
||||
// Check validity of DPoP headers
|
||||
// Note that this is not a comprehensive validation of the headers,
|
||||
// only some of the basics are checked.
|
||||
const { authorization, dpop } = request.headers;
|
||||
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
|
||||
const encodedDpopToken = authorization!.slice('dpop '.length);
|
||||
// These will throw if they can not be decoded with the JWKS from the server
|
||||
const decodedDpopToken = await jwtVerify(encodedDpopToken, jwks, { issuer: trimTrailingSlashes(baseUrl) });
|
||||
expect(decodedDpopToken.payload).toMatchObject({
|
||||
webid: serverWebId,
|
||||
});
|
||||
const decodedDpopProof = await jwtVerify(dpop as string, jwks);
|
||||
expect(decodedDpopProof.payload).toMatchObject({
|
||||
htu: target,
|
||||
htm: 'POST',
|
||||
});
|
||||
|
||||
// Close the connection so the server can shut down
|
||||
response.end();
|
||||
});
|
||||
|
||||
it('sends a notification if a state value was sent along.', async(): Promise<void> => {
|
||||
const clientPromise = new Promise<{ request: IncomingMessage; response: ServerResponse }>((resolve): void => {
|
||||
clientServer.on('request', (request, response): void => {
|
||||
resolve({ request, response });
|
||||
});
|
||||
});
|
||||
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' });
|
||||
|
||||
// Will resolve even though the resource did not change since subscribing
|
||||
const { request, response } = await clientPromise;
|
||||
expect(request.headers['content-type']).toBe('application/ld+json');
|
||||
const notification = await readJsonStream(request);
|
||||
|
||||
expectNotification(notification, topic, 'Update');
|
||||
|
||||
// Close the connection so the server can shut down
|
||||
response.end();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { App } from '../../src/init/App';
|
||||
import type { ResourceStore } from '../../src/storage/ResourceStore';
|
||||
import { 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));
|
||||
|
||||
52
test/integration/config/webhook-notifications.json
Normal file
52
test/integration/config/webhook-notifications.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
|
||||
"import": [
|
||||
"css:config/app/main/default.json",
|
||||
"css:config/app/init/initialize-root.json",
|
||||
"css:config/app/setup/disabled.json",
|
||||
"css:config/http/handler/default.json",
|
||||
"css:config/http/middleware/default.json",
|
||||
"css:config/http/notifications/webhooks.json",
|
||||
"css:config/http/server-factory/http.json",
|
||||
"css:config/http/static/default.json",
|
||||
"css:config/identity/access/public.json",
|
||||
"css:config/identity/email/default.json",
|
||||
"css:config/identity/handler/default.json",
|
||||
"css:config/identity/ownership/token.json",
|
||||
"css:config/identity/pod/static.json",
|
||||
"css:config/identity/registration/enabled.json",
|
||||
"css:config/ldp/authentication/debug-auth-header.json",
|
||||
"css:config/ldp/authorization/webacl.json",
|
||||
"css:config/ldp/handler/default.json",
|
||||
"css:config/ldp/metadata-parser/default.json",
|
||||
"css:config/ldp/metadata-writer/default.json",
|
||||
"css:config/ldp/modes/default.json",
|
||||
|
||||
"css:config/storage/key-value/resource-store.json",
|
||||
"css:config/storage/middleware/default.json",
|
||||
"css:config/util/auxiliary/acl.json",
|
||||
"css:config/util/identifiers/suffix.json",
|
||||
"css:config/util/index/default.json",
|
||||
"css:config/util/logging/winston.json",
|
||||
"css:config/util/representation-conversion/default.json",
|
||||
|
||||
"css:config/util/variables/default.json"
|
||||
],
|
||||
"@graph": [
|
||||
{
|
||||
"comment": "WebSocket notifications with debug authentication.",
|
||||
"@id": "urn:solid-server:test:Instances",
|
||||
"@type": "RecordObject",
|
||||
"record": [
|
||||
{
|
||||
"RecordObject:_record_key": "app",
|
||||
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
|
||||
},
|
||||
{
|
||||
"RecordObject:_record_key": "store",
|
||||
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user