From 2c42920fd7a608c67369bdbad6c13169f478516a Mon Sep 17 00:00:00 2001 From: Paulius Date: Wed, 10 Jul 2024 16:22:34 +0100 Subject: [PATCH] Feature/vr 153 (#94) * VR-153: abstracting PIN submission form to it's own page. * VR-153: abstracting PIN submission form to it's own page. (un-staged files) * VR-153: pin submission view unit tests * VR-153: comments. * VR-153: a linting, xss-scan and other clean-up procedures before making a PR. * VR-153: leftovers, restoring action name. * VR-153: version bump. * VR-153: snapshot update. * VR-154: a few copy changes. * VR-153: copy changes. * VR-153: missing snapshot. * VR-153: saving before the meeting. * VR-153: after testing locally from two nodes. * VR-153: pushing latest. * VR-153: after linting. * VR-153: comments. * VR-153: comments for Matt. * Working pin submission * Remove empty test file * Version bump * More validation * Remove comments --------- Co-authored-by: Matthew Dean --- docker-compose.yml | 2 + package-lock.json | 4 +- package.json | 2 +- public/styles/main.css | 5 - public/styles/new-invite.css | 14 +- .../connection/__tests__/fixtures.ts | 13 ++ .../connection/__tests__/helpers.ts | 61 +++++- .../connection/__tests__/index.test.ts | 88 +++++++- .../__tests__/newConnection.test.ts | 89 ++------ src/controllers/connection/index.ts | 90 +++++++- src/controllers/connection/newConnection.ts | 58 +---- src/controllers/homepageController.ts | 2 +- src/controllers/queries/__tests__/helpers.ts | 4 +- src/controllers/queries/index.ts | 4 +- src/errors.ts | 10 + .../__tests__/companyHouseEntity.test.ts | 9 + .../__tests__/helpers/mockCompanyHouse.ts | 1 + src/models/companyHouseEntity.ts | 18 +- src/models/db/index.ts | 3 +- src/models/strings.ts | 4 +- src/services/veritableCloudagentEvents.ts | 1 + src/views/__tests__/queries.test.ts.snap | 9 - .../__tests__/connection.test.ts | 0 .../__tests__/connection.test.ts.snap | 2 +- src/views/{ => connection}/connection.tsx | 44 ++-- src/views/{ => homepage}/homepage.tsx | 2 +- .../__tests__/fromInvite.test.ts | 10 +- .../__tests__/fromInvite.test.ts.snap | 18 +- .../__tests__/newInvite.test.ts.snap | 12 +- .../__tests__/pinSubmission.test.ts | 30 +++ .../__tests__/pinSubmission.test.ts.snap | 15 ++ src/views/newConnection/base.tsx | 16 +- src/views/newConnection/fromInvite.tsx | 78 +------ src/views/newConnection/newInvite.tsx | 6 +- src/views/newConnection/pinSubmission.tsx | 82 +++++++ .../{ => queries}/__tests__/queries.test.ts | 1 + .../queries/__tests__/queries.test.ts.snap | 3 + .../__tests__/queriesList.test.ts | 0 .../__tests__/queriesList.test.ts.snap | 0 src/views/{ => queries}/queries.tsx | 2 +- src/views/{ => queries}/queriesList.tsx | 2 +- test/helpers/cloudagent.ts | 8 +- test/helpers/companyHouse.ts | 1 + test/helpers/connection.ts | 201 ++++++++++++++++++ test/helpers/logger.ts | 5 + test/helpers/util.ts | 5 +- test/integration/pinVerification.test.ts | 198 +++++++++++++++++ 47 files changed, 938 insertions(+), 294 deletions(-) delete mode 100644 src/views/__tests__/queries.test.ts.snap rename src/views/{ => connection}/__tests__/connection.test.ts (100%) rename src/views/{ => connection}/__tests__/connection.test.ts.snap (90%) rename src/views/{ => connection}/connection.tsx (74%) rename src/views/{ => homepage}/homepage.tsx (98%) create mode 100644 src/views/newConnection/__tests__/pinSubmission.test.ts create mode 100644 src/views/newConnection/__tests__/pinSubmission.test.ts.snap create mode 100644 src/views/newConnection/pinSubmission.tsx rename src/views/{ => queries}/__tests__/queries.test.ts (99%) create mode 100644 src/views/queries/__tests__/queries.test.ts.snap rename src/views/{ => queries}/__tests__/queriesList.test.ts (100%) rename src/views/{ => queries}/__tests__/queriesList.test.ts.snap (100%) rename src/views/{ => queries}/queries.tsx (98%) rename src/views/{ => queries}/queriesList.tsx (98%) create mode 100644 test/helpers/connection.ts create mode 100644 test/helpers/logger.ts create mode 100644 test/integration/pinVerification.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index 4906bb8f..f74cea19 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,8 @@ services: postgres-veritable-ui-bob: image: postgres:16.3-alpine container_name: postgres-veritable-ui-bob + ports: + - 5433:5432 volumes: - postgres-veritable-ui-bob:/var/lib/postgresql/data environment: diff --git a/package-lock.json b/package-lock.json index ebd0f8b1..187e0690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "veritable-ui", - "version": "0.7.6", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "veritable-ui", - "version": "0.7.6", + "version": "0.8.0", "license": "Apache-2.0", "dependencies": { "@digicatapult/tsoa-oauth-express": "^0.1.20", diff --git a/package.json b/package.json index 900aa6cb..f29a577a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-ui", - "version": "0.7.6", + "version": "0.8.0", "description": "UI for Veritable", "main": "src/index.ts", "type": "module", diff --git a/public/styles/main.css b/public/styles/main.css index 11795b85..3aeffc53 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -494,11 +494,6 @@ a.list-table.icon { margin: 0; } - .card-body { - max-width: 100%; - margin: 1rem; - } - .search-window { margin-left: 10px; } diff --git a/public/styles/new-invite.css b/public/styles/new-invite.css index bba3125a..624ed606 100644 --- a/public/styles/new-invite.css +++ b/public/styles/new-invite.css @@ -170,8 +170,20 @@ #from-invite-invite-input-pin { border-radius: 8px; - color: #abafb1; background: var(--Input-defaultBackground, rgba(239, 241, 249, 0.6)); + + &:focus-visible { + outline: 2px solid var(--neutral-accent); + } + + &:not(:placeholder-shown) { + &:invalid { + outline-color: var(--negative-accent); + } + &:valid { + outline-color: var(--positive-accent); + } + } } /* Mobile view */ diff --git a/src/controllers/connection/__tests__/fixtures.ts b/src/controllers/connection/__tests__/fixtures.ts index 52a77ae7..e1cf7699 100644 --- a/src/controllers/connection/__tests__/fixtures.ts +++ b/src/controllers/connection/__tests__/fixtures.ts @@ -1,3 +1,6 @@ +import { randomUUID } from 'crypto' +import type { ConnectionRow } from '../../../models/db/types.js' + export const notFoundCompanyNumber = '00000000' export const invalidCompanyNumber = 'XXXXXXXX' export const validCompanyNumber = '00000001' @@ -50,6 +53,16 @@ export const validCompanyMap: Record = { [validCompanyNumberInactive]: validCompanyInactive, } +export const validConnection: ConnectionRow = { + id: '4a5d4085-5924-43c6-b60d-754440332e3d', + agent_connection_id: randomUUID(), + created_at: new Date(), + updated_at: new Date(), + status: 'pending', + company_number: validCompanyNumber, + company_name: 'must be a valid company name', +} + const buildBase64Invite = (companyNumber: string) => Buffer.from( JSON.stringify({ diff --git a/src/controllers/connection/__tests__/helpers.ts b/src/controllers/connection/__tests__/helpers.ts index 331f60d1..0eaf9efe 100644 --- a/src/controllers/connection/__tests__/helpers.ts +++ b/src/controllers/connection/__tests__/helpers.ts @@ -1,6 +1,7 @@ import { Readable } from 'node:stream' import { pino } from 'pino' +import sinon from 'sinon' import { Env } from '../../../env.js' import type { ILogger } from '../../../logger.js' import CompanyHouseEntity from '../../../models/companyHouseEntity.js' @@ -8,11 +9,18 @@ import Database from '../../../models/db/index.js' import { ConnectionRow } from '../../../models/db/types.js' import EmailService from '../../../models/emailService/index.js' import VeritableCloudagent from '../../../models/veritableCloudagent.js' -import ConnectionTemplates from '../../../views/connection.js' +import ConnectionTemplates from '../../../views/connection/connection.js' import { FormFeedback } from '../../../views/newConnection/base.js' import { FromInviteTemplates } from '../../../views/newConnection/fromInvite.js' import { NewInviteTemplates } from '../../../views/newConnection/newInvite.js' -import { notFoundCompanyNumber, validCompanyMap, validCompanyNumber, validExistingCompanyNumber } from './fixtures.js' +import { PinSubmissionTemplates } from '../../../views/newConnection/pinSubmission.js' +import { + notFoundCompanyNumber, + validCompanyMap, + validCompanyNumber, + validConnection, + validExistingCompanyNumber, +} from './fixtures.js' function templateFake(templateName: string, ...args: any[]) { return Promise.resolve([templateName, args.join('-'), templateName].join('_')) @@ -22,16 +30,42 @@ export const withConnectionMocks = () => { const templateMock = { listPage: (connections: ConnectionRow[]) => templateFake('list', connections[0].company_name, connections[0].status), - } as ConnectionTemplates + } const mockLogger: ILogger = pino({ level: 'silent' }) const dbMock = { - get: () => Promise.resolve([{ company_name: 'foo', status: 'verified' }]), - } as unknown as Database + get: () => + Promise.resolve([{ company_name: 'foo', status: 'unverified', agent_connection_id: 'AGENT_CONNECTION_ID' }]), + } + const cloudagentMock = { + proposeCredential: sinon.stub().resolves(), + } + const companyHouseMock = { + localCompanyHouseProfile: () => + Promise.resolve({ + company_number: 'COMPANY_NUMBER', + company_name: 'COMPANY_NAME', + }), + } + const pinSubmission = { + renderPinForm: (props: { connectionId: string; pin?: string; continuationFromInvite: boolean }) => + templateFake('renderPinForm', props.connectionId, props.pin, props.continuationFromInvite), + renderSuccess: (props: { companyName: string; stepCount: number }) => + templateFake('renderSuccess', props.companyName, props.stepCount), + } return { templateMock, mockLogger, dbMock, + cloudagentMock, + args: [ + dbMock as unknown as Database, + cloudagentMock as unknown as VeritableCloudagent, + companyHouseMock as unknown as CompanyHouseEntity, + templateMock as ConnectionTemplates, + pinSubmission as unknown as PinSubmissionTemplates, + mockLogger, + ] as const, } } @@ -39,12 +73,15 @@ export const withNewConnectionMocks = () => { const mockLogger: ILogger = pino({ level: 'silent' }) const mockTransactionDb = { insert: () => Promise.resolve([{ id: '42' }]), + get: () => Promise.resolve([validConnection]), + update: () => Promise.resolve(), } const mockDb = { get: (tableName: string, where?: Record) => { if (tableName !== 'connection') throw new Error('Invalid table') if (where?.company_number === validCompanyNumber) return [] if (where?.company_number === validExistingCompanyNumber) return [{}] + if (where?.id === '4a5d4085-5924-43c6-b60d-754440332e3d') return [validConnection] return [] }, withTransaction: (fn: Function) => { @@ -67,6 +104,7 @@ export const withNewConnectionMocks = () => { throw new Error('Invalid number') }, } as unknown as CompanyHouseEntity + const mockCloudagent = { createOutOfBandInvite: ({ companyName }: { companyName: string }) => { return { @@ -103,15 +141,20 @@ export const withNewConnectionMocks = () => { } as unknown as NewInviteTemplates const mockFromInvite = { fromInviteFormPage: (feedback: FormFeedback) => templateFake('fromInvitePage', feedback.type), - fromInviteForm: ({ feedback, formStage }: any) => + fromInviteForm: ({ feedback }: any) => templateFake( 'fromInviteForm', feedback.type, feedback.company?.company_name || '', - feedback.message || feedback.error || '', - formStage + feedback.message || feedback.error || '' ), } as unknown as FromInviteTemplates + const mockPinForm = { + renderPinForm: (props: { connectionId: string; pin?: string; continuationFromInvite: boolean }) => + templateFake('renderPinForm', props.connectionId, props.pin, props.continuationFromInvite), + renderSuccess: (props: { companyName: string; stepCount: number }) => + templateFake('renderSuccess', props.companyName, props.stepCount), + } as unknown as PinSubmissionTemplates const mockEnv = { get: (name: string) => { @@ -134,6 +177,7 @@ export const withNewConnectionMocks = () => { mockEmail, mockNewInvite, mockFromInvite, + mockPinForm, mockEnv, mockLogger, args: [ @@ -143,6 +187,7 @@ export const withNewConnectionMocks = () => { mockEmail, mockNewInvite, mockFromInvite, + mockPinForm, mockEnv, mockLogger, ] as const, diff --git a/src/controllers/connection/__tests__/index.test.ts b/src/controllers/connection/__tests__/index.test.ts index f37dcb93..8d052e7e 100644 --- a/src/controllers/connection/__tests__/index.test.ts +++ b/src/controllers/connection/__tests__/index.test.ts @@ -5,6 +5,7 @@ import sinon from 'sinon' import { toHTMLString, withConnectionMocks } from './helpers.js' import { ConnectionController } from '../index.js' +import { validConnection } from './fixtures.js' describe('ConnectionController', () => { afterEach(() => { @@ -13,27 +14,96 @@ describe('ConnectionController', () => { describe('listConnections', () => { it('should return rendered list template', async () => { - let { dbMock, mockLogger, templateMock } = withConnectionMocks() - const controller = new ConnectionController(dbMock, templateMock, mockLogger) + let { args } = withConnectionMocks() + const controller = new ConnectionController(...args) const result = await controller.listConnections().then(toHTMLString) - expect(result).to.equal('list_foo-verified_list') + expect(result).to.equal('list_foo-unverified_list') }) it('should call db as expected', async () => { - let { dbMock, mockLogger, templateMock } = withConnectionMocks() - const controller = new ConnectionController(dbMock, templateMock, mockLogger) - const spy = sinon.spy(dbMock, 'get') + let { dbMock, args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const spy: sinon.SinonSpy = sinon.spy(dbMock, 'get') await controller.listConnections().then(toHTMLString) expect(spy.calledWith('connection', {}, [['updated_at', 'desc']])).to.equal(true) }) it('should call db as expected', async () => { - let { dbMock, mockLogger, templateMock } = withConnectionMocks() - const controller = new ConnectionController(dbMock, templateMock, mockLogger) - const spy = sinon.spy(dbMock, 'get') + let { dbMock, args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const spy: sinon.SinonSpy = sinon.spy(dbMock, 'get') await controller.listConnections('dig').then(toHTMLString) const search = 'dig' const query = search ? [['company_name', 'ILIKE', `%${search}%`]] : {} expect(spy.calledWith('connection', query, [['updated_at', 'desc']])).to.equal(true) }) }) + + describe('renderPinCode', () => { + it('should render form', async () => { + let { args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const result = await controller.renderPinCode(validConnection.id, '123456').then(toHTMLString) + expect(result).to.equal('renderPinForm_4a5d4085-5924-43c6-b60d-754440332e3d-123456-false_renderPinForm') + }) + }) + + describe('submitPinCode', () => { + it('should render error if it is longer than 6 digits', async () => { + let { args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const result = await controller + .submitPinCode({ action: 'submitPinCode', pin: '123456782' }, validConnection.id) + .then(toHTMLString) + expect(result).to.equal('renderPinForm_4a5d4085-5924-43c6-b60d-754440332e3d-123456782-false_renderPinForm') + }) + + it('also should render error if it combined characters and numbers', async () => { + let { args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const result = await controller + .submitPinCode({ action: 'submitPinCode', pin: '1235235asdasd' }, validConnection.id) + .then(toHTMLString) + expect(result).to.equal('renderPinForm_4a5d4085-5924-43c6-b60d-754440332e3d-1235235asdasd-false_renderPinForm') + }) + + it('should accept only numbers', async () => { + let { args } = withConnectionMocks() + const controller = new ConnectionController(...args) + const result = await controller + .submitPinCode({ action: 'submitPinCode', pin: 'not-valid-code' }, validConnection.id) + .then(toHTMLString) + expect(result).to.equal('renderPinForm_4a5d4085-5924-43c6-b60d-754440332e3d-not-valid-code-false_renderPinForm') + }) + + it('renders a success screen', async () => { + let { args, cloudagentMock } = withConnectionMocks() + const controller = new ConnectionController(...args) + const result = await controller + .submitPinCode({ action: 'submitPinCode', pin: '111111' }, validConnection.id) + .then(toHTMLString) + expect(result).to.equal('renderSuccess_foo-2_renderSuccess') + expect(cloudagentMock.proposeCredential.calledOnce).to.equal(true) + expect(cloudagentMock.proposeCredential.firstCall.args).to.deep.equal([ + 'AGENT_CONNECTION_ID', + { + schemaName: 'COMPANY_DETAILS', + schemaVersion: '1.0.0', + attributes: [ + { + name: 'company_name', + value: 'COMPANY_NAME', + }, + { + name: 'company_number', + value: 'COMPANY_NUMBER', + }, + { + name: 'pin', + value: '111111', + }, + ], + }, + ]) + }) + }) }) diff --git a/src/controllers/connection/__tests__/newConnection.test.ts b/src/controllers/connection/__tests__/newConnection.test.ts index 8b0024ee..5f8c3ebc 100644 --- a/src/controllers/connection/__tests__/newConnection.test.ts +++ b/src/controllers/connection/__tests__/newConnection.test.ts @@ -54,6 +54,7 @@ describe('NewConnectionController', () => { it('should return rendered error when company not found', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) + const result = await controller.verifyCompanyForm(notFoundCompanyNumber).then(toHTMLString) expect(result).to.equal('companyFormInput_error--Company number does not exist-form--00000000_companyFormInput') }) @@ -103,35 +104,35 @@ describe('NewConnectionController', () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(invalidBase64Invite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should rendered error when invite invalid format', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(invalidInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should rendered error when company number invalid', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(invalidCompanyNumberInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should return rendered error when company not found', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(notFoundCompanyNumberInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Company number does not exist-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Company number does not exist_fromInviteForm') }) it('should return rendered error when company already connected', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(validExistingCompanyNumberInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2_fromInviteForm') }) it('should return rendered error when company registered office in dispute', async () => { @@ -139,7 +140,7 @@ describe('NewConnectionController', () => { const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(validCompanyNumberInDisputeInvite).then(toHTMLString) expect(result).to.equal( - 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute-invite_fromInviteForm' + 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute_fromInviteForm' ) }) @@ -147,60 +148,14 @@ describe('NewConnectionController', () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(validCompanyNumberInactiveInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active_fromInviteForm') }) it('should return success form', async () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.verifyInviteForm(validCompanyNumberInvite).then(toHTMLString) - expect(result).to.equal('fromInviteForm_companyFound-NAME--invite_fromInviteForm') - }) - }) - - describe('fromInvitePin', () => { - it('should render error if it is longer than 6 digits', async () => { - let { args } = withNewConnectionMocks() - const controller = new NewConnectionController(...args) - const result = await controller - .submitFromInvite({ invite: 'invite', pin: '12345678', action: 'pinSubmission' }) - .then(toHTMLString) - expect(result).to.equal( - 'fromInviteForm_message--PIN code has been submitted for other party to verify.-success_fromInviteForm' - ) - }) - - it('also should render error if it combined characters and numbers', async () => { - let { args } = withNewConnectionMocks() - const controller = new NewConnectionController(...args) - const result = await controller - .submitFromInvite({ invite: 'invite', pin: '12345678asdg', action: 'pinSubmission' }) - .then(toHTMLString) - expect(result).to.equal( - 'fromInviteForm_message--PIN code has been submitted for other party to verify.-success_fromInviteForm' - ) - }) - - it('should accept only numbers', async () => { - let { args } = withNewConnectionMocks() - const controller = new NewConnectionController(...args) - const result = await controller - .submitFromInvite({ invite: 'invite', pin: 'asdasd', action: 'pinSubmission' }) - .then(toHTMLString) - expect(result).to.equal( - 'fromInviteForm_message--PIN code has been submitted for other party to verify.-success_fromInviteForm' - ) - }) - - it('renders a success screen', async () => { - let { args } = withNewConnectionMocks() - const controller = new NewConnectionController(...args) - const result = await controller - .submitFromInvite({ invite: 'invite', pin: '123456', action: 'pinSubmission' }) - .then(toHTMLString) - expect(result).to.equal( - 'fromInviteForm_message--PIN code has been submitted for other party to verify.-success_fromInviteForm' - ) + expect(result).to.equal('fromInviteForm_companyFound-NAME-_fromInviteForm') }) }) @@ -409,7 +364,7 @@ describe('NewConnectionController', () => { let { args } = withNewConnectionMocks() const controller = new NewConnectionController(...args) const result = await controller.submitFromInvite({ invite: '', action: 'createConnection' }).then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should rendered error when invite invalid base64', async () => { @@ -418,7 +373,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: invalidBase64Invite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should rendered error when invite invalid format', async () => { @@ -427,7 +382,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: invalidInvite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should rendered error when company number invalid', async () => { @@ -436,7 +391,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: invalidCompanyNumberInvite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Invitation is not valid-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Invitation is not valid_fromInviteForm') }) it('should return rendered error when company not found', async () => { @@ -445,7 +400,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: notFoundCompanyNumberInvite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Company number does not exist-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Company number does not exist_fromInviteForm') }) it('should return rendered error when company already connected', async () => { @@ -454,7 +409,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: validExistingCompanyNumberInvite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME2_fromInviteForm') }) it('should return rendered error when company registered office in dispute', async () => { @@ -464,7 +419,7 @@ describe('NewConnectionController', () => { .submitFromInvite({ invite: validCompanyNumberInDisputeInvite, action: 'createConnection' }) .then(toHTMLString) expect(result).to.equal( - 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute-invite_fromInviteForm' + 'fromInviteForm_error--Cannot validate company NAME3 as address is currently in dispute_fromInviteForm' ) }) @@ -474,7 +429,7 @@ describe('NewConnectionController', () => { const result = await controller .submitFromInvite({ invite: validCompanyNumberInactiveInvite, action: 'createConnection' }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Company NAME4 is not active_fromInviteForm') }) it('should return rendered error when unique constraint is violated', async () => { @@ -492,7 +447,7 @@ describe('NewConnectionController', () => { }) .then(toHTMLString) - expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME-invite_fromInviteForm') + expect(result).to.equal('fromInviteForm_error--Connection already exists with NAME_fromInviteForm') }) it('should return success even if email send fails', async () => { @@ -508,9 +463,7 @@ describe('NewConnectionController', () => { }) .then(toHTMLString) - expect(result).to.equal( - 'fromInviteForm_message--This is a second step of verification. Please enter a 6 digit code.-pin_fromInviteForm' - ) + expect(result).to.equal('renderPinForm_42--true_renderPinForm') }) describe('happy path assertions', function () { @@ -540,9 +493,7 @@ describe('NewConnectionController', () => { }) it('should return success form', () => { - expect(result).to.equal( - 'fromInviteForm_message--This is a second step of verification. Please enter a 6 digit code.-pin_fromInviteForm' - ) + expect(result).to.equal('renderPinForm_42--true_renderPinForm') }) it('should insert two row', () => { diff --git a/src/controllers/connection/index.ts b/src/controllers/connection/index.ts index 50e35108..484956d3 100644 --- a/src/controllers/connection/index.ts +++ b/src/controllers/connection/index.ts @@ -1,9 +1,16 @@ -import { Get, Produces, Query, Route, Security, SuccessResponse } from 'tsoa' +import { Body, Get, Path, Post, Produces, Query, Route, Security, SuccessResponse } from 'tsoa' import { inject, injectable, singleton } from 'tsyringe' +import { pinCodeRegex, type PIN_CODE, type UUID } from '../../models/strings.js' +import ConnectionTemplates from '../../views/connection/connection.js' + +import { InvalidInputError, NotFoundError } from '../../errors.js' import { Logger, type ILogger } from '../../logger.js' +import CompanyHouseEntity, { CompanyProfile } from '../../models/companyHouseEntity.js' import Database from '../../models/db/index.js' -import ConnectionTemplates from '../../views/connection.js' +import { ConnectionRow } from '../../models/db/types.js' +import VeritableCloudagent from '../../models/veritableCloudagent.js' +import { PinSubmissionTemplates } from '../../views/newConnection/pinSubmission.js' import { HTML, HTMLController } from '../HTMLController.js' @singleton() @@ -14,11 +21,14 @@ import { HTML, HTMLController } from '../HTMLController.js' export class ConnectionController extends HTMLController { constructor( private db: Database, + private cloudagent: VeritableCloudagent, + private companyHouse: CompanyHouseEntity, private connectionTemplates: ConnectionTemplates, + private pinSubmission: PinSubmissionTemplates, @inject(Logger) private logger: ILogger ) { super() - this.logger = logger.child({ controller: '/' }) + this.logger = logger.child({ controller: '/conecttion' }) } /** @@ -34,4 +44,78 @@ export class ConnectionController extends HTMLController { this.setHeader('HX-Replace-Url', search ? `/connection?search=${encodeURIComponent(search)}` : `/connection`) return this.html(this.connectionTemplates.listPage(connections, search)) } + + /** + * render pin code submission form + * @param companyNumber - for retrieving a connection from a db + * @param pin - a pin code + * @returns + */ + @SuccessResponse(200) + @Get('/{connectionId}/pin-submission') + public async renderPinCode(@Path() connectionId: UUID, @Query() pin?: PIN_CODE | string): Promise { + this.logger.debug('PIN_SUBMISSION GET: %o', { connectionId, pin }) + + const [connection]: ConnectionRow[] = await this.db.get('connection', { id: connectionId }) + if (!connection) { + throw new NotFoundError(`[connection]: ${connectionId}`) + } + + return this.html(this.pinSubmission.renderPinForm({ connectionId, pin: pin ?? '', continuationFromInvite: false })) + } + + /** + * handles PIN code submission form submit action + * @param body - contains forms inputs + * @returns + */ + @SuccessResponse(200) + @Post('/{connectionId}/pin-submission') + public async submitPinCode( + @Body() body: { action: 'submitPinCode'; pin: PIN_CODE | string; stepCount?: number }, + @Path() connectionId: UUID + ): Promise { + this.logger.debug('PIN_SUBMISSION POST: %o', { body }) + const { pin } = body + + if (!pin.match(pinCodeRegex)) { + return this.html(this.pinSubmission.renderPinForm({ connectionId, pin, continuationFromInvite: false })) + } + + const profile = await this.companyHouse.localCompanyHouseProfile() + + const [connection]: ConnectionRow[] = await this.db.get('connection', { id: connectionId }) + + if (!connection) throw new NotFoundError(`[connection]: ${connectionId}`) + + const agentConnectionId = connection.agent_connection_id + if (!agentConnectionId) throw new InvalidInputError('Cannot verify PIN on a pending connection') + + await this.verifyReceiveConnection(agentConnectionId, profile, pin) + + return this.html( + this.pinSubmission.renderSuccess({ companyName: connection.company_name, stepCount: body.stepCount ?? 2 }) + ) + } + + private async verifyReceiveConnection(agentConnectionId: string, profile: CompanyProfile, pin: string) { + await this.cloudagent.proposeCredential(agentConnectionId, { + schemaName: 'COMPANY_DETAILS', + schemaVersion: '1.0.0', + attributes: [ + { + name: 'company_name', + value: profile.company_name, + }, + { + name: 'company_number', + value: profile.company_number, + }, + { + name: 'pin', + value: pin, + }, + ], + }) + } } diff --git a/src/controllers/connection/newConnection.ts b/src/controllers/connection/newConnection.ts index e10fc57e..78e28b8d 100644 --- a/src/controllers/connection/newConnection.ts +++ b/src/controllers/connection/newConnection.ts @@ -20,12 +20,12 @@ import { import VeritableCloudagent from '../../models/veritableCloudagent.js' import { FromInviteTemplates } from '../../views/newConnection/fromInvite.js' import { NewInviteFormStage, NewInviteTemplates } from '../../views/newConnection/newInvite.js' +import { PinSubmissionTemplates } from '../../views/newConnection/pinSubmission.js' import { HTML, HTMLController } from '../HTMLController.js' const submitToFormStage = { back: 'form', continue: 'confirmation', - pin: 'pin', submit: 'success', } as const @@ -48,6 +48,7 @@ export class NewConnectionController extends HTMLController { private email: EmailService, private newInvite: NewInviteTemplates, private fromInvite: FromInviteTemplates, + private pinSubmission: PinSubmissionTemplates, private env: Env, @inject(Logger) private logger: ILogger ) { @@ -135,7 +136,6 @@ export class NewConnectionController extends HTMLController { type: 'companyFound', company, }, - formStage: 'invite', }) ) } @@ -202,27 +202,11 @@ export class NewConnectionController extends HTMLController { @Post('/receive-invitation') public async submitFromInvite( @Body() - body: - | { - invite: BASE_64_URL - action: 'createConnection' - } - | { - invite: BASE_64_URL - pin: string - action: 'pinSubmission' - } - ): Promise { - // handle pinSubmission - if (body.action === 'pinSubmission' && body.pin) { - this.logger.info(`pin code [${body.pin}] has been submitted.`) - return this.receivePinSuccessHtml(body.pin) - } - - if (body.action !== 'createConnection') { - return this.receiveInviteErrorHtml('Invalid action supplied with invitation') + body: { + invite: BASE_64_URL | string + action: 'createConnection' } - + ): Promise { if (body.invite && !body.invite.match(base64UrlRegex)) { return this.receiveInviteErrorHtml('Invitation is not valid') } @@ -261,7 +245,8 @@ export class NewConnectionController extends HTMLController { await this.sendAdminEmail(inviteOrError.company, pin) this.logger.debug('NEW_CONNECTION: complete: %s', dbResult.connectionId) - return this.receiveInviteSuccessHtml(inviteOrError.inviteUrl) + this.setHeader('HX-Replace-Url', `/connection/${dbResult.connectionId}/pin-submission`) + return this.receivePinSubmissionHtml(dbResult.connectionId) } private async decodeInvite( @@ -436,30 +421,8 @@ export class NewConnectionController extends HTMLController { ) } - private receivePinSuccessHtml(pin: string) { - return this.html( - this.fromInvite.fromInviteForm({ - feedback: { - type: 'message', - message: 'PIN code has been submitted for other party to verify.', - }, - pin, - formStage: 'success', - }) - ) - } - - private receiveInviteSuccessHtml(invite: string) { - return this.html( - this.fromInvite.fromInviteForm({ - feedback: { - type: 'message', - message: 'This is a second step of verification. Please enter a 6 digit code.', - }, - invite: Buffer.from(JSON.stringify(invite), 'utf8').toString('base64url'), - formStage: 'pin', - }) - ) + private receivePinSubmissionHtml(connectionId: string) { + return this.html(this.pinSubmission.renderPinForm({ connectionId, continuationFromInvite: true })) } private receiveInviteErrorHtml(message: string) { @@ -469,7 +432,6 @@ export class NewConnectionController extends HTMLController { type: 'error', error: message, }, - formStage: 'invite', }) ) } diff --git a/src/controllers/homepageController.ts b/src/controllers/homepageController.ts index 434e1187..8ace56bb 100644 --- a/src/controllers/homepageController.ts +++ b/src/controllers/homepageController.ts @@ -3,7 +3,7 @@ import { inject, injectable, singleton } from 'tsyringe' import { Logger, type ILogger } from '../logger.js' -import HomepageTemplates from '../views/homepage.js' +import HomepageTemplates from '../views/homepage/homepage.js' import { HTML, HTMLController } from './HTMLController.js' @singleton() diff --git a/src/controllers/queries/__tests__/helpers.ts b/src/controllers/queries/__tests__/helpers.ts index 3586da71..db8dbdb4 100644 --- a/src/controllers/queries/__tests__/helpers.ts +++ b/src/controllers/queries/__tests__/helpers.ts @@ -2,8 +2,8 @@ import { Readable } from 'node:stream' import { pino } from 'pino' import { ILogger } from '../../../logger.js' import Database from '../../../models/db/index.js' -import QueriesTemplates from '../../../views/queries.js' -import QueryListTemplates from '../../../views/queriesList.js' +import QueriesTemplates from '../../../views/queries/queries.js' +import QueryListTemplates from '../../../views/queries/queriesList.js' type QueryStatus = 'resolved' | 'pending_your_input' | 'pending_their_input' diff --git a/src/controllers/queries/index.ts b/src/controllers/queries/index.ts index a072bb74..664300ff 100644 --- a/src/controllers/queries/index.ts +++ b/src/controllers/queries/index.ts @@ -5,8 +5,8 @@ import { Logger, type ILogger } from '../../logger.js' import Database from '../../models/db/index.js' import { ConnectionRow, QueryRow } from '../../models/db/types.js' -import QueriesTemplates from '../../views/queries.js' -import QueryListTemplates from '../../views/queriesList.js' +import QueriesTemplates from '../../views/queries/queries.js' +import QueryListTemplates from '../../views/queries/queriesList.js' import { HTML, HTMLController } from '../HTMLController.js' type QueryStatus = 'resolved' | 'pending_your_input' | 'pending_their_input' diff --git a/src/errors.ts b/src/errors.ts index c037f6cb..314efa1b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -2,6 +2,16 @@ export abstract class HttpError extends Error { public abstract get code(): number } +export class NotFoundError extends HttpError { + constructor(item: string) { + super(`${item} - not found`) + } + + public get code(): number { + return 404 + } +} + export class InvalidInputError extends HttpError { constructor(message?: string) { super(message) diff --git a/src/models/__tests__/companyHouseEntity.test.ts b/src/models/__tests__/companyHouseEntity.test.ts index 806e1752..64fc81ef 100644 --- a/src/models/__tests__/companyHouseEntity.test.ts +++ b/src/models/__tests__/companyHouseEntity.test.ts @@ -42,4 +42,13 @@ describe('companyHouseEntity', () => { expect((errorMessage as Error).message).equals(`Error calling CompanyHouse API`) }) }) + + describe('localCompanyHouseProfile', () => { + it('should return company found', async () => { + const environment = new Env() + const companyHouseObject = new CompanyHouseEntity(environment) + const response = await companyHouseObject.localCompanyHouseProfile() + expect(response).deep.equal(successResponse) + }) + }) }) diff --git a/src/models/__tests__/helpers/mockCompanyHouse.ts b/src/models/__tests__/helpers/mockCompanyHouse.ts index d1ffb4cc..bd9731cc 100644 --- a/src/models/__tests__/helpers/mockCompanyHouse.ts +++ b/src/models/__tests__/helpers/mockCompanyHouse.ts @@ -25,6 +25,7 @@ export function withCompanyHouseMock() { method: 'GET', }) .reply(200, successResponse) + .persist() client .intercept({ diff --git a/src/models/companyHouseEntity.ts b/src/models/companyHouseEntity.ts index 52b498aa..c05b455e 100644 --- a/src/models/companyHouseEntity.ts +++ b/src/models/companyHouseEntity.ts @@ -49,7 +49,19 @@ export type CompanyProfileResult = @singleton() @injectable() export default class CompanyHouseEntity { - constructor(private env: Env) {} + private localCompanyHouseProfilePromise: Promise + + constructor(private env: Env) { + this.localCompanyHouseProfilePromise = this.getCompanyProfileByCompanyNumber( + env.get('INVITATION_FROM_COMPANY_NUMBER') + ).then((result) => { + if (result.type === 'notFound') { + throw new Error('Invalid local company house number configuration') + } + + return result.company + }) + } private async makeCompanyProfileRequest(route: string): Promise { const url = new URL(route) @@ -82,4 +94,8 @@ export default class CompanyHouseEntity { ? { type: 'notFound' } : { type: 'found', company: companyProfileSchema.parse(companyProfile) } } + + async localCompanyHouseProfile(): Promise { + return await this.localCompanyHouseProfilePromise + } } diff --git a/src/models/db/index.ts b/src/models/db/index.ts index 1ad5e3e6..808f2fff 100644 --- a/src/models/db/index.ts +++ b/src/models/db/index.ts @@ -30,10 +30,9 @@ export default class Database { private db: IDatabase constructor(private client = clientSingleton) { - this.client = client const models: IDatabase = tablesList.reduce((acc, name) => { return { - [name]: () => clientSingleton(name), + [name]: () => this.client(name), ...acc, } }, {}) as IDatabase diff --git a/src/models/strings.ts b/src/models/strings.ts index e8aa4809..c40634b1 100644 --- a/src/models/strings.ts +++ b/src/models/strings.ts @@ -30,13 +30,13 @@ export type EMAIL = string /** * Pin Code format - * @pattern ^[1-9][0-9]{5}$ + * @pattern ^[0-9]{6}$ * @minLength 6 * @maxLength 6 * @example 123456 */ export type PIN_CODE = string -export const pinCodeRegex = /^[1-9][0-9]{5}$/ +export const pinCodeRegex = /^[0-9]{6}$/ /** * Company number format diff --git a/src/services/veritableCloudagentEvents.ts b/src/services/veritableCloudagentEvents.ts index 6ad2ae5d..bf22a435 100644 --- a/src/services/veritableCloudagentEvents.ts +++ b/src/services/veritableCloudagentEvents.ts @@ -142,6 +142,7 @@ export default class VeritableCloudagentEvents extends IndexedAsyncEventEmitter< this.socket.removeEventListener('close', this.closeHandler) this.socket.close() this.socket = undefined + this.removeAllListeners() return } diff --git a/src/views/__tests__/queries.test.ts.snap b/src/views/__tests__/queries.test.ts.snap deleted file mode 100644 index 270d1730..00000000 --- a/src/views/__tests__/queries.test.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConnectionTemplates listPage should escape html in name 1`] = `"Veritable - Queries

Query Management

Query Management
Query Request
Query Management
Company NameQuery TypeDirectionRequested deadlineVerification StatusActions
<div>I own you</div>Type A

Received

Tue Jul 02 2024 15:38:10 GMT+0100 (British Summer Time)
Resolved
some action
"`; - -exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Queries
"`; - -exports[`ConnectionTemplates listPage should render with single query 1`] = `"Veritable - Queries

Query Management

Query Management
Query Request
Query Management
Company NameQuery TypeDirectionRequested deadlineVerification StatusActions
Company AType A

Received

Tue Jul 02 2024 15:38:10 GMT+0100 (British Summer Time)
Resolved
some action
"`; - -exports[`ConnectionTemplates listPage should render with single qury 1`] = `"Veritable - Queries

Query Management

Query Management
Query Request
Query Management
Company NameQuery TypeDirectionRequested deadlineVerification StatusActions
Company AType A

Received

Tue Jul 02 2024 15:37:09 GMT+0100 (British Summer Time)
Resolved
some action
"`; diff --git a/src/views/__tests__/connection.test.ts b/src/views/connection/__tests__/connection.test.ts similarity index 100% rename from src/views/__tests__/connection.test.ts rename to src/views/connection/__tests__/connection.test.ts diff --git a/src/views/__tests__/connection.test.ts.snap b/src/views/connection/__tests__/connection.test.ts.snap similarity index 90% rename from src/views/__tests__/connection.test.ts.snap rename to src/views/connection/__tests__/connection.test.ts.snap index c269e3de..32f61824 100644 --- a/src/views/__tests__/connection.test.ts.snap +++ b/src/views/connection/__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
Complete Verification
"`; -exports[`ConnectionTemplates listPage should render multiple with each status 1`] = `"Veritable - Connections

Connections

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

Connections

Connections
Company NameVerification StatusActions
Company A
Disconnected
Complete Verification
Company B
Pending
Complete Verification
Company C
Unverified
Complete Verification
Company D
Verified - Established Connection
Complete Verification
Company E
Pending Your Verification
Complete Verification
Company F
Pending Their Verification
Complete Verification
"`; 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/connection.tsx similarity index 74% rename from src/views/connection.tsx rename to src/views/connection/connection.tsx index f14d026a..fa02174c 100644 --- a/src/views/connection.tsx +++ b/src/views/connection/connection.tsx @@ -1,12 +1,14 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { ButtonIcon, Page } from './common.js' +import { ConnectionRow } from '../../models/db/types.js' +import { ButtonIcon, Page } from '../common.js' type ConnectionStatus = 'pending' | 'unverified' | 'verified_them' | 'verified_us' | 'verified_both' | 'disconnected' interface connection { company_name: string status: ConnectionStatus + id?: string } @singleton() @@ -38,7 +40,7 @@ export default class ConnectionTemplates { } } - public listPage = (connections: connection[], search: string = '') => { + public listPage = (connections: ConnectionRow[] | connection[], search: string = '') => { return ( No Connections for that search query. Try again or add a new connection ) : ( - connections.map((connection) => ( - - {Html.escapeHtml(connection.company_name)} - {this.statusToClass(connection.status)} - - - - - )) + connections.map(({ company_name, id, status }) => { + const isVerified = ['unverified', 'verified_them'].includes(status) + const actionHref = isVerified ? `/connection/${id}/pin-submission` : '#' + return ( + + {Html.escapeHtml(company_name)} + {this.statusToClass(status)} + + + + + ) + }) )} diff --git a/src/views/homepage.tsx b/src/views/homepage/homepage.tsx similarity index 98% rename from src/views/homepage.tsx rename to src/views/homepage/homepage.tsx index 8f0c444d..106e9a32 100644 --- a/src/views/homepage.tsx +++ b/src/views/homepage/homepage.tsx @@ -1,6 +1,6 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { Page } from './common.js' +import { Page } from '../common.js' @singleton() export default class HomepageTemplates { diff --git a/src/views/newConnection/__tests__/fromInvite.test.ts b/src/views/newConnection/__tests__/fromInvite.test.ts index 05254815..f5f42131 100644 --- a/src/views/newConnection/__tests__/fromInvite.test.ts +++ b/src/views/newConnection/__tests__/fromInvite.test.ts @@ -4,17 +4,11 @@ import { describe, it } from 'mocha' import { FromInviteTemplates } from '../fromInvite.js' import { testErrorTargetBox, testMessageTargetBox, testSuccessTargetBox } from './fixtures.js' -describe('NewInviteTemplates', () => { +describe('FromInviteTemplates', () => { describe('show form', () => { it('should render form with a error message and invalid response', async () => { const templates = new FromInviteTemplates() - const rendered = await templates.fromInviteForm({ feedback: testMessageTargetBox, formStage: 'invite' }) - expect(rendered).to.matchSnapshot() - }) - - it('should render form with a valid response', async () => { - const templates = new FromInviteTemplates() - const rendered = await templates.fromInviteForm({ feedback: testSuccessTargetBox, formStage: 'success' }) + const rendered = await templates.fromInviteForm({ feedback: testMessageTargetBox }) expect(rendered).to.matchSnapshot() }) diff --git a/src/views/newConnection/__tests__/fromInvite.test.ts.snap b/src/views/newConnection/__tests__/fromInvite.test.ts.snap index d0a61597..5187e2e6 100644 --- a/src/views/newConnection/__tests__/fromInvite.test.ts.snap +++ b/src/views/newConnection/__tests__/fromInvite.test.ts.snap @@ -1,11 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 3

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; +exports[`FromInviteTemplates show form should render a web page with the a form in an empty state 1`] = `"Veritable - New Connection
Invite New Connection
Step 1 of 3

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; -exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 2`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 3
This is a message
Cancel
"`; +exports[`FromInviteTemplates show form should render a web page with the a form in an empty state 2`] = `"Veritable - New Connection
Invite New Connection
Step 1 of 3
This is a message
Cancel
"`; -exports[`NewInviteTemplates show form should render a web page with the a form in an error state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 3
This is a test error message
Cancel
"`; +exports[`FromInviteTemplates show form should render a web page with the a form in an error state 1`] = `"Veritable - New Connection
Invite New Connection
Step 1 of 3
This is a test error message
Cancel
"`; -exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
*\\">
Step 1 of 3
This is a message
Cancel
"`; +exports[`FromInviteTemplates show form should render form with a error message and invalid response 1`] = `"
Step 1 of 3
This is a message
Cancel
"`; -exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
*\\">
Step 3 of 3

Your connection has been established, but still needs to be verified. You should receive a verification letter at your registered business with instructions on how to do this. A reciprocal verification request has been sent in the post on your behalf to the address on the right to verify their identity

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; +exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 2

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; + +exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 2`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 2
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should render a web page with the a form in an error state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 2
This is a test error message
Cancel
"`; + +exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
*\\">
Step 1 of 2
This is a message
Cancel
"`; + +exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
*\\">
Step 2 of 2

Your connection has been established, but still needs to be verified. You should receive a verification letter at your registered business with instructions on how to do this. A reciprocal verification request has been sent in the post on your behalf to the address on the right to verify their identity

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; diff --git a/src/views/newConnection/__tests__/newInvite.test.ts.snap b/src/views/newConnection/__tests__/newInvite.test.ts.snap index 5df8e548..4267ad08 100644 --- a/src/views/newConnection/__tests__/newInvite.test.ts.snap +++ b/src/views/newConnection/__tests__/newInvite.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`NewInviteTemplates show form should a web page with the a form in an empty state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 3
This is a message
Cancel
"`; +exports[`NewInviteTemplates show form should a web page with the a form in an empty state 1`] = `"Veritable - New Connection
Invite New Connection
Step 1 of 3
This is a message
Cancel
"`; -exports[`NewInviteTemplates show form should a web page with the a form in an error state 1`] = `"Veritable - New Connection
Invite New Connection
*\\">
Step 1 of 3
This is a test error message
Cancel
"`; +exports[`NewInviteTemplates show form should a web page with the a form in an error state 1`] = `"Veritable - New Connection
Invite New Connection
Step 1 of 3
This is a test error message
Cancel
"`; -exports[`NewInviteTemplates show form should render a confirmation page with given email and company number 1`] = `"
*\\">
Step 2 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: 123@123.com

After clicking submit, a connection invitation will be sent to their email and postal address.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; +exports[`NewInviteTemplates show form should render a confirmation page with given email and company number 1`] = `"
Step 2 of 3

Please confirm the details of the connection before sending

Company House Number: 07964699

Email Address: 123@123.com

After clicking submit, a connection invitation will be sent to their email and postal address.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; -exports[`NewInviteTemplates show form should render a success response page with a single button to return to home 1`] = `"
*\\">
Step 3 of 3

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; +exports[`NewInviteTemplates show form should render a success response page with a single button to return to home 1`] = `"
Step 3 of 3

Your connection invitation has been sent. Please wait for their verification. As the post may take 2-3 days to arrive, please wait for their verification and keep updated by viewing the verification status.

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
"`; -exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
*\\">
Step 1 of 3
This is a message
Cancel
"`; +exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"
Step 1 of 3
This is a message
Cancel
"`; -exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
*\\">
Step 1 of 3

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; +exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"
Step 1 of 3

Registered Office AddressDIGITAL CATAPULT, Level 9, 101 Euston Road, London, NW1 2RA

Company Statusactive

\\"Description
Cancel
"`; diff --git a/src/views/newConnection/__tests__/pinSubmission.test.ts b/src/views/newConnection/__tests__/pinSubmission.test.ts new file mode 100644 index 00000000..7b963a67 --- /dev/null +++ b/src/views/newConnection/__tests__/pinSubmission.test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { PinSubmissionTemplates } from '../pinSubmission.js' + +describe('PinSubmissionTemplates', () => { + describe('show form', () => { + it('should render form as a continuation of from invite', async () => { + const templates = new PinSubmissionTemplates() + const rendered = await templates.renderPinForm({ connectionId: 'CONNECTION_ID', continuationFromInvite: true }) + expect(rendered).to.matchSnapshot() + }) + + it('should render form as a stand alone flow', async () => { + const templates = new PinSubmissionTemplates() + const rendered = await templates.renderPinForm({ connectionId: 'CONNECTION_ID', continuationFromInvite: false }) + expect(rendered).to.matchSnapshot() + }) + + it('should render form with PIN', async () => { + const templates = new PinSubmissionTemplates() + const rendered = await templates.renderPinForm({ + connectionId: 'CONNECTION_ID', + continuationFromInvite: true, + pin: '123456', + }) + expect(rendered).to.matchSnapshot() + }) + }) +}) diff --git a/src/views/newConnection/__tests__/pinSubmission.test.ts.snap b/src/views/newConnection/__tests__/pinSubmission.test.ts.snap new file mode 100644 index 00000000..cc64b4d9 --- /dev/null +++ b/src/views/newConnection/__tests__/pinSubmission.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewInviteTemplates show form should render a web page with the a form in an empty state 1`] = `"
*\\">
Step 2 of 2

PIN Code 123456 has been submitted for 00000001 company ID. Please wait for the verification code to be confirmed by viewing the verification. status.

[submitPinCode]: PIN code has been sucessfully submitted and will need to be verified by the issuer now.
"`; + +exports[`NewInviteTemplates show form should render form with a error message and invalid response 1`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
*\\">
Step 1 of 2

Please enter the verification code from the physical letter

"`; + +exports[`NewInviteTemplates show form should render form with a valid response 1`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
*\\">
Step 1 of 2

Please enter the verification code from the physical letter

"`; + +exports[`NewInviteTemplates show form should render form with a valid response 2`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
*\\">
Step 1 of 2

Please enter the verification code from the physical letter

"`; + +exports[`PinSubmissionTemplates show form should render form as a continuation of from invite 1`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
Step 2 of 3

Please enter the verification code from the physical letter

"`; + +exports[`PinSubmissionTemplates show form should render form as a stand alone flow 1`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
Step 1 of 2

Please enter the verification code from the physical letter

"`; + +exports[`PinSubmissionTemplates show form should render form with PIN 1`] = `"Veritable - New Connection

New Connection - PIN Verification

PIN Code submission
Step 2 of 3

Please enter the verification code from the physical letter

"`; diff --git a/src/views/newConnection/base.tsx b/src/views/newConnection/base.tsx index c1bb9e7d..0e49d847 100644 --- a/src/views/newConnection/base.tsx +++ b/src/views/newConnection/base.tsx @@ -31,18 +31,26 @@ export type FormAction = export abstract class NewConnectionTemplates { protected newConnectionForm = ( props: Html.PropsWithChildren<{ - submitRoute: 'create-invitation' | 'receive-invitation' | 'pin-submission' - feedback: FormFeedback + submitRoute?: string + feedback?: FormFeedback progressStep: number progressStepCount: number actions: FormAction[] }> ): JSX.Element => { + const htmxProps = props.submitRoute + ? { + 'hx-post': `/connection/${props.submitRoute}`, + 'hx-swap': 'outerHTML', + 'hx-select': '#new-invite-form', + } + : {} + return ( -
+ {props.children} - + {props.feedback && }
{props.actions.map((action, i) => { const lastIndex = props.actions.length - 1 diff --git a/src/views/newConnection/fromInvite.tsx b/src/views/newConnection/fromInvite.tsx index 5d3a21a3..42187128 100644 --- a/src/views/newConnection/fromInvite.tsx +++ b/src/views/newConnection/fromInvite.tsx @@ -1,17 +1,12 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { CompanyProfile } from '../../models/companyHouseEntity.js' -import { BASE_64_URL, pinCodeRegex } from '../../models/strings.js' +import { BASE_64_URL } from '../../models/strings.js' import { Page } from '../common.js' import { FormFeedback, NewConnectionTemplates } from './base.js' -export type FromInviteFormStage = 'invite' | 'pin' | 'success' export type FormInviteProps = { feedback: FormFeedback - formStage: FromInviteFormStage - pin?: string invite?: BASE_64_URL - company?: CompanyProfile } @singleton() @@ -20,7 +15,7 @@ export class FromInviteTemplates extends NewConnectionTemplates { super() } - public fromInviteFormPage = (feedback: FormFeedback, pin?: string) => { + public fromInviteFormPage = (feedback: FormFeedback) => { return ( Invite New Connection
- +
) } public fromInviteForm = (props: FormInviteProps): JSX.Element => { - switch (props.formStage) { - case 'invite': - return - case 'pin': - return - case 'success': - return - } - } - - private fromInviteInvite = (props: FormInviteProps): JSX.Element => { return ( - {props.invite ? Html.escapeHtml(props.invite) : ''} + {Html.escapeHtml(props.invite || '')} ) } - - public fromInvitePin = (props: FormInviteProps): JSX.Element => { - return ( - -
-

Please enter the verification code from the physical letter

- - -
-
- ) - } - - private fromInviteSuccess = (props: FormInviteProps): JSX.Element => { - return ( - -
-

- Your connection has been established, but still needs to be verified. You should receive a verification - letter at your registered business with instructions on how to do this. A reciprocal verification request - has been sent in the post on your behalf to the address on the right to verify their identity -

-
-
- ) - } } diff --git a/src/views/newConnection/newInvite.tsx b/src/views/newConnection/newInvite.tsx index cfd4f2c7..68bd6adc 100644 --- a/src/views/newConnection/newInvite.tsx +++ b/src/views/newConnection/newInvite.tsx @@ -57,7 +57,7 @@ export class NewInviteTemplates extends NewConnectionTemplates { }): JSX.Element => { return ( { return ( { return ( { + const stepCount = props.continuationFromInvite ? 3 : 2 + return ( + +
+ PIN Code submission +
+
+ +
+

Please enter the verification code from the physical letter

+ + +
+
+
+
+ ) + } + + public renderSuccess = (props: { companyName: string; stepCount: number }): JSX.Element => { + return ( + +
+

+ PIN Code has been submitted for {props.companyName} company ID. Please wait for the verification code to be + confirmed by viewing the verification. status. +

+
+
+ ) + } +} diff --git a/src/views/__tests__/queries.test.ts b/src/views/queries/__tests__/queries.test.ts similarity index 99% rename from src/views/__tests__/queries.test.ts rename to src/views/queries/__tests__/queries.test.ts index cee3dc26..3c53498b 100644 --- a/src/views/__tests__/queries.test.ts +++ b/src/views/queries/__tests__/queries.test.ts @@ -1,6 +1,7 @@ import { expect } from 'chai' import { describe, it } from 'mocha' import QueriesTemplates from '../queries.js' + describe('ConnectionTemplates', () => { describe('listPage', () => { it('should render with no connections', async () => { diff --git a/src/views/queries/__tests__/queries.test.ts.snap b/src/views/queries/__tests__/queries.test.ts.snap new file mode 100644 index 00000000..437404ee --- /dev/null +++ b/src/views/queries/__tests__/queries.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConnectionTemplates listPage should render with no connections 1`] = `"Veritable - Queries
"`; diff --git a/src/views/__tests__/queriesList.test.ts b/src/views/queries/__tests__/queriesList.test.ts similarity index 100% rename from src/views/__tests__/queriesList.test.ts rename to src/views/queries/__tests__/queriesList.test.ts diff --git a/src/views/__tests__/queriesList.test.ts.snap b/src/views/queries/__tests__/queriesList.test.ts.snap similarity index 100% rename from src/views/__tests__/queriesList.test.ts.snap rename to src/views/queries/__tests__/queriesList.test.ts.snap diff --git a/src/views/queries.tsx b/src/views/queries/queries.tsx similarity index 98% rename from src/views/queries.tsx rename to src/views/queries/queries.tsx index a81d6ac4..39816035 100644 --- a/src/views/queries.tsx +++ b/src/views/queries/queries.tsx @@ -1,6 +1,6 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { Page } from './common.js' +import { Page } from '../common.js' @singleton() export default class QueriesTemplates { diff --git a/src/views/queriesList.tsx b/src/views/queries/queriesList.tsx similarity index 98% rename from src/views/queriesList.tsx rename to src/views/queries/queriesList.tsx index 043a0787..f45285d6 100644 --- a/src/views/queriesList.tsx +++ b/src/views/queries/queriesList.tsx @@ -1,6 +1,6 @@ import Html from '@kitajs/html' import { singleton } from 'tsyringe' -import { ButtonIcon, Page } from './common.js' +import { ButtonIcon, Page } from '../common.js' type QueryStatus = 'resolved' | 'pending_your_input' | 'pending_their_input' diff --git a/test/helpers/cloudagent.ts b/test/helpers/cloudagent.ts index 95e374fe..5b5cb276 100644 --- a/test/helpers/cloudagent.ts +++ b/test/helpers/cloudagent.ts @@ -1,13 +1,9 @@ -import { pino } from 'pino' +import { container } from 'tsyringe' import { Env } from '../../src/env.js' import VeritableCloudagent from '../../src/models/veritableCloudagent.js' - -import { container } from 'tsyringe' -import { type ILogger } from '../../src/logger.js' import { validCompanyName, validCompanyNumber } from './fixtures.js' - -const mockLogger: ILogger = pino({ level: 'silent' }) +import { mockLogger } from './logger.js' const cleanupShared = async function (agent: VeritableCloudagent) { const connections = await agent.getConnections() diff --git a/test/helpers/companyHouse.ts b/test/helpers/companyHouse.ts index cd7d5f64..fcc41b63 100644 --- a/test/helpers/companyHouse.ts +++ b/test/helpers/companyHouse.ts @@ -21,6 +21,7 @@ export function withCompanyHouseMock() { method: 'GET', }) .reply(200, successResponse) + .persist() }) afterEach(function () { setGlobalDispatcher(originalDispatcher) diff --git a/test/helpers/connection.ts b/test/helpers/connection.ts new file mode 100644 index 00000000..a516d663 --- /dev/null +++ b/test/helpers/connection.ts @@ -0,0 +1,201 @@ +import argon2 from 'argon2' +import type express from 'express' +import knex from 'knex' +import { container } from 'tsyringe' + +import sinon from 'sinon' +import { Env } from '../../src/env.js' +import Database from '../../src/models/db/index.js' +import EmailService from '../../src/models/emailService/index.js' +import VeritableCloudagent from '../../src/models/veritableCloudagent.js' +import { validCompanyName, validCompanyNumber } from './fixtures.js' +import { mockLogger } from './logger.js' +import { post } from './routeHelper.js' +import { delay } from './util.js' + +const remoteDbConfig = { + client: 'pg', + connection: { + host: 'localhost', + database: 'veritable-ui', + user: 'postgres', + password: 'postgres', + port: 5433, + }, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: 'migrations', + }, +} + +const mockEnv = { + get(name) { + if (name === 'CLOUDAGENT_ADMIN_ORIGIN') { + return 'http://localhost:3101' + } + throw new Error('Unexpected env variable request') + }, +} as Env + +export const withEstablishedConnectionFromUs = function (context: { + app: express.Express + remoteDatabase: Database + remoteCloudagent: VeritableCloudagent + inviteUrl: string + remoteVerificationPin: string + localVerificationPin: string + remoteConnectionId: string + localConnectionId: string +}) { + let emailSendStub: sinon.SinonStub + + const cleanupRemote = async () => { + const remoteConnections = await context.remoteCloudagent.getConnections() + for (const { id } of remoteConnections) { + await context.remoteCloudagent.deleteConnection(id) + } + await context.remoteDatabase.delete('connection', {}) + } + + beforeEach(async function () { + const localDatabase = container.resolve(Database) + context.remoteDatabase = new Database(knex(remoteDbConfig)) + context.remoteCloudagent = new VeritableCloudagent(mockEnv, mockLogger) + + await cleanupRemote() + + const email = container.resolve(EmailService) + emailSendStub = sinon.stub(email, 'sendMail') + await post(context.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 + + const adminEmailArgs = emailSendStub.args.find(([name]) => name === 'connection_invite_admin') || [] + context.remoteVerificationPin = adminEmailArgs[1].pin + + context.localVerificationPin = '123456' + const pinHash = await argon2.hash(context.localVerificationPin, { secret: Buffer.from('secret', 'utf8') }) + const { connectionRecord, outOfBandRecord } = await context.remoteCloudagent.receiveOutOfBandInvite({ + companyName: validCompanyName, + invitationUrl: context.inviteUrl, + }) + + const [{ id: remoteConnectionId }] = await context.remoteDatabase.insert('connection', { + agent_connection_id: connectionRecord.id, + company_name: validCompanyName, + company_number: validCompanyNumber, + status: 'unverified', + }) + await context.remoteDatabase.insert('connection_invite', { + connection_id: remoteConnectionId, + expires_at: new Date(new Date().getTime() + 60 * 1000), + oob_invite_id: outOfBandRecord.id, + pin_hash: pinHash, + }) + context.remoteConnectionId = remoteConnectionId + + // wait for status to not be pending + for (let i = 0; i < 100; i++) { + const connections = await localDatabase.get('connection') + if (connections[0].status === 'pending') { + await delay(10) + continue + } + context.localConnectionId = connections[0].id + return + } + throw new Error('Timeout Error initialising connection') + }) + + afterEach(async function () { + await cleanupRemote() + emailSendStub.restore() + }) +} + +export const withEstablishedConnectionFromThem = function (context: { + app: express.Express + remoteDatabase: Database + remoteCloudagent: VeritableCloudagent + invite: string + remoteVerificationPin: string + localVerificationPin: string + remoteConnectionId: string + localConnectionId: string +}) { + let emailSendStub: sinon.SinonStub + + const cleanupRemote = async () => { + const remoteConnections = await context.remoteCloudagent.getConnections() + for (const { id } of remoteConnections) { + await context.remoteCloudagent.deleteConnection(id) + } + await context.remoteDatabase.delete('connection', {}) + } + + beforeEach(async function () { + const localDatabase = container.resolve(Database) + context.remoteDatabase = new Database(knex(remoteDbConfig)) + context.remoteCloudagent = new VeritableCloudagent(mockEnv, mockLogger) + + await cleanupRemote() + + const invite = await context.remoteCloudagent.createOutOfBandInvite({ companyName: validCompanyName }) + context.invite = Buffer.from( + JSON.stringify({ + companyNumber: validCompanyNumber, + inviteUrl: invite.invitationUrl, + }), + 'utf8' + ).toString('base64url') + + const [{ id: remoteConnectionId }] = await context.remoteDatabase.insert('connection', { + company_name: validCompanyName, + company_number: validCompanyNumber, + status: 'pending', + agent_connection_id: null, + }) + context.remoteConnectionId = remoteConnectionId + context.localVerificationPin = '123456' + const pinHash = await argon2.hash(context.localVerificationPin, { secret: Buffer.from('secret', 'utf8') }) + await context.remoteDatabase.insert('connection_invite', { + connection_id: remoteConnectionId, + expires_at: new Date(new Date().getTime() + 60 * 1000), + oob_invite_id: invite.outOfBandRecord.id, + pin_hash: pinHash, + }) + + const email = container.resolve(EmailService) + emailSendStub = sinon.stub(email, 'sendMail') + await post(context.app, '/connection/new/receive-invitation', { + invite: context.invite, + action: 'createConnection', + }) + const adminEmailArgs = emailSendStub.args.find(([name]) => name === 'connection_invite_admin') || [] + context.remoteVerificationPin = adminEmailArgs[1].pin + + // wait for status to not be pending + for (let i = 0; i < 100; i++) { + const connections = await localDatabase.get('connection') + if (connections[0].status === 'pending') { + await delay(10) + continue + } + context.localConnectionId = connections[0].id + return + } + throw new Error('Timeout Error initialising connection') + }) + + afterEach(async function () { + await cleanupRemote() + emailSendStub.restore() + }) +} diff --git a/test/helpers/logger.ts b/test/helpers/logger.ts new file mode 100644 index 00000000..26b2a01e --- /dev/null +++ b/test/helpers/logger.ts @@ -0,0 +1,5 @@ +import { pino } from 'pino' + +import { ILogger } from '../../src/logger.js' + +export const mockLogger: ILogger = pino({ level: 'silent' }) diff --git a/test/helpers/util.ts b/test/helpers/util.ts index 92a02a49..40e1adae 100644 --- a/test/helpers/util.ts +++ b/test/helpers/util.ts @@ -1 +1,4 @@ -export const delay = (delayMs: number) => new Promise((r) => setTimeout(r, delayMs)) +export const delay = (delayMs: number) => new Promise((r) => setTimeout(r, delayMs)) + +export const delayAndReject = (delayMs: number, message: string = 'Timeout') => + new Promise((_, r) => setTimeout(r, delayMs, new Error(message))) diff --git a/test/integration/pinVerification.test.ts b/test/integration/pinVerification.test.ts new file mode 100644 index 00000000..be77eb05 --- /dev/null +++ b/test/integration/pinVerification.test.ts @@ -0,0 +1,198 @@ +import { expect } from 'chai' +import type express from 'express' +import { afterEach, beforeEach, describe } from 'mocha' + +import { container } from 'tsyringe' +import Database from '../../src/models/db/index.js' +import VeritableCloudagent from '../../src/models/veritableCloudagent.js' +import createHttpServer from '../../src/server.js' +import VeritableCloudagentEvents from '../../src/services/veritableCloudagentEvents.js' +import { cleanupCloudagent } from '../helpers/cloudagent.js' +import { withCompanyHouseMock } from '../helpers/companyHouse.js' +import { withEstablishedConnectionFromThem, withEstablishedConnectionFromUs } from '../helpers/connection.js' +import { cleanup } from '../helpers/db.js' +import { post } from '../helpers/routeHelper.js' +import { delay, delayAndReject } from '../helpers/util.js' + +describe('pin-submission', function () { + const db = container.resolve(Database) + + afterEach(async () => { + await cleanup() + }) + withCompanyHouseMock() + + describe('pin verification of sender', function () { + type Context = { + app: express.Express + cloudagentEvents: VeritableCloudagentEvents + remoteDatabase: Database + remoteCloudagent: VeritableCloudagent + inviteUrl: string + remoteVerificationPin: string + localVerificationPin: string + remoteConnectionId: string + localConnectionId: string + } + const context: Context = {} as Context + + beforeEach(async function () { + await cleanup() + await cleanupCloudagent() + const server = await createHttpServer(true) + Object.assign(context, { + ...server, + inviteUrl: '', + localConnectionId: '', + localVerificationPin: '', + remoteConnectionId: '', + remoteVerificationPin: '', + }) + }) + + afterEach(async function () { + await cleanupCloudagent() + context.cloudagentEvents.stop() + }) + + withEstablishedConnectionFromUs(context) + + // method under test + beforeEach(async function () { + const cloudagentEvents = container.resolve(VeritableCloudagentEvents) + const credentialDonePromise = new Promise((resolve) => { + cloudagentEvents.on( + 'CredentialStateChanged', + ({ + payload: { + credentialRecord: { state }, + }, + }) => { + if (state === 'done') { + resolve() + } + } + ) + }) + + await post(context.app, `/connection/${context.localConnectionId}/pin-submission`, { + action: 'submitPinCode', + pin: context.localVerificationPin, + }) + + await Promise.race([ + credentialDonePromise, + delayAndReject(1000, 'Timeout waiting for credential to reach done state'), + ]) + }) + + it('should set local verification status to verified_us', async function () { + for (let i = 0; i < 100; i++) { + const [connection] = await db.get('connection', { id: context.localConnectionId }) + if (connection.status === 'verified_us') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state verified_us') + }) + + it('should set remote verification status to verified_them', async function () { + for (let i = 0; i < 100; i++) { + const [connection] = await context.remoteDatabase.get('connection', { id: context.remoteConnectionId }) + if (connection.status === 'verified_them') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state verified_them') + }) + }) + + describe('pin verification of receiver (send side)', function () { + type Context = { + app: express.Express + cloudagentEvents: VeritableCloudagentEvents + remoteDatabase: Database + remoteCloudagent: VeritableCloudagent + invite: string + remoteVerificationPin: string + localVerificationPin: string + remoteConnectionId: string + localConnectionId: string + } + const context: Context = {} as Context + + beforeEach(async function () { + await cleanup() + await cleanupCloudagent() + const server = await createHttpServer(true) + Object.assign(context, { + ...server, + inviteUrl: '', + localConnectionId: '', + localVerificationPin: '', + remoteConnectionId: '', + remoteVerificationPin: '', + }) + }) + + afterEach(async function () { + await cleanupCloudagent() + context.cloudagentEvents.stop() + }) + + withEstablishedConnectionFromThem(context) + + // method under test + beforeEach(async function () { + const cloudagentEvents = container.resolve(VeritableCloudagentEvents) + const credentialDonePromise = new Promise((resolve) => { + cloudagentEvents.on( + 'CredentialStateChanged', + ({ + payload: { + credentialRecord: { state }, + }, + }) => { + if (state === 'done') { + resolve() + } + } + ) + }) + + await post(context.app, `/connection/${context.localConnectionId}/pin-submission`, { + action: 'submitPinCode', + pin: context.localVerificationPin, + }) + + await Promise.race([ + credentialDonePromise, + delayAndReject(1000, 'Timeout waiting for credential to reach done state'), + ]) + }) + + it('should set local verification status to verified_us', async function () { + for (let i = 0; i < 100; i++) { + const [connection] = await db.get('connection', { id: context.localConnectionId }) + if (connection.status === 'verified_us') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state verified_us') + }) + + it('should set remote verification status to verified_them', async function () { + for (let i = 0; i < 100; i++) { + const [connection] = await context.remoteDatabase.get('connection', { id: context.remoteConnectionId }) + if (connection.status === 'verified_them') { + return + } + await delay(10) + } + expect.fail('Expected connection to update to state verified_them') + }) + }) +})