feat: Add support for the Notification specification

This commit is contained in:
Joachim Van Herwegen
2022-09-30 10:20:36 +02:00
parent be7af277bb
commit cbc07c6ef3
48 changed files with 2164 additions and 19 deletions

View File

@@ -0,0 +1,50 @@
import { BaseStateHandler } from '../../../../src/server/notifications/BaseStateHandler';
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
import type { SubscriptionInfo, SubscriptionStorage } from '../../../../src/server/notifications/SubscriptionStorage';
describe('A BaseStateHandler', (): void => {
let info: SubscriptionInfo;
let notificationHandler: jest.Mocked<NotificationHandler>;
let storage: jest.Mocked<SubscriptionStorage>;
let handler: BaseStateHandler;
beforeEach(async(): Promise<void> => {
info = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
state: '123',
};
notificationHandler = {
handleSafe: jest.fn(),
} as any;
storage = {
update: jest.fn(),
} as any;
handler = new BaseStateHandler(notificationHandler, storage);
});
it('calls the handler if there is a trigger.', async(): Promise<void> => {
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
// Note that jest stores a reference to the input object so we can't see that the state value was still there
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
expect(info.state).toBeUndefined();
expect(storage.update).toHaveBeenCalledTimes(1);
expect(storage.update).toHaveBeenLastCalledWith(info);
});
it('does not delete the state parameter if something goes wrong.', async(): Promise<void> => {
notificationHandler.handleSafe.mockRejectedValue(new Error('bad input'));
await expect(handler.handleSafe({ info })).resolves.toBeUndefined();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ topic: { path: info.topic }, info });
expect(info.state).toBe('123');
expect(storage.update).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,82 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import { ComposedNotificationHandler } from '../../../../src/server/notifications/ComposedNotificationHandler';
import type { NotificationGenerator } from '../../../../src/server/notifications/generate/NotificationGenerator';
import type { Notification } from '../../../../src/server/notifications/Notification';
import type { NotificationEmitter } from '../../../../src/server/notifications/NotificationEmitter';
import type { NotificationSerializer } from '../../../../src/server/notifications/serialize/NotificationSerializer';
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
describe('A ComposedNotificationHandler', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
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',
state: '123',
};
let info: SubscriptionInfo;
const representation = new BasicRepresentation();
let generator: jest.Mocked<NotificationGenerator>;
let serializer: jest.Mocked<NotificationSerializer>;
let emitter: jest.Mocked<NotificationEmitter>;
let handler: ComposedNotificationHandler;
beforeEach(async(): Promise<void> => {
info = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
generator = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(notification),
} as any;
serializer = {
handleSafe: jest.fn().mockResolvedValue(representation),
} as any;
emitter = {
handleSafe: jest.fn(),
} as any;
handler = new ComposedNotificationHandler({ generator, serializer, emitter });
});
it('can only handle input supported by the generator.', async(): Promise<void> => {
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
generator.canHandle.mockRejectedValue(new Error('bad input'));
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
});
it('calls the three wrapped classes in order.', async(): Promise<void> => {
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
expect(generator.handle).toHaveBeenCalledTimes(1);
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
expect(serializer.handleSafe).toHaveBeenCalledTimes(1);
expect(serializer.handleSafe).toHaveBeenLastCalledWith({ info, notification });
expect(emitter.handleSafe).toHaveBeenCalledTimes(1);
expect(emitter.handleSafe).toHaveBeenLastCalledWith({ info, representation });
});
it('does not emit the notification if its state matches the info state.', async(): Promise<void> => {
info.state = notification.state;
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
expect(generator.handle).toHaveBeenCalledTimes(1);
expect(generator.handle).toHaveBeenLastCalledWith({ info, topic });
expect(serializer.handleSafe).toHaveBeenCalledTimes(0);
expect(emitter.handleSafe).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,173 @@
import { v4 } from 'uuid';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
import { KeyValueSubscriptionStorage } from '../../../../src/server/notifications/KeyValueSubscriptionStorage';
import type { Subscription } from '../../../../src/server/notifications/Subscription';
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import type { ReadWriteLocker } from '../../../../src/util/locking/ReadWriteLocker';
import resetAllMocks = jest.resetAllMocks;
jest.mock('uuid', (): any => ({ v4: (): string => '4c9b88c1-7502-4107-bb79-2a3a590c7aa3' }));
jest.mock('../../../../src/logging/LogUtil', (): any => {
const logger: Logger = { info: jest.fn(), error: jest.fn() } as any;
return { getLoggerFor: (): Logger => logger };
});
describe('A KeyValueSubscriptionStorage', (): void => {
const logger = getLoggerFor('mock');
const topic = 'http://example.com/foo';
const identifier = { path: topic };
const subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic,
} as Subscription;
const features = { aa: 'bb' };
let info: SubscriptionInfo<Record<string, string>>;
let internalMap: Map<string, any>;
let internalStorage: KeyValueStorage<string, any>;
let locker: ReadWriteLocker;
let storage: KeyValueSubscriptionStorage<Record<string, string>>;
beforeEach(async(): Promise<void> => {
resetAllMocks();
info = {
id: `WebSocketSubscription2021:${v4()}:http://example.com/foo`,
topic,
type: 'WebSocketSubscription2021',
features,
lastEmit: 0,
};
internalMap = new Map();
internalStorage = internalMap as any;
locker = {
withWriteLock: jest.fn(async <T,>(id: ResourceIdentifier, whileLocked: () => T | Promise<T>):
Promise<T> => whileLocked()),
withReadLock: jest.fn(),
};
storage = new KeyValueSubscriptionStorage(internalStorage, locker);
});
describe('#create', (): void => {
it('creates info based on a subscription.', async(): Promise<void> => {
expect(storage.create(subscription, features)).toEqual(info);
});
});
describe('#get', (): void => {
it('returns undefined if there is no match.', async(): Promise<void> => {
await expect(storage.get('notexists')).resolves.toBeUndefined();
});
it('returns the matching info.', async(): Promise<void> => {
await storage.add(info);
await expect(storage.get(info.id)).resolves.toEqual(info);
});
it('deletes expired info.', async(): Promise<void> => {
info.expiration = 0;
await storage.add(info);
await expect(storage.get(info.id)).resolves.toBeUndefined();
expect(internalMap.size).toBe(0);
});
});
describe('#getAll', (): void => {
it('returns an empty array if there is no match.', async(): Promise<void> => {
await expect(storage.getAll(identifier)).resolves.toEqual([]);
});
it('returns the identifiers of all the matching infos.', async(): Promise<void> => {
await storage.add(info);
await expect(storage.getAll(identifier)).resolves.toEqual([ info.id ]);
});
});
describe('#add', (): void => {
it('adds the info and adds its id to the topic collection.', async(): Promise<void> => {
await expect(storage.add(info)).resolves.toBeUndefined();
expect(internalMap.size).toBe(2);
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
[ info.id ],
info,
]));
});
});
describe('#update', (): void => {
it('changes the info.', async(): Promise<void> => {
await storage.add(info);
const newInfo = {
...info,
state: '123456',
};
await expect(storage.update(newInfo)).resolves.toBeUndefined();
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
[ info.id ],
newInfo,
]));
});
it('rejects update requests that change the topic.', async(): Promise<void> => {
await storage.add(info);
const newInfo = {
...info,
topic: 'http://example.com/other',
};
await expect(storage.update(newInfo)).rejects.toThrow(`Trying to change the topic of subscription ${info.id}`);
});
it('rejects update request targeting a non-info value.', async(): Promise<void> => {
await storage.add(info);
// Looking for the key so this test doesn't depend on the internal keys used
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
const newInfo = {
...info,
id,
};
await expect(storage.update(newInfo)).rejects.toThrow(`Trying to update ${id} which is not a SubscriptionInfo.`);
});
});
describe('#delete', (): void => {
it('removes the info and its reference.', async(): Promise<void> => {
const info2 = {
...info,
id: 'differentId',
};
await storage.add(info);
await storage.add(info2);
expect(internalMap.size).toBe(3);
await expect(storage.delete(info.id)).resolves.toBeUndefined();
expect(internalMap.size).toBe(2);
expect([ ...internalMap.values() ]).toEqual(expect.arrayContaining([
[ info2.id ],
info2,
]));
});
it('removes the references for an identifier if the array is empty.', async(): Promise<void> => {
await storage.add(info);
await expect(storage.delete(info.id)).resolves.toBeUndefined();
expect(internalMap.size).toBe(0);
});
it('does nothing if the target does not exist.', async(): Promise<void> => {
await expect(storage.delete(info.id)).resolves.toBeUndefined();
});
it('logs an error if the target can not be found in the list of references.', async(): Promise<void> => {
await storage.add(info);
// Looking for the key so this test doesn't depend on the internal keys used
const id = [ ...internalMap.entries() ].find((entry): boolean => Array.isArray(entry[1]))![0];
internalMap.set(id, []);
await expect(storage.delete(info.id)).resolves.toBeUndefined();
expect(logger.error).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,108 @@
import { EventEmitter } from 'events';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { Logger } from '../../../../src/logging/Logger';
import { getLoggerFor } from '../../../../src/logging/LogUtil';
import type { ActivityEmitter } from '../../../../src/server/notifications/ActivityEmitter';
import { ListeningActivityHandler } from '../../../../src/server/notifications/ListeningActivityHandler';
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
import type { SubscriptionInfo, SubscriptionStorage } from '../../../../src/server/notifications/SubscriptionStorage';
import { AS } from '../../../../src/util/Vocabularies';
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 ListeningActivityHandler', (): void => {
const logger: jest.Mocked<Logger> = getLoggerFor('mock') as any;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const activity = AS.terms.Update;
let info: SubscriptionInfo;
let storage: jest.Mocked<SubscriptionStorage>;
let emitter: ActivityEmitter;
let notificationHandler: jest.Mocked<NotificationHandler>;
let handler: ListeningActivityHandler;
beforeEach(async(): Promise<void> => {
jest.clearAllMocks();
info = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
storage = {
getAll: jest.fn().mockResolvedValue([ info.id ]),
get: jest.fn().mockResolvedValue(info),
} as any;
emitter = new EventEmitter() as any;
notificationHandler = {
handleSafe: jest.fn().mockResolvedValue(undefined),
} as any;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handler = new ListeningActivityHandler(storage, emitter, notificationHandler);
});
it('calls the NotificationHandler if there is an event.', async(): Promise<void> => {
emitter.emit('changed', topic, activity);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(notificationHandler.handleSafe).toHaveBeenLastCalledWith({ info, activity, topic });
expect(logger.error).toHaveBeenCalledTimes(0);
});
it('does not emit an event on subscriptions if their rate does not yet allow it.', async(): Promise<void> => {
info.rate = 100000;
info.lastEmit = Date.now();
emitter.emit('changed', topic, activity);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(0);
});
it('does not stop if one subscription causes an error.', async(): Promise<void> => {
storage.getAll.mockResolvedValue([ info.id, info.id ]);
notificationHandler.handleSafe.mockRejectedValueOnce(new Error('bad input'));
emitter.emit('changed', topic, activity);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(`Error trying to handle notification for ${info.id}: bad input`);
});
it('logs an error if something goes wrong handling the event.', async(): Promise<void> => {
storage.getAll.mockRejectedValue(new Error('bad event'));
emitter.emit('changed', topic, activity);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenLastCalledWith(`Something went wrong emitting notifications: bad event`);
});
it('ignores undefined subscriptions.', async(): Promise<void> => {
storage.get.mockResolvedValue(undefined);
emitter.emit('changed', topic, activity);
await flushPromises();
expect(notificationHandler.handleSafe).toHaveBeenCalledTimes(0);
expect(logger.error).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,35 @@
import 'jest-rdf';
import { DataFactory } from 'n3';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import {
AbsolutePathInteractionRoute,
} from '../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import { NotificationDescriber } from '../../../../src/server/notifications/NotificationDescriber';
import { NOTIFY, RDF } from '../../../../src/util/Vocabularies';
const { namedNode, quad } = DataFactory;
describe('A NotificationDescriber', (): void => {
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/websockets/');
const relative = '#websocketNotification';
const type = 'http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021';
let describer: NotificationDescriber;
beforeEach(async(): Promise<void> => {
describer = new NotificationDescriber(route, relative, type);
});
it('outputs the expected quads.', async(): Promise<void> => {
const subscription = namedNode('http://example.com/foo#websocketNotification');
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/websockets/')),
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),
]);
});
});

View File

@@ -0,0 +1,136 @@
import type { CredentialsExtractor } from '../../../../src/authentication/CredentialsExtractor';
import type { Authorizer } from '../../../../src/authorization/Authorizer';
import type { PermissionReader } from '../../../../src/authorization/PermissionReader';
import type { AccessMap } from '../../../../src/authorization/permissions/Permissions';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { HttpRequest } from '../../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../../src/server/HttpResponse';
import { NotificationSubscriber } from '../../../../src/server/notifications/NotificationSubscriber';
import { SUBSCRIBE_SCHEMA } from '../../../../src/server/notifications/Subscription';
import type { SubscriptionType } from '../../../../src/server/notifications/SubscriptionType';
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';
describe('A NotificationSubscriber', (): void => {
let subscriptionBody: any;
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
let subscriptionType: jest.Mocked<SubscriptionType>;
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
let permissionReader: jest.Mocked<PermissionReader>;
let authorizer: jest.Mocked<Authorizer>;
let subscriber: NotificationSubscriber;
beforeEach(async(): Promise<void> => {
subscriptionBody = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'SubscriptionType',
topic: topic.path,
};
operation = {
method: 'POST',
target: { path: 'http://example.com/.notifications/websockets/' },
body: new BasicRepresentation(JSON.stringify(subscriptionBody), 'application/ld+json'),
preferences: {},
};
subscriptionType = {
type: 'SubscriptionType',
schema: SUBSCRIBE_SCHEMA,
extractModes: jest.fn(async(subscription): Promise<AccessMap> =>
new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]) as AccessMap),
subscribe: jest.fn().mockResolvedValue({ response: new BasicRepresentation(), info: {}}),
};
credentialsExtractor = {
handleSafe: jest.fn().mockResolvedValue({ public: {}}),
} as any;
permissionReader = {
handleSafe: jest.fn().mockResolvedValue(new IdentifierMap([[ topic, AccessMode.read ]])),
} as any;
authorizer = {
handleSafe: jest.fn(),
} as any;
subscriber = new NotificationSubscriber({ subscriptionType, credentialsExtractor, permissionReader, authorizer });
});
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);
});
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);
});
it('returns the representation generated by the subscribe call.', async(): Promise<void> => {
const description = await subscriber.handle({ operation, request, response });
expect(description.statusCode).toBe(200);
const subscribeResult = await subscriptionType.subscribe.mock.results[0].value;
expect(description.data).toBe(subscribeResult.response.data);
expect(description.metadata).toBe(subscribeResult.response.metadata);
});
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');
});
it('updates the subscription expiration if a max is defined.', async(): Promise<void> => {
jest.useFakeTimers();
jest.setSystemTime();
subscriber = new NotificationSubscriber({
subscriptionType,
credentialsExtractor,
permissionReader,
authorizer,
maxDuration: 60,
});
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000),
}));
operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody,
expiration: new Date(Date.now() + 99999999999999).toISOString(),
}));
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + (60 * 60 * 1000),
}));
operation.body.data = guardedStreamFrom(JSON.stringify({
...subscriptionBody,
expiration: new Date(Date.now() + 5).toISOString(),
}));
await subscriber.handle({ operation, request, response });
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
expiration: Date.now() + 5,
}));
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,65 @@
import { SUBSCRIBE_SCHEMA } from '../../../../src/server/notifications/Subscription';
describe('A Subscription', (): void => {
const validSubscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'SubscriptionType',
topic: 'http://example.com/foo',
};
it('requires a minimal set of values.', async(): Promise<void> => {
await expect(SUBSCRIBE_SCHEMA.isValid(validSubscription)).resolves.toBe(true);
});
it('requires the notification context header to be present.', async(): Promise<void> => {
let subscription: unknown = {
type: 'SubscriptionType',
topic: 'http://example.com/foo',
};
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(false);
subscription = {
'@context': [ 'wrongContext' ],
type: 'SubscriptionType',
topic: 'http://example.com/foo',
};
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(false);
subscription = {
'@context': [ 'contextA', 'https://www.w3.org/ns/solid/notification/v1', 'contextB' ],
type: 'SubscriptionType',
topic: 'http://example.com/foo',
};
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(true);
subscription = {
'@context': 'https://www.w3.org/ns/solid/notification/v1',
type: 'SubscriptionType',
topic: 'http://example.com/foo',
};
await expect(SUBSCRIBE_SCHEMA.isValid(subscription)).resolves.toBe(true);
});
it('converts the expiration date to a number.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
const subscription: unknown = {
...validSubscription,
expiration: date,
};
await expect(SUBSCRIBE_SCHEMA.validate(subscription)).resolves.toEqual(expect.objectContaining({
expiration: ms,
}));
});
it('converts the rate to a number.', async(): Promise<void> => {
const subscription: unknown = {
...validSubscription,
rate: 'PT10S',
};
await expect(SUBSCRIBE_SCHEMA.validate(subscription)).resolves.toEqual(expect.objectContaining({
rate: 10 * 1000,
}));
});
});

