mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
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 {
|
|
WebhookChannel2023,
|
|
} from '../../../../../src/server/notifications/WebHookChannel2023/WebhookChannel2023Type';
|
|
import { WebHookEmitter } from '../../../../../src/server/notifications/WebHookChannel2023/WebHookEmitter';
|
|
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');
|
|
|
|
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 serverWebId = 'http://example.com/.notifcations/webhooks/webid';
|
|
const webIdRoute = new AbsolutePathInteractionRoute(serverWebId);
|
|
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: 'http://example.com/foo',
|
|
published: '123',
|
|
};
|
|
let representation: Representation;
|
|
const channel: WebhookChannel2023 = {
|
|
id: 'id',
|
|
topic: 'http://example.com/foo',
|
|
type: NOTIFY.WebhookChannel2023,
|
|
sendTo: 'http://example.org/somewhere-else',
|
|
};
|
|
|
|
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('errors if the channel type is wrong.', async(): Promise<void> => {
|
|
const badChannel = {
|
|
...channel,
|
|
type: 'something else',
|
|
};
|
|
await expect(emitter.canHandle({ channel: badChannel, representation })).rejects.toThrow(NotImplementedHttpError);
|
|
});
|
|
|
|
it('sends out the necessary data and headers.', async(): Promise<void> => {
|
|
const now = Date.now();
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(now);
|
|
await expect(emitter.handle({ channel, 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: serverWebId,
|
|
azp: serverWebId,
|
|
sub: serverWebId,
|
|
cnf: { jkt: await calculateJwkThumbprint(publicJwk, 'sha256') },
|
|
iat: now,
|
|
exp: now + (20 * 60 * 1000),
|
|
aud: [ serverWebId, '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: channel.sendTo,
|
|
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({ channel, representation })).resolves.toBeUndefined();
|
|
|
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
expect(logger.error).toHaveBeenLastCalledWith(
|
|
`There was an issue emitting a WebHook notification with target ${channel.sendTo}: invalid request`,
|
|
);
|
|
});
|
|
});
|