diff --git a/docker-compose.yml b/docker-compose.yml index 9bcbf449..124bdd5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,7 @@ services: build: context: . dockerfile: Dockerfile - restart: no + restart: always depends_on: - postgres-veritable-ui-bob - veritable-cloudagent-bob @@ -90,6 +90,7 @@ services: - DB_NAME=veritable-ui - PUBLIC_URL=http://localhost:3001 - CLOUDAGENT_ADMIN_ORIGIN=http://veritable-cloudagent-bob:3000 + - CLOUDAGENT_ADMIN_WS_ORIGIN=ws://veritable-cloudagent-bob:3000 - COOKIE_SESSION_KEYS=secret - DB_PASSWORD=postgres - DB_USERNAME=postgres diff --git a/package-lock.json b/package-lock.json index 80ac1228..29a658e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.4.7", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.4.7", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.10", @@ -30,6 +30,7 @@ "swagger-ui-express": "^5.0.1", "tsoa": "^6.3.1", "tsyringe": "^4.8.0", + "ws": "^8.17.1", "zod": "^3.23.8" }, "devDependencies": { @@ -45,6 +46,7 @@ "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", + "@types/ws": "^8.5.10", "chai": "^5.1.1", "chai-jest-snapshot": "^2.0.0", "depcheck": "^1.4.7", @@ -1419,6 +1421,16 @@ "@types/serve-static": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vue/compiler-core": { "version": "3.4.21", "dev": true, @@ -1690,11 +1702,13 @@ } }, "node_modules/braces": { - "version": "3.0.2", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2517,7 +2531,9 @@ "dev": true }, "node_modules/fill-range": { - "version": "7.0.1", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", "dependencies": { @@ -3099,6 +3115,8 @@ }, "node_modules/is-number": { "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", "engines": { @@ -5365,6 +5383,8 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5645,6 +5665,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index fe400076..d2563a66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.4.7", + "version": "0.5.0", "description": "UI for Veritable", "main": "src/index.ts", "type": "module", @@ -50,6 +50,7 @@ "swagger-ui-express": "^5.0.1", "tsoa": "^6.3.1", "tsyringe": "^4.8.0", + "ws": "^8.17.1", "zod": "^3.23.8" }, "devDependencies": { @@ -65,12 +66,13 @@ "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", "@types/swagger-ui-express": "^4.1.6", + "@types/ws": "^8.5.10", "chai": "^5.1.1", "chai-jest-snapshot": "^2.0.0", "depcheck": "^1.4.7", "mocha": "^10.4.0", - "prettier": "^3.3.2", "pino-colada": "^2.2.2", + "prettier": "^3.3.2", "prettier-plugin-organize-imports": "^3.2.4", "sinon": "^18.0.0", "supertest": "^7.0.0", diff --git a/src/controllers/connection/__tests__/helpers.ts b/src/controllers/connection/__tests__/helpers.ts index 9d09e3a0..aaad989d 100644 --- a/src/controllers/connection/__tests__/helpers.ts +++ b/src/controllers/connection/__tests__/helpers.ts @@ -69,9 +69,7 @@ export const withNewConnectionMocks = () => { const mockCloudagent = { createOutOfBandInvite: ({ companyName }: { companyName: string }) => { return { - invitation: { - id: `id-${companyName}`, - }, + outOfBandRecord: { id: `id-${companyName}` }, invitationUrl: `url-${companyName}`, } }, diff --git a/src/controllers/connection/newConnection.ts b/src/controllers/connection/newConnection.ts index 6e18da7e..154df9b7 100644 --- a/src/controllers/connection/newConnection.ts +++ b/src/controllers/connection/newConnection.ts @@ -173,7 +173,7 @@ export class NewConnectionController extends HTMLController { ]) // insert the connection - const dbResult = await this.insertNewConnection(company, pinHash, invite.invitation.id, null) + const dbResult = await this.insertNewConnection(company, pinHash, invite.outOfBandRecord.id, null) if (dbResult.type === 'error') { return this.newInviteErrorHtml(dbResult.error, body.email, company.company_number) } diff --git a/src/env.ts b/src/env.ts index f08ff993..1f22d1c1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -57,6 +57,7 @@ const envConfig = { EMAIL_FROM_ADDRESS: envalid.email({ default: 'hello@veritable.com' }), EMAIL_ADMIN_ADDRESS: envalid.email({ default: 'admin@veritable.com' }), CLOUDAGENT_ADMIN_ORIGIN: envalid.url({ devDefault: 'http://localhost:3100' }), + CLOUDAGENT_ADMIN_WS_ORIGIN: envalid.url({ devDefault: 'ws://localhost:3100' }), INVITATION_PIN_SECRET: envalid.str({ devDefault: 'secret' }), INVITATION_FROM_COMPANY_NUMBER: envalid.str({ devDefault: '07964699' }), } diff --git a/src/index.ts b/src/index.ts index c214226c..1f12da06 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import 'reflect-metadata' -import { Express } from 'express' import { container } from 'tsyringe' import { Env } from './env.js' @@ -8,7 +7,8 @@ import Server from './server.js' import { logger } from './logger.js' ;(async () => { - const app: Express = await Server() + const { app } = await Server() + const env = container.resolve(Env) app.listen(env.get('PORT'), () => { diff --git a/src/models/__tests__/fixtures/cloudagentFixtures.ts b/src/models/__tests__/fixtures/cloudagentFixtures.ts index 2b350447..b3a5375c 100644 --- a/src/models/__tests__/fixtures/cloudagentFixtures.ts +++ b/src/models/__tests__/fixtures/cloudagentFixtures.ts @@ -1,15 +1,8 @@ -export const createInviteSuccessResponse = { - invitationUrl: 'example.com', - invitation: { - '@id': 'example-id', - }, -} +import { pino } from 'pino' -export const createInviteSuccessResponseTransformed = { +export const createInviteSuccessResponse = { invitationUrl: 'example.com', - invitation: { - id: 'example-id', - }, + outOfBandRecord: { id: 'example-id' }, } export const receiveInviteSuccessResponse = { @@ -21,4 +14,14 @@ export const receiveInviteSuccessResponse = { }, } +export const getConnectionsSuccessResponse = [ + { + id: 'connection-id', + state: 'completed', + outOfBandId: 'oob-id', + }, +] + export const invalidResponse = {} + +export const mockLogger = pino({ level: 'silent' }) diff --git a/src/models/__tests__/helpers/mockCloudagent.ts b/src/models/__tests__/helpers/mockCloudagent.ts index baa48270..67ea396e 100644 --- a/src/models/__tests__/helpers/mockCloudagent.ts +++ b/src/models/__tests__/helpers/mockCloudagent.ts @@ -5,7 +5,7 @@ import { Env } from '../../../env.js' const env = container.resolve(Env) -export function withCloudagentMock(path: string, code: number, responseBody: any) { +export function withCloudagentMock(method: 'GET' | 'POST' | 'DELETE', path: string, code: number, responseBody: any) { let originalDispatcher: Dispatcher let agent: MockAgent beforeEach(function () { @@ -17,7 +17,7 @@ export function withCloudagentMock(path: string, code: number, responseBody: any client .intercept({ path, - method: 'POST', + method, }) .reply(code, responseBody) }) diff --git a/src/models/__tests__/veritableCloudagent.test.ts b/src/models/__tests__/veritableCloudagent.test.ts index 18b755ce..505d6119 100644 --- a/src/models/__tests__/veritableCloudagent.test.ts +++ b/src/models/__tests__/veritableCloudagent.test.ts @@ -3,8 +3,9 @@ import { describe, it } from 'mocha' import { Env } from '../../env.js' import { createInviteSuccessResponse, - createInviteSuccessResponseTransformed, + getConnectionsSuccessResponse, invalidResponse, + mockLogger, receiveInviteSuccessResponse, } from './fixtures/cloudagentFixtures.js' import { withCloudagentMock } from './helpers/mockCloudagent.js' @@ -20,22 +21,22 @@ describe('veritableCloudagent', () => { describe('createOutOfBandInvite', () => { describe('success', function () { - withCloudagentMock(`/v1/oob/create-invitation`, 200, createInviteSuccessResponse) + withCloudagentMock('POST', `/v1/oob/create-invitation`, 200, createInviteSuccessResponse) it('should give back out-of-band invite', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) const response = await cloudagent.createOutOfBandInvite({ companyName: 'Digital Catapult' }) - expect(response).deep.equal(createInviteSuccessResponseTransformed) + expect(response).deep.equal(createInviteSuccessResponse) }) }) describe('error (response code)', function () { - withCloudagentMock(`/v1/oob/create-invitation`, 400, {}) + withCloudagentMock('POST', `/v1/oob/create-invitation`, 400, {}) it('should throw internal error', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) let error: unknown = null try { @@ -48,11 +49,11 @@ describe('veritableCloudagent', () => { }) describe('error (response invalid)', function () { - withCloudagentMock(`/v1/oob/create-invitation`, 200, invalidResponse) + withCloudagentMock('POST', `/v1/oob/create-invitation`, 200, invalidResponse) it('should throw internal error', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) let error: unknown = null try { @@ -67,11 +68,11 @@ describe('veritableCloudagent', () => { describe('receiveOutOfBandInvite', () => { describe('success', function () { - withCloudagentMock('/v1/oob/receive-invitation-url', 200, receiveInviteSuccessResponse) + withCloudagentMock('POST', '/v1/oob/receive-invitation-url', 200, receiveInviteSuccessResponse) it('should give back out-of-band invite', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) const response = await cloudagent.receiveOutOfBandInvite({ companyName: 'Digital Catapult', invitationUrl: 'http://example.com', @@ -81,11 +82,11 @@ describe('veritableCloudagent', () => { }) describe('error (response code)', function () { - withCloudagentMock('/v1/oob/receive-invitation-url', 400, {}) + withCloudagentMock('POST', '/v1/oob/receive-invitation-url', 400, {}) it('should throw internal error', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) let error: unknown = null try { @@ -101,11 +102,11 @@ describe('veritableCloudagent', () => { }) describe('error (response invalid)', function () { - withCloudagentMock('/v1/oob/receive-invitation-url', 200, invalidResponse) + withCloudagentMock('POST', '/v1/oob/receive-invitation-url', 200, invalidResponse) it('should throw internal error', async () => { const environment = new Env() - const cloudagent = new VeritableCloudagent(environment) + const cloudagent = new VeritableCloudagent(environment, mockLogger) let error: unknown = null try { @@ -120,4 +121,81 @@ describe('veritableCloudagent', () => { }) }) }) + + describe('getConnections', () => { + describe('success', function () { + withCloudagentMock('GET', '/v1/connections', 200, getConnectionsSuccessResponse) + + it('should respond with connections', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment, mockLogger) + const response = await cloudagent.getConnections() + expect(response).deep.equal(getConnectionsSuccessResponse) + }) + }) + + describe('error (response code)', function () { + withCloudagentMock('GET', '/v1/connections', 400, {}) + + it('should throw internal error', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment, mockLogger) + + let error: unknown = null + try { + await cloudagent.getConnections() + } catch (err) { + error = err + } + expect(error).instanceOf(InternalError) + }) + }) + + describe('error (response invalid)', function () { + withCloudagentMock('GET', '/v1/connections', 200, invalidResponse) + + it('should throw internal error', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment, mockLogger) + + let error: unknown = null + try { + await cloudagent.getConnections() + } catch (err) { + error = err + } + expect(error).instanceOf(InternalError) + }) + }) + }) + + describe('deleteConnection', () => { + describe('success', function () { + withCloudagentMock('DELETE', '/v1/connections/42', 204, '') + + it('should success', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment, mockLogger) + const response = await cloudagent.deleteConnection('42') + expect(response).deep.equal(undefined) + }) + }) + + describe('error (response code)', function () { + withCloudagentMock('GET', '/v1/connections', 400, {}) + + it('should throw internal error', async () => { + const environment = new Env() + const cloudagent = new VeritableCloudagent(environment, mockLogger) + + let error: unknown = null + try { + await cloudagent.deleteConnection('42') + } catch (err) { + error = err + } + expect(error).instanceOf(InternalError) + }) + }) + }) }) diff --git a/src/models/veritableCloudagent.ts b/src/models/veritableCloudagent.ts index 0458203f..6a8077a3 100644 --- a/src/models/veritableCloudagent.ts +++ b/src/models/veritableCloudagent.ts @@ -1,16 +1,13 @@ -import { injectable, singleton } from 'tsyringe' +import { inject, injectable, singleton } from 'tsyringe' import { z } from 'zod' import { Env } from '../env.js' import { InternalError } from '../errors.js' +import { Logger, type ILogger } from '../logger.js' const oobParser = z.object({ invitationUrl: z.string(), - invitation: z - .object({ - '@id': z.string(), - }) - .transform(({ '@id': id }) => ({ id })), + outOfBandRecord: z.object({ id: z.string() }), }) type OutOfBandInvite = z.infer @@ -24,10 +21,34 @@ const receiveUrlParser = z.object({ }) type ReceiveUrlResponse = z.infer +export const connectionParser = z.object({ + id: z.string(), + state: z.union([ + z.literal('start'), + z.literal('invitation-sent'), + z.literal('invitation-received'), + z.literal('request-sent'), + z.literal('request-received'), + z.literal('response-sent'), + z.literal('response-received'), + z.literal('abandoned'), + z.literal('completed'), + ]), + outOfBandId: z.string(), +}) +export type Connection = z.infer + +const connectionListParser = z.array(connectionParser) + +type parserFn = (res: Response) => O | Promise + @singleton() @injectable() export default class VeritableCloudagent { - constructor(private env: Env) {} + constructor( + private env: Env, + @inject(Logger) protected logger: ILogger + ) {} public async createOutOfBandInvite(params: { companyName: string }): Promise { return this.postRequest( @@ -38,7 +59,7 @@ export default class VeritableCloudagent { multiUseInvitation: false, autoAcceptConnection: true, }, - oobParser + this.buildParser(oobParser) ) } @@ -55,19 +76,61 @@ export default class VeritableCloudagent { reuseConnection: true, invitationUrl: params.invitationUrl, }, - receiveUrlParser + this.buildParser(receiveUrlParser) ) } - private async postRequest( + public async getConnections(): Promise { + return this.getRequest('/v1/connections', this.buildParser(connectionListParser)) + } + + public async deleteConnection(id: string): Promise { + return this.deleteRequest(`/v1/connections/${id}`, () => {}) + } + + private async getRequest(path: string, parse: parserFn): Promise { + return this.noBodyRequest('GET', path, parse) + } + + private async deleteRequest(path: string, parse: parserFn): Promise { + return this.noBodyRequest('DELETE', path, parse) + } + + private async noBodyRequest(method: 'GET' | 'DELETE', path: string, parse: parserFn): Promise { + const url = `${this.env.get('CLOUDAGENT_ADMIN_ORIGIN')}${path}` + + const response = await fetch(url, { + method, + }) + + if (!response.ok) { + throw new InternalError(`Unexpected error calling GET ${path}: ${response.statusText}`) + } + + try { + return await parse(response) + } catch (err) { + if (err instanceof Error) { + throw new InternalError(`Error parsing response from calling GET ${path}: ${err.name} - ${err.message}`) + } + throw new InternalError(`Unknown error parsing response to calling GET ${path}`) + } + } + + private async postRequest(path: string, body: Record, parse: parserFn): Promise { + return this.bodyRequest('POST', path, body, parse) + } + + private async bodyRequest( + method: 'POST' | 'PUT', path: string, body: Record, - parser: z.ZodType + parse: parserFn ): Promise { const url = `${this.env.get('CLOUDAGENT_ADMIN_ORIGIN')}${path}` const response = await fetch(url, { - method: 'POST', + method, headers: { 'Content-Type': 'application/json', }, @@ -79,7 +142,7 @@ export default class VeritableCloudagent { } try { - return parser.parse(await response.json()) + return await parse(response) } catch (err) { if (err instanceof Error) { throw new InternalError(`Error parsing response from calling POST ${path}: ${err.name} - ${err.message}`) @@ -87,4 +150,11 @@ export default class VeritableCloudagent { throw new InternalError(`Unknown error parsing response to calling POST ${path}`) } } + + private buildParser = + (parser: z.ZodType) => + async (response: Response) => { + const asJson = await response.json() + return parser.parse(asJson) + } } diff --git a/src/server.ts b/src/server.ts index 91698018..4a383be6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,8 @@ import { Env } from './env.js' import { ForbiddenError, HttpError } from './errors.js' import { ILogger, Logger } from './logger.js' import { RegisterRoutes } from './routes.js' +import ConnectionEvents from './services/connectionEvents.js' +import VeritableCloudagentEvents from './services/veritableCloudagentEvents.js' import loadApiSpec from './swagger.js' const env = container.resolve(Env) @@ -32,8 +34,15 @@ const customCssToInject: string = ` .swagger-ui section.models { background-color: #f7f7f7; } ` -export default async (): Promise => { +export default async (startEvents: boolean = true) => { const logger = container.resolve(Logger) + + container.resolve(ConnectionEvents).start() + const cloudagentEvents = container.resolve(VeritableCloudagentEvents) + if (startEvents) { + await cloudagentEvents.start() + } + const app: Express = express() const options: SwaggerUiOptions = { @@ -116,5 +125,5 @@ export default async (): Promise => { next() }) - return app + return { app, cloudagentEvents } } diff --git a/src/services/connectionEvents.ts b/src/services/connectionEvents.ts new file mode 100644 index 00000000..67112228 --- /dev/null +++ b/src/services/connectionEvents.ts @@ -0,0 +1,56 @@ +import { inject, injectable, singleton } from 'tsyringe' + +import { Logger, type ILogger } from '../logger.js' +import Database from '../models/db/index.js' +import VeritableCloudagentEvents from './veritableCloudagentEvents.js' + +declare const CloudagentOn: VeritableCloudagentEvents['on'] +type eventData = Parameters>[1] + +@singleton() +@injectable() +export default class ConnectionEvents { + constructor( + private db: Database, + private cloudagent: VeritableCloudagentEvents, + @inject(Logger) protected logger: ILogger + ) {} + + public start() { + this.cloudagent.on('ConnectionStateChanged', this.connectionStateChangedHandler) + } + + private connectionStateChangedHandler: eventData<'ConnectionStateChanged'> = async (event) => { + const connectionState = event.payload.connectionRecord.state + if (connectionState !== 'abandoned' && connectionState !== 'completed') { + return + } + + const { id: cloudAgentConnectionId, outOfBandId } = event.payload.connectionRecord + await this.db.withTransaction(async (db) => { + const [inviteRecord] = await db.get('connection_invite', { oob_invite_id: outOfBandId }) + if (!inviteRecord) { + this.logger.warn('Connection event on unknown connection %s', cloudAgentConnectionId) + throw new Error('Connection event on unknown connection') + } + + const [connection] = await db.get('connection', { id: inviteRecord.connection_id }) + + if (connectionState === 'abandoned' && connection.status === 'disconnected') { + return + } + + if (connectionState === 'completed' && connection.status !== 'pending') { + return + } + + const updateStatus = connectionState === 'completed' ? 'unverified' : 'disconnected' + await db.update( + 'connection', + { id: inviteRecord.connection_id, status: connection.status }, + { status: updateStatus } + ) + return + }) + } +} diff --git a/src/services/veritableCloudagentEvents.ts b/src/services/veritableCloudagentEvents.ts new file mode 100644 index 00000000..c4984f8f --- /dev/null +++ b/src/services/veritableCloudagentEvents.ts @@ -0,0 +1,139 @@ +import { inject, injectable, singleton } from 'tsyringe' +import WebSocket from 'ws' +import { z } from 'zod' + +import { Env } from '../env.js' +import { Logger, type ILogger } from '../logger.js' +import VeritableCloudagent, { connectionParser } from '../models/veritableCloudagent.js' +import IndexedAsyncEventEmitter from '../utils/indexedAsyncEventEmitter.js' +import { MapDiscriminatedUnion } from '../utils/types.js' + +const eventParser = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('ConnectionStateChanged'), + payload: z.object({ + connectionRecord: connectionParser, + }), + }), + z.object({ type: z.literal('BasicMessageStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('ConnectionDidRotated'), payload: z.object({}) }), + z.object({ type: z.literal('CredentialStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('RevocationNotificationReceived'), payload: z.object({}) }), + z.object({ type: z.literal('DrpcRequestStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('DrpcResponseStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('ProofStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('TrustPingReceivedEvent'), payload: z.object({}) }), + z.object({ type: z.literal('TrustPingResponseReceivedEvent'), payload: z.object({}) }), + z.object({ type: z.literal('VerifiedDrpcRequestStateChanged'), payload: z.object({}) }), + z.object({ type: z.literal('VerifiedDrpcResponseStateChanged'), payload: z.object({}) }), +]) +type WebSocketEvent = z.infer +type WebSocketEventMap = MapDiscriminatedUnion +type WebSocketEventNames = WebSocketEvent['type'] + +@singleton() +@injectable() +export default class VeritableCloudagentEvents extends IndexedAsyncEventEmitter< + WebSocketEventNames, + { + [key in WebSocketEventNames]: WebSocketEventMap[key] + } +> { + private internalStatus: 'IDLE' | 'CONNECTED' | 'ERRORED' = 'IDLE' + private socket?: WebSocket + private closeTimeoutId?: NodeJS.Timeout + + constructor( + private env: Env, + private veritable: VeritableCloudagent, + @inject(Logger) protected logger: ILogger + ) { + super() + } + + get status() { + return this.internalStatus + } + + public start = (): Promise => { + if (this.socket) { + throw new Error('WebSocket already started') + } + + let resolve: () => void + const returnedPromise = new Promise((res) => { + resolve = res + }) + + const socket = new WebSocket(this.env.get('CLOUDAGENT_ADMIN_WS_ORIGIN')) + socket.addEventListener('open', async () => { + this.logger.info('WebSocket connection to Cloudagent established') + this.internalStatus = 'CONNECTED' + const connectionSeen = new Set() + + socket.addEventListener('message', (ev: WebSocket.MessageEvent) => { + this.logger.debug('WebSocket Message Received') + let data: WebSocketEvent + try { + data = eventParser.parse(JSON.parse(ev.data.toString())) + } catch (err) { + this.logger.warn('Unusable event received %o', ev.data) + return + } + + let id: string + const type = data.type + this.logger.debug('WebSocket Message if of type %s', type) + switch (type) { + case 'ConnectionStateChanged': + id = data.payload.connectionRecord.id + connectionSeen.add(id) + break + default: + this.logger.trace('Skipping %s event', type) + return + } + + this.emitIndexed(data.type, id, data) + }) + + for (const connection of await this.veritable.getConnections()) { + if (!connectionSeen.has(connection.id)) { + this.emitIndexed('ConnectionStateChanged', connection.id, { + type: 'ConnectionStateChanged', + payload: { connectionRecord: connection }, + }) + } + } + + resolve() + }) + + socket.addEventListener('close', this.closeHandler) + + this.socket = socket + + return returnedPromise + } + + public stop = () => { + this.internalStatus = 'IDLE' + + if (this.socket) { + this.socket.removeEventListener('close', this.closeHandler) + this.socket.close() + this.socket = undefined + return + } + + if (this.closeTimeoutId) { + clearTimeout(this.closeTimeoutId) + } + } + + private closeHandler = () => { + this.internalStatus = 'ERRORED' + this.socket = undefined + this.closeTimeoutId = setTimeout(this.start, 10000) + } +} diff --git a/src/utils/__tests__/fixtures/testIndexedAsyncEventEmitter.ts b/src/utils/__tests__/fixtures/testIndexedAsyncEventEmitter.ts new file mode 100644 index 00000000..df327301 --- /dev/null +++ b/src/utils/__tests__/fixtures/testIndexedAsyncEventEmitter.ts @@ -0,0 +1,24 @@ +import { pino } from 'pino' + +import { ILogger } from '../../../logger.js' +import IndexedAsyncEventEmitter from '../../indexedAsyncEventEmitter.js' + +const mockLogger = pino({ level: 'silent' }) + +export type EventNames = 'A' | 'B' | 'C' +export type EventData = { + A: { dataA: string } + B: { dataB: string } + C: { dataA: string } +} + +export class TestIndexedAsyncEventEmitter extends IndexedAsyncEventEmitter { + protected logger: ILogger = mockLogger +} + +export const eventA = (value: string = 'test') => ({ + dataA: value, +}) +export const eventB = (value: string = 'test') => ({ + dataB: value, +}) diff --git a/src/utils/__tests__/indexedAsyncEventEmitter.test.ts b/src/utils/__tests__/indexedAsyncEventEmitter.test.ts new file mode 100644 index 00000000..dc03e23a --- /dev/null +++ b/src/utils/__tests__/indexedAsyncEventEmitter.test.ts @@ -0,0 +1,306 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe } from 'mocha' +import sinon from 'sinon' + +import { EventData, TestIndexedAsyncEventEmitter, eventA, eventB } from './fixtures/testIndexedAsyncEventEmitter.js' + +describe('IndexedAsyncEventEmitter', function () { + let clock: sinon.SinonFakeTimers + beforeEach(async () => { + clock = sinon.useFakeTimers(0) + }) + + afterEach(async () => { + clock.restore() + }) + + describe('emitIndexedEvent', function () { + describe('emit single event', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().resolves() + handlerB = sinon.stub().resolves() + event = eventA() + + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event) + await clock.nextAsync() + }) + + it('should call event handlerA', async function () { + expect(handlerA.callCount).equal(1) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event) + }) + + it('should not call event handlerB', async function () { + expect(handlerB.callCount).equal(0) + }) + }) + + describe('emit multiple distinct events same type', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event1: EventData['A'] + let event2: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().resolves() + handlerB = sinon.stub().resolves() + event1 = eventA('1') + event2 = eventA('2') + + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event1) + emitter.emitIndexed('A', '1', event2) + await clock.runAllAsync() + }) + + it('should call event handler twice', async function () { + expect(handlerA.callCount).equal(2) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event1) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event2) + }) + + it('should not call event handlerB', async function () { + expect(handlerB.callCount).equal(0) + }) + }) + + describe('emit multiple distinct events different types', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event1: EventData['A'] + let event2: EventData['B'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().resolves() + handlerB = sinon.stub().resolves() + event1 = eventA('1') + event2 = eventB('2') + + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event1) + emitter.emitIndexed('B', '1', event2) + await clock.runAllAsync() + }) + + it('should call event handlerA', async function () { + expect(handlerA.callCount).equal(1) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event1) + }) + + it('should call event handlerB', async function () { + expect(handlerB.callCount).equal(1) + expect(handlerB.firstCall.args.length).equal(1) + expect(handlerB.firstCall.args[0]).equal(event2) + }) + }) + + describe('event handler retry', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().onFirstCall().rejects(new Error()).onSecondCall().resolves() + handlerB = sinon.stub().resolves() + event = eventA() + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event) + await clock.tickAsync(0) + }) + + it('should call event handlerA once', async function () { + expect(handlerA.callCount).equal(1) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event) + }) + + it('should not call event handlerB', async function () { + expect(handlerB.callCount).equal(0) + }) + + it('should call handlerA again after 500ms', async function () { + await clock.tickAsync(500) + expect(handlerA.callCount).equal(2) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event) + }) + }) + + describe('event handler retry (n)', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().rejects(new Error()).onCall(3).resolves() + handlerB = sinon.stub().resolves() + event = eventA() + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event) + await clock.tickAsync(0) + }) + + it('should call event handlerA once', async function () { + expect(handlerA.callCount).equal(1) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event) + }) + + it('should not call event handlerB', async function () { + expect(handlerB.callCount).equal(0) + }) + + it('should call handlerA again after 500ms', async function () { + await clock.tickAsync(500) + expect(handlerA.callCount).equal(2) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event) + }) + + it('should call handlerA again after 1500ms', async function () { + await clock.tickAsync(1500) + expect(handlerA.callCount).equal(3) + expect(handlerA.thirdCall.args.length).equal(1) + expect(handlerA.thirdCall.args[0]).equal(event) + }) + }) + + describe("event handler doesn't retry with subsequent event matching type and index", function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerB: sinon.SinonStub + let event1: EventData['A'] + let event2: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().rejects(new Error()).onSecondCall().resolves() + handlerB = sinon.stub().resolves() + event1 = eventA() + event2 = eventA() + emitter.on('A', handlerA) + emitter.on('B', handlerB) + emitter.emitIndexed('A', '1', event1) + await clock.tickAsync(0) + emitter.emitIndexed('A', '1', event2) + await clock.tickAsync(0) + }) + + it('should call event handlerA once', async function () { + expect(handlerA.callCount).equal(2) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event1) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event2) + }) + + it('should not call event handlerB', async function () { + expect(handlerB.callCount).equal(0) + }) + + it("doesn't call handler again with old event after 500ms", async function () { + await clock.tickAsync(500) + expect(handlerA.callCount).equal(2) + }) + }) + + describe('event handler retry with subsequent event non-matching type', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let handlerC: sinon.SinonStub + let event1: EventData['A'] + let event2: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().resolves().onFirstCall().rejects(new Error()) + handlerC = sinon.stub().resolves() + event1 = eventA() + event2 = eventA() + emitter.on('A', handlerA) + emitter.on('C', handlerC) + emitter.emitIndexed('A', '1', event1) + await clock.tickAsync(0) + emitter.emitIndexed('C', '1', event2) + await clock.tickAsync(0) + }) + + it('should call event handlerA once', async function () { + expect(handlerA.callCount).equal(1) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event1) + }) + + it('should call event handlerC once', async function () { + expect(handlerC.callCount).equal(1) + expect(handlerC.firstCall.args.length).equal(1) + expect(handlerC.firstCall.args[0]).equal(event2) + }) + + it('calls handler again with old event after 500ms', async function () { + await clock.tickAsync(500) + expect(handlerA.callCount).equal(2) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event1) + }) + }) + + describe('event handler retry with subsequent event non-matching index', function () { + let emitter: TestIndexedAsyncEventEmitter + let handlerA: sinon.SinonStub + let event1: EventData['A'] + let event2: EventData['A'] + + beforeEach(async function () { + emitter = new TestIndexedAsyncEventEmitter() + handlerA = sinon.stub().resolves().onFirstCall().rejects(new Error()) + event1 = eventA() + event2 = eventA() + emitter.on('A', handlerA) + emitter.emitIndexed('A', '1', event1) + await clock.tickAsync(0) + emitter.emitIndexed('A', '2', event2) + await clock.tickAsync(0) + }) + + it('should call event handlerA twice', async function () { + expect(handlerA.callCount).equal(2) + expect(handlerA.firstCall.args.length).equal(1) + expect(handlerA.firstCall.args[0]).equal(event1) + expect(handlerA.secondCall.args.length).equal(1) + expect(handlerA.secondCall.args[0]).equal(event2) + }) + + it('calls handler again with old event after 500ms', async function () { + await clock.tickAsync(500) + expect(handlerA.callCount).equal(3) + expect(handlerA.thirdCall.args.length).equal(1) + expect(handlerA.thirdCall.args[0]).equal(event1) + }) + }) + }) +}) diff --git a/src/utils/__tests__/twoWayMap.test.ts b/src/utils/__tests__/twoWayMap.test.ts new file mode 100644 index 00000000..ebd546fd --- /dev/null +++ b/src/utils/__tests__/twoWayMap.test.ts @@ -0,0 +1,160 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import TwoWayMap from '../twoWayMap.js' + +describe('twoWayMap', function () { + describe('ctor', function () { + it('should initialise map with keys', function () { + const map = new TwoWayMap([ + ['a', 1], + ['b', 2], + ]) + expect(map.get('a')).equal(1) + expect(map.get('b')).equal(2) + }) + + it('should initialise map with values', function () { + const map = new TwoWayMap([ + ['a', 1], + ['b', 2], + ]) + expect(map.getRev(1)).equal('a') + expect(map.getRev(2)).equal('b') + }) + }) + + describe('set', function () { + it('should set the key to provided value', function () { + const map = new TwoWayMap() + map.set('a', 1) + + expect(map.get('a')).to.equal(1) + }) + + it('should set the value to provided key', function () { + const map = new TwoWayMap() + map.set('a', 1) + + expect(map.getRev(1)).to.equal('a') + }) + + it('should override already initialised value', function () { + const map = new TwoWayMap([['a', 1]]) + map.set('a', 2) + + expect(map.get('a')).to.equal(2) + }) + + it('should set value with already initialised value', function () { + const map = new TwoWayMap([['a', 1]]) + map.set('a', 2) + + expect(map.getRev(2)).to.equal('a') + }) + + it('should remove old value when key is changed', function () { + const map = new TwoWayMap([['a', 1]]) + map.set('a', 2) + + expect(map.getRev(1)).to.equal(undefined) + }) + }) + + describe('setRev', function () { + it('should set the value to provided key', function () { + const map = new TwoWayMap() + map.setRev(1, 'a') + + expect(map.get('a')).to.equal(1) + }) + + it('should set the key to provided value', function () { + const map = new TwoWayMap() + map.setRev(1, 'a') + + expect(map.getRev(1)).to.equal('a') + }) + + it('should override already initialised value', function () { + const map = new TwoWayMap([['a', 1]]) + map.setRev(1, 'b') + + expect(map.getRev(1)).to.equal('b') + }) + + it('should set key with already initialised key', function () { + const map = new TwoWayMap([['a', 1]]) + map.setRev(1, 'b') + + expect(map.get('b')).to.equal(1) + }) + + it('should remove old key when value is changed', function () { + const map = new TwoWayMap([['a', 1]]) + map.setRev(1, 'b') + + expect(map.get('a')).to.equal(undefined) + }) + }) + + describe('delete', function () { + it('should remove provided key', function () { + const map = new TwoWayMap([['a', 1]]) + map.delete('a') + + expect(map.get('a')).to.equal(undefined) + }) + + it('should remove associated value', function () { + const map = new TwoWayMap([['a', 1]]) + map.delete('a') + + expect(map.getRev(1)).to.equal(undefined) + }) + + it('should leave key unset if not exts', function () { + const map = new TwoWayMap() + map.delete('a') + + expect(map.get('a')).to.equal(undefined) + }) + + it('should leave value unset if not exts', function () { + const map = new TwoWayMap() + map.delete('a') + + expect(map.getRev(1)).to.equal(undefined) + }) + }) + + describe('deleteRev', function () { + it('should remove provided value', function () { + const map = new TwoWayMap([['a', 1]]) + map.deleteRev(1) + + expect(map.getRev(1)).to.equal(undefined) + }) + + it('should remove associated key', function () { + const map = new TwoWayMap([['a', 1]]) + map.deleteRev(1) + + expect(map.get('a')).to.equal(undefined) + }) + + it('should leave value unset if not exts', function () { + const map = new TwoWayMap() + map.delete(1) + + expect(map.getRev(1)).to.equal(undefined) + }) + + it('should leave key unset if not exts', function () { + const map = new TwoWayMap() + map.deleteRev(1) + + expect(map.get('a')).to.equal(undefined) + }) + }) +}) diff --git a/src/utils/indexedAsyncEventEmitter.ts b/src/utils/indexedAsyncEventEmitter.ts new file mode 100644 index 00000000..5fd9629e --- /dev/null +++ b/src/utils/indexedAsyncEventEmitter.ts @@ -0,0 +1,150 @@ +import { EventEmitter, captureRejectionSymbol } from 'node:events' + +import { type ILogger } from '../logger.js' +import TwoWayMap from '../utils/twoWayMap.js' + +type IndexedAsyncEventEmitterOptions = { + maxRetryCount: number + retryTimeoutMs: number + retryBackOffFactor: number + retryExhaustedBehaviour: 'THROW' | 'IGNORE' +} + +const defaultOptions: IndexedAsyncEventEmitterOptions = { + maxRetryCount: 10, + retryTimeoutMs: 500, + retryBackOffFactor: 2, + retryExhaustedBehaviour: 'THROW', +} + +const eventIdentifierSymbol = Symbol('IndexedAsyncEventEmitter') +type IndexedEvent = { [eventIdentifierSymbol]: Symbol } + +/** + * Event emitter that will retry events where handlers reject. + * Events are indexed and will only be retried if a more recent event with the same index has not been emitted + * Emitted events that aren't indexed will not be retried + */ +export default abstract class IndexedAsyncEventEmitter< + K extends string, + T extends Record, + I = string, +> extends EventEmitter<{ + [key in keyof T]: T[key][] +}> { + private latestEventIdentifiers: { [key in keyof T]?: TwoWayMap } = {} + private retryCount = new WeakMap() + + private options: IndexedAsyncEventEmitterOptions + protected abstract logger: ILogger + + constructor(options?: Partial) { + super({ captureRejections: true }) + + this.options = { + ...defaultOptions, + ...(options || {}), + } + } + + // handle if async handlers throw. Non indexed events will follow what's in `options.retryExhaustedBehaviour` + [captureRejectionSymbol](err: Error, eventName: E | K, ...args: object[]) { + this.logger.warn('Error processing %s event: %s', eventName, err.message) + this.logger.debug('Error processing %s event: %o stack: %s', args[0], err.stack) + + const data = args[0] + if (this.isIndexedEvent(data) && this.isTrackedEventName(eventName)) { + const eventIdentifier = data[eventIdentifierSymbol] + const retryCount = this.retryCount.get(eventIdentifier) ?? this.options.maxRetryCount + const timeoutMs = this.options.retryTimeoutMs * Math.pow(this.options.retryBackOffFactor, retryCount) + this.checkRetry(err, eventName, data) + + // trampoline and retry + setTimeout(this.retryEmit.bind(this), timeoutMs, err, eventName, data) + return + } + + if (this.options.retryExhaustedBehaviour === 'THROW') { + this.logger.fatal('Error processing %s event: %o stack: %s', args[0], err.stack) + throw err + } + } + + /** + * Emits an indexed event that can be retried if handlers reject. Event will only be retried if another event with the same index hasn't since been emitted. + * @param eventName Name of the event to emit + * @param index Indexing value that identified the resource that created this event + * @param arg Value associated with the event + * @returns Whether the event had handlers or not + */ + public emitIndexed(eventName: E, index: I, arg: T[E]): boolean { + const stateMap = this.latestEventIdentifiers[eventName] || new TwoWayMap() + this.latestEventIdentifiers[eventName] = stateMap + + // if data is populated with the identifier symbol it must have previously been emitted. + // clone the data so we don't mutate the ongoing event + let distinctArg = this.isIndexedEvent(arg) ? { ...arg } : arg + + // generate a unique identifier for this emit + const eventIdentifier = Symbol(`${eventName}-${index}`) + const withIdentifier = Object.assign(distinctArg, { [eventIdentifierSymbol]: eventIdentifier }) + + // record that this is the latest event for this index and set retry to 0 + this.retryCount.set(eventIdentifier, 0) + stateMap.set(index, eventIdentifier) + + return this.emitIndexedInternal(eventName, withIdentifier) + } + + private retryEmit(err: Error, eventName: K, data: T[K] & IndexedEvent) { + // check if we should retry and if so get the current count + const retry = this.checkRetry(err, eventName, data) + if (retry === false) { + return + } + + // increment the retry count + this.retryCount.set(data[eventIdentifierSymbol], retry + 1) + + this.emitIndexedInternal(eventName, data) + } + + private emitIndexedInternal(eventName: K, data: T[K]): boolean { + // this is extremely ugly but the typechecker finds it hard to realise the types are correct here + const params = [eventName, data] as unknown as Parameters> + return this.emit(...params) + } + + private checkRetry(err: Error, eventName: E, data: IndexedEvent) { + // if the event isn't the latest event for some eventname we shouldn't retry + const index = this.latestEventIdentifiers[eventName]?.getRev(data[eventIdentifierSymbol]) + if (index === undefined) { + return false + } + + // get the latest retry count. We need to provide some default in case it's not in the weak map so just make sure we don't retry if somehow that happens + const retryCount = this.retryCount.get(data[eventIdentifierSymbol]) ?? this.options.maxRetryCount + if (retryCount >= this.options.maxRetryCount) { + this.logger.error('Recount limit exceeded for ConnectionStateChanged id=%s', index) + if (this.options.retryExhaustedBehaviour === 'THROW') { + throw err + } + return false + } + + return retryCount + } + + // note this assertion is logically flawed. We are checking that we have the symbol and the only way an object + // can be created with that symbol is if it's valid, but some exotic coding could make this fail + private isIndexedEvent(eventData: object): eventData is T[K] & IndexedEvent { + return Object.hasOwn(eventData, eventIdentifierSymbol) + } + + private isTrackedEventName(eventName: E | K): eventName is K { + if (typeof eventName === 'string' || typeof eventName === 'number' || typeof eventName === 'symbol') + return Object.hasOwn(this.latestEventIdentifiers, eventName) + + return false + } +} diff --git a/src/utils/twoWayMap.ts b/src/utils/twoWayMap.ts new file mode 100644 index 00000000..bca7d6de --- /dev/null +++ b/src/utils/twoWayMap.ts @@ -0,0 +1,65 @@ +export default class TwoWayMap { + private forwardMap: Map + private reverseMap: Map + + constructor(items?: [K, V][]) { + this.forwardMap = new Map(items) + + this.reverseMap = new Map() + for (const [k, v] of this.forwardMap) { + this.reverseMap.set(v, k) + } + } + + set(key: K, value: V) { + if (this.reverseMap.has(value)) { + throw new Error('Value already set') + } + + this.delete(key) + this.forwardMap.set(key, value) + this.reverseMap.set(value, key) + } + + setRev(value: V, key: K) { + if (this.forwardMap.has(key)) { + throw new Error('Key already set') + } + + this.deleteRev(value) + this.forwardMap.set(key, value) + this.reverseMap.set(value, key) + } + + delete(key: K) { + const value = this.forwardMap.get(key) + if (value) { + this.forwardMap.delete(key) + this.reverseMap.delete(value) + } + } + + deleteRev(value: V) { + const key = this.reverseMap.get(value) + if (key) { + this.forwardMap.delete(key) + this.reverseMap.delete(value) + } + } + + get(key: K) { + return this.forwardMap.get(key) + } + + getRev(value: V) { + return this.reverseMap.get(value) + } + + has(key: K) { + return this.forwardMap.has(key) + } + + hasRev(value: V) { + return this.reverseMap.has(value) + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 00000000..2600e09c --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,5 @@ +export type DiscriminateUnion = T extends Record ? T : never + +export type MapDiscriminatedUnion, K extends keyof T> = { + [V in T[K]]: DiscriminateUnion +} diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/__tests__/connection.test.ts.snap index ea2e92ce..46f8102f 100644 --- a/src/views/__tests__/connection.test.ts.snap +++ b/src/views/__tests__/connection.test.ts.snap @@ -2,7 +2,7 @@ exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
<div>I own you</div>
Verified - Established Connection
some action
"`; -exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
'Pending Your Verification'
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; +exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
Company A
Disconnected
some action
Company B
Pending
some action
Company C
Unverified
some action
Company D
Verified - Established Connection
some action
Company E
Pending Your Verification
some action
Company F
Pending Their Verification
some action
"`; exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Connections