View File

@@ -0,0 +1,49 @@
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { NotificationHandler } from '../../../../src/server/notifications/NotificationHandler';
import type { SubscriptionInfo } from '../../../../src/server/notifications/SubscriptionStorage';
import { TypedNotificationHandler } from '../../../../src/server/notifications/TypedNotificationHandler';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
describe('A TypedNotificationHandler', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const info: SubscriptionInfo = {
id: 'id',
topic: topic.path,
type: 'SubscriptionType',
features: {},
lastEmit: 0,
};
let source: jest.Mocked<NotificationHandler>;
let handler: TypedNotificationHandler;
beforeEach(async(): Promise<void> => {
source = {
canHandle: jest.fn(),
handle: jest.fn(),
handleSafe: jest.fn(),
};
handler = new TypedNotificationHandler(info.type, source);
});
it('requires the input info to have the correct type.', async(): Promise<void> => {
await expect(handler.canHandle({ info, topic })).resolves.toBeUndefined();
const wrongInfo = {
...info,
type: 'somethingElse',
};
await expect(handler.canHandle({ info: wrongInfo, topic })).rejects.toThrow(NotImplementedHttpError);
});
it('rejects input the source handler can not handle.', async(): Promise<void> => {
source.canHandle.mockRejectedValue(new Error('bad input'));
await expect(handler.canHandle({ info, topic })).rejects.toThrow('bad input');
});
it('calls the source handle function.', async(): Promise<void> => {
await expect(handler.handle({ info, topic })).resolves.toBeUndefined();
expect(source.handle).toHaveBeenCalledTimes(1);
expect(source.handle).toHaveBeenLastCalledWith({ info, topic });
});
});

