mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for WebSocketSubscription2021
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
} from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import {
|
||||
WebSocket2021Emitter,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
|
||||
import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap';
|
||||
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
|
||||
|
||||
describe('A WebSocket2021Emitter', (): void => {
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
let webSocket: jest.Mocked<WebSocket>;
|
||||
let socketMap: SetMultiMap<string, WebSocket>;
|
||||
let emitter: WebSocket2021Emitter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
webSocket = new EventEmitter() as any;
|
||||
webSocket.send = jest.fn();
|
||||
webSocket.close = jest.fn();
|
||||
|
||||
socketMap = new WrappedSetMultiMap();
|
||||
|
||||
emitter = new WebSocket2021Emitter(socketMap);
|
||||
});
|
||||
|
||||
it('emits notifications to the stored WebSockets.', async(): Promise<void> => {
|
||||
socketMap.add(info.id, webSocket);
|
||||
|
||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||
});
|
||||
|
||||
it('destroys the representation if there is no matching WebSocket.', async(): Promise<void> => {
|
||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
||||
expect(representation.data.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('can send to multiple matching WebSockets.', async(): Promise<void> => {
|
||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||
webSocket2.send = jest.fn();
|
||||
|
||||
socketMap.add(info.id, webSocket);
|
||||
socketMap.add(info.id, webSocket2);
|
||||
|
||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||
expect(webSocket2.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket2.send).toHaveBeenLastCalledWith('notification');
|
||||
});
|
||||
|
||||
it('only sends to the matching WebSockets.', async(): Promise<void> => {
|
||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||
webSocket2.send = jest.fn();
|
||||
const info2: SubscriptionInfo = {
|
||||
...info,
|
||||
id: 'other',
|
||||
};
|
||||
|
||||
socketMap.add(info.id, webSocket);
|
||||
socketMap.add(info2.id, webSocket2);
|
||||
|
||||
const representation = new BasicRepresentation('notification', 'text/plain');
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
|
||||
expect(webSocket2.send).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { Server } from 'http';
|
||||
import type { WebSocket } from 'ws';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionStorage,
|
||||
} from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type {
|
||||
WebSocket2021Handler,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler';
|
||||
import {
|
||||
WebSocket2021Listener,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener';
|
||||
import { flushPromises } from '../../../../util/Util';
|
||||
|
||||
jest.mock('ws', (): any => ({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
WebSocketServer: jest.fn().mockImplementation((): any => ({
|
||||
handleUpgrade(upgradeRequest: any, socket: any, head: any, callback: any): void {
|
||||
callback(socket, upgradeRequest);
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('A WebSocket2021Listener', (): void => {
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
const auth = '123456';
|
||||
let server: Server;
|
||||
let webSocket: WebSocket;
|
||||
let upgradeRequest: HttpRequest;
|
||||
let storage: jest.Mocked<SubscriptionStorage>;
|
||||
let handler: jest.Mocked<WebSocket2021Handler>;
|
||||
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
|
||||
let listener: WebSocket2021Listener;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
server = new EventEmitter() as any;
|
||||
webSocket = new EventEmitter() as any;
|
||||
webSocket.send = jest.fn();
|
||||
webSocket.close = jest.fn();
|
||||
|
||||
upgradeRequest = { url: `/foo?auth=${auth}` } as any;
|
||||
|
||||
storage = {
|
||||
get: jest.fn().mockResolvedValue(info),
|
||||
} as any;
|
||||
|
||||
handler = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
listener = new WebSocket2021Listener(storage, handler, route);
|
||||
await listener.handle(server);
|
||||
});
|
||||
|
||||
it('rejects request targeting an unknown path.', async(): Promise<void> => {
|
||||
upgradeRequest.url = '/wrong';
|
||||
server.emit('upgrade', upgradeRequest, webSocket);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.');
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(1);
|
||||
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects request with no url.', async(): Promise<void> => {
|
||||
delete upgradeRequest.url;
|
||||
server.emit('upgrade', upgradeRequest, webSocket);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.');
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(1);
|
||||
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects requests without an auth parameter.', async(): Promise<void> => {
|
||||
upgradeRequest.url = '/foo';
|
||||
server.emit('upgrade', upgradeRequest, webSocket);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith('Missing auth parameter from WebSocket URL.');
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(1);
|
||||
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects requests with an unknown auth parameter.', async(): Promise<void> => {
|
||||
storage.get.mockResolvedValue(undefined);
|
||||
server.emit('upgrade', upgradeRequest, webSocket);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket.send).toHaveBeenLastCalledWith(`Subscription has expired`);
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(1);
|
||||
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('calls the handler when receiving a valid request.', async(): Promise<void> => {
|
||||
server.emit('upgrade', upgradeRequest, webSocket);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.send).toHaveBeenCalledTimes(0);
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(0);
|
||||
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(handler.handleSafe).toHaveBeenLastCalledWith({ webSocket, info });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionStorage,
|
||||
} from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
|
||||
import {
|
||||
WebSocket2021Storer,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer';
|
||||
import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap';
|
||||
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
|
||||
import { flushPromises } from '../../../../util/Util';
|
||||
|
||||
describe('A WebSocket2021Storer', (): void => {
|
||||
const info: SubscriptionInfo = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {},
|
||||
lastEmit: 0,
|
||||
};
|
||||
let webSocket: jest.Mocked<WebSocket>;
|
||||
let storage: jest.Mocked<SubscriptionStorage>;
|
||||
let socketMap: SetMultiMap<string, WebSocket>;
|
||||
let storer: WebSocket2021Storer;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
webSocket = new EventEmitter() as any;
|
||||
webSocket.close = jest.fn();
|
||||
|
||||
storage = {
|
||||
get: jest.fn(),
|
||||
} as any;
|
||||
|
||||
socketMap = new WrappedSetMultiMap();
|
||||
|
||||
storer = new WebSocket2021Storer(storage, socketMap);
|
||||
});
|
||||
|
||||
it('stores WebSockets.', async(): Promise<void> => {
|
||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
||||
expect([ ...socketMap.keys() ]).toHaveLength(1);
|
||||
expect(socketMap.has(info.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('removes closed WebSockets.', async(): Promise<void> => {
|
||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
||||
expect(socketMap.has(info.id)).toBe(true);
|
||||
webSocket.emit('close');
|
||||
expect(socketMap.has(info.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes erroring WebSockets.', async(): Promise<void> => {
|
||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
||||
expect(socketMap.has(info.id)).toBe(true);
|
||||
webSocket.emit('error');
|
||||
expect(socketMap.has(info.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes expired WebSockets.', async(): Promise<void> => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Need to create class after fake timers have been enabled
|
||||
storer = new WebSocket2021Storer(storage, socketMap);
|
||||
|
||||
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||
webSocket2.close = jest.fn();
|
||||
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
|
||||
webSocketOther.close = jest.fn();
|
||||
const infoOther: SubscriptionInfo = {
|
||||
...info,
|
||||
id: 'other',
|
||||
};
|
||||
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
|
||||
await expect(storer.handle({ info, webSocket: webSocket2 })).resolves.toBeUndefined();
|
||||
await expect(storer.handle({ info: infoOther, webSocket: webSocketOther })).resolves.toBeUndefined();
|
||||
|
||||
// `info` expired, `infoOther` did not
|
||||
storage.get.mockImplementation((id): any => {
|
||||
if (id === infoOther.id) {
|
||||
return infoOther;
|
||||
}
|
||||
});
|
||||
|
||||
jest.advanceTimersToNextTimer();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(webSocket.close).toHaveBeenCalledTimes(1);
|
||||
expect(webSocket2.close).toHaveBeenCalledTimes(1);
|
||||
expect(webSocketOther.close).toHaveBeenCalledTimes(0);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import type { Subscription } from '../../../../../src/server/notifications/Subscription';
|
||||
import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import {
|
||||
WebSocketSubscription2021,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
|
||||
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
|
||||
import { readJsonStream } from '../../../../../src/util/StreamUtil';
|
||||
|
||||
describe('A WebSocketSubscription2021', (): void => {
|
||||
let subscription: Subscription;
|
||||
let storage: jest.Mocked<SubscriptionStorage>;
|
||||
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
|
||||
let subscriptionType: WebSocketSubscription2021;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
subscription = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebSocketSubscription2021',
|
||||
topic: 'https://storage.example/resource',
|
||||
state: undefined,
|
||||
expiration: undefined,
|
||||
accept: undefined,
|
||||
rate: undefined,
|
||||
};
|
||||
|
||||
storage = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
id: '123',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'WebSocketSubscription2021',
|
||||
lastEmit: 0,
|
||||
features: {},
|
||||
}),
|
||||
add: jest.fn(),
|
||||
} as any;
|
||||
|
||||
subscriptionType = new WebSocketSubscription2021(storage, route);
|
||||
});
|
||||
|
||||
it('has the correct type.', async(): Promise<void> => {
|
||||
expect(subscriptionType.type).toBe('WebSocketSubscription2021');
|
||||
});
|
||||
|
||||
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);
|
||||
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: 'WebSocketSubscription2021',
|
||||
source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user