Connections

Connections
Company NameVerification StatusActions
No Connections for that search query. Try again or add a new connection
"`; diff --git a/src/views/connection.tsx b/src/views/connection.tsx index cef23bc2..e4aa962d 100644 --- a/src/views/connection.tsx +++ b/src/views/connection.tsx @@ -16,7 +16,7 @@ export default class ConnectionTemplates { private statusToClass = (status: string | ConnectionStatus): JSX.Element => { switch (status) { case 'pending': - return
'Pending Your Verification'
+ return
Pending
case 'verified_them': case 'verified_us': return ( diff --git a/test/helpers/cloudagent.ts b/test/helpers/cloudagent.ts index 9d2d0753..1a5a5f61 100644 --- a/test/helpers/cloudagent.ts +++ b/test/helpers/cloudagent.ts @@ -1,19 +1,40 @@ +import { pino } from 'pino' + import { Env } from '../../src/env.js' import VeritableCloudagent from '../../src/models/veritableCloudagent.js' +import { container } from 'tsyringe' import { validCompanyName, validCompanyNumber } from './fixtures.js' +const mockLogger = pino({ level: 'silent' }) + +const cleanupShared = async function (agent: VeritableCloudagent) { + const connections = await agent.getConnections() + for (const connection of connections) { + await agent.deleteConnection(connection.id) + } +} + +export async function cleanupCloudagent() { + let agent = container.resolve(VeritableCloudagent) + await cleanupShared(agent) +} + export function withBobCloudAgentInvite(context: { invite: string }) { - let agent = new VeritableCloudagent({ - get(name) { - if (name === 'CLOUDAGENT_ADMIN_ORIGIN') { - return 'http://localhost:3101' - } - throw new Error('Unexpected env variable request') - }, - } as Env) + let agent = new VeritableCloudagent( + { + get(name) { + if (name === 'CLOUDAGENT_ADMIN_ORIGIN') { + return 'http://localhost:3101' + } + throw new Error('Unexpected env variable request') + }, + } as Env, + mockLogger + ) beforeEach(async function () { + await cleanupShared(agent) const invite = await agent.createOutOfBandInvite({ companyName: validCompanyName }) context.invite = Buffer.from( JSON.stringify({ @@ -23,4 +44,27 @@ export function withBobCloudAgentInvite(context: { invite: string }) { 'utf8' ).toString('base64url') }) + + afterEach(async () => await cleanupShared(agent)) +} + +export function withBobCloudagentAcceptInvite(context: { inviteUrl: string }) { + let agent = new VeritableCloudagent( + { + get(name) { + if (name === 'CLOUDAGENT_ADMIN_ORIGIN') { + return 'http://localhost:3101' + } + throw new Error('Unexpected env variable request') + }, + } as Env, + mockLogger + ) + + beforeEach(async function () { + await cleanupShared(agent) + await agent.receiveOutOfBandInvite({ companyName: validCompanyName, invitationUrl: context.inviteUrl }) + }) + + afterEach(async () => await cleanupShared(agent)) } diff --git a/test/helpers/util.ts b/test/helpers/util.ts new file mode 100644 index 00000000..92a02a49 --- /dev/null +++ b/test/helpers/util.ts @@ -0,0 +1 @@ +export const delay = (delayMs: number) => new Promise((r) => setTimeout(r, delayMs)) diff --git a/test/integration/newConnection.test.ts b/test/integration/newConnection.test.ts index 613eeff3..f1f58411 100644 --- a/test/integration/newConnection.test.ts +++ b/test/integration/newConnection.test.ts @@ -1,20 +1,24 @@ import { expect } from 'chai' import express from 'express' -import { describe, it } from 'mocha' +import { afterEach, beforeEach, describe, it } from 'mocha' import { container } from 'tsyringe' +import sinon from 'sinon' import Database from '../../src/models/db/index.js' +import EmailService from '../../src/models/emailService/index.js' import createHttpServer from '../../src/server.js' -import { withBobCloudAgentInvite } from '../helpers/cloudagent.js' +import VeritableCloudagentEvents from '../../src/services/veritableCloudagentEvents.js' +import { cleanupCloudagent, withBobCloudAgentInvite, withBobCloudagentAcceptInvite } from '../helpers/cloudagent.js' import { withCompanyHouseMock } from '../helpers/companyHouse.js' import { cleanup } from '../helpers/db.js' import { validCompanyNumber } from '../helpers/fixtures.js' import { post } from '../helpers/routeHelper.js' +import { delay } from '../helpers/util.js' const db = container.resolve(Database) describe('NewConnectionController', () => { - let app: express.Express + let server: { app: express.Express; cloudagentEvents: VeritableCloudagentEvents } afterEach(async () => { await cleanup() @@ -26,14 +30,20 @@ describe('NewConnectionController', () => { beforeEach(async () => { await cleanup() - app = await createHttpServer() - response = await post(app, '/connection/new/create-invitation', { + await cleanupCloudagent() + server = await createHttpServer() + response = await post(server.app, '/connection/new/create-invitation', { companyNumber: validCompanyNumber, email: 'alice@example.com', action: 'submit', }) }) + afterEach(async () => { + await cleanupCloudagent() + server.cloudagentEvents.stop() + }) + it('should return success', async () => { expect(response.status).to.equal(200) }) @@ -60,13 +70,18 @@ describe('NewConnectionController', () => { beforeEach(async () => { await cleanup() - app = await createHttpServer() - response = await post(app, '/connection/new/receive-invitation', { + await cleanupCloudagent() + server = await createHttpServer(false) + response = await post(server.app, '/connection/new/receive-invitation', { invite: context.invite, action: 'createConnection', }) }) + afterEach(async () => { + await cleanupCloudagent() + }) + it('should return success', async () => { expect(response.status).to.equal(200) }) @@ -84,4 +99,79 @@ describe('NewConnectionController', () => { expect(invites.length).to.equal(1) }) }) + + describe('connection complete (receive side)', function () { + const context: { invite: string } = { invite: '' } + + withBobCloudAgentInvite(context) + + beforeEach(async () => { + await cleanup() + await cleanupCloudagent() + server = await createHttpServer(true) + await post(server.app, '/connection/new/receive-invitation', { + invite: context.invite, + action: 'createConnection', + }) + }) + + afterEach(async () => { + await cleanupCloudagent() + server.cloudagentEvents.stop() + }) + + it('should update connection to unverified once connection is established', async () => { + for (let i = 0; i < 100; i++) { + const [connection] = await db.get('connection') + if (connection.status === 'unverified') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state unverified') + }) + }) + + describe('connection complete (send side)', function () { + const context: { inviteUrl: string } = { inviteUrl: '' } + let emailSendStub: sinon.SinonStub + + beforeEach(async () => { + await cleanup() + await cleanupCloudagent() + server = await createHttpServer(true) + + const email = container.resolve(EmailService) + emailSendStub = sinon.stub(email, 'sendMail') + await post(server.app, '/connection/new/create-invitation', { + companyNumber: validCompanyNumber, + email: 'alice@example.com', + action: 'submit', + }) + const invite = (emailSendStub.args.find(([name]) => name === 'connection_invite') || [])[1].invite + context.inviteUrl = JSON.parse(Buffer.from(invite, 'base64url').toString('utf8')).inviteUrl + }) + + withBobCloudagentAcceptInvite(context) + + afterEach(async () => { + if (emailSendStub) { + emailSendStub.restore() + } + + await cleanupCloudagent() + server.cloudagentEvents.stop() + }) + + it('should update connection to unverified once connection is established', async () => { + for (let i = 0; i < 100; i++) { + const [connection] = await db.get('connection') + if (connection.status === 'unverified') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state unverified') + }) + }) }) diff --git a/test/test.env b/test/test.env index 8aeca259..ae86e8d8 100644 --- a/test/test.env +++ b/test/test.env @@ -1 +1,2 @@ COMPANY_PROFILE_API_KEY=API_KEY +LOG_LEVEL=silent