View File

@@ -0,0 +1,67 @@
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import {
ActivityNotificationGenerator,
} from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
describe('An ActivityNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const info: SubscriptionInfo = {
id: 'id',
topic: topic.path,
type: 'type',
features: {},
lastEmit: 0,
};
const activity = AS.terms.Update;
const metadata = new RepresentationMetadata({
[RDF.type]: LDP.terms.Resource,
// Needed for ETag
[DC.modified]: new Date().toISOString(),
});
let store: jest.Mocked<ResourceStore>;
let generator: ActivityNotificationGenerator;
beforeEach(async(): Promise<void> => {
store = {
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', metadata)),
} as any;
generator = new ActivityNotificationGenerator(store);
});
it('only handles defined activities.', async(): Promise<void> => {
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only defined activities are supported.');
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
});
it('generates a notification.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
jest.useFakeTimers();
jest.setSystemTime(ms);
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: `urn:${ms}:http://example.com/foo`,
type: [ 'Update' ],
object: {
id: 'http://example.com/foo',
type: [
LDP.Resource,
],
},
state: expect.stringMatching(/"\d+"/u),
published: date,
});
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,49 @@
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import {
DeleteNotificationGenerator,
} from '../../../../../src/server/notifications/generate/DeleteNotificationGenerator';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import { AS } from '../../../../../src/util/Vocabularies';
describe('A DeleteNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const info: SubscriptionInfo = {
id: 'id',
topic: topic.path,
type: 'type',
features: {},
lastEmit: 0,
};
const activity = AS.terms.Delete;
const generator = new DeleteNotificationGenerator();
it('can only handle input with the Delete activity.', async(): Promise<void> => {
await expect(generator.canHandle({ topic, info })).rejects.toThrow('Only Delete activity updates are supported.');
await expect(generator.canHandle({ topic, info, activity: AS.terms.Update }))
.rejects.toThrow('Only Delete activity updates are supported.');
await expect(generator.canHandle({ topic, info, activity })).resolves.toBeUndefined();
});
it('generates a Delete notification.', async(): Promise<void> => {
const date = '1988-03-09T14:48:00.000Z';
const ms = Date.parse(date);
jest.useFakeTimers();
jest.setSystemTime(ms);
await expect(generator.handle({ topic, info, activity })).resolves.toEqual({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: `urn:${ms}:http://example.com/foo`,
type: [ 'Delete' ],
object: {
id: 'http://example.com/foo',
type: [],
},
published: date,
});
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,71 @@
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
import type { NotificationGenerator } from '../../../../../src/server/notifications/generate/NotificationGenerator';
import {
StateNotificationGenerator,
} from '../../../../../src/server/notifications/generate/StateNotificationGenerator';
import type { Notification } from '../../../../../src/server/notifications/Notification';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import type { ResourceSet } from '../../../../../src/storage/ResourceSet';
import { AS } from '../../../../../src/util/Vocabularies';
describe('A StateNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
const info: SubscriptionInfo = {
id: 'id',
topic: topic.path,
type: 'type',
features: {},
lastEmit: 0,
};
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 source: jest.Mocked<NotificationGenerator>;
let resourceSet: jest.Mocked<ResourceSet>;
let generator: StateNotificationGenerator;
beforeEach(async(): Promise<void> => {
source = {
handleSafe: jest.fn().mockResolvedValue(notification),
} as any;
resourceSet = {
hasResource: jest.fn().mockResolvedValue(true),
};
generator = new StateNotificationGenerator(source, resourceSet);
});
it('returns the source notification if there is an activity.', async(): Promise<void> => {
await expect(generator.handle({ topic, info, activity: AS.terms.Update })).resolves.toBe(notification);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
expect(resourceSet.hasResource).toHaveBeenCalledTimes(0);
});
it('calls the source with an Update notification if the topic exists.', async(): Promise<void> => {
resourceSet.hasResource.mockResolvedValue(true);
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Update });
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
});
it('calls the source with a Delete notification if the topic does not exist.', async(): Promise<void> => {
resourceSet.hasResource.mockResolvedValue(false);
await expect(generator.handle({ topic, info })).resolves.toBe(notification);
expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ topic, info, activity: AS.terms.Delete });
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,75 @@
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../../src/http/representation/Representation';
import type { Notification } from '../../../../../src/server/notifications/Notification';
import {
ConvertingNotificationSerializer,
} from '../../../../../src/server/notifications/serialize/ConvertingNotificationSerializer';
import type { NotificationSerializer } from '../../../../../src/server/notifications/serialize/NotificationSerializer';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import type { RepresentationConverter } from '../../../../../src/storage/conversion/RepresentationConverter';
describe('A ConvertingNotificationSerializer', (): void => {
let info: SubscriptionInfo;
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;
let source: jest.Mocked<NotificationSerializer>;
let converter: jest.Mocked<RepresentationConverter>;
let serializer: ConvertingNotificationSerializer;
beforeEach(async(): Promise<void> => {
info = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
representation = new BasicRepresentation();
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(representation),
} as any;
converter = {
handleSafe: jest.fn(({ representation: rep }): Representation => rep),
} as any;
serializer = new ConvertingNotificationSerializer(source, converter);
});
it('can handle input its source can handle.', async(): Promise<void> => {
await expect(serializer.canHandle({ info, notification })).resolves.toBeUndefined();
source.canHandle.mockRejectedValue(new Error('bad input'));
await expect(serializer.canHandle({ info, notification })).rejects.toThrow('bad input');
});
it('returns the source result if there is no accept value.', async(): Promise<void> => {
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
expect(converter.handleSafe).toHaveBeenCalledTimes(0);
});
it('converts the source result if there is an accept value.', async(): Promise<void> => {
info.accept = 'text/turtle';
await expect(serializer.handle({ info, notification })).resolves.toBe(representation);
expect(converter.handleSafe).toHaveBeenCalledTimes(1);
expect(converter.handleSafe).toHaveBeenLastCalledWith({
representation,
preferences: { type: { 'text/turtle': 1 }},
identifier: { path: notification.id },
});
});
});

View File

@@ -0,0 +1,37 @@
import type { Notification } from '../../../../../src/server/notifications/Notification';
import {
JsonLdNotificationSerializer,
} from '../../../../../src/server/notifications/serialize/JsonLdNotificationSerializer';
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
import { readableToString } from '../../../../../src/util/StreamUtil';
describe('A JsonLdNotificationSerializer', (): void => {
const info: SubscriptionInfo = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
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',
};
const serializer = new JsonLdNotificationSerializer();
it('converts notifications into JSON-LD.', async(): Promise<void> => {
const representation = await serializer.handle({ notification, info });
expect(representation.metadata.contentType).toBe('application/ld+json');
expect(JSON.parse(await readableToString(representation.data))).toEqual(notification);
});
});