From a68b074182b3098231fcbd0439d0277502cf6de2 Mon Sep 17 00:00:00 2001 From: Levan Basharuli Date: Sun, 25 Jun 2023 20:28:36 +0400 Subject: [PATCH] feat: Replace structured events with binary ones (#182) closes #154 --- k8s/{broker.yml => api-broker.yml} | 2 +- k8s/api-service.yml | 2 +- k8s/awala-broker.yml | 4 + k8s/awala-service.yml | 69 +++++ k8s/member-bundle-request-trigger.yml | 1 - k8s/mock-awala-middleware.yml | 17 +- k8s/queue-service.yml | 4 +- src/api/server.spec.ts | 5 - src/api/server.ts | 7 +- .../routes/awala.routes.spec.ts | 261 +++++++++++------- src/{api => awala}/routes/awala.routes.ts | 78 ++++-- src/awala/server.spec.ts | 21 ++ src/awala/server.ts | 25 ++ src/backgroundQueue/server.spec.ts | 4 +- src/backgroundQueue/server.ts | 18 +- .../sinks/memberBundleRequest.sink.spec.ts | 40 +-- .../sinks/memberBundleRequest.sink.ts | 35 +-- .../memberBundleRequestTrigger.sink.spec.ts | 21 +- .../sinks/memberBundleRequestTrigger.sink.ts | 13 +- src/bin/server.ts | 2 + src/events/bundleRequest.event.ts | 15 +- .../incomingServiceMessage.event.spec.ts | 197 +++++++++++++ src/events/incomingServiceMessage.event.ts | 97 +++++++ .../outgoingServiceMessage.event.spec.ts | 14 +- src/events/outgoingServiceMessage.event.ts | 5 +- .../{e2e.test.ts => awala.test.ts} | 61 ++-- src/functionalTests/backgroundQueue.test.ts | 17 +- src/functionalTests/utils/awala.ts | 11 - src/functionalTests/utils/events.ts | 10 + src/functionalTests/utils/knative.ts | 4 - .../utils/mockAwalaMiddleware.ts | 72 ----- src/functionalTests/utils/time.ts | 12 - src/memberKeyImportToken.spec.ts | 47 +--- src/memberKeyImportToken.ts | 33 +-- src/org.spec.ts | 7 +- src/schemas/awala.schema.ts | 3 +- src/serviceTypes.ts | 4 +- src/testUtils/assertions.ts | 3 + src/testUtils/awalaServer.ts | 10 + src/testUtils/eventing/cloudEvents.ts | 2 +- src/testUtils/eventing/stubs.ts | 4 + src/utilities/buffer.spec.ts | 24 +- src/utilities/buffer.ts | 9 + src/utilities/eventing/Emitter.spec.ts | 15 +- src/utilities/eventing/Emitter.ts | 10 +- 45 files changed, 844 insertions(+), 471 deletions(-) rename k8s/{broker.yml => api-broker.yml} (67%) create mode 100644 k8s/awala-broker.yml create mode 100644 k8s/awala-service.yml rename src/{api => awala}/routes/awala.routes.spec.ts (52%) rename src/{api => awala}/routes/awala.routes.ts (60%) create mode 100644 src/awala/server.spec.ts create mode 100644 src/awala/server.ts create mode 100644 src/events/incomingServiceMessage.event.spec.ts create mode 100644 src/events/incomingServiceMessage.event.ts rename src/functionalTests/{e2e.test.ts => awala.test.ts} (71%) delete mode 100644 src/functionalTests/utils/awala.ts create mode 100644 src/functionalTests/utils/events.ts delete mode 100644 src/functionalTests/utils/mockAwalaMiddleware.ts delete mode 100644 src/functionalTests/utils/time.ts create mode 100644 src/testUtils/assertions.ts create mode 100644 src/testUtils/awalaServer.ts diff --git a/k8s/broker.yml b/k8s/api-broker.yml similarity index 67% rename from k8s/broker.yml rename to k8s/api-broker.yml index 7c10ea8f..3e3b944d 100644 --- a/k8s/broker.yml +++ b/k8s/api-broker.yml @@ -1,4 +1,4 @@ apiVersion: eventing.knative.dev/v1 kind: Broker metadata: - name: authority-broker + name: authority-api-broker diff --git a/k8s/api-service.yml b/k8s/api-service.yml index b4f7ad56..1accb726 100644 --- a/k8s/api-service.yml +++ b/k8s/api-service.yml @@ -78,4 +78,4 @@ spec: ref: apiVersion: eventing.knative.dev/v1 kind: Broker - name: authority-broker + name: authority-api-broker diff --git a/k8s/awala-broker.yml b/k8s/awala-broker.yml new file mode 100644 index 00000000..d223ee88 --- /dev/null +++ b/k8s/awala-broker.yml @@ -0,0 +1,4 @@ +apiVersion: eventing.knative.dev/v1 +kind: Broker +metadata: + name: authority-awala-broker diff --git a/k8s/awala-service.yml b/k8s/awala-service.yml new file mode 100644 index 00000000..7166518e --- /dev/null +++ b/k8s/awala-service.yml @@ -0,0 +1,69 @@ +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: veraid-authority-awala +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/min-scale: "1" + spec: + containers: + - name: queue + image: dev.local/veraid-authority + args: [awala] + readinessProbe: + httpGet: + path: / + initialDelaySeconds: 1 + env: + - name: AUTHORITY_VERSION + value: "1.0.0dev1" + - name: MONGODB_USERNAME + valueFrom: + configMapKeyRef: + name: credentials + key: mongodb_username + - name: MONGODB_PASSWORD + valueFrom: + secretKeyRef: + name: credentials + key: mongodb_password + - name: MONGODB_URI + value: mongodb://$(MONGODB_USERNAME):$(MONGODB_PASSWORD)@mongodb + + # Mock AWS KMS (used by WebCrypto KMS) + - name: KMS_ADAPTER + value: AWS + - name: AWS_ACCESS_KEY_ID + valueFrom: + configMapKeyRef: + name: mock-aws-kms + key: access_key_id + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: credentials + key: aws_kms_secret_access_key + - name: AWS_KMS_ENDPOINT + valueFrom: + configMapKeyRef: + name: mock-aws-kms + key: endpoint + - name: AWS_KMS_REGION + value: eu-west-2 +--- +apiVersion: sources.knative.dev/v1 +kind: SinkBinding +metadata: + name: veraid-authority-awala-sink-binding +spec: + subject: + apiVersion: serving.knative.dev/v1 + kind: Service + name: veraid-authority-awala + sink: + ref: + apiVersion: eventing.knative.dev/v1 + kind: Broker + name: authority-awala-broker diff --git a/k8s/member-bundle-request-trigger.yml b/k8s/member-bundle-request-trigger.yml index 7888fa17..2427be70 100644 --- a/k8s/member-bundle-request-trigger.yml +++ b/k8s/member-bundle-request-trigger.yml @@ -4,7 +4,6 @@ metadata: name: member-bundle-request-trigger spec: schedule: "*/1 * * * *" - contentType: "application/cloudevents+json" data: '{"id":"ce-id","type":"net.veraid.authority.member-bundle-request-trigger","source":"https://veraid.net/authority","specversion":"1.0","data":{}}' sink: ref: diff --git a/k8s/mock-awala-middleware.yml b/k8s/mock-awala-middleware.yml index 2007949b..fd700773 100644 --- a/k8s/mock-awala-middleware.yml +++ b/k8s/mock-awala-middleware.yml @@ -15,27 +15,12 @@ spec: - name: MOCKSERVER_SERVER_PORT value: "8080" --- -apiVersion: sources.knative.dev/v1 -kind: SinkBinding -metadata: - name: mock-awala-middleware-sink-binding -spec: - subject: - apiVersion: serving.knative.dev/v1 - kind: Service - name: mock-awala-middleware - sink: - ref: - apiVersion: eventing.knative.dev/v1 - kind: Broker - name: authority-broker ---- apiVersion: eventing.knative.dev/v1 kind: Trigger metadata: name: mock-awala-middleware-trigger spec: - broker: authority-broker + broker: authority-awala-broker subscriber: ref: apiVersion: serving.knative.dev/v1 diff --git a/k8s/queue-service.yml b/k8s/queue-service.yml index e8652722..0be01f16 100644 --- a/k8s/queue-service.yml +++ b/k8s/queue-service.yml @@ -66,14 +66,14 @@ spec: ref: apiVersion: eventing.knative.dev/v1 kind: Broker - name: authority-broker + name: authority-api-broker --- apiVersion: eventing.knative.dev/v1 kind: Trigger metadata: name: veraid-authority-queue-trigger spec: - broker: authority-broker + broker: authority-api-broker subscriber: ref: apiVersion: serving.knative.dev/v1 diff --git a/src/api/server.spec.ts b/src/api/server.spec.ts index 717fc247..43ed0f49 100644 --- a/src/api/server.spec.ts +++ b/src/api/server.spec.ts @@ -4,11 +4,6 @@ import type { FastifyInstance } from 'fastify'; import { mockSpy } from '../testUtils/jest.js'; -const mockRegisterAwalaRoute = mockSpy(jest.fn()); -jest.unstable_mockModule('./routes/awala.routes.js', () => ({ - default: mockRegisterAwalaRoute, -})); - const mockFastify: FastifyInstance = { register: mockSpy(jest.fn()), } as any; diff --git a/src/api/server.ts b/src/api/server.ts index a75e31e5..2108b120 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -7,14 +7,9 @@ import jwksPlugin from '../utilities/fastify/plugins/jwksAuthentication.js'; import healthcheckRoutes from './routes/healthcheck.routes.js'; import orgRoutes from './routes/org.routes.js'; -import awalaRoutes from './routes/awala.routes.js'; export async function makeApiServerPlugin(server: FastifyInstance): Promise { - const rootRoutes: FastifyPluginCallback[] = [ - healthcheckRoutes, - orgRoutes, - awalaRoutes, - ]; + const rootRoutes: FastifyPluginCallback[] = [healthcheckRoutes, orgRoutes]; await server.register(jwksPlugin); await Promise.all(rootRoutes.map((route) => server.register(route))); diff --git a/src/api/routes/awala.routes.spec.ts b/src/awala/routes/awala.routes.spec.ts similarity index 52% rename from src/api/routes/awala.routes.spec.ts rename to src/awala/routes/awala.routes.spec.ts index 7b1d955a..9b1d0d3a 100644 --- a/src/api/routes/awala.routes.spec.ts +++ b/src/awala/routes/awala.routes.spec.ts @@ -1,5 +1,7 @@ import { jest } from '@jest/globals'; import type { FastifyInstance } from 'fastify'; +import { HTTP, CloudEvent } from 'cloudevents'; +import { addDays, formatISO } from 'date-fns'; import { HTTP_STATUS_CODES } from '../../utilities/http.js'; import { mockSpy } from '../../testUtils/jest.js'; @@ -15,6 +17,10 @@ import { MEMBER_KEY_IMPORT_TOKEN, SIGNATURE, } from '../../testUtils/stubs.js'; +import { CE_ID } from '../../testUtils/eventing/stubs.js'; +import { INCOMING_SERVICE_MESSAGE_TYPE } from '../../events/incomingServiceMessage.event.js'; +import { postEvent } from '../../testUtils/eventing/cloudEvents.js'; +import type { MemberKeyImportRequest } from '../../schemas/awala.schema.js'; const mockProcessMemberKeyImportToken = mockSpy( jest.fn<() => Promise>>(), @@ -27,18 +33,33 @@ jest.unstable_mockModule('../../memberKeyImportToken.js', () => ({ const mockCreateMemberBundleRequest = mockSpy( jest.fn<() => Promise>>(), ); + +const mockGenerateMemberBundle = mockSpy( + jest.fn< + () => Promise< + Result< + ArrayBuffer, + { + shouldRetry: boolean; + } + > + > + >(), +); jest.unstable_mockModule('../../memberBundle.js', () => ({ createMemberBundleRequest: mockCreateMemberBundleRequest, + generateMemberBundle: mockGenerateMemberBundle, + CERTIFICATE_EXPIRY_DAYS: 90, })); -const { makeTestApiServer } = await import('../../testUtils/apiServer.js'); +const { setUpTestAwalaServer } = await import('../../testUtils/awalaServer.js'); const { publicKey } = await generateKeyPair(); const publicKeyBuffer = await derSerialisePublicKey(publicKey); const publicKeyBase64 = publicKeyBuffer.toString('base64'); -describe('awala routes', () => { - const getTestServerFixture = makeTestApiServer(); +describe('Awala routes', () => { + const getTestServerFixture = setUpTestAwalaServer(); let server: FastifyInstance; let logs: MockLogSet; beforeEach(() => { @@ -48,7 +69,7 @@ describe('awala routes', () => { test('Invalid content type should resolve to unsupported media type error', async () => { const response = await server.inject({ method: 'POST', - url: '/awala', + url: '/', headers: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -61,7 +82,7 @@ describe('awala routes', () => { test('Content type application/json should resolve to unsupported media type error', async () => { const response = await server.inject({ method: 'POST', - url: '/awala', + url: '/', headers: { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -71,49 +92,86 @@ describe('awala routes', () => { expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.UNSUPPORTED_MEDIA_TYPE); }); + test('Missing headers should resolve into bad request', async () => { + const response = await server.inject({ + method: 'POST', + url: '/', + }); + + expect(response.statusCode).toBe(HTTP_STATUS_CODES.BAD_REQUEST); + expect(logs).toContainEqual( + partialPinoLog('info', 'Refused invalid CloudEvent', { + err: expect.objectContaining({ message: 'no cloud event detected' }), + }), + ); + }); + + test('Invalid service message event should should be refused', async () => { + const cloudEvent = new CloudEvent({ + id: CE_ID, + source: AWALA_PEER_ID, + type: 'invalid message type', + subject: 'https://relaycorp.tech/awala-endpoint-internet', + datacontenttype: 'application/vnd.veraid.member-bundle-request', + }); + + const response = await postEvent(cloudEvent, server); + + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); + expect(logs).toContainEqual( + partialPinoLog('error', 'Refused invalid type', { + parcelId: CE_ID, + }), + ); + }); + describe('Member bundle request', () => { - const validPayload = { + const expiry = addDays(Date.now(), 5); + const validMessageContent = { publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, memberBundleStartDate: '2023-04-13T20:05:38.285Z', - peerId: AWALA_PEER_ID, signature: SIGNATURE, }; - const validHeaders = { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'content-type': 'application/vnd.veraid.member-bundle-request', - }; + + const cloudEvent = new CloudEvent({ + id: CE_ID, + source: AWALA_PEER_ID, + type: INCOMING_SERVICE_MESSAGE_TYPE, + subject: 'https://relaycorp.tech/awala-endpoint-internet', + datacontenttype: 'application/vnd.veraid.member-bundle-request', + expiry: formatISO(expiry), + data: JSON.stringify(validMessageContent), + }); test('Valid data should be accepted', async () => { - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: validPayload, - }); mockCreateMemberBundleRequest.mockResolvedValueOnce({ didSucceed: true, }); + const response = await postEvent(cloudEvent, server); + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED); - expect(mockCreateMemberBundleRequest).toHaveBeenCalledOnceWith(validPayload, { - logger: server.log, - dbConnection: server.mongoose, - }); + expect(mockCreateMemberBundleRequest).toHaveBeenCalledOnceWith( + { ...validMessageContent, peerId: AWALA_PEER_ID }, + { + logger: expect.anything(), + dbConnection: server.mongoose, + }, + ); }); - test('Malformed date should be refused', async () => { - const methodPayload = { - ...validPayload, - memberBundleStartDate: 'INVALID_DATE', - }; + test('Malformed member bundle start date should be refused', async () => { + const event = new CloudEvent({ + ...cloudEvent, - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: methodPayload, + data: JSON.stringify({ + ...validMessageContent, + memberBundleStartDate: 'INVALID_DATE', + }), }); + const response = await postEvent(event, server); + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { @@ -123,17 +181,32 @@ describe('awala routes', () => { ); }); - test('Empty peer id should be refused', async () => { - const methodPayload = { - ...validPayload, - peerId: '', - }; + test('Malformed content should be refused', async () => { + const event = new CloudEvent({ + ...cloudEvent, + data: 'MALFORMED_CONTENT', + }); + + const response = await postEvent(event, server); + + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); + expect(logs).toContainEqual(partialPinoLog('info', 'Refused invalid json format')); + }); + + test('Empty source should be refused', async () => { + const message = HTTP.binary(cloudEvent); const response = await server.inject({ method: 'POST', - url: '/awala', - headers: validHeaders, - payload: methodPayload, + url: '/', + + headers: { + ...message.headers, + // eslint-disable-next-line @typescript-eslint/naming-convention + 'ce-source': '', + }, + + payload: message.body as string, }); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); @@ -146,18 +219,17 @@ describe('awala routes', () => { }); test('Malformed signature should be refused', async () => { - const methodPayload = { - ...validPayload, - signature: 'INVALID_BASE_64', - }; + const event = new CloudEvent({ + ...cloudEvent, - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: methodPayload, + data: JSON.stringify({ + ...validMessageContent, + signature: 'INVALID_BASE_64', + }), }); + const response = await postEvent(event, server); + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { @@ -169,69 +241,61 @@ describe('awala routes', () => { }); describe('Process member key import request', () => { - const validPayload = { + const expiry = addDays(Date.now(), 5); + const importRequest: MemberKeyImportRequest = { publicKeyImportToken: MEMBER_KEY_IMPORT_TOKEN, publicKey: publicKeyBase64, - peerId: AWALA_PEER_ID, - }; - const validHeaders = { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'content-type': 'application/vnd.veraid.member-public-key-import', }; + const cloudEvent = new CloudEvent({ + id: CE_ID, + source: AWALA_PEER_ID, + type: INCOMING_SERVICE_MESSAGE_TYPE, + subject: 'https://relaycorp.tech/awala-endpoint-internet', + datacontenttype: 'application/vnd.veraid.member-public-key-import', + expiry: formatISO(expiry), + data: JSON.stringify(importRequest), + }); + test('Valid data should be accepted', async () => { mockProcessMemberKeyImportToken.mockResolvedValueOnce({ didSucceed: true, }); - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: validPayload, - }); + const response = await postEvent(cloudEvent, server); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.ACCEPTED); - expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, { - logger: server.log, - dbConnection: server.mongoose, - }); + expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith( + AWALA_PEER_ID, + importRequest, + { logger: expect.anything(), dbConnection: server.mongoose }, + ); }); - test('Empty peer id should be refused', async () => { - const methodPayload = { - ...validPayload, - peerId: '', - }; - - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: methodPayload, + test('Malformed content should be refused', async () => { + const event = new CloudEvent({ + ...cloudEvent, + data: 'MALFORMED_CONTENT', }); + const response = await postEvent(event, server); + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(logs).toContainEqual( - partialPinoLog('info', 'Refused invalid member bundle request', { - reason: expect.stringContaining('peerId'), - }), - ); + expect(logs).toContainEqual(partialPinoLog('info', 'Refused invalid json format')); }); test('Missing public key import token should be refused', async () => { - const methodPayload = { - ...validPayload, - publicKeyImportToken: undefined, - }; + const event = new CloudEvent({ + ...cloudEvent, - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: methodPayload, + data: JSON.stringify({ + ...importRequest, + publicKeyImportToken: undefined, + }), }); + const response = await postEvent(event, server); + expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); expect(logs).toContainEqual( partialPinoLog('info', 'Refused invalid member bundle request', { @@ -249,18 +313,17 @@ describe('awala routes', () => { context: reason, }); - const response = await server.inject({ - method: 'POST', - url: '/awala', - headers: validHeaders, - payload: validPayload, - }); + const response = await postEvent(cloudEvent, server); expect(response).toHaveProperty('statusCode', HTTP_STATUS_CODES.BAD_REQUEST); - expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith(validPayload, { - logger: server.log, - dbConnection: server.mongoose, - }); + expect(mockProcessMemberKeyImportToken).toHaveBeenCalledOnceWith( + AWALA_PEER_ID, + importRequest, + { + logger: expect.anything(), + dbConnection: server.mongoose, + }, + ); }); }); }); diff --git a/src/api/routes/awala.routes.ts b/src/awala/routes/awala.routes.ts similarity index 60% rename from src/api/routes/awala.routes.ts rename to src/awala/routes/awala.routes.ts index a6ec599c..e82f9b26 100644 --- a/src/api/routes/awala.routes.ts +++ b/src/awala/routes/awala.routes.ts @@ -1,4 +1,5 @@ import type { RouteOptions } from 'fastify'; +import { type CloudEventV1, HTTP, type Message } from 'cloudevents'; import { HTTP_STATUS_CODES } from '../../utilities/http.js'; import type { PluginDone } from '../../utilities/fastify/PluginDone.js'; @@ -13,12 +14,28 @@ import type { ServiceOptions } from '../../serviceTypes.js'; import { processMemberKeyImportToken } from '../../memberKeyImportToken.js'; import { validateMessage } from '../../utilities/validateMessage.js'; import { createMemberBundleRequest } from '../../memberBundle.js'; +import { + getIncomingServiceMessageEvent, + type IncomingServiceMessageOptions, +} from '../../events/incomingServiceMessage.event.js'; +import { bufferToJson } from '../../utilities/buffer.js'; async function processMemberBundleRequest( - data: unknown, + incomingMessage: IncomingServiceMessageOptions, options: ServiceOptions, ): Promise { - const validationResult = validateMessage(data, MEMBER_BUNDLE_REQUEST_SCHEMA); + const data = bufferToJson(incomingMessage.content); + if (!data) { + options.logger.info('Refused invalid json format'); + return false; + } + const validationResult = validateMessage( + { + ...data, + peerId: incomingMessage.senderId, + }, + MEMBER_BUNDLE_REQUEST_SCHEMA, + ); if (typeof validationResult === 'string') { options.logger.info( { @@ -35,15 +52,19 @@ async function processMemberBundleRequest( } async function processMemberKeyImportRequest( - data: unknown, + incomingMessage: IncomingServiceMessageOptions, options: ServiceOptions, ): Promise { + const data = bufferToJson(incomingMessage.content); + if (!data) { + options.logger.info('Refused invalid json format'); + return false; + } const validationResult = validateMessage(data, MEMBER_KEY_IMPORT_REQUEST_SCHEMA); if (typeof validationResult === 'string') { options.logger.info( { publicKeyImportToken: (data as MemberKeyImportRequest).publicKeyImportToken, - reason: validationResult, }, 'Refused invalid member bundle request', @@ -52,10 +73,10 @@ async function processMemberKeyImportRequest( } const result = await processMemberKeyImportToken( + incomingMessage.senderId, { publicKey: validationResult.publicKey, publicKeyImportToken: validationResult.publicKeyImportToken, - peerId: validationResult.peerId, }, options, ); @@ -68,7 +89,10 @@ enum AwalaRequestMessageType { } const awalaEventToProcessor: { - [key in AwalaRequestMessageType]: (data: unknown, options: ServiceOptions) => Promise; + [key in AwalaRequestMessageType]: ( + incomingMessage: IncomingServiceMessageOptions, + options: ServiceOptions, + ) => Promise; } = { [AwalaRequestMessageType.MEMBER_BUNDLE_REQUEST]: processMemberBundleRequest, [AwalaRequestMessageType.MEMBER_PUBLIC_KEY_IMPORT]: processMemberKeyImportRequest, @@ -82,39 +106,53 @@ export default function registerRoutes( done: PluginDone, ): void { fastify.removeAllContentTypeParsers(); - fastify.addContentTypeParser( awalaRequestMessageTypeList, - { - parseAs: 'string', + { parseAs: 'buffer' }, + (_request, payload, next) => { + next(null, payload); }, - fastify.getDefaultJsonParser('ignore', 'ignore'), ); fastify.route({ method: ['POST'], - url: '/awala', + url: '/', async handler(request, reply): Promise { const contentType = awalaRequestMessageTypeList.find( (messageType) => messageType === request.headers['content-type'], ); - const serviceOptions = { - logger: this.log, - dbConnection: this.mongoose, - }; - const processor = awalaEventToProcessor[contentType!]; - const didSucceed = await processor(request.body, serviceOptions); + const message: Message = { headers: request.headers, body: request.body }; + let event; + try { + event = HTTP.toEvent(message) as CloudEventV1; + } catch (err) { + request.log.info({ err }, 'Refused invalid CloudEvent'); + return reply.status(HTTP_STATUS_CODES.BAD_REQUEST).send(); + } + + const parcelAwareLogger = request.log.child({ + parcelId: event.id, + }); + + const incomingMessage = getIncomingServiceMessageEvent(event, parcelAwareLogger); + if (!incomingMessage) { + return reply.status(HTTP_STATUS_CODES.BAD_REQUEST).send(); + } + + const didSucceed = await processor(incomingMessage, { + logger: parcelAwareLogger, + dbConnection: this.mongoose, + }); if (didSucceed) { - await reply.code(HTTP_STATUS_CODES.ACCEPTED).send(); - return; + return reply.code(HTTP_STATUS_CODES.ACCEPTED).send(); } - await reply.code(HTTP_STATUS_CODES.BAD_REQUEST).send(); + return reply.code(HTTP_STATUS_CODES.BAD_REQUEST).send(); }, }); done(); diff --git a/src/awala/server.spec.ts b/src/awala/server.spec.ts new file mode 100644 index 00000000..9fb0d931 --- /dev/null +++ b/src/awala/server.spec.ts @@ -0,0 +1,21 @@ +import type { FastifyInstance } from 'fastify'; + +import { setUpTestAwalaServer } from '../testUtils/awalaServer.js'; +import { HTTP_STATUS_CODES } from '../utilities/http.js'; + +describe('makeAwalaServer', () => { + const getTestServerFixture = setUpTestAwalaServer(); + let server: FastifyInstance; + beforeEach(() => { + ({ server } = getTestServerFixture()); + }); + + describe('GET', () => { + test('Response should be 200 OK', async () => { + const response = await server.inject({ method: 'GET', url: '/' }); + + expect(response.statusCode).toBe(HTTP_STATUS_CODES.OK); + expect(response.body).toBe('It works'); + }); + }); +}); diff --git a/src/awala/server.ts b/src/awala/server.ts new file mode 100644 index 00000000..23b350b2 --- /dev/null +++ b/src/awala/server.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import type { BaseLogger } from 'pino'; + +import { makeFastify } from '../utilities/fastify/server.js'; +import { HTTP_STATUS_CODES } from '../utilities/http.js'; +import type { PluginDone } from '../utilities/fastify/PluginDone.js'; +import type { FastifyTypedInstance } from '../utilities/fastify/FastifyTypedInstance.js'; + +import awalaRoutes from './routes/awala.routes.js'; + +async function makeAwalaServerPlugin( + server: FastifyTypedInstance, + _opts: FastifyPluginOptions, + done: PluginDone, +) { + server.get('/', async (_request, reply) => { + await reply.status(HTTP_STATUS_CODES.OK).send('It works'); + }); + await server.register(awalaRoutes); + done(); +} + +export async function makeAwalaServer(logger?: BaseLogger): Promise { + return makeFastify(makeAwalaServerPlugin, logger); +} diff --git a/src/backgroundQueue/server.spec.ts b/src/backgroundQueue/server.spec.ts index 4dbe4b66..68581f76 100644 --- a/src/backgroundQueue/server.spec.ts +++ b/src/backgroundQueue/server.spec.ts @@ -29,8 +29,8 @@ describe('makeQueueServer', () => { const response = await server.inject({ method: 'POST', url: '/', - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: { 'content-type': 'application/cloudevents+json' }, + + headers: {}, payload: 'null', }); diff --git a/src/backgroundQueue/server.ts b/src/backgroundQueue/server.ts index 382e9643..20fee36e 100644 --- a/src/backgroundQueue/server.ts +++ b/src/backgroundQueue/server.ts @@ -1,4 +1,4 @@ -import { type CloudEvent, type CloudEventV1, HTTP, type Message } from 'cloudevents'; +import { type CloudEvent, HTTP, type Message } from 'cloudevents'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import type { BaseLogger } from 'pino'; @@ -24,11 +24,10 @@ function makeQueueServerPlugin( _opts: FastifyPluginOptions, done: PluginDone, ): void { - server.addContentTypeParser( - 'application/cloudevents+json', - { parseAs: 'string' }, - server.getDefaultJsonParser('ignore', 'ignore'), - ); + server.removeAllContentTypeParsers(); + server.addContentTypeParser('*', { parseAs: 'buffer' }, (_request, payload, next) => { + next(null, payload); + }); server.get('/', async (_request, reply) => { await reply.status(HTTP_STATUS_CODES.OK).send('It works'); @@ -36,9 +35,9 @@ function makeQueueServerPlugin( server.post('/', async (request, reply) => { const message: Message = { headers: request.headers, body: request.body }; - let events: CloudEventV1; + let event; try { - events = HTTP.toEvent(message) as CloudEventV1; + event = HTTP.toEvent(message) as CloudEvent; } catch { await reply .status(HTTP_STATUS_CODES.BAD_REQUEST) @@ -46,7 +45,6 @@ function makeQueueServerPlugin( return; } - const event = events as CloudEvent; const sink = SINK_BY_TYPE[event.type] as Sink | undefined; if (sink === undefined) { await reply @@ -56,7 +54,7 @@ function makeQueueServerPlugin( } await sink(event, { - logger: server.log, + logger: request.log, dbConnection: server.mongoose, }); await reply.status(HTTP_STATUS_CODES.NO_CONTENT).send(); diff --git a/src/backgroundQueue/sinks/memberBundleRequest.sink.spec.ts b/src/backgroundQueue/sinks/memberBundleRequest.sink.spec.ts index 37bbe358..08a14e5e 100644 --- a/src/backgroundQueue/sinks/memberBundleRequest.sink.spec.ts +++ b/src/backgroundQueue/sinks/memberBundleRequest.sink.spec.ts @@ -64,11 +64,7 @@ describe('memberBundleIssuance', () => { id: MEMBER_PUBLIC_KEY_MONGO_ID, source: CE_SOURCE, type: BUNDLE_REQUEST_TYPE, - - data: { - peerId: AWALA_PEER_ID, - publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, - }, + subject: AWALA_PEER_ID, }); beforeEach(() => { @@ -94,7 +90,7 @@ describe('memberBundleIssuance', () => { expect(logs).toContainEqual( partialPinoLog('debug', 'Starting member bundle request trigger', { - eventId: MEMBER_PUBLIC_KEY_MONGO_ID, + publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, }), ); }); @@ -105,7 +101,7 @@ describe('memberBundleIssuance', () => { expect(mockGenerateMemberBundle).toHaveBeenCalledOnceWith( MEMBER_PUBLIC_KEY_MONGO_ID, expect.objectContaining({ - logger: server.log, + logger: expect.anything(), dbConnection: server.mongoose, }), ); @@ -117,8 +113,7 @@ describe('memberBundleIssuance', () => { expect(logs).toContainEqual( partialPinoLog('debug', 'Emitting member bundle event', { - eventId: MEMBER_PUBLIC_KEY_MONGO_ID, - memberPublicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, + publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, }), ); expect(publishedEvents).toHaveLength(1); @@ -127,11 +122,7 @@ describe('memberBundleIssuance', () => { test('Version should be 1.0', async () => { await postEvent(triggerEvent, server); - expect(publishedEvents).toContainEqual( - expect.objectContaining({ - specversion: '1.0', - }), - ); + expect(publishedEvents).toContainEqual(expect.objectContaining({ specversion: '1.0' })); }); test('Type should be outgoing service message', async () => { @@ -243,32 +234,26 @@ describe('memberBundleIssuance', () => { ); expect(memberBundleRequestCheck).toBeNull(); expect(logs).toContainEqual( - partialPinoLog('info', 'Removed Bundle Request', { - eventId: MEMBER_PUBLIC_KEY_MONGO_ID, - memberPublicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, + partialPinoLog('info', 'Deleted bundle request', { + publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, }), ); }); }); - test('Malformed event data should stop execution', async () => { + test('Event with missing subject should be refused', async () => { const invalidTriggerEvent = new CloudEvent({ id: MEMBER_PUBLIC_KEY_MONGO_ID, source: CE_SOURCE, type: BUNDLE_REQUEST_TYPE, - - data: { - publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, - } as any, }); await postEvent(invalidTriggerEvent, server); expect(mockGenerateMemberBundle).not.toHaveBeenCalled(); expect(logs).toContainEqual( - partialPinoLog('info', 'Refusing malformed member bundle request event', { - eventId: MEMBER_PUBLIC_KEY_MONGO_ID, - validationError: expect.stringContaining('peerId'), + partialPinoLog('info', 'Refusing member bundle request with missing subject', { + publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, }), ); }); @@ -341,9 +326,8 @@ describe('memberBundleIssuance', () => { ); expect(memberBundleRequestCheck).toBeNull(); expect(logs).toContainEqual( - partialPinoLog('info', 'Removed Bundle Request', { - eventId: MEMBER_PUBLIC_KEY_MONGO_ID, - memberPublicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, + partialPinoLog('info', 'Deleted bundle request', { + publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, }), ); }); diff --git a/src/backgroundQueue/sinks/memberBundleRequest.sink.ts b/src/backgroundQueue/sinks/memberBundleRequest.sink.ts index 4aa1df35..7faf33b5 100644 --- a/src/backgroundQueue/sinks/memberBundleRequest.sink.ts +++ b/src/backgroundQueue/sinks/memberBundleRequest.sink.ts @@ -2,10 +2,8 @@ import type { CloudEvent } from 'cloudevents'; import { getModelForClass } from '@typegoose/typegoose'; import { addDays } from 'date-fns'; -import { MEMBER_BUNDLE_REQUEST_PAYLOAD } from '../../events/bundleRequest.event.js'; import { CERTIFICATE_EXPIRY_DAYS, generateMemberBundle } from '../../memberBundle.js'; import { MemberBundleRequestModelSchema } from '../../models/MemberBundleRequest.model.js'; -import { validateMessage } from '../../utilities/validateMessage.js'; import { Emitter } from '../../utilities/eventing/Emitter.js'; import { makeOutgoingServiceMessageEvent } from '../../events/outgoingServiceMessage.event.js'; import { VeraidContentType } from '../../utilities/veraid.js'; @@ -15,32 +13,28 @@ export default async function memberBundleIssuance( event: CloudEvent, options: ServiceOptions, ): Promise { - options.logger.debug({ eventId: event.id }, 'Starting member bundle request trigger'); + const publicKeyId = event.id; + const keyAwareLogger = options.logger.child({ publicKeyId }); - const validatedData = validateMessage(event.data, MEMBER_BUNDLE_REQUEST_PAYLOAD); - if (typeof validatedData === 'string') { - options.logger.info( - { eventId: event.id, validationError: validatedData }, - 'Refusing malformed member bundle request event', - ); + keyAwareLogger.debug('Starting member bundle request trigger'); + + if (event.subject === undefined) { + keyAwareLogger.info('Refusing member bundle request with missing subject'); return; } - const memberBundle = await generateMemberBundle(validatedData.publicKeyId, options); + const memberBundle = await generateMemberBundle(publicKeyId, options); if (!memberBundle.didSucceed && memberBundle.context.shouldRetry) { return; } if (memberBundle.didSucceed) { - options.logger.debug( - { eventId: event.id, memberPublicKeyId: validatedData.publicKeyId }, - 'Emitting member bundle event', - ); + keyAwareLogger.debug('Emitting member bundle event'); const now = new Date(); const message = makeOutgoingServiceMessageEvent({ - publicKeyId: validatedData.publicKeyId, - peerId: validatedData.peerId, + publicKeyId, + peerId: event.subject, contentType: VeraidContentType.MEMBER_BUNDLE, content: Buffer.from(memberBundle.result), creationDate: now, @@ -54,11 +48,6 @@ export default async function memberBundleIssuance( const memberBundleRequestModel = getModelForClass(MemberBundleRequestModelSchema, { existingConnection: options.dbConnection, }); - await memberBundleRequestModel.deleteOne({ - publicKeyId: validatedData.publicKeyId, - }); - options.logger.info( - { eventId: event.id, memberPublicKeyId: validatedData.publicKeyId }, - 'Removed Bundle Request', - ); + await memberBundleRequestModel.deleteOne({ publicKeyId }); + keyAwareLogger.info('Deleted bundle request'); } diff --git a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts index 95c3b95d..a8f21f5d 100644 --- a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts +++ b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.spec.ts @@ -12,10 +12,7 @@ import { type MemberBundleRequestTriggerPayload, } from '../../events/bundleRequestTrigger.event.js'; import { mockEmitter } from '../../testUtils/eventing/mockEmitter.js'; -import { - BUNDLE_REQUEST_TYPE, - type MemberBundleRequestPayload, -} from '../../events/bundleRequest.event.js'; +import { BUNDLE_REQUEST_TYPE } from '../../events/bundleRequest.event.js'; import { AWALA_PEER_ID, MEMBER_MONGO_ID, @@ -70,27 +67,19 @@ describe('triggerBundleRequest', () => { const publishedEvents = getEvents(); expect(publishedEvents).toHaveLength(2); expect(publishedEvents).toContainEqual( - expect.objectContaining>>({ + expect.objectContaining>({ id: MEMBER_PUBLIC_KEY_MONGO_ID, source: 'https://veraid.net/authority/bundle-request-trigger', type: BUNDLE_REQUEST_TYPE, - - data: { - peerId: AWALA_PEER_ID, - publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, - }, + subject: AWALA_PEER_ID, }), ); expect(publishedEvents).toContainEqual( - expect.objectContaining>>({ + expect.objectContaining>({ id: mongoId, source: 'https://veraid.net/authority/bundle-request-trigger', type: BUNDLE_REQUEST_TYPE, - - data: { - peerId: AWALA_PEER_ID, - publicKeyId: mongoId, - }, + subject: AWALA_PEER_ID, }), ); }); diff --git a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.ts b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.ts index 9aab95ab..c01ddd58 100644 --- a/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.ts +++ b/src/backgroundQueue/sinks/memberBundleRequestTrigger.sink.ts @@ -11,23 +11,20 @@ import { import { MemberBundleRequestModelSchema } from '../../models/MemberBundleRequest.model.js'; import type { ServiceOptions } from '../../serviceTypes.js'; -const triggerMemberBundleIssuance = async ( +async function triggerMemberBundleIssuance( memberBundleRequest: HydratedDocument, emitter: Emitter, -) => { +): Promise { await emitter.emit( new CloudEvent({ id: memberBundleRequest.publicKeyId, source: 'https://veraid.net/authority/bundle-request-trigger', type: BUNDLE_REQUEST_TYPE, - - data: { - peerId: memberBundleRequest.peerId, - publicKeyId: memberBundleRequest.publicKeyId, - }, + subject: memberBundleRequest.peerId, }), ); -}; +} + export const BUNDLE_REQUEST_DATE_RANGE = 3; export default async function triggerBundleRequest( diff --git a/src/bin/server.ts b/src/bin/server.ts index 1e55d907..a94104f1 100755 --- a/src/bin/server.ts +++ b/src/bin/server.ts @@ -2,12 +2,14 @@ import { argv } from 'node:process'; import { makeApiServer } from '../api/server.js'; import { makeQueueServer } from '../backgroundQueue/server.js'; +import { makeAwalaServer } from '../awala/server.js'; import { runFastify } from '../utilities/fastify/server.js'; import type { ServerMaker } from '../utilities/fastify/ServerMaker.js'; const SERVER_MAKERS: { [key: string]: ServerMaker } = { api: makeApiServer, queue: makeQueueServer, + awala: makeAwalaServer, }; const [, scriptName, serverName] = argv; diff --git a/src/events/bundleRequest.event.ts b/src/events/bundleRequest.event.ts index 7e0f18ea..19341d5f 100644 --- a/src/events/bundleRequest.event.ts +++ b/src/events/bundleRequest.event.ts @@ -1,16 +1,3 @@ -import type { FromSchema } from 'json-schema-to-ts'; - export const BUNDLE_REQUEST_TYPE = 'net.veraid.authority.member-bundle-request'; -export const MEMBER_BUNDLE_REQUEST_PAYLOAD = { - type: 'object', - - properties: { - publicKeyId: { type: 'string' }, - peerId: { type: 'string' }, - }, - - required: ['publicKeyId', 'peerId'], -} as const; - -export type MemberBundleRequestPayload = FromSchema; +export type MemberBundleRequestPayload = ''; diff --git a/src/events/incomingServiceMessage.event.spec.ts b/src/events/incomingServiceMessage.event.spec.ts new file mode 100644 index 00000000..d36141ab --- /dev/null +++ b/src/events/incomingServiceMessage.event.spec.ts @@ -0,0 +1,197 @@ +import { addDays, formatISO, parseISO, subDays } from 'date-fns'; +import { CloudEvent } from 'cloudevents'; + +import { AWALA_PEER_ID } from '../testUtils/stubs.js'; +import { + CE_ID, + CE_SERVICE_MESSAGE_CONTENT, + CE_SERVICE_MESSAGE_CONTENT_TYPE, + CE_SOURCE, +} from '../testUtils/eventing/stubs.js'; +import { makeMockLogging, partialPinoLog } from '../testUtils/logging.js'; +import { assertNull } from '../testUtils/assertions.js'; + +import { + getIncomingServiceMessageEvent, + INCOMING_SERVICE_MESSAGE_TYPE, +} from './incomingServiceMessage.event.js'; + +describe('getIncomingServiceMessageEvent', () => { + const mockLogging = makeMockLogging(); + const creationDate = new Date(); + const expiry = addDays(creationDate, 5); + const cloudEvent = new CloudEvent({ + specversion: '1.0', + id: CE_ID, + source: AWALA_PEER_ID, + type: INCOMING_SERVICE_MESSAGE_TYPE, + subject: CE_SOURCE, + datacontenttype: CE_SERVICE_MESSAGE_CONTENT_TYPE, + expiry: formatISO(expiry), + time: formatISO(creationDate), + data: CE_SERVICE_MESSAGE_CONTENT, + }); + + test('Parcel id should be the same as event id', () => { + const { parcelId } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(parcelId).toBe(cloudEvent.id); + }); + + test('Sender id should be the same as source', () => { + const { senderId } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(senderId).toBe(cloudEvent.source); + }); + + test('Recipient id should be the same as subject', () => { + const { recipientId } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(recipientId).toBe(cloudEvent.subject); + }); + + test('Content type should be the same as datacontenttype', () => { + const { contentType } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(contentType).toBe(cloudEvent.datacontenttype); + }); + + test('Content should be event data', () => { + const { content } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(content).toStrictEqual(CE_SERVICE_MESSAGE_CONTENT); + }); + + test('Content should be buffer even if Content Type is JSON', () => { + const data = { foo: 'bar' }; + const jsonEvent = cloudEvent.cloneWith({ data, datacontenttype: 'application/json' }); + + const { content } = getIncomingServiceMessageEvent(jsonEvent, mockLogging.logger)!; + + expect(content).toStrictEqual(Buffer.from(JSON.stringify(data))); + }); + + test('Missing data should be accepted', () => { + // eslint-disable-next-line @typescript-eslint/naming-convention,camelcase + const event = cloudEvent.cloneWith({ data: undefined, data_base64: undefined }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + expect(result?.content).toStrictEqual(Buffer.from('', 'base64')); + }); + + test('Creation date should be taken from event time', () => { + const { creationDate: creation } = getIncomingServiceMessageEvent( + cloudEvent, + mockLogging.logger, + )!; + + expect(creation).toStrictEqual(parseISO(cloudEvent.time!)); + }); + + test('Expiry date should be taken from event expiry', () => { + const { expiryDate } = getIncomingServiceMessageEvent(cloudEvent, mockLogging.logger)!; + + expect(expiryDate).toStrictEqual(parseISO(cloudEvent.expiry as string)); + }); + + describe('Failure', () => { + test('Invalid type should be refused', () => { + const event = new CloudEvent({ + ...cloudEvent, + type: 'INVALID', + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('error', 'Refused invalid type', { parcelId: event.id, type: event.type }), + ); + }); + + test('Missing subject should be refused', () => { + const event = new CloudEvent({ + ...cloudEvent, + subject: undefined, + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused missing subject', { parcelId: event.id }), + ); + }); + + test('Missing datacontenttype should be refused', () => { + const event = new CloudEvent({ + ...cloudEvent, + datacontenttype: undefined, + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused missing data content type', { parcelId: event.id }), + ); + }); + + test('Missing expiry should be refused', () => { + const { expiry: ignore, ...eventData } = cloudEvent; + const event = new CloudEvent(eventData); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused missing expiry', { parcelId: event.id }), + ); + }); + + test('Non string expiry should be refused', () => { + const event = new CloudEvent({ + ...cloudEvent, + expiry: {}, + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused malformed expiry', { parcelId: event.id }), + ); + }); + + test('Malformed expiry should be refused', () => { + const event = new CloudEvent({ + ...cloudEvent, + expiry: 'INVALID DATE', + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused malformed expiry', { parcelId: event.id }), + ); + }); + + test('Expiry less than time should be refused', () => { + const time = new Date(); + const past = subDays(time, 10); + const event = new CloudEvent({ + ...cloudEvent, + expiry: past.toISOString(), + }); + + const result = getIncomingServiceMessageEvent(event, mockLogging.logger); + + assertNull(result); + expect(mockLogging.logs).toContainEqual( + partialPinoLog('info', 'Refused expiry less than time', { parcelId: event.id }), + ); + }); + }); +}); diff --git a/src/events/incomingServiceMessage.event.ts b/src/events/incomingServiceMessage.event.ts new file mode 100644 index 00000000..bcd74d53 --- /dev/null +++ b/src/events/incomingServiceMessage.event.ts @@ -0,0 +1,97 @@ +import type { CloudEventV1 } from 'cloudevents'; +import { differenceInSeconds, isValid, parseISO } from 'date-fns'; +import type { BaseLogger } from 'pino'; +import type { FastifyBaseLogger } from 'fastify'; + +function getExpiryDate(expiry: unknown, creationDate: Date, logger: BaseLogger) { + if (expiry === undefined) { + logger.info('Refused missing expiry'); + return null; + } + + if (typeof expiry !== 'string') { + logger.info('Refused malformed expiry'); + return null; + } + + const expiryDate = parseISO(expiry); + + if (!isValid(expiryDate)) { + logger.info('Refused malformed expiry'); + return null; + } + + const difference = differenceInSeconds(expiryDate, creationDate); + + if (difference < 0) { + logger.info('Refused expiry less than time'); + return null; + } + return expiryDate; +} + +function encodeContent(event: CloudEventV1) { + let content: Buffer; + if (event.data === undefined) { + content = Buffer.from([]); + } else if (Buffer.isBuffer(event.data)) { + content = event.data; + } else { + content = Buffer.from(JSON.stringify(event.data)); + } + return content; +} + +export const INCOMING_SERVICE_MESSAGE_TYPE = + 'com.relaycorp.awala.endpoint-internet.incoming-service-message'; + +export interface IncomingServiceMessageOptions { + readonly creationDate: Date; + readonly expiryDate: Date; + readonly parcelId: string; + readonly recipientId: string; + readonly senderId: string; + readonly contentType: string; + readonly content: Buffer; +} + +export function getIncomingServiceMessageEvent( + event: CloudEventV1, + logger: FastifyBaseLogger, +): IncomingServiceMessageOptions | null { + const parcelAwareLogger = logger.child({ + parcelId: event.id, + }); + + if (event.type !== INCOMING_SERVICE_MESSAGE_TYPE) { + parcelAwareLogger.error({ type: event.type }, 'Refused invalid type'); + return null; + } + + if (event.subject === undefined) { + parcelAwareLogger.info('Refused missing subject'); + return null; + } + + if (event.datacontenttype === undefined) { + parcelAwareLogger.info('Refused missing data content type'); + return null; + } + + const creationDate = new Date(event.time!); + const expiryDate = getExpiryDate(event.expiry, creationDate, parcelAwareLogger); + + if (expiryDate === null) { + return null; + } + + return { + parcelId: event.id, + senderId: event.source, + recipientId: event.subject, + contentType: event.datacontenttype, + content: encodeContent(event), + expiryDate, + creationDate, + }; +} diff --git a/src/events/outgoingServiceMessage.event.spec.ts b/src/events/outgoingServiceMessage.event.spec.ts index c77a6835..711f8f79 100644 --- a/src/events/outgoingServiceMessage.event.spec.ts +++ b/src/events/outgoingServiceMessage.event.spec.ts @@ -3,16 +3,16 @@ import { randomUUID } from 'node:crypto'; import { addMinutes } from 'date-fns'; import { AWALA_PEER_ID } from '../testUtils/stubs.js'; +import { + CE_SERVICE_MESSAGE_CONTENT, + CE_SERVICE_MESSAGE_CONTENT_TYPE, +} from '../testUtils/eventing/stubs.js'; import { type OutgoingServiceMessageOptions, makeOutgoingServiceMessageEvent, } from './outgoingServiceMessage.event.js'; -const CE_SERVICE_MESSAGE_CONTENT_TYPE = 'application/test'; - -const CE_SERVICE_MESSAGE_CONTENT = Buffer.from('Test'); - describe('makeIncomingServiceMessageEvent', () => { const options: OutgoingServiceMessageOptions = { creationDate: new Date(), @@ -59,10 +59,10 @@ describe('makeIncomingServiceMessageEvent', () => { expect(contentType).toBe(options.contentType); }); - test('Event data should be the service message content, base64-encoded', () => { - const { data_base64: dataBase64 } = makeOutgoingServiceMessageEvent(options); + test('Event data should be the service message content', () => { + const { data } = makeOutgoingServiceMessageEvent(options); - expect(dataBase64).toBe(options.content.toString('base64')); + expect(data).toBe(options.content); }); test('Event time should be parcel creation time', () => { diff --git a/src/events/outgoingServiceMessage.event.ts b/src/events/outgoingServiceMessage.event.ts index 8f5640cf..5a8e81ae 100644 --- a/src/events/outgoingServiceMessage.event.ts +++ b/src/events/outgoingServiceMessage.event.ts @@ -16,7 +16,7 @@ export interface OutgoingServiceMessageOptions { export function makeOutgoingServiceMessageEvent( options: OutgoingServiceMessageOptions, -): CloudEvent { +): CloudEvent { return new CloudEvent({ specversion: '1.0', type: OUTGOING_SERVICE_MESSAGE_TYPE, @@ -24,8 +24,7 @@ export function makeOutgoingServiceMessageEvent( source: OUTGOING_MESSAGE_SOURCE, subject: options.peerId, datacontenttype: options.contentType, - // eslint-disable-next-line @typescript-eslint/naming-convention,camelcase - data_base64: options.content.toString('base64'), + data: options.content, time: options.creationDate.toISOString(), expiry: options.expiryDate.toISOString(), }); diff --git a/src/functionalTests/e2e.test.ts b/src/functionalTests/awala.test.ts similarity index 71% rename from src/functionalTests/e2e.test.ts rename to src/functionalTests/awala.test.ts index a9f3ebe7..cff7ab7d 100644 --- a/src/functionalTests/e2e.test.ts +++ b/src/functionalTests/awala.test.ts @@ -10,6 +10,8 @@ import { } from '@relaycorp/veraid-authority'; import { getModelForClass } from '@typegoose/typegoose'; import { createConnection } from 'mongoose'; +import { CloudEvent } from 'cloudevents'; +import { addMinutes, formatISO } from 'date-fns'; import type { MemberKeyImportRequest } from '../schemas/awala.schema.js'; import { OrgModelSchema } from '../models/Org.model.js'; @@ -17,17 +19,19 @@ import { generateKeyPair } from '../testUtils/webcrypto.js'; import { derSerialisePublicKey } from '../utilities/webcrypto.js'; import { AWALA_PEER_ID, TEST_SERVICE_OID } from '../testUtils/stubs.js'; import { HTTP_STATUS_CODES } from '../utilities/http.js'; -import { VeraidContentType } from '../utilities/veraid.js'; +import { CE_ID } from '../testUtils/eventing/stubs.js'; +import { INCOMING_SERVICE_MESSAGE_TYPE } from '../events/incomingServiceMessage.event.js'; import { connectToClusterService } from './utils/kubernetes.js'; import { makeClient, SUPER_ADMIN_EMAIL } from './utils/api.js'; import { ORG_PRIVATE_KEY_ARN, ORG_PUBLIC_KEY_DER, TEST_ORG_NAME } from './utils/veraid.js'; -import { KEY_IMPORT_CONTENT_TYPE, postAwalaMessage } from './utils/awala.js'; -import { getMockRequestsByContentType, mockAwalaMiddleware } from './utils/mockAwalaMiddleware.js'; -import { sleep } from './utils/time.js'; +import { getServiceUrl } from './utils/knative.js'; +import { postEvent } from './utils/events.js'; const CLIENT = await makeClient(SUPER_ADMIN_EMAIL); +const AWALA_SERVER_URL = await getServiceUrl('veraid-authority-awala'); + const MONGODB_PORT = 27_017; const MONGODB_LOCAL_BASE_URI = 'mongodb://root:password123@localhost'; @@ -92,46 +96,37 @@ async function createKeyImportToken(endpoint: string) { return publicKeyImportToken; } -async function claimKeyImportTokenViaAwala( - publicKeyImportToken: string, - memberPublicKey: CryptoKey, -) { +async function makeKeyImportEvent(memberPublicKey: CryptoKey, publicKeyImportToken: string) { const publicKeyDer = await derSerialisePublicKey(memberPublicKey); - const importMessage: MemberKeyImportRequest = { + const importRequest: MemberKeyImportRequest = { publicKey: publicKeyDer.toString('base64'), - peerId: AWALA_PEER_ID, publicKeyImportToken, }; - const requestBody = JSON.stringify(importMessage); - const response = await postAwalaMessage(KEY_IMPORT_CONTENT_TYPE, requestBody); - expect(response.status).toBe(HTTP_STATUS_CODES.ACCEPTED); + const now = new Date(); + return new CloudEvent({ + id: CE_ID, + source: AWALA_PEER_ID, + type: INCOMING_SERVICE_MESSAGE_TYPE, + subject: 'https://relaycorp.tech/awala-endpoint-internet', + time: formatISO(now), + expiry: formatISO(addMinutes(now, 1)), + datacontenttype: 'application/vnd.veraid.member-public-key-import', + data: importRequest, + }); } -describe('E2E', () => { - test('Get member bundle via Awala', async () => { +describe('Awala', () => { + test('Claim key import token', async () => { // Create the necessary setup as an admin: const { members: membersEndpoint } = await createTestOrg(); const keyImportTokenEndpoint = await createTestMember(membersEndpoint); const publicKeyImportToken = await createKeyImportToken(keyImportTokenEndpoint); // Claim the token as a member via Awala: - await mockAwalaMiddleware(); const { publicKey: memberPublicKey } = await generateKeyPair(); - await claimKeyImportTokenViaAwala(publicKeyImportToken, memberPublicKey); - - // Allow sufficient time for the member bundle request to be processed: - await sleep(2000); - - // Check that the member bundle was issued and published: - const awalaMiddlewareRequests = await getMockRequestsByContentType( - VeraidContentType.MEMBER_BUNDLE, - ); - expect(awalaMiddlewareRequests).toHaveLength(1); - const [{ body: bundleRequestBody }] = awalaMiddlewareRequests; - const { base64Bytes } = bundleRequestBody as { - base64Bytes: string; - }; - expect(base64Bytes).not.toBeNull(); - expect(base64Bytes.length).toBeGreaterThan(0); - }, 20_000); + const event = await makeKeyImportEvent(memberPublicKey, publicKeyImportToken); + const response = await postEvent(event, AWALA_SERVER_URL); + + expect(response.status).toBe(HTTP_STATUS_CODES.ACCEPTED); + }, 10_000); }); diff --git a/src/functionalTests/backgroundQueue.test.ts b/src/functionalTests/backgroundQueue.test.ts index 69cead52..2b220205 100644 --- a/src/functionalTests/backgroundQueue.test.ts +++ b/src/functionalTests/backgroundQueue.test.ts @@ -1,23 +1,14 @@ -import { CloudEvent, HTTP } from 'cloudevents'; +import { CloudEvent } from 'cloudevents'; import { HTTP_STATUS_CODES } from '../utilities/http.js'; import { BUNDLE_REQUEST_TRIGGER_TYPE } from '../events/bundleRequestTrigger.event.js'; import { CE_ID, CE_SOURCE } from '../testUtils/eventing/stubs.js'; import { getServiceUrl } from './utils/knative.js'; +import { postEvent } from './utils/events.js'; const QUEUE_URL = await getServiceUrl('veraid-authority-queue'); -async function postEvent(event: CloudEvent): Promise { - const message = HTTP.structured(event); - - return fetch(QUEUE_URL, { - method: 'POST', - headers: message.headers as HeadersInit, - body: message.body as string, - }); -} - describe('Background queue', () => { test('Supported event should be accepted', async () => { const event = new CloudEvent({ @@ -26,7 +17,7 @@ describe('Background queue', () => { source: CE_SOURCE, }); - const response = await postEvent(event); + const response = await postEvent(event, QUEUE_URL); expect(response.status).toBe(HTTP_STATUS_CODES.NO_CONTENT); }); @@ -38,7 +29,7 @@ describe('Background queue', () => { source: CE_SOURCE, }); - const response = await postEvent(event); + const response = await postEvent(event, QUEUE_URL); expect(response.status).toBe(HTTP_STATUS_CODES.BAD_REQUEST); }); diff --git a/src/functionalTests/utils/awala.ts b/src/functionalTests/utils/awala.ts deleted file mode 100644 index 5b70acc3..00000000 --- a/src/functionalTests/utils/awala.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { API_URL } from './api.js'; - -export const KEY_IMPORT_CONTENT_TYPE = 'application/vnd.veraid.member-public-key-import'; - -export async function postAwalaMessage(contentType: string, body: BodyInit): Promise { - return fetch(`${API_URL}/awala`, { - method: 'POST', - headers: new Headers([['Content-Type', contentType]]), - body, - }); -} diff --git a/src/functionalTests/utils/events.ts b/src/functionalTests/utils/events.ts new file mode 100644 index 00000000..8fc91072 --- /dev/null +++ b/src/functionalTests/utils/events.ts @@ -0,0 +1,10 @@ +import { type CloudEvent, HTTP } from 'cloudevents'; + +export async function postEvent(event: CloudEvent, url: string): Promise { + const message = HTTP.binary(event); + return fetch(url, { + method: 'POST', + headers: message.headers as HeadersInit, + body: message.body as string, + }); +} diff --git a/src/functionalTests/utils/knative.ts b/src/functionalTests/utils/knative.ts index 3e4f9be5..25008fd2 100644 --- a/src/functionalTests/utils/knative.ts +++ b/src/functionalTests/utils/knative.ts @@ -12,10 +12,6 @@ async function getServiceOutput(serviceName: string, output: string) { return stdout.trim(); } -export async function getServiceActiveRevision(serviceName: string): Promise { - return getServiceOutput(serviceName, 'jsonpath={.status.latestReadyRevisionName}'); -} - export async function getServiceUrl(serviceName: string): Promise { const output = 'url'; return getServiceOutput(serviceName, output); diff --git a/src/functionalTests/utils/mockAwalaMiddleware.ts b/src/functionalTests/utils/mockAwalaMiddleware.ts deleted file mode 100644 index 872cf7ca..00000000 --- a/src/functionalTests/utils/mockAwalaMiddleware.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { mockServerClient, type Expectation, type HttpResponse } from 'mockserver-client'; -import type { MockServerClient } from 'mockserver-client/mockServerClient.js'; - -import { HTTP_STATUS_CODES } from '../../utilities/http.js'; -import { VeraidContentType } from '../../utilities/veraid.js'; - -import { connectToClusterService } from './kubernetes.js'; -import { sleep } from './time.js'; -import { getServiceActiveRevision } from './knative.js'; - -const SERVICE_PORT = 80; - -const PORT_FORWARDING_DELAY_MS = 400; - -const EXPECTATIONS: Expectation[] = [ - { - httpRequest: { - method: 'POST', - path: '/', - // eslint-disable-next-line @typescript-eslint/naming-convention - headers: { 'Content-Type': VeraidContentType.MEMBER_BUNDLE }, - }, - - httpResponse: { - statusCode: HTTP_STATUS_CODES.ACCEPTED, - }, - }, -]; - -type Command = (client: MockServerClient) => Promise; - -async function connectToMockServer(command: Command): Promise { - const revision = await getServiceActiveRevision('mock-awala-middleware'); - const serviceName = `${revision}-private`; - await connectToClusterService(serviceName, SERVICE_PORT, async (localPort) => { - await sleep(PORT_FORWARDING_DELAY_MS); - - const client = mockServerClient('127.0.0.1', localPort); - await command(client); - }); -} - -async function getMockAwalaMiddlewareRequests(): Promise { - let requests: HttpResponse[] | undefined; - await connectToMockServer(async (client) => { - requests = await client.retrieveRecordedRequests({ path: '/' }); - }); - - if (requests === undefined) { - throw new Error('Failed to retrieve Awala Middleware requests'); - } - return requests; -} - -export async function mockAwalaMiddleware(): Promise { - await connectToMockServer(async (client) => { - await client.reset(); - await client.mockAnyResponse(EXPECTATIONS); - }); -} - -export async function getMockRequestsByContentType(contentType: string): Promise { - const requests = await getMockAwalaMiddlewareRequests(); - return requests.filter((req) => { - if (!req.headers) { - return []; - } - const headers = req.headers as { [key: string]: string[] }; - const contentTypes = headers['Content-Type']; - return contentTypes.find((reqContentType: string) => reqContentType === contentType); - }); -} diff --git a/src/functionalTests/utils/time.ts b/src/functionalTests/utils/time.ts deleted file mode 100644 index 43609548..00000000 --- a/src/functionalTests/utils/time.ts +++ /dev/null @@ -1,12 +0,0 @@ -import envVar from 'env-var'; - -const isCi = envVar.get('CI').default('false').asBool(); -const CI_WAIT_FACTOR = 2; - -export async function sleep(milliseconds: number): Promise { - const waitMilliseconds = isCi ? milliseconds * CI_WAIT_FACTOR : milliseconds; - // eslint-disable-next-line promise/avoid-new - return new Promise((resolve) => { - setTimeout(resolve, waitMilliseconds); - }); -} diff --git a/src/memberKeyImportToken.spec.ts b/src/memberKeyImportToken.spec.ts index 46a95458..8a8c6439 100644 --- a/src/memberKeyImportToken.spec.ts +++ b/src/memberKeyImportToken.spec.ts @@ -1,7 +1,7 @@ import { getModelForClass, type ReturnModelType } from '@typegoose/typegoose'; import type { Connection } from 'mongoose'; import { jest } from '@jest/globals'; -import type { CloudEventV1 } from 'cloudevents'; +import type { CloudEvent } from 'cloudevents'; import { setUpTestDbConnection } from './testUtils/db.js'; import { makeMockLogging, partialPinoLog } from './testUtils/logging.js'; @@ -22,10 +22,7 @@ import type { MemberPublicKeyCreationResult } from './memberPublicKeyTypes.js'; import { MemberPublicKeyImportProblemType } from './MemberKeyImportTokenProblemType.js'; import { MemberPublicKeyProblemType } from './MemberPublicKeyProblemType.js'; import { mockEmitter } from './testUtils/eventing/mockEmitter.js'; -import { - type MemberBundleRequestPayload, - BUNDLE_REQUEST_TYPE, -} from './events/bundleRequest.event.js'; +import { BUNDLE_REQUEST_TYPE } from './events/bundleRequest.event.js'; const { publicKey } = await generateKeyPair(); const publicKeyBuffer = await derSerialisePublicKey(publicKey); @@ -120,11 +117,8 @@ describe('member key import token', () => { }); const result = await processMemberKeyImportToken( - { - publicKey: publicKeyBase64, - publicKeyImportToken: keyImportToken._id.toString(), - peerId: AWALA_PEER_ID, - }, + AWALA_PEER_ID, + { publicKey: publicKeyBase64, publicKeyImportToken: keyImportToken._id.toString() }, serviceOptions, ); @@ -160,25 +154,20 @@ describe('member key import token', () => { }); const result = await processMemberKeyImportToken( - { - publicKey: publicKeyBase64, - publicKeyImportToken: keyImportToken._id.toString(), - peerId: AWALA_PEER_ID, - }, + AWALA_PEER_ID, + { publicKey: publicKeyBase64, publicKeyImportToken: keyImportToken._id.toString() }, serviceOptions, ); requireSuccessfulResult(result); expect(getEvents()).toContainEqual( - expect.objectContaining>>({ - id: MEMBER_MONGO_ID, + expect.objectContaining>>({ + id: MEMBER_PUBLIC_KEY_MONGO_ID, source: 'https://veraid.net/authority/awala-member-key-import', type: BUNDLE_REQUEST_TYPE, - - data: { - publicKeyId: MEMBER_PUBLIC_KEY_MONGO_ID, - peerId: AWALA_PEER_ID, - }, + subject: AWALA_PEER_ID, + datacontenttype: 'application/vnd.veraid.member-public-key-import', + data: '', }), ); }); @@ -187,11 +176,8 @@ describe('member key import token', () => { const invalidToken = '111111111111111111111111'; const result = await processMemberKeyImportToken( - { - publicKey: publicKeyBase64, - publicKeyImportToken: invalidToken, - peerId: AWALA_PEER_ID, - }, + AWALA_PEER_ID, + { publicKey: publicKeyBase64, publicKeyImportToken: invalidToken }, serviceOptions, ); @@ -215,11 +201,8 @@ describe('member key import token', () => { }); const result = await processMemberKeyImportToken( - { - publicKey: publicKeyBase64, - publicKeyImportToken: keyImportToken._id.toString(), - peerId: AWALA_PEER_ID, - }, + AWALA_PEER_ID, + { publicKey: publicKeyBase64, publicKeyImportToken: keyImportToken._id.toString() }, serviceOptions, ); diff --git a/src/memberKeyImportToken.ts b/src/memberKeyImportToken.ts index 30893284..fdd0ede8 100644 --- a/src/memberKeyImportToken.ts +++ b/src/memberKeyImportToken.ts @@ -9,10 +9,7 @@ import { MemberPublicKeyImportProblemType } from './MemberKeyImportTokenProblemT import { createMemberPublicKey } from './memberPublicKey.js'; import type { MemberKeyImportRequest } from './schemas/awala.schema.js'; import { Emitter } from './utilities/eventing/Emitter.js'; -import { - BUNDLE_REQUEST_TYPE, - type MemberBundleRequestPayload, -} from './events/bundleRequest.event.js'; +import { BUNDLE_REQUEST_TYPE } from './events/bundleRequest.event.js'; export async function createMemberKeyImportToken( memberId: string, @@ -42,19 +39,19 @@ export async function createMemberKeyImportToken( } export async function processMemberKeyImportToken( - keyImportData: MemberKeyImportRequest, + peerId: string, + keyImportRequest: MemberKeyImportRequest, options: ServiceOptions, ): Promise> { const memberKeyImportTokenModel = getModelForClass(MemberKeyImportTokenModelSchema, { existingConnection: options.dbConnection, }); - const memberKeyImportToken = await memberKeyImportTokenModel.findById( - keyImportData.publicKeyImportToken, + keyImportRequest.publicKeyImportToken, ); if (!memberKeyImportToken) { options.logger.info( - { memberKeyImportToken: keyImportData.publicKeyImportToken }, + { memberKeyImportToken: keyImportRequest.publicKeyImportToken }, 'Member public key import token not found', ); return { @@ -66,7 +63,7 @@ export async function processMemberKeyImportToken( const publicKeyCreationResult = await createMemberPublicKey( memberKeyImportToken.memberId, { - publicKey: keyImportData.publicKey, + publicKey: keyImportRequest.publicKey, serviceOid: memberKeyImportToken.serviceOid, }, options, @@ -79,22 +76,20 @@ export async function processMemberKeyImportToken( }; } - const emitter = Emitter.init() as Emitter; - const event = new CloudEvent({ - id: memberKeyImportToken.memberId, + const emitter = Emitter.init() as Emitter; + const event = new CloudEvent({ + id: publicKeyCreationResult.result.id, source: 'https://veraid.net/authority/awala-member-key-import', type: BUNDLE_REQUEST_TYPE, - - data: { - publicKeyId: publicKeyCreationResult.result.id, - peerId: keyImportData.peerId, - }, + subject: peerId, + datacontenttype: 'application/vnd.veraid.member-public-key-import', + data: '', }); await emitter.emit(event); - await memberKeyImportTokenModel.findByIdAndDelete(keyImportData.publicKeyImportToken); + await memberKeyImportTokenModel.findByIdAndDelete(keyImportRequest.publicKeyImportToken); options.logger.info( - { memberKeyImportToken: keyImportData.publicKeyImportToken }, + { memberKeyImportToken: keyImportRequest.publicKeyImportToken }, 'Member public key import token deleted', ); return { diff --git a/src/org.spec.ts b/src/org.spec.ts index 85abebc7..2ba9c09b 100644 --- a/src/org.spec.ts +++ b/src/org.spec.ts @@ -82,7 +82,8 @@ describe('org', () => { }); test('Clash with existing name should be refused', async () => { - const orgData: OrgSchema = { name: ORG_NAME }; + const name = `duplicated-${ORG_NAME}`; + const orgData: OrgSchema = { name }; await createOrg(orgData, serviceOptions); const methodResult = await createOrg(orgData, serviceOptions); @@ -90,9 +91,7 @@ describe('org', () => { requireFailureResult(methodResult); expect(methodResult.context).toBe(OrgProblemType.EXISTING_ORG_NAME); expect(mockLogging.logs).toContainEqual( - partialPinoLog('info', 'Refused duplicated org name', { - orgName: ORG_NAME, - }), + partialPinoLog('info', 'Refused duplicated org name', { orgName: name }), ); }); diff --git a/src/schemas/awala.schema.ts b/src/schemas/awala.schema.ts index 466df874..0cf9a443 100644 --- a/src/schemas/awala.schema.ts +++ b/src/schemas/awala.schema.ts @@ -21,10 +21,9 @@ export const MEMBER_KEY_IMPORT_REQUEST_SCHEMA = { properties: { publicKeyImportToken: { type: 'string' }, publicKey: { type: 'string' }, - peerId: { type: 'string', minLength: 1 }, }, - required: ['publicKeyImportToken', 'publicKey', 'peerId'], + required: ['publicKeyImportToken', 'publicKey'], } as const; export type MemberBundleRequest = FromSchema; diff --git a/src/serviceTypes.ts b/src/serviceTypes.ts index 5065b6c8..f804b038 100644 --- a/src/serviceTypes.ts +++ b/src/serviceTypes.ts @@ -1,9 +1,9 @@ +import type { FastifyBaseLogger } from 'fastify'; import type { Connection } from 'mongoose'; -import type { BaseLogger } from 'pino'; export interface ServiceOptions { readonly dbConnection: Connection; - readonly logger: BaseLogger; + readonly logger: FastifyBaseLogger; } export const MONGODB_DUPLICATE_INDEX_CODE = 11_000; diff --git a/src/testUtils/assertions.ts b/src/testUtils/assertions.ts new file mode 100644 index 00000000..97e25f93 --- /dev/null +++ b/src/testUtils/assertions.ts @@ -0,0 +1,3 @@ +export function assertNull(result: ResultType | null): asserts result is null { + expect(result).toBeNull(); +} diff --git a/src/testUtils/awalaServer.ts b/src/testUtils/awalaServer.ts new file mode 100644 index 00000000..ec892b81 --- /dev/null +++ b/src/testUtils/awalaServer.ts @@ -0,0 +1,10 @@ +import { makeAwalaServer } from '../awala/server.js'; + +import { makeTestServer, type TestServerFixture } from './server.js'; +import { REQUIRED_ENV_VARS } from './envVars.js'; + +const REQUIRED_AWALA_ENV_VARS = REQUIRED_ENV_VARS; + +export function setUpTestAwalaServer(): () => TestServerFixture { + return makeTestServer(makeAwalaServer, REQUIRED_AWALA_ENV_VARS); +} diff --git a/src/testUtils/eventing/cloudEvents.ts b/src/testUtils/eventing/cloudEvents.ts index 3df7c45e..3ab7b841 100644 --- a/src/testUtils/eventing/cloudEvents.ts +++ b/src/testUtils/eventing/cloudEvents.ts @@ -5,7 +5,7 @@ export async function postEvent( event: CloudEvent, fastify: FastifyInstance, ): Promise { - const message = HTTP.structured(event); + const message = HTTP.binary(event); return fastify.inject({ method: 'POST', diff --git a/src/testUtils/eventing/stubs.ts b/src/testUtils/eventing/stubs.ts index e9c90fc1..4381a4c7 100644 --- a/src/testUtils/eventing/stubs.ts +++ b/src/testUtils/eventing/stubs.ts @@ -3,3 +3,7 @@ export const K_SINK = 'https://example.com/sink'; export const CE_ID = 'ce-id'; export const CE_SOURCE = 'https://example.com/ce-source'; + +export const CE_SERVICE_MESSAGE_CONTENT_TYPE = 'application/test'; + +export const CE_SERVICE_MESSAGE_CONTENT = Buffer.from('Test'); diff --git a/src/utilities/buffer.spec.ts b/src/utilities/buffer.spec.ts index 8500798e..b2ddd6c9 100644 --- a/src/utilities/buffer.spec.ts +++ b/src/utilities/buffer.spec.ts @@ -1,4 +1,4 @@ -import { bufferToArrayBuffer } from './buffer.js'; +import { bufferToArrayBuffer, bufferToJson } from './buffer.js'; describe('bufferToArrayBuffer', () => { test('Buffer should be converted to ArrayBuffer', () => { @@ -11,3 +11,25 @@ describe('bufferToArrayBuffer', () => { expect(arrayBufferView).toStrictEqual(new Uint8Array(array)); }); }); + +describe('bufferToJson', () => { + test('Buffer should be converted to object', () => { + const json = { + test: 1, + }; + const jsonString = JSON.stringify(json); + const buffer = Buffer.from(jsonString); + + const result = bufferToJson(buffer); + + expect(result).toStrictEqual(json); + }); + + test('Invalid JSON should return null', () => { + const buffer = Buffer.from('INVALID_JSON'); + + const result = bufferToJson(buffer); + + expect(result).toBeNull(); + }); +}); diff --git a/src/utilities/buffer.ts b/src/utilities/buffer.ts index 482db493..fc73bc24 100644 --- a/src/utilities/buffer.ts +++ b/src/utilities/buffer.ts @@ -1,3 +1,12 @@ export function bufferToArrayBuffer(buffer: Uint8Array): ArrayBuffer { return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); } + +export function bufferToJson(buffer: Buffer): object | null { + const jsonString = buffer.toString(); + try { + return JSON.parse(jsonString) as object; + } catch { + return null; + } +} diff --git a/src/utilities/eventing/Emitter.spec.ts b/src/utilities/eventing/Emitter.spec.ts index 342b0084..9cd3fd7f 100644 --- a/src/utilities/eventing/Emitter.spec.ts +++ b/src/utilities/eventing/Emitter.spec.ts @@ -6,11 +6,16 @@ import { configureMockEnvVars } from '../../testUtils/envVars.js'; import { mockSpy } from '../../testUtils/jest.js'; import { CE_ID, CE_SOURCE, K_SINK } from '../../testUtils/eventing/stubs.js'; +enum CloudEventMode { + BINARY = 'binary', +} const mockEmitterFunction = mockSpy(jest.fn()); const mockTransport = Symbol('mockTransport'); jest.unstable_mockModule('cloudevents', () => ({ emitterFor: jest.fn().mockReturnValue(mockEmitterFunction), httpTransport: jest.fn().mockReturnValue(mockTransport), + // eslint-disable-next-line @typescript-eslint/naming-convention + Mode: CloudEventMode, })); // eslint-disable-next-line @typescript-eslint/naming-convention const { Emitter } = await import('./Emitter.js'); @@ -69,7 +74,15 @@ describe('Emitter', () => { await emitter.emit(event); - expect(emitterFor).toHaveBeenCalledWith(mockTransport); + expect(emitterFor).toHaveBeenCalledWith(mockTransport, expect.anything()); + }); + + test('Emitter should use binary mode', async () => { + const emitter = Emitter.init(); + + await emitter.emit(event); + + expect(emitterFor).toHaveBeenCalledWith(expect.anything(), { mode: 'binary' }); }); test('Emitter function should be cached', async () => { diff --git a/src/utilities/eventing/Emitter.ts b/src/utilities/eventing/Emitter.ts index 40daf91c..93e81d14 100644 --- a/src/utilities/eventing/Emitter.ts +++ b/src/utilities/eventing/Emitter.ts @@ -1,10 +1,16 @@ -import { type CloudEvent, emitterFor, type EmitterFunction, httpTransport } from 'cloudevents'; +import { + type CloudEvent, + emitterFor, + type EmitterFunction, + httpTransport, + Mode, +} from 'cloudevents'; import envVar from 'env-var'; function makeEmitterFunction() { const sinkUrl = envVar.get('K_SINK').required().asUrlString(); const transport = httpTransport(sinkUrl); - return emitterFor(transport); + return emitterFor(transport, { mode: Mode.BINARY }); } /**