diff --git a/src/app/controllers/encrypt-submissions.server.controller.js b/src/app/controllers/encrypt-submissions.server.controller.js index 6db814a2a7..01be69d3d1 100644 --- a/src/app/controllers/encrypt-submissions.server.controller.js +++ b/src/app/controllers/encrypt-submissions.server.controller.js @@ -1,6 +1,5 @@ 'use strict' const crypto = require('crypto') -const moment = require('moment-timezone') const { StatusCodes } = require('http-status-codes') const mongoose = require('mongoose') @@ -8,8 +7,6 @@ const errorHandler = require('../utils/handle-mongo-error') const { getEncryptSubmissionModel, } = require('../models/submission.server.model') -const getSubmissionModel = require('../models/submission.server.model').default -const Submission = getSubmissionModel(mongoose) const EncryptSubmission = getEncryptSubmissionModel(mongoose) const { checkIsEncryptedEncoding } = require('../utils/encryption') @@ -268,66 +265,3 @@ exports.getMetadata = function (req, res) { }) } } - -/** - * Return actual encrypted form responses matching submission id - * @param {Object} req - Express request object - * @param {String} req.query.submissionId - submission to return data for - * @param {Object} req.form - the form - * @param {Object} res - Express response object - */ -exports.getEncryptedResponse = function (req, res) { - let { submissionId } = req.query || {} - - Submission.findOne( - { - form: req.form._id, - _id: submissionId, - submissionType: 'encryptSubmission', - }, - { - encryptedContent: 1, - verifiedContent: 1, - attachmentMetadata: 1, - created: 1, - }, - ).exec(async (err, response) => { - if (err || !response) { - logger.error({ - message: 'Failure retrieving encrypted submission from database', - meta: { - action: 'getEncryptedResponse', - ...createReqMeta(req), - }, - error: err, - }) - return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ - message: errorHandler.getMongoErrorMessage(err), - }) - } else { - const entry = { - refNo: response._id, - submissionTime: moment(response.created) - .tz('Asia/Singapore') - .format('ddd, D MMM YYYY, hh:mm:ss A'), - content: response.encryptedContent, - verified: response.verifiedContent, - } - // make sure client obtains S3 presigned URLs to download attachments - if (response.attachmentMetadata) { - const attachmentMetadata = {} - for (let [key, objectPath] of response.attachmentMetadata) { - attachmentMetadata[key] = await s3.getSignedUrlPromise('getObject', { - Bucket: attachmentS3Bucket, - Key: objectPath, - Expires: req.session.cookie.maxAge / 1000, // Remaining login duration in seconds - }) - } - entry.attachmentMetadata = attachmentMetadata - } else { - entry.attachmentMetadata = {} - } - return res.json(entry) - } - }) -} diff --git a/src/app/models/submission.server.model.ts b/src/app/models/submission.server.model.ts index c99f72ea0d..047d4d790e 100644 --- a/src/app/models/submission.server.model.ts +++ b/src/app/models/submission.server.model.ts @@ -320,6 +320,25 @@ const getSubmissionCursorByFormId: IEncryptSubmissionModel['getSubmissionCursorB EncryptSubmissionSchema.statics.getSubmissionCursorByFormId = getSubmissionCursorByFormId +EncryptSubmissionSchema.statics.findEncryptedSubmissionById = function ( + this: IEncryptSubmissionModel, + formId: string, + submissionId: string, +) { + return this.findOne({ + _id: submissionId, + form: formId, + submissionType: SubmissionType.Encrypt, + }) + .select({ + encryptedContent: 1, + verifiedContent: 1, + attachmentMetadata: 1, + created: 1, + }) + .exec() +} + const compileSubmissionModel = (db: Mongoose): ISubmissionModel => { const Submission = db.model('Submission', SubmissionSchema) Submission.discriminator(SubmissionType.Email, EmailSubmissionSchema) diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts new file mode 100644 index 0000000000..4d8123762e --- /dev/null +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.controller.spec.ts @@ -0,0 +1,117 @@ +import { errAsync, okAsync } from 'neverthrow' +import { mocked } from 'ts-jest/utils' + +import { DatabaseError } from 'src/app/modules/core/core.errors' +import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-form.errors' +import { SubmissionData } from 'src/types' + +import expressHandler from 'tests/unit/backend/helpers/jest-express' + +import { SubmissionNotFoundError } from '../../submission.errors' +import { handleGetEncryptedResponse } from '../encrypt-submission.controller' +import * as EncryptSubmissionService from '../encrypt-submission.service' + +jest.mock('../encrypt-submission.service') +const MockEncryptSubService = mocked(EncryptSubmissionService) + +describe('encrypt-submission.controller', () => { + describe('handleGetEncryptedResponse', () => { + const MOCK_REQ = expressHandler.mockRequest({ + params: { formId: 'mockFormId' }, + query: { submissionId: 'mockSubmissionId' }, + session: { + cookie: { + maxAge: 20000, + }, + }, + }) + + it('should return 200 with encrypted response', async () => { + // Arrange + const mockSubData: SubmissionData = { + _id: 'some id', + encryptedContent: 'some encrypted content', + verifiedContent: 'some verified content', + created: new Date('2020-10-10'), + } as SubmissionData + const mockSignedUrls = { + someKey1: 'some-signed-url', + someKey2: 'another-signed-url', + } + const mockRes = expressHandler.mockResponse() + + // Mock service responses. + MockEncryptSubService.getEncryptedSubmissionData.mockReturnValueOnce( + okAsync(mockSubData), + ) + MockEncryptSubService.transformAttachmentMetasToSignedUrls.mockReturnValueOnce( + okAsync(mockSignedUrls), + ) + + // Act + await handleGetEncryptedResponse(MOCK_REQ, mockRes, jest.fn()) + + // Assert + const expected = { + refNo: mockSubData._id, + submissionTime: 'Sat, 10 Oct 2020, 08:00:00 AM', + content: mockSubData.encryptedContent, + verified: mockSubData.verifiedContent, + attachmentMetadata: mockSignedUrls, + } + expect(mockRes.json).toHaveBeenCalledWith(expected) + }) + + it('should return 404 when submissionId cannot be found in the database', async () => { + // Arrange + const mockErrorString = 'not found' + MockEncryptSubService.getEncryptedSubmissionData.mockReturnValueOnce( + errAsync(new SubmissionNotFoundError(mockErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await handleGetEncryptedResponse(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(404) + expect(mockRes.json).toHaveBeenCalledWith({ message: mockErrorString }) + }) + + it('should return 500 when database error occurs', async () => { + // Arrange + const mockErrorString = 'database error occurred' + MockEncryptSubService.getEncryptedSubmissionData.mockReturnValueOnce( + errAsync(new DatabaseError(mockErrorString)), + ) + const mockRes = expressHandler.mockResponse() + + // Act + await handleGetEncryptedResponse(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ message: mockErrorString }) + }) + + it('should return 500 when error occurs when generating presigned URLs', async () => { + // Arrange + const mockErrorString = 'presigned url error occured' + MockEncryptSubService.getEncryptedSubmissionData.mockReturnValueOnce( + okAsync({} as SubmissionData), + ) + MockEncryptSubService.transformAttachmentMetasToSignedUrls.mockReturnValueOnce( + errAsync(new CreatePresignedUrlError(mockErrorString)), + ) + + const mockRes = expressHandler.mockResponse() + + // Act + await handleGetEncryptedResponse(MOCK_REQ, mockRes, jest.fn()) + + // Assert + expect(mockRes.status).toHaveBeenCalledWith(500) + expect(mockRes.json).toHaveBeenCalledWith({ message: mockErrorString }) + }) + }) +}) diff --git a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts index 94d1799cdb..b89cea5aa3 100644 --- a/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts +++ b/src/app/modules/submission/encrypt-submission/__tests__/encrypt-submission.service.spec.ts @@ -5,12 +5,19 @@ import mongoose from 'mongoose' import { PassThrough, Transform } from 'stream' import { getEncryptSubmissionModel } from 'src/app/models/submission.server.model' -import { MalformedParametersError } from 'src/app/modules/core/core.errors' +import { + DatabaseError, + MalformedParametersError, +} from 'src/app/modules/core/core.errors' +import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-form.errors' import { aws } from 'src/config/config' -import { SubmissionCursorData } from 'src/types' +import { SubmissionCursorData, SubmissionData } from 'src/types' +import { SubmissionNotFoundError } from '../../submission.errors' import { + getEncryptedSubmissionData, getSubmissionCursor, + transformAttachmentMetasToSignedUrls, transformAttachmentMetaStream, } from '../encrypt-submission.service' @@ -306,6 +313,188 @@ describe('encrypt-submission.service', () => { expect(actualErrors).toEqual([expectedError]) }) }) + + describe('getEncryptedSubmissionData', () => { + it('should return submission data successfully', async () => { + // Arrange + const expected = { + encryptedContent: 'mock encrypted content', + verifiedContent: 'mock verified content', + attachmentMetadata: new Map([ + ['key1', 'objectPath1'], + ['key2', 'objectPath2'], + ]), + created: new Date(), + } as SubmissionData + + const getSubmissionSpy = jest + .spyOn(EncryptSubmission, 'findEncryptedSubmissionById') + .mockResolvedValueOnce(expected) + const mockFormId = new ObjectId().toHexString() + const mockSubmissionId = new ObjectId().toHexString() + + // Act + const actualResult = await getEncryptedSubmissionData( + mockFormId, + mockSubmissionId, + ) + + // Assert + expect(actualResult.isOk()).toEqual(true) + expect(actualResult._unsafeUnwrap()).toEqual(expected) + expect(getSubmissionSpy).toHaveBeenCalledWith( + mockFormId, + mockSubmissionId, + ) + }) + + it('should return SubmissionNotFoundError when submissionId does not exist in the database', async () => { + // Arrange + // Return null submission. + const getSubmissionSpy = jest + .spyOn(EncryptSubmission, 'findEncryptedSubmissionById') + .mockResolvedValueOnce(null) + const mockFormId = new ObjectId().toHexString() + const mockSubmissionId = new ObjectId().toHexString() + + // Act + const actualResult = await getEncryptedSubmissionData( + mockFormId, + mockSubmissionId, + ) + + // Assert + // Should be error. + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual( + new SubmissionNotFoundError( + 'Unable to find encrypted submission from database', + ), + ) + expect(getSubmissionSpy).toHaveBeenCalledWith( + mockFormId, + mockSubmissionId, + ) + }) + + it('should return DatabaseError when error occurs during query', async () => { + // Arrange + // Return error when querying for submission. + const mockErrorString = 'some error' + const getSubmissionSpy = jest + .spyOn(EncryptSubmission, 'findEncryptedSubmissionById') + .mockRejectedValueOnce(new Error(mockErrorString)) + const mockFormId = new ObjectId().toHexString() + const mockSubmissionId = new ObjectId().toHexString() + + // Act + const actualResult = await getEncryptedSubmissionData( + mockFormId, + mockSubmissionId, + ) + + // Assert + // Should be error. + expect(actualResult.isErr()).toEqual(true) + expect(actualResult._unsafeUnwrapErr()).toEqual( + new DatabaseError(mockErrorString), + ) + expect(getSubmissionSpy).toHaveBeenCalledWith( + mockFormId, + mockSubmissionId, + ) + }) + }) + + describe('transformAttachmentMetasToSignedUrls', () => { + const MOCK_METADATA = new Map([ + ['key1', 'objectPath1'], + ['key2', 'objectPath2'], + ]) + + it('should return map with transformed signed urls', async () => { + // Arrange + // Mock promise implementation. + jest + .spyOn(aws.s3, 'getSignedUrlPromise') + .mockImplementation((_operation, params) => { + return Promise.resolve( + `https://some-fake-url/${params.Key}/${params.Expires}`, + ) + }) + + // Act + const actualResult = await transformAttachmentMetasToSignedUrls( + MOCK_METADATA, + 200, + ) + + // Assert + expect(actualResult.isOk()).toEqual(true) + // Should return signed urls mapped to original key. + expect(actualResult._unsafeUnwrap()).toEqual({ + key1: 'https://some-fake-url/objectPath1/200', + key2: 'https://some-fake-url/objectPath2/200', + }) + }) + + it('should return empty object when given attachmentMetadata is undefined', async () => { + // Arrange + // Mock promise implementation. + const awsSpy = jest.spyOn(aws.s3, 'getSignedUrlPromise') + + // Act + const actualResult = await transformAttachmentMetasToSignedUrls( + undefined, + 200, + ) + + // Assert + expect(actualResult.isOk()).toEqual(true) + // Should return empty object. + expect(actualResult._unsafeUnwrap()).toEqual({}) + expect(awsSpy).not.toHaveBeenCalled() + }) + + it('should return empty object when given attachmentMetadata is empty map', async () => { + // Arrange + // Mock promise implementation. + const awsSpy = jest.spyOn(aws.s3, 'getSignedUrlPromise') + + // Act + const actualResult = await transformAttachmentMetasToSignedUrls( + new Map(), + 200, + ) + + // Assert + expect(actualResult.isOk()).toEqual(true) + // Should return empty object. + expect(actualResult._unsafeUnwrap()).toEqual({}) + expect(awsSpy).not.toHaveBeenCalled() + }) + + it('should return CreatePresignedUrlError when error occurs during the signed url creation process', async () => { + // Arrange + jest + .spyOn(aws.s3, 'getSignedUrlPromise') + .mockResolvedValueOnce('this passed') + .mockRejectedValueOnce(new Error('now this fails')) + + // Act + const actualResult = await transformAttachmentMetasToSignedUrls( + MOCK_METADATA, + 1000, + ) + + // Assert + expect(actualResult.isErr()).toEqual(true) + // Should reject even if there are some passing promises. + expect(actualResult._unsafeUnwrapErr()).toEqual( + new CreatePresignedUrlError('Failed to create attachment URL'), + ) + }) + }) }) /** diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index c3849915a6..d323d17eb1 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -2,15 +2,19 @@ import { RequestHandler } from 'express' import { ParamsDictionary, Query } from 'express-serve-static-core' import { StatusCodes } from 'http-status-codes' import JSONStream from 'JSONStream' +import moment from 'moment-timezone' import { createLoggerWithLabel } from '../../../../config/logger' import { IPopulatedForm } from '../../../../types' import { createReqMeta } from '../../../utils/request' import { + getEncryptedSubmissionData, getSubmissionCursor, + transformAttachmentMetasToSignedUrls, transformAttachmentMetaStream, } from './encrypt-submission.service' +import { mapRouteError } from './encrypt-submission.utils' const logger = createLoggerWithLabel(module) @@ -30,7 +34,7 @@ export const handleStreamEncryptedResponses: RequestHandler< unknown, unknown, Query & { startDate?: string; endDate?: string; downloadAttachments: boolean } -> = async function (req, res) { +> = async (req, res) => { const { startDate, endDate } = req.query // TODO (#42): Remove typecast once app has migrated away from middlewares. @@ -41,20 +45,27 @@ export const handleStreamEncryptedResponses: RequestHandler< endDate, }) - if (cursorResult.isErr()) { - return res.status(StatusCodes.BAD_REQUEST).json({ - message: 'Malformed date parameter', - }) - } - - const cursor = cursorResult.value - const logMeta = { action: 'handleStreamEncryptedResponses', ...createReqMeta(req), formId, } + if (cursorResult.isErr()) { + logger.error({ + message: 'Given date query params are malformed', + meta: logMeta, + error: cursorResult.error, + }) + + const { statusCode, errorMessage } = mapRouteError(cursorResult.error) + return res.status(statusCode).json({ + message: errorMessage, + }) + } + + const cursor = cursorResult.value + cursor .on('error', (error) => { logger.error({ @@ -115,3 +126,83 @@ export const handleStreamEncryptedResponses: RequestHandler< return res.end() }) } + +/** + * Handler for GET /:formId/adminform/submissions + * + * @returns 200 with encrypted submission data response + * @returns 404 if submissionId cannot be found in the database + * @returns 500 if any errors occurs in database query or generating signed URL + */ +export const handleGetEncryptedResponse: RequestHandler< + { formId: string }, + unknown, + unknown, + { submissionId: string } +> = async (req, res) => { + const { submissionId } = req.query + const { formId } = req.params + + const logMeta = { + action: 'handleGetEncryptedResponse', + submissionId, + formId, + } + + // Step 1: Retrieve submission. + const submissionResult = await getEncryptedSubmissionData( + formId, + submissionId, + ) + + if (submissionResult.isErr()) { + logger.error({ + message: 'Failure retrieving encrypted submission from database', + meta: logMeta, + error: submissionResult.error, + }) + + const { statusCode, errorMessage } = mapRouteError(submissionResult.error) + return res.status(statusCode).json({ + message: errorMessage, + }) + } + + // Step 2: Retrieve presigned URLs for attachments. + const submission = submissionResult.value + // Remaining login duration in seconds. + const urlExpiry = (req.session?.cookie.maxAge ?? 0) / 1000 + const presignedUrlsResult = await transformAttachmentMetasToSignedUrls( + submission.attachmentMetadata, + urlExpiry, + ) + + if (presignedUrlsResult.isErr()) { + logger.error({ + message: 'Failure transforming attachment metadata into presigned URLs', + meta: logMeta, + error: presignedUrlsResult.error, + }) + + const { statusCode, errorMessage } = mapRouteError( + presignedUrlsResult.error, + ) + return res.status(statusCode).json({ + message: errorMessage, + }) + } + + // Successfully retrieved both submission and transforming presigned URLs, + // return to client. + const responseData = { + refNo: submission._id, + submissionTime: moment(submission.created) + .tz('Asia/Singapore') + .format('ddd, D MMM YYYY, hh:mm:ss A'), + content: submission.encryptedContent, + verified: submission.verifiedContent, + attachmentMetadata: presignedUrlsResult.value, + } + + return res.json(responseData) +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts index 07d34c1e46..7e0b95dcbd 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.service.ts @@ -1,13 +1,17 @@ +import Bluebird from 'bluebird' import mongoose from 'mongoose' -import { err, ok, Result } from 'neverthrow' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' import { Transform } from 'stream' import { aws as AwsConfig } from '../../../../config/config' import { createLoggerWithLabel } from '../../../../config/logger' -import { SubmissionCursorData } from '../../../../types' +import { SubmissionCursorData, SubmissionData } from '../../../../types' import { getEncryptSubmissionModel } from '../../../models/submission.server.model' import { isMalformedDate } from '../../../utils/date' -import { MalformedParametersError } from '../../core/core.errors' +import { getMongoErrorMessage } from '../../../utils/handle-mongo-error' +import { DatabaseError, MalformedParametersError } from '../../core/core.errors' +import { CreatePresignedUrlError } from '../../form/admin-form/admin-form.errors' +import { SubmissionNotFoundError } from '../submission.errors' const logger = createLoggerWithLabel(module) const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose) @@ -107,3 +111,95 @@ export const transformAttachmentMetaStream = ({ }, }) } + +/** + * Retrieves required subset of encrypted submission data from the database + * @param formId the id of the form to filter submissions for + * @param submissionId the submission itself to retrieve + * @returns ok(SubmissionData) + * @returns err(SubmissionNotFoundError) if given submissionId does not exist in the database + * @returns err(DatabaseError) when error occurs during query + */ +export const getEncryptedSubmissionData = ( + formId: string, + submissionId: string, +): ResultAsync => { + return ResultAsync.fromPromise( + EncryptSubmissionModel.findEncryptedSubmissionById(formId, submissionId), + (error) => { + logger.error({ + message: 'Failure retrieving encrypted submission from database', + meta: { + action: 'getEncryptedSubmissionData', + formId, + submissionId, + }, + error, + }) + + return new DatabaseError(getMongoErrorMessage(error)) + }, + ).andThen((submission) => { + if (!submission) { + logger.error({ + message: 'Unable to find encrypted submission from database', + meta: { + action: 'getEncryptedResponse', + formId, + submissionId, + }, + }) + return errAsync( + new SubmissionNotFoundError( + 'Unable to find encrypted submission from database', + ), + ) + } + + return okAsync(submission) + }) +} + +/** + * Transforms given attachment metadata to their S3 signed url counterparts. + * @param attachmentMetadata the metadata to transform + * @param urlValidDuration the duration the S3 signed url will be valid for + * @returns ok(map with object path replaced with their signed url counterparts) + * @returns err(CreatePresignedUrlError) if any of the signed url creation processes results in an error + */ +export const transformAttachmentMetasToSignedUrls = ( + attachmentMetadata: Map | undefined, + urlValidDuration: number, +): ResultAsync, CreatePresignedUrlError> => { + if (!attachmentMetadata) { + return okAsync({}) + } + const keyToSignedUrlPromises: Record> = {} + + for (const [key, objectPath] of attachmentMetadata) { + keyToSignedUrlPromises[key] = AwsConfig.s3.getSignedUrlPromise( + 'getObject', + { + Bucket: AwsConfig.attachmentS3Bucket, + Key: objectPath, + Expires: urlValidDuration, + }, + ) + } + + return ResultAsync.fromPromise( + Bluebird.props(keyToSignedUrlPromises), + (error) => { + logger.error({ + message: 'Failed to retrieve signed URLs for attachments', + meta: { + action: 'transformAttachmentMetasToSignedUrls', + attachmentMetadata, + }, + error, + }) + + return new CreatePresignedUrlError('Failed to create attachment URL') + }, + ) +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts new file mode 100644 index 0000000000..4a84921b46 --- /dev/null +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.utils.ts @@ -0,0 +1,48 @@ +import { StatusCodes } from 'http-status-codes' + +import { createLoggerWithLabel } from '../../../../config/logger' +import { MapRouteError } from '../../../../types/routing' +import { DatabaseError, MalformedParametersError } from '../../core/core.errors' +import { CreatePresignedUrlError } from '../../form/admin-form/admin-form.errors' +import { SubmissionNotFoundError } from '../submission.errors' + +const logger = createLoggerWithLabel(module) + +/** + * Handler to map ApplicationErrors to their correct status code and error + * messages. + * @param error The error to retrieve the status codes and error messages + */ +export const mapRouteError: MapRouteError = (error) => { + switch (error.constructor) { + case MalformedParametersError: + return { + statusCode: StatusCodes.BAD_REQUEST, + errorMessage: error.message, + } + case SubmissionNotFoundError: + return { + statusCode: StatusCodes.NOT_FOUND, + errorMessage: error.message, + } + case CreatePresignedUrlError: + case DatabaseError: + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: error.message, + } + default: + logger.error({ + message: 'Unknown route error observed', + meta: { + action: 'mapRouteError', + }, + error, + }) + + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorMessage: 'Something went wrong. Please try again.', + } + } +} diff --git a/src/app/modules/submission/submission.errors.ts b/src/app/modules/submission/submission.errors.ts index 816ba3daf5..8ffd374afa 100644 --- a/src/app/modules/submission/submission.errors.ts +++ b/src/app/modules/submission/submission.errors.ts @@ -11,3 +11,9 @@ export class ConflictError extends ApplicationError { super(message, StatusCodes.CONFLICT, meta) } } + +export class SubmissionNotFoundError extends ApplicationError { + constructor(message: string) { + super(message) + } +} diff --git a/src/app/routes/admin-forms.server.routes.js b/src/app/routes/admin-forms.server.routes.js index 89b92ebf4a..bfab45828b 100644 --- a/src/app/routes/admin-forms.server.routes.js +++ b/src/app/routes/admin-forms.server.routes.js @@ -395,7 +395,10 @@ module.exports = function (app) { */ app .route('/:formId([a-fA-F0-9]{24})/adminform/submissions') - .get(authEncryptedResponseAccess, encryptSubmissions.getEncryptedResponse) + .get( + authEncryptedResponseAccess, + EncryptSubmissionController.handleGetEncryptedResponse, + ) /** * Count the number of submissions for a public form diff --git a/src/app/utils/handle-mongo-error.ts b/src/app/utils/handle-mongo-error.ts index d7f5db029c..9e3af5ce25 100644 --- a/src/app/utils/handle-mongo-error.ts +++ b/src/app/utils/handle-mongo-error.ts @@ -2,7 +2,7 @@ import { MongoError } from 'mongodb' import { Error as MongooseError } from 'mongoose' export const getMongoErrorMessage = ( - err?: MongoError | MongooseError | string, + err?: unknown, // Default error message if no more specific error defaultErrorMessage = 'An unexpected error happened. Please try again.', ): string => { @@ -30,9 +30,13 @@ export const getMongoErrorMessage = ( return joinedMessage ?? err.message ?? defaultErrorMessage } - if (err instanceof MongooseError) { + if (err instanceof MongooseError || err instanceof Error) { return err.message ?? defaultErrorMessage } - return err ?? defaultErrorMessage + if (typeof err === 'string') { + return err ?? defaultErrorMessage + } + + return defaultErrorMessage } diff --git a/src/types/submission.ts b/src/types/submission.ts index b21bf4b081..61bb729599 100644 --- a/src/types/submission.ts +++ b/src/types/submission.ts @@ -110,6 +110,11 @@ export type SubmissionCursorData = Pick< 'encryptedContent' | 'verifiedContent' | 'created' | 'id' > & { attachmentMetadata: Record } & Document +export type SubmissionData = Omit< + IEncryptedSubmissionSchema, + 'version' | 'webhookResponses' +> + export type IEmailSubmissionModel = Model & ISubmissionModel export type IEncryptSubmissionModel = Model & @@ -158,6 +163,11 @@ export type IEncryptSubmissionModel = Model & endDate?: string }, ): QueryCursor + + findEncryptedSubmissionById( + formId: string, + submissionId: string, + ): Promise } export interface IWebhookResponseSchema extends IWebhookResponse, Document {} diff --git a/tests/unit/backend/models/encrypt-submission.server.model.spec.ts b/tests/unit/backend/models/encrypt-submission.server.model.spec.ts index a9cb103bf1..fb28541361 100644 --- a/tests/unit/backend/models/encrypt-submission.server.model.spec.ts +++ b/tests/unit/backend/models/encrypt-submission.server.model.spec.ts @@ -7,6 +7,7 @@ import getSubmissionModel, { getEncryptSubmissionModel, } from 'src/app/models/submission.server.model' import { + IEmailSubmissionSchema, IEncryptedSubmissionSchema, ISubmissionSchema, SubmissionMetadata, @@ -339,5 +340,77 @@ describe('Encrypt Submission Model', () => { expect(retrievedSubmissions).toEqual([]) }) }) + + describe('findEncryptedSubmissionById', () => { + it('should return correct submission by its id', async () => { + // Arrange + const validFormId = new ObjectId().toHexString() + const validSubmission = await Submission.create({ + submissionType: SubmissionType.Encrypt, + form: validFormId, + encryptedContent: 'mock encrypted content abc', + version: 1, + attachmentMetadata: { someFileName: 'some url of attachment' }, + }) + + // Act + const actual = await EncryptSubmission.findEncryptedSubmissionById( + validFormId, + validSubmission._id, + ) + + // Assert + const expected = pick( + validSubmission.toObject(), + '_id', + 'attachmentMetadata', + 'created', + 'encryptedContent', + 'submissionType', + ) + expect(actual).not.toBeNull() + expect(actual?.toObject()).toEqual(expected) + }) + + it('should return null when submission id does not exist', async () => { + // Arrange + // Form ID does not matter. + const formId = new ObjectId().toHexString() + const invalidSubmissionId = new ObjectId().toHexString() + + // Act + const actual = await EncryptSubmission.findEncryptedSubmissionById( + formId, + invalidSubmissionId, + ) + + // Assert + expect(actual).toBeNull() + }) + + it('should return null when type of submission with given id is not SubmissionType.Encrypt', async () => { + // Arrange + const validFormId = new ObjectId().toHexString() + const validEmailSubmission = await Submission.create< + IEmailSubmissionSchema + >({ + submissionType: SubmissionType.Email, + form: validFormId, + recipientEmails: ['any@example.com'], + responseHash: 'any hash', + responseSalt: 'any salt', + }) + + // Act + const actual = await EncryptSubmission.findEncryptedSubmissionById( + validFormId, + validEmailSubmission._id, + ) + + // Assert + // Should still be null even when formId and submissionIds are valid + expect(actual).toBeNull() + }) + }) }) })