diff --git a/README.md b/README.md index 8722437c..d958d4bb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Core delivery platform Node.js Backend Template. - [Node.js](#nodejs) - [Local development](#local-development) - [Setup](#setup) + - [Generating a public/private key pair](#generating-a-publicprivate-key-pair) - [Development](#development) - [Production](#production) - [Npm scripts](#npm-scripts) @@ -72,6 +73,26 @@ PUBLIC_KEY_FOR_SECRETS="" For proxy options, see https://www.npmjs.com/package/proxy-from-env which is used by https://github.com/TooTallNate/proxy-agents/tree/main/packages/proxy-agent. It's currently supports Hapi Wreck only, e.g. in the JWKS lookup. +### Generating a public/private key pair + +The public key is used by forms-manager to encrypt secrets. The private key is used by forms-runner to decrypt secrets. + +``` +openssl genrsa -out private.pem 4096 +openssl rsa -pubout -in private.pem -out public.pem +cat public.pem #[copy the output and paste as in the next command] +echo -n "" | base64 +``` + +Copy/paste the encoded result as the env var PUBLIC_KEY_FOR_SECRETS (in forms-manager) + +``` +cat private.pem #[copy the output and paste as in the next command] +echo -n "" | base64 +``` + +Copy/paste the encoded result as the env var PRIVATE_KEY_FOR_SECRETS (in forms-runner) + ### Development To run the application in `development` mode run: diff --git a/docker-compose.integration-test.yml b/docker-compose.integration-test.yml index 4231feaa..393abd84 100644 --- a/docker-compose.integration-test.yml +++ b/docker-compose.integration-test.yml @@ -105,6 +105,8 @@ services: SNS_TOPIC_ARN: arn:aws:sns:eu-west-2:000000000000:forms_manager_events AWS_ACCESS_KEY_ID: test AWS_SECRET_ACCESS_KEY: test + # PUBLIC_KEY is a test value - do not use for any deployments + PUBLIC_KEY_FOR_SECRETS: "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE0WU53YUJjenlBdWpJZ2k2aTBmawpINUVYeGxtTmNBd1AzUkpRaDB2ckxjeDIxU2RCSkFOUU5JWmdTcWhST3ViUlozWTJWQ2Zsbkd3cFVSTTNrY0tFCkNHUlk2Nk5leVUwL3QzS2hVOUY1cG1heVBSVUFiWkRjalFCL0xYbVNGUUdGelZDQlp0ZjBIeHJiSnBUNDFiSGIKR05udFpDdm94WjZQOUJxdU4wa2c0Y1JXcTdyWUozVE1xWXArc1RzQU01UFZwMVh6WklIRFJ2UVJ2bktnMDVUUQovRFA1cXcvYkdwelVsQ01WVU9MTHhxL2xTY040RnhDRTRUUi9kSWNNM25wTU1PUUVjaEhLcEE2SWs3UmhmQml0CjZBSEpINWN2c0dWUTFHUmlvUXBIWXBWclZLRFV5ZmIrS3VzbjlWUTVTQ3JGZDJ3WWRaRzBkdVNOZFp3VklJQm8KVElWZjlrSUMrZjl5YTVLMmcvalJhMXUyVGdNc3UvZnM4UWVRZHk2disxNkFtWFA1cXNXYUtFK00zRkJoRitrZAozbS91YjNJVFRRVmplYWY1M2ZsTVlMZlZmN2NVR2FQN0VIajlUY0dGYVlGTzhuWGNWbS9lT2lkMXBGL3p5NCtzCnpFZG5oOFNRYm5ncElwdFJiMk51Wmw5RWZNR01CTFlyQ3FiWnMyYU1KV0N5M1FGWFZDeTYzUzZhRDkrbzQ1N0cKUDNGVnNZY2d2eHAzTGVTQ0phUHMzL0FlSjh1MUFSeHZwMG02ZjdPLy9yMFFQYzJnd2RyZjRVMUkwdlduclh2eApCc2RMWU5NMXVYVjhMKzExYloxeGpVaFU4QldTd3dnTTdQMGxqVTJ4UE9WbG55MlFGeXh0ZTgzK0E5SXNBcmFxCmthNlhEdDhYZkNZYm84dEpnem9mbEs4Q0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==" depends_on: mongo_test: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index 7e3e58c5..7561b0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/client-sns": "^3.995.0", - "@defra/forms-model": "^3.0.629", + "@defra/forms-model": "^3.0.633", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/hapi": "^21.4.4", @@ -2759,9 +2759,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.629", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.629.tgz", - "integrity": "sha512-MnNgpAHON8eKhVttqI/2xjLpFNCAE43J7Iaqe84XftN2ML0UvLeZHFh4Jx1ePu/h+dvctmPXwCeooaZ+aPVWyg==", + "version": "3.0.633", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.633.tgz", + "integrity": "sha512-qFrMMrpLQksWe00fKaALX56eIImwQeFmpzTkzbJ+3n8QTgqjxcAlr+BHzSeGgQxBA11oZT/A5GSyeX9viEqGLw==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", diff --git a/package.json b/package.json index eb3251f7..4b7ff5ef 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.995.0", "@aws-sdk/client-sns": "^3.995.0", - "@defra/forms-model": "^3.0.629", + "@defra/forms-model": "^3.0.633", "@defra/hapi-tracing": "^1.29.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/hapi": "^21.4.4", diff --git a/src/api/forms/repositories/secrets-repository.js b/src/api/forms/repositories/secrets-repository.js index d8e11c6d..dafcd47f 100644 --- a/src/api/forms/repositories/secrets-repository.js +++ b/src/api/forms/repositories/secrets-repository.js @@ -64,6 +64,7 @@ export async function exists(formId, secretName, session) { return { exists: !!document, createdAt: document?.createdAt, + renamedAt: document?.renamedAt, updatedAt: document?.updatedAt } } catch (err) { @@ -130,6 +131,98 @@ export async function save(formId, secretName, secretValue, session) { } } +/** + * Deletes a form secret from the database + * @param {string} formId - id of the form + * @param {string} secretName - name of the secret + * @param {ClientSession} session - mongo transaction session + */ +export async function deleteSecret(formId, secretName, session) { + logger.info(`Deleting secret '${secretName}' for form ID ${formId}`) + + const coll = /** @satisfies {Collection} */ ( + db.collection(SECRETS_COLLECTION_NAME) + ) + + try { + const result = await coll.deleteOne( + { + formId, + secretName + }, + { session } + ) + + logger.info( + `Secret deleted with name '${secretName}' for form ID ${formId}` + ) + + return result + } catch (err) { + const message = `Secret with name '${secretName}' for form ID ${formId} failed to delete` + + if (err instanceof MongoServerError) { + logger.error( + err, + `[mongoError] ${message} - MongoDB error code: ${err.code} - ${err.message}` + ) + } else { + logger.error(err, `[deleteError] ${message} - ${getErrorMessage(err)}`) + } + throw err + } +} + +/** + * Changes the name of a form secret in the database + * @param {string} formId - id of the form + * @param {string} secretNameFrom - name of the secret to be renamed + * @param {string} secretNameTo - the new name of the secret + * @param {ClientSession} session - mongo transaction session + */ +export async function rename(formId, secretNameFrom, secretNameTo, session) { + logger.info( + `Renaming secret '${secretNameFrom}' to '${secretNameTo}' for form ID ${formId}` + ) + + const coll = /** @satisfies {Collection} */ ( + db.collection(SECRETS_COLLECTION_NAME) + ) + + const now = new Date() + + try { + const result = await coll.findOneAndUpdate( + { + formId, + secretName: secretNameFrom + }, + { + $set: { secretName: secretNameTo, renamedAt: now } + }, + { session } + ) + + logger.info( + `Secret renamed from '${secretNameFrom}' to '${secretNameTo}' for form ID ${formId}` + ) + + return result + } catch (err) { + const message = `Secret with name '${secretNameFrom}' for form ID ${formId} failed to rename` + + if (err instanceof MongoServerError) { + logger.error( + err, + `[mongoError] ${message} - MongoDB error code: ${err.code} - ${err.message}` + ) + } else { + logger.error(err, `[renameError] ${message} - ${getErrorMessage(err)}`) + } + throw err + } +} + /** * @import { FormSecret } from '@defra/forms-model' * @import { ClientSession, Collection } from 'mongodb' diff --git a/src/api/forms/repositories/secrets-repository.test.js b/src/api/forms/repositories/secrets-repository.test.js index 33d411b3..c9993802 100644 --- a/src/api/forms/repositories/secrets-repository.test.js +++ b/src/api/forms/repositories/secrets-repository.test.js @@ -3,8 +3,10 @@ import { MongoServerError } from 'mongodb' import { buildMockCollection } from '~/src/api/forms/__stubs__/mongo.js' import { + deleteSecret, exists, get, + rename, save } from '~/src/api/forms/repositories/secrets-repository.js' import author from '~/src/api/forms/service/__stubs__/author.js' @@ -186,6 +188,79 @@ describe('secrets-repository', () => { }) }) + describe('delete', () => { + it('should delete if the secret exists', async () => { + mockCollection.deleteOne.mockResolvedValue({ deletedCount: 1 }) + const result = await deleteSecret(formId, 'my-secret', mockSession) + expect(result).toEqual({ deletedCount: 1 }) + }) + + it('should throw if db error', async () => { + mockCollection.deleteOne.mockImplementationOnce(() => { + throw new MongoServerError({ message: 'DB error deleting' }) + }) + await expect(() => + deleteSecret(formId, 'my-secret', mockSession) + ).rejects.toThrow('DB error deleting') + expect(mockLoggerError).toHaveBeenCalledWith( + expect.anything(), + "[mongoError] Secret with name 'my-secret' for form ID fe339c6a-1f6e-4ab8-88c6-73fa1528dc90 failed to delete - MongoDB error code: undefined - DB error deleting" + ) + }) + + it('should throw if other error', async () => { + mockCollection.deleteOne.mockImplementationOnce(() => { + throw new Error('DB error deleting') + }) + await expect(() => + deleteSecret(formId, 'my-secret', mockSession) + ).rejects.toThrow('DB error deleting') + expect(mockLoggerError).toHaveBeenCalledWith( + expect.anything(), + "[deleteError] Secret with name 'my-secret' for form ID fe339c6a-1f6e-4ab8-88c6-73fa1528dc90 failed to delete - DB error deleting" + ) + }) + }) + + describe('rename', () => { + it('should rename if the secret exists', async () => { + mockCollection.findOneAndUpdate.mockResolvedValue(secret) + const result = await rename( + formId, + 'my-secret before', + 'my secret after', + mockSession + ) + expect(result).toEqual(secret) + }) + + it('should throw if db error', async () => { + mockCollection.findOneAndUpdate.mockImplementationOnce(() => { + throw new MongoServerError({ message: 'DB error rename' }) + }) + await expect(() => + rename(formId, 'my-secret before', 'my secret after', mockSession) + ).rejects.toThrow('DB error rename') + expect(mockLoggerError).toHaveBeenCalledWith( + expect.anything(), + "[mongoError] Secret with name 'my-secret before' for form ID fe339c6a-1f6e-4ab8-88c6-73fa1528dc90 failed to rename - MongoDB error code: undefined - DB error rename" + ) + }) + + it('should throw if other error', async () => { + mockCollection.findOneAndUpdate.mockImplementationOnce(() => { + throw new Error('DB error rename') + }) + await expect(() => + rename(formId, 'my-secret before', 'my secret after', mockSession) + ).rejects.toThrow('DB error rename') + expect(mockLoggerError).toHaveBeenCalledWith( + expect.anything(), + "[renameError] Secret with name 'my-secret before' for form ID fe339c6a-1f6e-4ab8-88c6-73fa1528dc90 failed to rename - DB error rename" + ) + }) + }) + describe('save', () => { it('should return true if the secret exists', async () => { mockCollection.findOneAndUpdate.mockResolvedValue(secret) diff --git a/src/api/forms/service/definition.js b/src/api/forms/service/definition.js index 2a3be6e5..49354fbb 100644 --- a/src/api/forms/service/definition.js +++ b/src/api/forms/service/definition.js @@ -11,6 +11,11 @@ import { makeFormLiveErrorMessages } from '~/src/api/forms/constants.js' import * as formDefinition from '~/src/api/forms/repositories/form-definition-repository.js' import { deleteDraft } from '~/src/api/forms/repositories/form-definition-repository.js' import * as formMetadata from '~/src/api/forms/repositories/form-metadata-repository.js' +import { + deleteSecret, + exists, + rename +} from '~/src/api/forms/repositories/secrets-repository.js' import { getValidationSchema } from '~/src/api/forms/service/helpers/definition.js' import { getForm } from '~/src/api/forms/service/index.js' import { existsFormSecret } from '~/src/api/forms/service/secrets.js' @@ -30,6 +35,7 @@ import { import { client } from '~/src/mongo.js' export const PAYMENT_LIVE_API_KEY = 'payment-live-api-key' +export const PAYMENT_LIVE_API_KEY_PENDING = 'payment-live-api-key-pending' /** * Retrieves a paginated list of forms with filter options @@ -253,6 +259,34 @@ function validateFormForPublishing( } } +/** + * Overwrite a live payment API key with the value of the pending live payment API key. + * Essentially make the pending key the live one for forms-runner to access. + * @param {boolean} formHasPayment - true if the form includes a payment question + * @param {string} formId - the id of the form + * @param {ClientSession} session + */ +export async function makePaymentKeyLive(formHasPayment, formId, session) { + if (!formHasPayment) { + return + } + + const pendingKeyExists = await exists( + formId, + PAYMENT_LIVE_API_KEY_PENDING, + session + ) + if (pendingKeyExists.exists) { + await deleteSecret(formId, PAYMENT_LIVE_API_KEY, session) + await rename( + formId, + PAYMENT_LIVE_API_KEY_PENDING, + PAYMENT_LIVE_API_KEY, + session + ) + } +} + /** * Creates the live form from the current draft state * @param {string} formId - ID of the form @@ -274,8 +308,11 @@ export async function createLiveFromDraft(formId, author) { /** @type {boolean} */ let paymentKeyExists if (formHasPayment) { - const { exists } = await existsFormSecret(formId, PAYMENT_LIVE_API_KEY) - paymentKeyExists = exists + const [liveExists, pendingExists] = await Promise.all([ + existsFormSecret(formId, PAYMENT_LIVE_API_KEY), + existsFormSecret(formId, PAYMENT_LIVE_API_KEY_PENDING) + ]) + paymentKeyExists = liveExists.exists || pendingExists.exists } else { paymentKeyExists = true } @@ -325,6 +362,9 @@ export async function createLiveFromDraft(formId, author) { await createFormVersion(formId, session) + // Make payment key live if a pending one is stored + await makePaymentKeyLive(formHasPayment, formId, session) + // Publish audit message await publishLiveCreatedFromDraftEvent(formId, now, author) }) diff --git a/src/api/forms/service/definition.test.js b/src/api/forms/service/definition.test.js index fa6a040d..726dd1ab 100644 --- a/src/api/forms/service/definition.test.js +++ b/src/api/forms/service/definition.test.js @@ -29,6 +29,11 @@ import { modifyReorderPages, modifyReorderSections } from '~/src/api/forms/repositories/helpers.js' +import { + deleteSecret, + exists, + rename +} from '~/src/api/forms/repositories/secrets-repository.js' import { formMetadataDocument, formMetadataInput, @@ -43,6 +48,7 @@ import { deleteDraftFormDefinition, getFormDefinition, listForms, + makePaymentKeyLive, missingPrivacyNotice, reorderDraftFormDefinitionComponents, reorderDraftFormDefinitionPages, @@ -66,6 +72,7 @@ jest.mock('~/src/helpers/get-author.js') jest.mock('~/src/api/forms/repositories/form-definition-repository.js') jest.mock('~/src/api/forms/repositories/form-metadata-repository.js') jest.mock('~/src/api/forms/repositories/form-versions-repository.js') +jest.mock('~/src/api/forms/repositories/secrets-repository.js') jest.mock('~/src/api/forms/templates.js') jest.mock('~/src/mongo.js') jest.mock('~/src/messaging/publish-base.js') @@ -121,7 +128,8 @@ describe('Forms service', () => { jest.mocked(existsFormSecret).mockResolvedValue({ exists: true, createdAt: undefined, - updatedAt: undefined + updatedAt: undefined, + renamedAt: undefined }) }) @@ -336,11 +344,20 @@ describe('Forms service', () => { } jest.mocked(formMetadata.get).mockResolvedValue(metadata) - jest.mocked(existsFormSecret).mockResolvedValueOnce({ - exists: false, - createdAt: undefined, - updatedAt: undefined - }) + jest + .mocked(existsFormSecret) + .mockResolvedValueOnce({ + exists: false, + createdAt: undefined, + updatedAt: undefined, + renamedAt: undefined + }) + .mockResolvedValueOnce({ + exists: false, + createdAt: undefined, + updatedAt: undefined, + renamedAt: undefined + }) const definitionWithPayment = buildDefinition() definitionWithPayment.pages.push( @@ -1562,9 +1579,48 @@ describe('Forms service', () => { expect(missingPrivacyNotice(metadata)).toBe(false) }) }) + + describe('makePaymentKeyLive', () => { + const formId = 'ea8154b9-e724-4bb4-a9cb-46f4159a53fa' + const mockSession = /** @type {ClientSession} */ ({}) + + it('should ignore if not a payment question', async () => { + jest.mocked(exists).mockResolvedValueOnce({ + exists: true, + createdAt: new Date(), + updatedAt: undefined, + renamedAt: undefined + }) + await makePaymentKeyLive(false, formId, mockSession) + expect(exists).not.toHaveBeenCalled() + }) + + it('should ignore if not exists', async () => { + jest.mocked(exists).mockResolvedValueOnce({ + exists: false, + createdAt: new Date(), + updatedAt: undefined, + renamedAt: undefined + }) + await makePaymentKeyLive(true, formId, mockSession) + expect(rename).not.toHaveBeenCalled() + }) + + it('should delete and rename if exists', async () => { + jest.mocked(exists).mockResolvedValueOnce({ + exists: true, + createdAt: new Date(), + updatedAt: undefined, + renamedAt: undefined + }) + await makePaymentKeyLive(true, formId, mockSession) + expect(deleteSecret).toHaveBeenCalled() + expect(rename).toHaveBeenCalled() + }) + }) }) /** - * @import { FormDefinition, FormMetadata, FormMetadataDocument, PageQuestion, QueryOptions } from '@defra/forms-model' - * @import { WithId } from 'mongodb' + * @import { FormDefinition, FormMetadata, FormMetadataDocument, QueryOptions } from '@defra/forms-model' + * @import { ClientSession, WithId } from 'mongodb' */ diff --git a/src/api/forms/service/secrets.js b/src/api/forms/service/secrets.js index e35c899b..ddd59604 100644 --- a/src/api/forms/service/secrets.js +++ b/src/api/forms/service/secrets.js @@ -4,7 +4,10 @@ import * as formMetadata from '~/src/api/forms/repositories/form-metadata-reposi import * as secretsRepository from '~/src/api/forms/repositories/secrets-repository.js' import { encryptSecret } from '~/src/api/forms/service/helpers/crypto.js' import { logger } from '~/src/api/forms/service/shared.js' -import { publishSavedFormSecretEvent } from '~/src/messaging/publish.js' +import { + publishDeletedFormSecretEvent, + publishSavedFormSecretEvent +} from '~/src/messaging/publish.js' import { client } from '~/src/mongo.js' /** @@ -61,6 +64,37 @@ export async function saveFormSecret(formId, secretName, secretValue, author) { } } +/** + * Deletes a secret value. + * @param {string} formId - id of the form + * @param {string} secretName - name of the secret + * @param {FormMetadataAuthor} author + */ +export async function deleteFormSecret(formId, secretName, author) { + const session = client.startSession() + + try { + await session.withTransaction(async () => { + const metadata = await formMetadata.get(formId, session) + + await secretsRepository.deleteSecret(formId, secretName, session) + + await publishDeletedFormSecretEvent(metadata, secretName, author) + }) + + logger.info(`Deleted secret '${secretName}' to form ID ${formId}`) + } catch (err) { + logger.error( + err, + `[assignSections] Failed to delete secret '${secretName}' to form ID ${formId} - ${getErrorMessage(err)}` + ) + + throw err + } finally { + await session.endSession() + } +} + /** * @import { FormMetadataAuthor } from '@defra/forms-model' */ diff --git a/src/api/forms/service/secrets.test.js b/src/api/forms/service/secrets.test.js index 0f947377..3cdcb327 100644 --- a/src/api/forms/service/secrets.test.js +++ b/src/api/forms/service/secrets.test.js @@ -3,12 +3,14 @@ import { pino } from 'pino' import * as formMetadata from '~/src/api/forms/repositories/form-metadata-repository.js' import { + deleteSecret, exists, get, save } from '~/src/api/forms/repositories/secrets-repository.js' import { formMetadataDocument } from '~/src/api/forms/service/__stubs__/service.js' import { + deleteFormSecret, existsFormSecret, getFormSecret, saveFormSecret @@ -58,9 +60,12 @@ describe('secrets', () => { describe('existsFormSecret', () => { it('should return true if a form secret exists', async () => { const now = new Date() - jest - .mocked(exists) - .mockResolvedValueOnce({ exists: true, createdAt: now, updatedAt: now }) + jest.mocked(exists).mockResolvedValueOnce({ + exists: true, + createdAt: now, + updatedAt: now, + renamedAt: undefined + }) const secretName = 'my-secret-name' const res = await existsFormSecret(formId, secretName) @@ -73,6 +78,44 @@ describe('secrets', () => { }) }) + describe('deleteFormSecret', () => { + it('should delete form secret and publish audit event', async () => { + jest.mocked(formMetadata.get).mockResolvedValue(formMetadataDocument) + const publishEventSpy = jest.spyOn(publishBase, 'publishEvent') + + const secretName = 'my-secret-name' + await deleteFormSecret(formId, secretName, defaultAuthor) + + // Verify repository was called with correct arguments + const [formIdCalled, secretNameCalled] = + jest.mocked(deleteSecret).mock.calls[0] + expect(formId).toBe(formIdCalled) + expect(secretName).toBe(secretNameCalled) + + // Verify audit event was published + const [auditMessage] = publishEventSpy.mock.calls[0] + expect(auditMessage).toMatchObject({ + type: AuditEventMessageType.FORM_SECRET_DELETED + }) + expect(auditMessage.data).toMatchObject({ + formId, + secretName: 'my-secret-name' + }) + }) + + it('should throw when error', async () => { + jest.mocked(formMetadata.get).mockResolvedValue(formMetadataDocument) + jest.mocked(deleteSecret).mockImplementationOnce(() => { + throw new Error('Deleting error') + }) + + const secretName = 'my-secret-name' + await expect(() => + deleteFormSecret(formId, secretName, defaultAuthor) + ).rejects.toThrow('Deleting error') + }) + }) + describe('saveFormSecret', () => { it('should save form secret and publish audit event', async () => { jest.mocked(formMetadata.get).mockResolvedValue(formMetadataDocument) diff --git a/src/api/types.js b/src/api/types.js index 69b780a9..51233c82 100644 --- a/src/api/types.js +++ b/src/api/types.js @@ -25,6 +25,8 @@ * @typedef {Request<{ Server: { db: Db }, Params: {id: string, versionNumber: string} }>} RequestFormVersionById * @typedef {Request<{ Server: { db: Db }, Params: FormByIdInput, Payload: SectionAssignmentPayload }>} RequestSectionAssignment * @typedef {Request<{ Server: { db: Db }, Params: {id: string, name: string} }>} RequestGetFormSecret + * @typedef {Request<{ Server: { db: Db }, Params: {id: string, name: string} }>} RequestDeleteFormSecret + * @typedef {Request<{ Server: { db: Db }, Params: {id: string, nameBefore: string, nameAfter: string } }>} RequestRenameFormSecret * @typedef {Request<{ Server: { db: Db }, Params: {id: string, name: string}, Payload: { secretValue: string } }>} RequestSaveFormSecret */ diff --git a/src/messaging/mappers/form-events.js b/src/messaging/mappers/form-events.js index c0f44fd2..374639e5 100644 --- a/src/messaging/mappers/form-events.js +++ b/src/messaging/mappers/form-events.js @@ -484,7 +484,7 @@ export function formUpdatedMapper(metadata, requestType, { payload, s3Meta }) { * @param {AuditUser} createdBy * @returns {FormSecretSavedMessage} */ -export function savedFormSecretMapper(metadata, secretName, createdBy) { +export function createFormSecretBase(metadata, secretName, createdBy) { const baseData = createFormMessageDataBase(metadata) const now = new Date() return { @@ -504,6 +504,61 @@ export function savedFormSecretMapper(metadata, secretName, createdBy) { } /** - * @import { FormDefinitionS3Meta, FormUpdatedMessage, FormDefinitionRequestType, FormDraftDeletedMessage, AuditUser, FormTitleUpdatedMessageData, FormOrganisationUpdatedMessage, FormOrganisationUpdatedMessageData, FormMetadata, FormCreatedMessage, FormCreatedMessageData, FormTitleUpdatedMessage, FormTeamNameUpdatedMessage, FormTeamNameUpdatedMessageData, FormTeamEmailUpdatedMessage, FormTeamEmailUpdatedMessageData, FormPrivacyNoticeUpdatedMessage, FormPrivacyNoticeUpdatedMessageData, FormTermsAndConditionsAgreedMessage, FormTermsAndConditionsAgreedMessageData, FormSubmissionGuidanceUpdatedMessage, FormSubmissionGuidanceUpdatedMessageData, FormNotificationEmailUpdatedMessage, FormNotificationEmailUpdatedMessageData, FormSupportContactUpdatedMessage, FormSupportContactUpdatedMessageData, FormLiveCreatedFromDraftMessage, FormDraftCreatedFromLiveMessage, FormMigratedMessage, FormSecretSavedMessage } from '@defra/forms-model' + * @param {FormMetadata} metadata + * @param {string} secretName + * @param {AuditUser} createdBy + * @returns {FormSecretSavedMessage} + */ +export function savedFormSecretMapper(metadata, secretName, createdBy) { + const base = createFormSecretBase(metadata, secretName, createdBy) + + return { + ...base, + type: AuditEventMessageType.FORM_SECRET_SAVED + } +} + +/** + * @param {FormMetadata} metadata + * @param {string} secretName + * @param {AuditUser} createdBy + * @returns {FormSecretDeletedMessage} + */ +export function deletedFormSecretMapper(metadata, secretName, createdBy) { + const base = createFormSecretBase(metadata, secretName, createdBy) + + return { + ...base, + type: AuditEventMessageType.FORM_SECRET_DELETED + } +} + +/** + * @param {FormMetadata} metadata + * @param {string} secretNameFrom + * @param {string} secretNameTo + * @param {AuditUser} createdBy + * @returns {FormSecretRenamedMessage} + */ +export function renamedFormSecretMapper( + metadata, + secretNameFrom, + secretNameTo, + createdBy +) { + const base = createFormSecretBase(metadata, secretNameFrom, createdBy) + base.data.payload = { + secretNameFrom, + secretNameTo + } + + return { + ...base, + type: AuditEventMessageType.FORM_SECRET_RENAMED + } +} + +/** + * @import { FormDefinitionS3Meta, FormUpdatedMessage, FormDefinitionRequestType, FormDraftDeletedMessage, AuditUser, FormTitleUpdatedMessageData, FormOrganisationUpdatedMessage, FormOrganisationUpdatedMessageData, FormMetadata, FormCreatedMessage, FormCreatedMessageData, FormTitleUpdatedMessage, FormTeamNameUpdatedMessage, FormTeamNameUpdatedMessageData, FormTeamEmailUpdatedMessage, FormTeamEmailUpdatedMessageData, FormPrivacyNoticeUpdatedMessage, FormPrivacyNoticeUpdatedMessageData, FormTermsAndConditionsAgreedMessage, FormTermsAndConditionsAgreedMessageData, FormSubmissionGuidanceUpdatedMessage, FormSubmissionGuidanceUpdatedMessageData, FormNotificationEmailUpdatedMessage, FormNotificationEmailUpdatedMessageData, FormSupportContactUpdatedMessage, FormSupportContactUpdatedMessageData, FormLiveCreatedFromDraftMessage, FormDraftCreatedFromLiveMessage, FormMigratedMessage, FormSecretDeletedMessage, FormSecretRenamedMessage, FormSecretSavedMessage } from '@defra/forms-model' * @import { PartialFormMetadataDocument } from '~/src/api/types.js' */ diff --git a/src/messaging/publish.js b/src/messaging/publish.js index 518ea404..693f1618 100644 --- a/src/messaging/publish.js +++ b/src/messaging/publish.js @@ -3,6 +3,7 @@ import Joi from 'joi' import { mapForm } from '~/src/api/forms/service/shared.js' import { + deletedFormSecretMapper, formCreatedEventMapper, formDraftCreatedFromLiveMapper, formDraftDeletedMapper, @@ -194,6 +195,23 @@ export async function publishSavedFormSecretEvent( return validateAndPublishEvent(auditMessage) } +/** + * Publish saved form secret event + * @param {WithId>} metadataDocument + * @param {string} secretName + * @param {FormMetadataAuthor} author + */ +export async function publishDeletedFormSecretEvent( + metadataDocument, + secretName, + author +) { + const metadata = mapForm(metadataDocument) + const auditMessage = deletedFormSecretMapper(metadata, secretName, author) + + return validateAndPublishEvent(auditMessage) +} + /** * @import { FormDefinition, FormMetadataAuthor, FormMetadataDocument, AuditEventMessageType, FormMetadata, AuditMessage, AuditUser, FormDefinitionS3Meta } from '@defra/forms-model' * @import { WithId } from 'mongodb' diff --git a/src/messaging/publish.test.js b/src/messaging/publish.test.js index 0ab28db5..0aad124c 100644 --- a/src/messaging/publish.test.js +++ b/src/messaging/publish.test.js @@ -21,6 +21,7 @@ import { buildFormOrganisationUpdatedMessage } from '~/src/messaging/__stubs__/m import { publishEvent } from '~/src/messaging/publish-base.js' import { bulkPublishEvents, + publishDeletedFormSecretEvent, publishDraftCreatedFromLiveEvent, publishFormCreatedEvent, publishFormDraftDeletedEvent, @@ -327,6 +328,28 @@ describe('publish', () => { }) }) + describe('publishDeletedFormSecretEvent', () => { + it('should publish a FORM_SECRET_DELETED event', async () => { + await publishDeletedFormSecretEvent( + formMetadataDocument, + 'my-new-secret', + author + ) + + const [publishEventCall] = jest.mocked(publishEvent).mock.calls[0] + expect(publishEventCall).toMatchObject({ + schemaVersion: AuditEventMessageSchemaVersion.V1, + category: AuditEventMessageCategory.FORM, + type: AuditEventMessageType.FORM_SECRET_DELETED, + createdBy: author + }) + expect(publishEventCall.data).toMatchObject({ + slug: formMetadataDocument.slug, + secretName: 'my-new-secret' + }) + }) + }) + describe('publishSavedFormSecretEvent', () => { it('should publish a FORM_SECRET_SAVED event', async () => { await publishSavedFormSecretEvent( diff --git a/src/routes/secrets.js b/src/routes/secrets.js index 47490b4c..cfb21066 100644 --- a/src/routes/secrets.js +++ b/src/routes/secrets.js @@ -3,6 +3,7 @@ import { StatusCodes } from 'http-status-codes' import Joi from 'joi' import { + deleteFormSecret, existsFormSecret, getFormSecret, saveFormSecret @@ -21,6 +22,15 @@ export const formSecretSchema = Joi.object() }) .required() +// Schema to rename form secret by form id +export const formSecretRenameSchema = Joi.object() + .keys({ + id: idSchema, + nameBefore: nameSchema, + nameAfter: nameSchema + }) + .required() + export const formSecretPayloadSchema = Joi.object() .keys({ secretValue: Joi.string().trim().required() @@ -98,10 +108,35 @@ export default [ failAction } } + }, + { + method: 'DELETE', + path: ROUTE_SECRETS, + /** + * @param {RequestDeleteFormSecret} request + */ + async handler(request) { + const { auth, params } = request + const { id, name } = params + const author = getAuthor(auth.credentials.user) + + await deleteFormSecret(id, name, author) + + return StatusCodes.OK + }, + options: { + auth: { + scope: [`+${Scopes.FormEdit}`] + }, + validate: { + params: formSecretSchema, + failAction + } + } } ] /** * @import { ServerRoute } from '@hapi/hapi' - * @import { RequestGetFormSecret, RequestSaveFormSecret } from '~/src/api/types.js' + * @import { RequestDeleteFormSecret, RequestGetFormSecret, RequestRenameFormSecret, RequestSaveFormSecret } from '~/src/api/types.js' */ diff --git a/src/routes/secrets.test.js b/src/routes/secrets.test.js index 3f57b3bc..99bd8222 100644 --- a/src/routes/secrets.test.js +++ b/src/routes/secrets.test.js @@ -1,4 +1,5 @@ import { + deleteFormSecret, existsFormSecret, getFormSecret, saveFormSecret @@ -45,7 +46,8 @@ describe('Secrets routes', () => { jest.mocked(existsFormSecret).mockResolvedValueOnce({ exists: true, createdAt: undefined, - updatedAt: undefined + updatedAt: undefined, + renamedAt: undefined }) const response = await server.inject({ @@ -79,6 +81,22 @@ describe('Secrets routes', () => { expect(calledName).toBe('my-new-secret') expect(calledValue).toBe('My new secret value') }) + + test('Testing DELETE /forms/{id}/secrets/{name}', async () => { + const deleteSecret = jest.mocked(deleteFormSecret) + + const response = await server.inject({ + method: 'DELETE', + url: `/forms/${id}/secrets/my-secret`, + auth + }) + + expect(response.statusCode).toEqual(okStatusCode) + expect(response.headers['content-type']).toContain(jsonContentType) + expect(response.result).toBe(okStatusCode) + const [, calledName] = deleteSecret.mock.calls[0] + expect(calledName).toBe('my-secret') + }) }) describe('Error responses', () => { diff --git a/test/integration/postman/forms-manager-ci-mock.postman_collection.json b/test/integration/postman/forms-manager-ci-mock.postman_collection.json index cb963ccd..3dea77f0 100644 --- a/test/integration/postman/forms-manager-ci-mock.postman_collection.json +++ b/test/integration/postman/forms-manager-ci-mock.postman_collection.json @@ -1,6 +1,6 @@ { "info": { - "_postman_id": "25fad961-499a-4c75-8728-4d076cd26d45", + "_postman_id": "bdcca91c-fac2-4df3-aaae-2afb359a65e1", "name": "forms-manager", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "23938788" @@ -1882,6 +1882,199 @@ } }, "response": [] + }, + { + "name": "Save secret", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"response is ok\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"secretValue\": \"my-secret-value\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{root}}/forms/{{go_live_form_id}}/secrets/my-secret", + "host": [ + "{{root}}" + ], + "path": [ + "forms", + "{{go_live_form_id}}", + "secrets", + "my-secret" + ] + } + }, + "response": [] + }, + { + "name": "Get secret 1", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"response is ok\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{root}}/forms/{{go_live_form_id}}/secrets/my-secret", + "host": [ + "{{root}}" + ], + "path": [ + "forms", + "{{go_live_form_id}}", + "secrets", + "my-secret" + ] + } + }, + "response": [] + }, + { + "name": "Delete secret", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"response is ok\", function () {", + " pm.response.to.have.status(200);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{root}}/forms/{{go_live_form_id}}/secrets/my-secret", + "host": [ + "{{root}}" + ], + "path": [ + "forms", + "{{go_live_form_id}}", + "secrets", + "my-secret" + ] + } + }, + "response": [] + }, + { + "name": "Get deleted secret", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"response is not found\", function () {", + " pm.response.to.have.status(404);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{root}}/forms/{{go_live_form_id}}/secrets/my-secret", + "host": [ + "{{root}}" + ], + "path": [ + "forms", + "{{go_live_form_id}}", + "secrets", + "my-secret" + ] + } + }, + "response": [] } ], "auth": {