mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Add support for WebHookSubscription2021
This commit is contained in:
@@ -111,7 +111,7 @@ describe('A NotificationSubscriber', (): void => {
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + (60 * 60 * 1000),
|
||||
}));
|
||||
}), { public: {}});
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
...subscriptionBody,
|
||||
@@ -120,7 +120,7 @@ describe('A NotificationSubscriber', (): void => {
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + (60 * 60 * 1000),
|
||||
}));
|
||||
}), { public: {}});
|
||||
|
||||
operation.body.data = guardedStreamFrom(JSON.stringify({
|
||||
...subscriptionBody,
|
||||
@@ -129,7 +129,7 @@ describe('A NotificationSubscriber', (): void => {
|
||||
await subscriber.handle({ operation, request, response });
|
||||
expect(subscriptionType.subscribe).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
expiration: Date.now() + 5,
|
||||
}));
|
||||
}), { public: {}});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
generateWebHookUnsubscribeUrl, parseWebHookUnsubscribeUrl,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHook2021Util';
|
||||
|
||||
describe('WebHook2021Util', (): void => {
|
||||
describe('#generateWebHookUnsubscribeUrl', (): void => {
|
||||
it('generates the URL with the identifier.', async(): Promise<void> => {
|
||||
expect(generateWebHookUnsubscribeUrl('http://example.com/unsubscribe', '123$456'))
|
||||
.toBe('http://example.com/unsubscribe/123%24456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#parseWebHookUnsubscribeUrl', (): void => {
|
||||
it('returns the parsed identifier from the URL.', async(): Promise<void> => {
|
||||
expect(parseWebHookUnsubscribeUrl('http://example.com/unsubscribe/123%24456')).toBe('123$456');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'jest-rdf';
|
||||
import { DataFactory } from 'n3';
|
||||
import type { ResourceIdentifier } from '../../../../../src/http/representation/ResourceIdentifier';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import { WebHookDescriber } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookDescriber';
|
||||
import { NOTIFY, RDF } from '../../../../../src/util/Vocabularies';
|
||||
const { namedNode, quad } = DataFactory;
|
||||
|
||||
describe('A WebHookDescriber', (): void => {
|
||||
const identifier: ResourceIdentifier = { path: 'http://example.com/foo' };
|
||||
const route = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/');
|
||||
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifications/webhooks/webId');
|
||||
const relative = '#webhookNotification';
|
||||
const type = 'http://www.w3.org/ns/solid/notifications#WebHookSubscription2021';
|
||||
let describer: WebHookDescriber;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
describer = new WebHookDescriber({ route, webIdRoute, relative });
|
||||
});
|
||||
|
||||
it('outputs the expected quads.', async(): Promise<void> => {
|
||||
const subscription = namedNode('http://example.com/foo#webhookNotification');
|
||||
const quads = await describer.handle(identifier);
|
||||
expect(quads).toBeRdfIsomorphic([
|
||||
quad(namedNode(identifier.path), NOTIFY.terms.notificationChannel, subscription),
|
||||
quad(subscription, RDF.terms.type, namedNode(type)),
|
||||
quad(subscription, NOTIFY.terms.subscription, namedNode('http://example.com/.notifications/webhooks/')),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.accept),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.expiration),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.rate),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.state),
|
||||
quad(subscription, NOTIFY.terms.feature, NOTIFY.terms.webhookAuth),
|
||||
quad(subscription, NOTIFY.terms.webid, namedNode(webIdRoute.getPath())),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import fetch from 'cross-fetch';
|
||||
import { calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, jwtVerify } from 'jose';
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import type { Representation } from '../../../../../src/http/representation/Representation';
|
||||
import type { AlgJwk, JwkGenerator } from '../../../../../src/identity/configuration/JwkGenerator';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import type { Logger } from '../../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||
import type { Notification } from '../../../../../src/server/notifications/Notification';
|
||||
import type { SubscriptionInfo } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookEmitter';
|
||||
import type {
|
||||
WebHookFeatures,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
import { matchesAuthorizationScheme } from '../../../../../src/util/HeaderUtil';
|
||||
import { trimTrailingSlashes } from '../../../../../src/util/PathUtil';
|
||||
|
||||
jest.mock('cross-fetch');
|
||||
|
||||
jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger =
|
||||
{ error: jest.fn(), debug: jest.fn() } as any;
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
describe('A WebHookEmitter', (): void => {
|
||||
const fetchMock: jest.Mock = fetch as any;
|
||||
const baseUrl = 'http://example.com/';
|
||||
const webIdRoute = new AbsolutePathInteractionRoute('http://example.com/.notifcations/webhooks/webid');
|
||||
const notification: Notification = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://www.w3.org/ns/solid/notification/v1',
|
||||
],
|
||||
id: `urn:123:http://example.com/foo`,
|
||||
type: [ 'Update' ],
|
||||
object: {
|
||||
id: 'http://example.com/foo',
|
||||
type: [],
|
||||
},
|
||||
published: '123',
|
||||
};
|
||||
let representation: Representation;
|
||||
const info: SubscriptionInfo<WebHookFeatures> = {
|
||||
id: 'id',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'type',
|
||||
features: {
|
||||
target: 'http://example.org/somewhere-else',
|
||||
webId: webIdRoute.getPath(),
|
||||
},
|
||||
lastEmit: 0,
|
||||
};
|
||||
|
||||
let privateJwk: AlgJwk;
|
||||
let publicJwk: AlgJwk;
|
||||
let jwkGenerator: jest.Mocked<JwkGenerator>;
|
||||
let emitter: WebHookEmitter;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
fetchMock.mockResolvedValue({ status: 200 });
|
||||
|
||||
representation = new BasicRepresentation(JSON.stringify(notification), 'application/ld+json');
|
||||
|
||||
const { privateKey, publicKey } = await generateKeyPair('ES256');
|
||||
|
||||
privateJwk = { ...await exportJWK(privateKey), alg: 'ES256' };
|
||||
publicJwk = { ...await exportJWK(publicKey), alg: 'ES256' };
|
||||
|
||||
jwkGenerator = {
|
||||
alg: 'ES256',
|
||||
getPrivateKey: jest.fn().mockResolvedValue(privateJwk),
|
||||
getPublicKey: jest.fn().mockResolvedValue(publicJwk),
|
||||
};
|
||||
|
||||
emitter = new WebHookEmitter(baseUrl, webIdRoute, jwkGenerator);
|
||||
});
|
||||
|
||||
it('sends out the necessary data and headers.', async(): Promise<void> => {
|
||||
const now = Date.now();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(call[0]).toBe('http://example.org/somewhere-else');
|
||||
const { authorization, dpop, 'content-type': contentType } = call[1].headers;
|
||||
expect(contentType).toBe('application/ld+json');
|
||||
|
||||
expect(matchesAuthorizationScheme('DPoP', authorization)).toBe(true);
|
||||
const encodedDpopToken = authorization.slice('DPoP '.length);
|
||||
|
||||
const publicObject = await importJWK(publicJwk);
|
||||
|
||||
// Check all the DPoP token fields
|
||||
const decodedDpopToken = await jwtVerify(encodedDpopToken, publicObject, { issuer: trimTrailingSlashes(baseUrl) });
|
||||
expect(decodedDpopToken.payload).toMatchObject({
|
||||
webid: info.features.webId,
|
||||
azp: info.features.webId,
|
||||
sub: info.features.webId,
|
||||
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
|
||||
iat: now,
|
||||
exp: now + (20 * 60 * 1000),
|
||||
aud: [ info.features.webId, 'solid' ],
|
||||
jti: expect.stringContaining('-'),
|
||||
});
|
||||
expect(decodedDpopToken.protectedHeader).toMatchObject({
|
||||
alg: 'ES256',
|
||||
});
|
||||
|
||||
// CHeck the DPoP proof
|
||||
const decodedDpopProof = await jwtVerify(dpop, publicObject);
|
||||
expect(decodedDpopProof.payload).toMatchObject({
|
||||
htu: info.features.target,
|
||||
htm: 'POST',
|
||||
iat: now,
|
||||
jti: expect.stringContaining('-'),
|
||||
});
|
||||
expect(decodedDpopProof.protectedHeader).toMatchObject({
|
||||
alg: 'ES256',
|
||||
typ: 'dpop+jwt',
|
||||
jwk: publicJwk,
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('logs an error if the fetch request receives an invalid status code.', async(): Promise<void> => {
|
||||
const logger = getLoggerFor('mock');
|
||||
|
||||
fetchMock.mockResolvedValue({ status: 400, text: async(): Promise<string> => 'invalid request' });
|
||||
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenLastCalledWith(
|
||||
`There was an issue emitting a WebHook notification with target ${info.features.target}: invalid request`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { InferType } from 'yup';
|
||||
import type { Credentials } from '../../../../../src/authentication/Credentials';
|
||||
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
|
||||
import {
|
||||
AbsolutePathInteractionRoute,
|
||||
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
|
||||
import type { Logger } from '../../../../../src/logging/Logger';
|
||||
import { getLoggerFor } from '../../../../../src/logging/LogUtil';
|
||||
import type { StateHandler } from '../../../../../src/server/notifications/StateHandler';
|
||||
import type {
|
||||
SubscriptionInfo,
|
||||
SubscriptionStorage,
|
||||
} from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type {
|
||||
WebHookFeatures,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
import {
|
||||
WebHookSubscription2021,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
|
||||
import { joinUrl } from '../../../../../src/util/PathUtil';
|
||||
import { readableToString, readJsonStream } from '../../../../../src/util/StreamUtil';
|
||||
import { flushPromises } from '../../../../util/Util';
|
||||
|
||||
jest.mock('../../../../../src/logging/LogUtil', (): any => {
|
||||
const logger: Logger =
|
||||
{ error: jest.fn() } as any;
|
||||
return { getLoggerFor: (): Logger => logger };
|
||||
});
|
||||
|
||||
describe('A WebHookSubscription2021', (): void => {
|
||||
const credentials: Credentials = { agent: { webId: 'http://example.org/alice' }};
|
||||
const target = 'http://example.org/somewhere-else';
|
||||
let subscription: InferType<WebHookSubscription2021['schema']>;
|
||||
const unsubscribeRoute = new AbsolutePathInteractionRoute('http://example.com/unsubscribe');
|
||||
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
|
||||
let stateHandler: jest.Mocked<StateHandler>;
|
||||
let subscriptionType: WebHookSubscription2021;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
subscription = {
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebHookSubscription2021',
|
||||
topic: 'https://storage.example/resource',
|
||||
target,
|
||||
state: undefined,
|
||||
expiration: undefined,
|
||||
accept: undefined,
|
||||
rate: undefined,
|
||||
};
|
||||
|
||||
storage = {
|
||||
create: jest.fn((features: WebHookFeatures): SubscriptionInfo<WebHookFeatures> => ({
|
||||
id: '123',
|
||||
topic: 'http://example.com/foo',
|
||||
type: 'WebHookSubscription2021',
|
||||
lastEmit: 0,
|
||||
features,
|
||||
})),
|
||||
add: jest.fn(),
|
||||
} as any;
|
||||
|
||||
stateHandler = {
|
||||
handleSafe: jest.fn(),
|
||||
} as any;
|
||||
|
||||
subscriptionType = new WebHookSubscription2021(storage, unsubscribeRoute, stateHandler);
|
||||
});
|
||||
|
||||
it('has the correct type.', async(): Promise<void> => {
|
||||
expect(subscriptionType.type).toBe('WebHookSubscription2021');
|
||||
});
|
||||
|
||||
it('correctly parses subscriptions.', async(): Promise<void> => {
|
||||
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(true);
|
||||
|
||||
subscription.type = 'something else';
|
||||
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('requires Read permissions on the topic.', async(): Promise<void> => {
|
||||
await expect(subscriptionType.extractModes(subscription)).resolves
|
||||
.toEqual(new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]));
|
||||
});
|
||||
|
||||
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
|
||||
const { response } = await subscriptionType.subscribe(subscription, credentials);
|
||||
expect(response.metadata.contentType).toBe('application/ld+json');
|
||||
await expect(readJsonStream(response.data)).resolves.toEqual({
|
||||
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
|
||||
type: 'WebHookSubscription2021',
|
||||
target,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
unsubscribe_endpoint: joinUrl(unsubscribeRoute.getPath(), '123'),
|
||||
});
|
||||
});
|
||||
|
||||
it('errors if the credentials do not contain a WebID.', async(): Promise<void> => {
|
||||
await expect(subscriptionType.subscribe(subscription, {})).rejects
|
||||
.toThrow('A WebHookSubscription2021 subscription request needs to be authenticated with a WebID.');
|
||||
});
|
||||
|
||||
it('calls the state handler once the response has been read.', async(): Promise<void> => {
|
||||
const { response, info } = await subscriptionType.subscribe(subscription, credentials);
|
||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(response.data);
|
||||
|
||||
expect(stateHandler.handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(stateHandler.handleSafe).toHaveBeenLastCalledWith({ info });
|
||||
});
|
||||
|
||||
it('logs an error if something went wrong emitting the state notification.', async(): Promise<void> => {
|
||||
const logger = getLoggerFor('mock');
|
||||
stateHandler.handleSafe.mockRejectedValue(new Error('notification error'));
|
||||
|
||||
const { response } = await subscriptionType.subscribe(subscription, credentials);
|
||||
expect(logger.error).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Read out data to end stream correctly
|
||||
await readableToString(response.data);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).toHaveBeenLastCalledWith('Error emitting state notification: notification error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CredentialsExtractor } from '../../../../../src/authentication/CredentialsExtractor';
|
||||
import type { Operation } from '../../../../../src/http/Operation';
|
||||
import { ResetResponseDescription } from '../../../../../src/http/output/response/ResetResponseDescription';
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
||||
import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage';
|
||||
import type {
|
||||
WebHookFeatures,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookSubscription2021';
|
||||
import {
|
||||
WebHookUnsubscriber,
|
||||
} from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookUnsubscriber';
|
||||
import { ForbiddenHttpError } from '../../../../../src/util/errors/ForbiddenHttpError';
|
||||
import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
|
||||
|
||||
describe('A WebHookUnsubscriber', (): void => {
|
||||
const request: HttpRequest = {} as any;
|
||||
const response: HttpResponse = {} as any;
|
||||
let operation: Operation;
|
||||
const webId = 'http://example.com/alice';
|
||||
let credentialsExtractor: jest.Mocked<CredentialsExtractor>;
|
||||
let storage: jest.Mocked<SubscriptionStorage<WebHookFeatures>>;
|
||||
let unsubscriber: WebHookUnsubscriber;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'DELETE',
|
||||
target: { path: 'http://example.com/.notifications/webhooks/unsubscribe/134' },
|
||||
preferences: {},
|
||||
body: new BasicRepresentation(),
|
||||
};
|
||||
|
||||
credentialsExtractor = {
|
||||
handleSafe: jest.fn().mockResolvedValue({ agent: { webId }}),
|
||||
} as any;
|
||||
|
||||
storage = {
|
||||
get: jest.fn().mockResolvedValue({ features: { webId }}),
|
||||
delete: jest.fn(),
|
||||
} as any;
|
||||
|
||||
unsubscriber = new WebHookUnsubscriber(credentialsExtractor, storage);
|
||||
});
|
||||
|
||||
it('rejects if the id does not match any stored info.', async(): Promise<void> => {
|
||||
storage.get.mockResolvedValue(undefined);
|
||||
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(NotFoundHttpError);
|
||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects if credentials are wrong.', async(): Promise<void> => {
|
||||
credentialsExtractor.handleSafe.mockResolvedValue({ agent: { webId: 'http://example.com/bob' }});
|
||||
await expect(unsubscriber.handle({ operation, request, response })).rejects.toThrow(ForbiddenHttpError);
|
||||
expect(storage.delete).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('deletes the corresponding info.', async(): Promise<void> => {
|
||||
await expect(unsubscriber.handle({ operation, request, response }))
|
||||
.resolves.toEqual(new ResetResponseDescription());
|
||||
expect(storage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(storage.delete).toHaveBeenLastCalledWith('134');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DataFactory, Parser } from 'n3';
|
||||
import type { Operation } from '../../../../../src/http/Operation';
|
||||
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
|
||||
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
|
||||
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
|
||||
import { WebHookWebId } from '../../../../../src/server/notifications/WebHookSubscription2021/WebHookWebId';
|
||||
import { readableToString } from '../../../../../src/util/StreamUtil';
|
||||
import { SOLID } from '../../../../../src/util/Vocabularies';
|
||||
const { namedNode, quad } = DataFactory;
|
||||
|
||||
describe('A WebHookWebId', (): void => {
|
||||
const request: HttpRequest = {} as any;
|
||||
const response: HttpResponse = {} as any;
|
||||
let operation: Operation;
|
||||
const baseUrl = 'http://example.com/';
|
||||
let webIdHandler: WebHookWebId;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
operation = {
|
||||
method: 'GET',
|
||||
target: { path: 'http://example.com/.notifications/webhooks/webid' },
|
||||
preferences: {},
|
||||
body: new BasicRepresentation(),
|
||||
};
|
||||
|
||||
webIdHandler = new WebHookWebId(baseUrl);
|
||||
});
|
||||
|
||||
it('returns a solid:oidcIssuer triple.', async(): Promise<void> => {
|
||||
const turtle = await webIdHandler.handle({ operation, request, response });
|
||||
expect(turtle.statusCode).toBe(200);
|
||||
expect(turtle.metadata?.contentType).toBe('text/turtle');
|
||||
expect(turtle.data).toBeDefined();
|
||||
const quads = new Parser({ baseIRI: operation.target.path }).parse(await readableToString(turtle.data!));
|
||||
expect(quads).toHaveLength(1);
|
||||
expect(quads).toEqual([ quad(
|
||||
namedNode('http://example.com/.notifications/webhooks/webid'),
|
||||
SOLID.terms.oidcIssuer,
|
||||
namedNode('http://example.com'),
|
||||
) ]);
|
||||
});
|
||||
|
||||
it('errors if the base URL is invalid.', async(): Promise<void> => {
|
||||
expect((): any => new WebHookWebId('very invalid URL'))
|
||||
.toThrow('Invalid issuer URL: Unexpected "<very" on line 2.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user