mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Generalize and extend notification channel type behaviour
This commit is contained in:
@@ -16,7 +16,8 @@ import {
|
||||
getPresetConfigPath,
|
||||
getTestConfigPath,
|
||||
getTestFolder,
|
||||
instantiateFromConfig, removeFolder,
|
||||
instantiateFromConfig,
|
||||
removeFolder,
|
||||
} from './Config';
|
||||
import quad = DataFactory.quad;
|
||||
import namedNode = DataFactory.namedNode;
|
||||
@@ -26,7 +27,7 @@ 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 notificationType = NOTIFY.WebHookSubscription2021;
|
||||
|
||||
const rootFilePath = getTestFolder('WebHookSubscription2021');
|
||||
const stores: [string, any][] = [
|
||||
@@ -109,7 +110,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
|
||||
});
|
||||
|
||||
it('supports subscribing.', async(): Promise<void> => {
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { target });
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target });
|
||||
});
|
||||
|
||||
it('emits Created events.', async(): Promise<void> => {
|
||||
@@ -168,7 +169,7 @@ describe.each(stores)('A server supporting WebHookSubscription2021 using %s', (n
|
||||
});
|
||||
});
|
||||
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { target, state: 'abc' });
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { [NOTIFY.target]: target, state: 'abc' });
|
||||
|
||||
// Will resolve even though the resource did not change since subscribing
|
||||
const { request, response } = await clientPromise;
|
||||
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
getPresetConfigPath,
|
||||
getTestConfigPath,
|
||||
getTestFolder,
|
||||
instantiateFromConfig, removeFolder,
|
||||
instantiateFromConfig,
|
||||
removeFolder,
|
||||
} from './Config';
|
||||
import quad = DataFactory.quad;
|
||||
import namedNode = DataFactory.namedNode;
|
||||
|
||||
const port = getPort('WebSocketSubscription2021');
|
||||
const baseUrl = `http://localhost:${port}/`;
|
||||
const notificationType = 'WebSocketSubscription2021';
|
||||
const notificationType = NOTIFY.WebSocketSubscription2021;
|
||||
|
||||
const rootFilePath = getTestFolder('WebSocketSubscription2021');
|
||||
const stores: [string, any][] = [
|
||||
@@ -166,7 +167,7 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
|
||||
|
||||
const channel = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebSocketSubscription2021',
|
||||
type: NOTIFY.WebSocketSubscription2021,
|
||||
topic: restricted,
|
||||
};
|
||||
|
||||
@@ -212,7 +213,8 @@ describe.each(stores)('A server supporting WebSocketSubscription2021 using %s',
|
||||
});
|
||||
|
||||
it('removes expired channels.', async(): Promise<void> => {
|
||||
const { source } = await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: 1 }) as any;
|
||||
const { source } =
|
||||
await subscribe(notificationType, webId, subscriptionUrl, topic, { endAt: '1988-03-09T14:48:00.000Z' }) as any;
|
||||
|
||||
const socket = new WebSocket(source);
|
||||
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
|
||||
|
||||
190
test/unit/server/notifications/BaseChannelType.test.ts
Normal file
190
test/unit/server/notifications/BaseChannelType.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { DataFactory, Store } from 'n3';
|
||||
import type { Credentials } from '../../../../src/authentication/Credentials';
|
||||
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
|
||||
import { BaseChannelType } from '../../../../src/server/notifications/BaseChannelType';
|
||||
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||
import { DEFAULT_NOTIFICATION_FEATURES } from '../../../../src/server/notifications/NotificationDescriber';
|
||||
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
|
||||
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
|
||||
import { NOTIFY, RDF, XSD } from '../../../../src/util/Vocabularies';
|
||||
import namedNode = DataFactory.namedNode;
|
||||
import quad = DataFactory.quad;
|
||||
import blankNode = DataFactory.blankNode;
|
||||
import literal = DataFactory.literal;
|
||||
|
||||
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
|
||||
|
||||
const dummyType = namedNode('http://example.com/DummyType');
|
||||
class DummyChannelType extends BaseChannelType {
|
||||
public constructor(properties?: unknown[]) {
|
||||
super(
|
||||
dummyType,
|
||||
properties,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('A BaseChannelType', (): void => {
|
||||
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
|
||||
const credentials: Credentials = {};
|
||||
const channelType = new DummyChannelType();
|
||||
|
||||
describe('#initChannel', (): void => {
|
||||
let data: Store;
|
||||
const subject = blankNode();
|
||||
beforeEach(async(): Promise<void> => {
|
||||
data = new Store();
|
||||
data.addQuad(quad(subject, RDF.terms.type, dummyType));
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
|
||||
});
|
||||
|
||||
it('converts the quads to a channel with an identifier.', async(): Promise<void> => {
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual({
|
||||
id,
|
||||
type: dummyType.value,
|
||||
topic: 'https://storage.example/resource',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires exactly 1 topic.', async(): Promise<void> => {
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
data.removeQuads(data.getQuads(subject, NOTIFY.terms.topic, null, null));
|
||||
expect(data.size).toBe(1);
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
// Data is correct again now
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
|
||||
|
||||
// Also make sure we can't have 2 different subjects with 1 topic each
|
||||
data.addQuad(quad(blankNode(), NOTIFY.terms.topic, namedNode('https://storage.example/resource2')));
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
});
|
||||
|
||||
it('requires the correct type.', async(): Promise<void> => {
|
||||
data = new Store();
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode('https://storage.example/resource')));
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
data.addQuad(quad(subject, RDF.terms.type, namedNode('http://example.com/wrongType')));
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
data.addQuad(quad(subject, RDF.terms.type, dummyType));
|
||||
await expect(channelType.initChannel(data, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
data.removeQuads(data.getQuads(subject, RDF.terms.type, namedNode('http://example.com/wrongType'), null));
|
||||
data.addQuad(quad(subject, RDF.terms.type, dummyType));
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('converts the start date to a number.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
|
||||
data.addQuad(quad(subject, NOTIFY.terms.startAt, literal(date, XSD.terms.dateTime)));
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
|
||||
startAt: ms,
|
||||
}));
|
||||
});
|
||||
|
||||
it('converts the end date to a number.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
|
||||
data.addQuad(quad(subject, NOTIFY.terms.endAt, literal(date, XSD.terms.dateTime)));
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
|
||||
endAt: ms,
|
||||
}));
|
||||
});
|
||||
|
||||
it('converts the rate to a number.', async(): Promise<void> => {
|
||||
data.addQuad(quad(subject, NOTIFY.terms.rate, literal('PT10S', XSD.terms.duration)));
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(expect.objectContaining({
|
||||
rate: 10 * 1000,
|
||||
}));
|
||||
});
|
||||
|
||||
it('requires correct datatypes on the features.', async(): Promise<void> => {
|
||||
for (const feature of DEFAULT_NOTIFICATION_FEATURES) {
|
||||
const badData = new Store(data.getQuads(null, null, null, null));
|
||||
// No feature accepts an integer
|
||||
badData.addQuad(quad(subject, namedNode(feature), literal(123456, XSD.terms.integer)));
|
||||
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires that features occur at most once.', async(): Promise<void> => {
|
||||
const values = {
|
||||
[NOTIFY.startAt]: [
|
||||
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
|
||||
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
|
||||
],
|
||||
[NOTIFY.endAt]: [
|
||||
literal('1988-03-09T14:48:00.000Z', XSD.terms.dateTime),
|
||||
literal('2023-03-09T14:48:00.000Z', XSD.terms.dateTime),
|
||||
],
|
||||
[NOTIFY.rate]: [ literal('PT10S', XSD.terms.duration), literal('PT11S', XSD.terms.duration) ],
|
||||
[NOTIFY.accept]: [ literal('text/turtle'), literal('application/ld+json') ],
|
||||
[NOTIFY.state]: [ literal('123456'), literal('654321') ],
|
||||
};
|
||||
|
||||
for (const [ predicate, objects ] of Object.entries(values)) {
|
||||
const badData = new Store(data.getQuads(null, null, null, null));
|
||||
badData.addQuad(quad(subject, namedNode(predicate), objects[0]));
|
||||
// One entry is fine
|
||||
await expect(channelType.initChannel(badData, credentials)).resolves.toBeDefined();
|
||||
badData.addQuad(quad(subject, namedNode(predicate), objects[1]));
|
||||
await expect(channelType.initChannel(badData, credentials)).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('can convert a notification channel to a JSON-LD representation.', async(): Promise<void> => {
|
||||
const startDate = '1988-03-09T14:48:00.000Z';
|
||||
const endDate = '2022-03-09T14:48:00.000Z';
|
||||
const channel: NotificationChannel = {
|
||||
id,
|
||||
type: 'DummyType',
|
||||
topic: 'https://storage.example/resource',
|
||||
state: 'state',
|
||||
startAt: Date.parse(startDate),
|
||||
endAt: Date.parse(endDate),
|
||||
rate: 10 * 1000,
|
||||
accept: 'text/turtle',
|
||||
lastEmit: 123456789,
|
||||
};
|
||||
|
||||
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
id: channel.id,
|
||||
type: channel.type,
|
||||
topic: channel.topic,
|
||||
state: channel.state,
|
||||
startAt: startDate,
|
||||
endAt: endDate,
|
||||
rate: 'PT10S',
|
||||
accept: channel.accept,
|
||||
});
|
||||
});
|
||||
|
||||
it('requires read permissions on the topic.', async(): Promise<void> => {
|
||||
const channel: NotificationChannel = {
|
||||
id,
|
||||
type: 'DummyType',
|
||||
topic: 'https://storage.example/resource',
|
||||
};
|
||||
await expect(channelType.extractModes(channel)).resolves
|
||||
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
|
||||
});
|
||||
|
||||
it('does nothing when completing the channel.', async(): Promise<void> => {
|
||||
const channel: NotificationChannel = {
|
||||
id,
|
||||
type: 'DummyType',
|
||||
topic: 'https://storage.example/resource',
|
||||
};
|
||||
await expect(channelType.completeChannel(channel)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,7 @@ import type { ResourceIdentifier } from '../../../../src/http/representation/Res
|
||||
import type { Logger } from '../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||
import { KeyValueChannelStorage } from '../../../../src/server/notifications/KeyValueChannelStorage';
|
||||
import type {
|
||||
NotificationChannel,
|
||||
NotificationChannelJson,
|
||||
} from '../../../../src/server/notifications/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
|
||||
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
|
||||
import resetAllMocks = jest.resetAllMocks;
|
||||
@@ -21,12 +18,6 @@ describe('A KeyValueChannelStorage', (): void => {
|
||||
const logger = getLoggerFor('mock');
|
||||
const topic = 'http://example.com/foo';
|
||||
const identifier = { path: topic };
|
||||
const json = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebSocketSubscription2021',
|
||||
topic,
|
||||
} as NotificationChannelJson;
|
||||
const features = {};
|
||||
let channel: NotificationChannel;
|
||||
let internalMap: Map<string, any>;
|
||||
let internalStorage: KeyValueStorage<string, any>;
|
||||
@@ -53,12 +44,6 @@ describe('A KeyValueChannelStorage', (): void => {
|
||||
storage = new KeyValueChannelStorage(internalStorage, locker);
|
||||
});
|
||||
|
||||
describe('#create', (): void => {
|
||||
it('creates channel based on a notification channel.', async(): Promise<void> => {
|
||||
expect(storage.create(json, features)).toEqual(channel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#get', (): void => {
|
||||
it('returns undefined if there is no match.', async(): Promise<void> => {
|
||||
await expect(storage.get('notexists')).resolves.toBeUndefined();
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel';
|
||||
|
||||
describe('A NotificationChannel', (): void => {
|
||||
const validChannel = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'NotificationChannelType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
|
||||
it('requires a minimal set of values.', async(): Promise<void> => {
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(validChannel)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('requires the notification context header to be present.', async(): Promise<void> => {
|
||||
let channel: unknown = {
|
||||
type: 'NotificationChannelType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
|
||||
|
||||
channel = {
|
||||
'@context': [ 'wrongContext' ],
|
||||
type: 'NotificationChannelType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(false);
|
||||
|
||||
channel = {
|
||||
'@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ],
|
||||
type: 'NotificationChannelType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
|
||||
|
||||
channel = {
|
||||
'@context': 'https://www.w3.org/ns/solid/notification/v1',
|
||||
type: 'NotificationChannelType',
|
||||
topic: 'http://example.com/foo',
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.isValid(channel)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('converts the start date to a number.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
|
||||
const channel: unknown = {
|
||||
...validChannel,
|
||||
startAt: date,
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
|
||||
startAt: ms,
|
||||
}));
|
||||
});
|
||||
|
||||
it('converts the end date to a number.', async(): Promise<void> => {
|
||||
const date = '1988-03-09T14:48:00.000Z';
|
||||
const ms = Date.parse(date);
|
||||
|
||||
const channel: unknown = {
|
||||
...validChannel,
|
||||
endAt: date,
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
|
||||
endAt: ms,
|
||||
}));
|
||||
});
|
||||
|
||||
it('converts the rate to a number.', async(): Promise<void> => {
|
||||
const channel: unknown = {
|
||||
...validChannel,
|
||||
rate: 'PT10S',
|
||||
};
|
||||
await expect(NOTIFICATION_CHANNEL_SCHEMA.validate(channel)).resolves.toEqual(expect.objectContaining({
|
||||
rate: 10 * 1000,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -6,50 +6,67 @@ import { AccessMode } from '../../../../src/authorization/permissions/Permission
|
||||
import type { Operation } from '../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
|
||||
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
|
||||
import type { Logger } from '../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../src/logging/LogUtil';
|
||||
import type { HttpRequest } from '../../../../src/server/HttpRequest';
|
||||
import type { HttpResponse } from '../../../../src/server/HttpResponse';
|
||||
import { NOTIFICATION_CHANNEL_SCHEMA } from '../../../../src/server/notifications/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../../../src/server/notifications/NotificationChannel';
|
||||
import type { NotificationChannelStorage } from '../../../../src/server/notifications/NotificationChannelStorage';
|
||||
import type { NotificationChannelType } from '../../../../src/server/notifications/NotificationChannelType';
|
||||
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
|
||||
import type { RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
|
||||
import { INTERNAL_QUADS } from '../../../../src/util/ContentTypes';
|
||||
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
|
||||
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
|
||||
import { guardedStreamFrom } from '../../../../src/util/StreamUtil';
|
||||
import { readableToString } from '../../../../src/util/StreamUtil';
|
||||
import { flushPromises } from '../../../util/Util';
|
||||
|
||||
jest.mock('../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger =
|
||||
{ debug: jest.fn(), error: jest.fn() } as any;
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
describe('A NotificationSubscriber', (): void => {
|
||||
let channel: any;
|
||||
const request: HttpRequest = {} as any;
|
||||
const response: HttpResponse = {} as any;
|
||||
let operation: Operation;
|
||||
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
let channel: NotificationChannel;
|
||||
let channelType: jest.Mocked<NotificationChannelType>;
|
||||
let converter: jest.Mocked<RepresentationConverter>;
|
||||
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
|
||||
let permissionReader: jest.Mocked<PermissionReader>;
|
||||
let authorizer: jest.Mocked<Authorizer>;
|
||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||
let subscriber: NotificationSubscriber;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
channel = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'NotificationChannelType',
|
||||
topic: topic.path,
|
||||
};
|
||||
|
||||
operation = {
|
||||
method: 'POST',
|
||||
target: { path: 'http://example.com/.notifications/websockets/' },
|
||||
body: new BasicRepresentation(JSON.stringify(channel), 'application/ld+json'),
|
||||
body: new BasicRepresentation(),
|
||||
preferences: {},
|
||||
};
|
||||
|
||||
channelType = {
|
||||
channel = {
|
||||
type: 'NotificationChannelType',
|
||||
schema: NOTIFICATION_CHANNEL_SCHEMA,
|
||||
topic: topic.path,
|
||||
id: '123456',
|
||||
};
|
||||
|
||||
channelType = {
|
||||
initChannel: jest.fn().mockResolvedValue(channel),
|
||||
toJsonLd: jest.fn().mockResolvedValue({}),
|
||||
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
|
||||
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
|
||||
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), channel: {}}),
|
||||
completeChannel: jest.fn(),
|
||||
};
|
||||
|
||||
converter = {
|
||||
handleSafe: jest.fn().mockResolvedValue(new BasicRepresentation([], INTERNAL_QUADS)),
|
||||
} as any;
|
||||
|
||||
credentialsExtractor = {
|
||||
handleSafe: jest.fn().mockResolvedValue({ public: {}}),
|
||||
} as any;
|
||||
@@ -62,38 +79,40 @@ describe('A NotificationSubscriber', (): void => {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
subscriber = new NotificationSubscriber({ channelType, credentialsExtractor, permissionReader, authorizer });
|
||||
});
|
||||
storage = {
|
||||
add: jest.fn(),
|
||||
} as any;
|
||||
|
||||
it('requires the request to be JSON-LD.', async(): Promise<void> => {
|
||||
operation.body.metadata.contentType = 'text/turtle';
|
||||
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnsupportedMediaTypeHttpError);
|
||||
subscriber = new NotificationSubscriber(
|
||||
{ channelType, converter, credentialsExtractor, permissionReader, authorizer, storage },
|
||||
);
|
||||
});
|
||||
|
||||
it('errors if the request can not be parsed correctly.', async(): Promise<void> => {
|
||||
operation.body.data = guardedStreamFrom('not json');
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
|
||||
// Type is missing
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
topic,
|
||||
}));
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
converter.handleSafe.mockRejectedValueOnce(new Error('bad data'));
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('bad data');
|
||||
expect(storage.add).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('returns the representation generated by the subscribe call.', async(): Promise<void> => {
|
||||
it('errors if the channel type rejects the input.', async(): Promise<void> => {
|
||||
channelType.initChannel.mockRejectedValueOnce(new Error('bad data'));
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow(UnprocessableEntityHttpError);
|
||||
expect(storage.add).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('returns the JSON generated by the channel type.', async(): Promise<void> => {
|
||||
const description = await subscriber.handle({ operation, request, response });
|
||||
expect(description.statusCode).toBe(200);
|
||||
const subscribeResult = await channelType.subscribe.mock.results[0].value;
|
||||
expect(description.data).toBe(subscribeResult.response.data);
|
||||
expect(description.metadata).toBe(subscribeResult.response.metadata);
|
||||
expect(JSON.parse(await readableToString(description.data!))).toEqual({});
|
||||
expect(description.metadata?.contentType).toBe('application/ld+json');
|
||||
expect(storage.add).toHaveBeenCalledTimes(1);
|
||||
expect(storage.add).toHaveBeenLastCalledWith(channel);
|
||||
});
|
||||
|
||||
it('errors on requests the Authorizer rejects.', async(): Promise<void> => {
|
||||
authorizer.handleSafe.mockRejectedValue(new Error('not allowed'));
|
||||
await expect(subscriber.handle({ operation, request, response })).rejects.toThrow('not allowed');
|
||||
expect(storage.add).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('updates the channel expiration if a max is defined.', async(): Promise<void> => {
|
||||
@@ -102,35 +121,65 @@ describe('A NotificationSubscriber', (): void => {
|
||||
|
||||
subscriber = new NotificationSubscriber({
|
||||
channelType,
|
||||
converter,
|
||||
credentialsExtractor,
|
||||
permissionReader,
|
||||
authorizer,
|
||||
storage,
|
||||
maxDuration: 60,
|
||||
});
|
||||
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
endAt: Date.now() + (60 * 60 * 1000),
|
||||
}), { public: {}});
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
expect(storage.add).toHaveBeenCalledTimes(1);
|
||||
expect(storage.add).toHaveBeenLastCalledWith({
|
||||
...channel,
|
||||
endAt: new Date(Date.now() + 99999999999999).toISOString(),
|
||||
}));
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
endAt: Date.now() + (60 * 60 * 1000),
|
||||
}), { public: {}});
|
||||
});
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
...channel,
|
||||
endAt: new Date(Date.now() + 5).toISOString(),
|
||||
}));
|
||||
converter.handleSafe.mockResolvedValue(new BasicRepresentation());
|
||||
channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 99999999999999 });
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(channelType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expect(storage.add).toHaveBeenCalledTimes(2);
|
||||
expect(storage.add).toHaveBeenLastCalledWith({
|
||||
...channel,
|
||||
endAt: Date.now() + (60 * 60 * 1000),
|
||||
});
|
||||
|
||||
converter.handleSafe.mockResolvedValue(new BasicRepresentation());
|
||||
channelType.initChannel.mockResolvedValueOnce({ ...channel, endAt: Date.now() + 5 });
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(storage.add).toHaveBeenCalledTimes(3);
|
||||
expect(storage.add).toHaveBeenLastCalledWith({
|
||||
...channel,
|
||||
endAt: Date.now() + 5,
|
||||
}), { public: {}});
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('calls the completeChannel function after sending the response.', async(): Promise<void> => {
|
||||
const description = await subscriber.handle({ operation, request, response });
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(description.data!);
|
||||
await flushPromises();
|
||||
|
||||
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('logs an error if the completeChannel functions throws.', async(): Promise<void> => {
|
||||
const logger = getLoggerFor('mock');
|
||||
channelType.completeChannel.mockRejectedValue(new Error('notification error'));
|
||||
|
||||
const description = await subscriber.handle({ operation, request, response });
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(description.data!);
|
||||
await flushPromises();
|
||||
|
||||
expect(channelType.completeChannel).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error)
|
||||
.toHaveBeenLastCalledWith(`There was an issue completing notification channel ${channel.id}: notification error`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
import { NotImplementedHttpError } from '../../../../../src/util/errors/NotImplementedHttpError';
|
||||
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
|
||||
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
|
||||
import { NOTIFY } from '../../../../../src/util/Vocabularies';
|
||||
|
||||
jest.mock('cross-fetch');
|
||||
|
||||
@@ -43,7 +44,7 @@ describe('A WebHookEmitter', (): void => {
|
||||
const channel: WebHookSubscription2021Channel = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'WebHookSubscription2021',
|
||||
type: NOTIFY.WebHookSubscription2021,
|
||||
target: 'http://example.org/somewhere-else',
|
||||
webId: webIdRoute.getPath(),
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import type { InferType } from 'yup';
|
||||
import { DataFactory, Store } from 'n3';
|
||||
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 { CONTEXT_NOTIFICATION } from '../../../../../src/server/notifications/Notification';
|
||||
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||
import type {
|
||||
NotificationChannelStorage,
|
||||
} from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
||||
import type {
|
||||
WebHookSubscription2021Channel,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
import {
|
||||
isWebHook2021Channel,
|
||||
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';
|
||||
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
||||
import quad = DataFactory.quad;
|
||||
import blankNode = DataFactory.blankNode;
|
||||
import namedNode = DataFactory.namedNode;
|
||||
|
||||
jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger =
|
||||
@@ -26,93 +27,75 @@ jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
|
||||
|
||||
describe('A WebHookSubscription2021', (): void => {
|
||||
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
||||
const target = 'http://example.org/somewhere-else';
|
||||
let json: InferType<WebHookSubscription2021['schema']>;
|
||||
const topic = 'https://storage.example/resource';
|
||||
const subject = blankNode();
|
||||
let data: Store;
|
||||
let channel: WebHookSubscription2021Channel;
|
||||
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
|
||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||
let stateHandler: jest.Mocked<StateHandler>;
|
||||
let channelType: WebHookSubscription2021;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
json = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebHookSubscription2021',
|
||||
data = new Store();
|
||||
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebHookSubscription2021));
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
|
||||
data.addQuad(quad(subject, NOTIFY.terms.target, namedNode(target)));
|
||||
|
||||
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
|
||||
channel = {
|
||||
id,
|
||||
type: NOTIFY.WebHookSubscription2021,
|
||||
topic: 'https://storage.example/resource',
|
||||
target,
|
||||
state: undefined,
|
||||
startAt: undefined,
|
||||
endAt: undefined,
|
||||
accept: undefined,
|
||||
rate: undefined,
|
||||
webId: 'http://example.org/alice',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), encodeURIComponent(id)),
|
||||
};
|
||||
|
||||
storage = {
|
||||
create: jest.fn((features: Record<string, unknown>): NotificationChannel => ({
|
||||
id: '123',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'WebHookSubscription2021',
|
||||
...features,
|
||||
})),
|
||||
add: jest.fn(),
|
||||
} as any;
|
||||
|
||||
stateHandler = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
channelType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler);
|
||||
channelType = new WebHookSubscription2021(unsubscribeRoute, stateHandler);
|
||||
});
|
||||
|
||||
it('exposes a utility function to verify if a channel is a webhook channel.', async(): Promise<void> => {
|
||||
const channel = storage.create(json, {});
|
||||
expect(isWebHook2021Channel(channel)).toBe(true);
|
||||
|
||||
channel.type = 'something else';
|
||||
(channel as NotificationChannel).type = 'something else';
|
||||
expect(isWebHook2021Channel(channel)).toBe(false);
|
||||
});
|
||||
|
||||
it('has the correct type.', async(): Promise<void> => {
|
||||
expect(channelType.type).toBe('WebHookSubscription2021');
|
||||
});
|
||||
|
||||
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
||||
await expect(channelType.schema.isValid(json)).resolves.toBe(true);
|
||||
|
||||
json.type = 'something else';
|
||||
await expect(channelType.schema.isValid(json)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
||||
await expect(channelType.extractModes(json)).resolves
|
||||
.toEqual(new IdentifierSetMultiMap([[{ path: json.topic }, AccessMode.read ]]));
|
||||
});
|
||||
|
||||
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||
const { response } = await channelType.subscribe(json, 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'),
|
||||
});
|
||||
await expect(channelType.initChannel(data, credentials)).resolves.toEqual(channel);
|
||||
});
|
||||
|
||||
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
||||
await expect(channelType.subscribe(json, {})).rejects
|
||||
await expect(channelType.initChannel(data, {})).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, channel } = await channelType.subscribe(json, credentials);
|
||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(response.data);
|
||||
it('removes the WebID when converting back to JSON-LD.', async(): Promise<void> => {
|
||||
await expect(channelType.toJsonLd(channel)).resolves.toEqual({
|
||||
'@context': [
|
||||
CONTEXT_NOTIFICATION,
|
||||
],
|
||||
id: channel.id,
|
||||
type: NOTIFY.WebHookSubscription2021,
|
||||
target,
|
||||
topic,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
unsubscribe_endpoint: channel.unsubscribe_endpoint,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the state handler once the channel is completed.', async(): Promise<void> => {
|
||||
await channelType.completeChannel(channel);
|
||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ channel });
|
||||
});
|
||||
@@ -121,14 +104,7 @@ describe('A WebHookSubscription2021', (): void => {
|
||||
const logger = getLoggerFor('mock');
|
||||
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
|
||||
|
||||
const { response } = await channelType.subscribe(json, credentials);
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(response.data);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
await channelType.completeChannel(channel);
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} 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;
|
||||
@@ -34,7 +35,7 @@ describe('A WebHookUnsubscriber', (): void => {
|
||||
} as any;
|
||||
|
||||
storage = {
|
||||
get: jest.fn().mockResolvedValue({ type: 'WebHookSubscription2021', webId }),
|
||||
get: jest.fn().mockResolvedValue({ type: NOTIFY.WebHookSubscription2021, webId }),
|
||||
delete: jest.fn(),
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -1,70 +1,58 @@
|
||||
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
|
||||
import { DataFactory, Store } from 'n3';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import type { NotificationChannelJson } from '../../../../../src/server/notifications/NotificationChannel';
|
||||
import type { NotificationChannelStorage } from '../../../../../src/server/notifications/NotificationChannelStorage';
|
||||
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
|
||||
|
||||
import {
|
||||
generateWebSocketUrl,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Util';
|
||||
import type {
|
||||
WebSocketSubscription2021Channel,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
|
||||
import {
|
||||
isWebSocket2021Channel,
|
||||
WebSocketSubscription2021,
|
||||
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
|
||||
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
|
||||
import { readJsonStream } from '../../../../../src/util/StreamUtil';
|
||||
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
||||
import quad = DataFactory.quad;
|
||||
import blankNode = DataFactory.blankNode;
|
||||
import namedNode = DataFactory.namedNode;
|
||||
|
||||
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
|
||||
|
||||
describe('A WebSocketSubscription2021', (): void => {
|
||||
let channel: NotificationChannelJson;
|
||||
let storage: jest.Mocked<NotificationChannelStorage>;
|
||||
let data: Store;
|
||||
let channel: WebSocketSubscription2021Channel;
|
||||
const subject = blankNode();
|
||||
const topic = 'https://storage.example/resource';
|
||||
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
|
||||
let channelType: WebSocketSubscription2021;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
data = new Store();
|
||||
data.addQuad(quad(subject, RDF.terms.type, NOTIFY.terms.WebSocketSubscription2021));
|
||||
data.addQuad(quad(subject, NOTIFY.terms.topic, namedNode(topic)));
|
||||
|
||||
const id = '4c9b88c1-7502-4107-bb79-2a3a590c7aa3:https://storage.example/resource';
|
||||
channel = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebSocketSubscription2021',
|
||||
topic: 'https://storage.example/resource',
|
||||
state: undefined,
|
||||
startAt: undefined,
|
||||
endAt: undefined,
|
||||
accept: undefined,
|
||||
rate: undefined,
|
||||
id,
|
||||
type: NOTIFY.WebSocketSubscription2021,
|
||||
topic,
|
||||
source: generateWebSocketUrl(route.getPath(), id),
|
||||
};
|
||||
|
||||
storage = {
|
||||
create: jest.fn().mockReturnValue({
|
||||
id: '123',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'WebSocketSubscription2021',
|
||||
lastEmit: 0,
|
||||
features: {},
|
||||
}),
|
||||
add: jest.fn(),
|
||||
} as any;
|
||||
|
||||
channelType = new WebSocketSubscription2021(storage, route);
|
||||
channelType = new WebSocketSubscription2021(route);
|
||||
});
|
||||
|
||||
it('has the correct type.', async(): Promise<void> => {
|
||||
expect(channelType.type).toBe('WebSocketSubscription2021');
|
||||
it('exposes a utility function to verify if a channel is a websocket channel.', async(): Promise<void> => {
|
||||
expect(isWebSocket2021Channel(channel)).toBe(true);
|
||||
|
||||
(channel as NotificationChannel).type = 'something else';
|
||||
expect(isWebSocket2021Channel(channel)).toBe(false);
|
||||
});
|
||||
|
||||
it('correctly parses notification channel bodies.', async(): Promise<void> => {
|
||||
await expect(channelType.schema.isValid(channel)).resolves.toBe(true);
|
||||
|
||||
channel.type = 'something else';
|
||||
await expect(channelType.schema.isValid(channel)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
||||
await expect(channelType.extractModes(channel)).resolves
|
||||
.toEqual(new IdentifierSetMultiMap([[{ path: channel.topic }, AccessMode.read ]]));
|
||||
});
|
||||
|
||||
it('stores the channel and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||
const { response } = await channelType.subscribe(channel);
|
||||
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),
|
||||
});
|
||||
await expect(channelType.initChannel(data, {})).resolves.toEqual(channel);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
sanitizeUrlPart,
|
||||
splitCommaSeparated,
|
||||
isValidFileName,
|
||||
isValidFileName, msToDuration,
|
||||
} from '../../../src/util/StringUtil';
|
||||
|
||||
describe('HeaderUtil', (): void => {
|
||||
@@ -31,4 +31,21 @@ describe('HeaderUtil', (): void => {
|
||||
expect(isValidFileName('$%^*')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#msToDuration', (): void => {
|
||||
it('converts ms to a duration string.', async(): Promise<void> => {
|
||||
const ms = ((2 * 24 * 60 * 60) + (10 * 60 * 60) + (5 * 60) + 50.25) * 1000;
|
||||
expect(msToDuration(ms)).toBe('P2DT10H5M50.25S');
|
||||
});
|
||||
|
||||
it('ignores 0 values.', async(): Promise<void> => {
|
||||
const ms = ((2 * 24 * 60 * 60) + 50.25) * 1000;
|
||||
expect(msToDuration(ms)).toBe('P2DT50.25S');
|
||||
});
|
||||
|
||||
it('excludes the T if there is no time segment.', async(): Promise<void> => {
|
||||
const ms = ((2 * 24 * 60 * 60)) * 1000;
|
||||
expect(msToDuration(ms)).toBe('P2D');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import { fetch } from 'cross-fetch';
|
||||
|
||||
/**
|
||||
* Subscribes to a notification channel.
|
||||
* @param type - The type of the notification channel. E.g. "WebSocketSubscription2021".
|
||||
* @param type - The type of the notification channel, e.g., "NOTIFY.WebHookSubscription2021".
|
||||
* @param webId - The WebID to spoof in the authorization header. This assumes the config uses the debug auth import.
|
||||
* @param subscriptionUrl - The subscription URL where the request needs to be sent to.
|
||||
* @param subscriptionUrl - The subscription URL to which the request needs to be sent.
|
||||
* @param topic - The topic to subscribe to.
|
||||
* @param features - Any extra fields that need to be added to the subscription body.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user