Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref: collapse middlewares of /adminform/submissions/download #1442

Merged
merged 3 commits into from
Mar 24, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,22 +1,44 @@
import { ObjectId } from 'bson-ext'
import { errAsync, okAsync } from 'neverthrow'
import { err, errAsync, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import * as AuthService from 'src/app/modules/auth/auth.service'
import { DatabaseError } from 'src/app/modules/core/core.errors'
import { CreatePresignedUrlError } from 'src/app/modules/form/admin-form/admin-form.errors'
import { SubmissionData, SubmissionMetadata } from 'src/types'
import {
ForbiddenFormError,
FormDeletedError,
FormNotFoundError,
} from 'src/app/modules/form/form.errors'
import { MissingUserError } from 'src/app/modules/user/user.errors'
import * as UserService from 'src/app/modules/user/user.service'
import {
IPopulatedForm,
IPopulatedUser,
ResponseMode,
SubmissionData,
SubmissionMetadata,
} from 'src/types'

import expressHandler from 'tests/unit/backend/helpers/jest-express'

import { SubmissionNotFoundError } from '../../submission.errors'
import {
ResponseModeError,
SubmissionNotFoundError,
} from '../../submission.errors'
import {
handleGetEncryptedResponse,
handleGetMetadata,
handleStreamEncryptedResponses,
} from '../encrypt-submission.controller'
import * as EncryptSubmissionService from '../encrypt-submission.service'

jest.mock('../encrypt-submission.service')
jest.mock('src/app/modules/user/user.service')
jest.mock('src/app/modules/auth/auth.service')
const MockEncryptSubService = mocked(EncryptSubmissionService)
const MockUserService = mocked(UserService)
const MockAuthService = mocked(AuthService)

describe('encrypt-submission.controller', () => {
beforeEach(() => jest.clearAllMocks())
Expand Down Expand Up @@ -292,4 +314,219 @@ describe('encrypt-submission.controller', () => {
).toHaveBeenCalledWith(MOCK_FORM_ID, mockReq.query.page)
})
})

describe('handleStreamEncryptedResponses', () => {
const MOCK_USER_ID = new ObjectId().toHexString()
const MOCK_FORM_ID = new ObjectId().toHexString()
const MOCK_USER = {
_id: MOCK_USER_ID,
email: '[email protected]',
} as IPopulatedUser
const MOCK_FORM = {
admin: MOCK_USER,
_id: MOCK_FORM_ID,
title: 'mock title',
} as IPopulatedForm

const MOCK_REQ = expressHandler.mockRequest({
params: {
formId: MOCK_FORM_ID,
},
session: {
user: {
_id: MOCK_USER_ID,
},
},
})

// Not sure how to test streams in express controllers, so skipping...
it.todo('should successfully return stream of encrypted responses')
karrui marked this conversation as resolved.
Show resolved Hide resolved

it('should return 400 if form is not an encrypt mode form', async () => {
// Arrange
// Mock success case until form encrypt check
MockUserService.getPopulatedUserById.mockReturnValueOnce(
okAsync(MOCK_USER),
)
MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
okAsync(MOCK_FORM),
)
const expectedError = new ResponseModeError(
ResponseMode.Encrypt,
ResponseMode.Email,
)
MockEncryptSubService.checkFormIsEncryptMode.mockReturnValueOnce(
err(expectedError),
)

const mockRes = expressHandler.mockResponse()

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(400)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 403 when user does not have read permissions for form', async () => {
// Arrange
MockUserService.getPopulatedUserById.mockReturnValueOnce(
okAsync(MOCK_USER),
)
const expectedError = new ForbiddenFormError('no access')
MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
errAsync(expectedError),
)
const mockRes = expressHandler.mockResponse()

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(403)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 404 when form cannot be found', async () => {
// Arrange
MockUserService.getPopulatedUserById.mockReturnValueOnce(
okAsync(MOCK_USER),
)
const expectedError = new FormNotFoundError('not found')
MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
errAsync(expectedError),
)
const mockRes = expressHandler.mockResponse()

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(404)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 410 when form is already archived', async () => {
// Arrange
MockUserService.getPopulatedUserById.mockReturnValueOnce(
okAsync(MOCK_USER),
)
karrui marked this conversation as resolved.
Show resolved Hide resolved
const expectedError = new FormDeletedError('already archived')
MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
errAsync(expectedError),
)
const mockRes = expressHandler.mockResponse()

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(410)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 422 when user in session cannot be retrieved', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
const expectedError = new MissingUserError('user is not found')
MockUserService.getPopulatedUserById.mockReturnValueOnce(
errAsync(expectedError),
)

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(422)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
expect(
MockAuthService.getFormAfterPermissionChecks,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 500 when database error occurs whilst retrieving user in session', async () => {
// Arrange
const mockRes = expressHandler.mockResponse()
const expectedError = new DatabaseError('db went ????????')
MockUserService.getPopulatedUserById.mockReturnValueOnce(
errAsync(expectedError),
)

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
expect(
MockAuthService.getFormAfterPermissionChecks,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})

it('should return 500 when database error occurs whilst checking form permissions', async () => {
// Arrange
MockUserService.getPopulatedUserById.mockReturnValueOnce(
okAsync(MOCK_USER),
)
const expectedError = new DatabaseError('database error beep boop')
MockAuthService.getFormAfterPermissionChecks.mockReturnValueOnce(
errAsync(expectedError),
)
const mockRes = expressHandler.mockResponse()

// Act
await handleStreamEncryptedResponses(MOCK_REQ, mockRes, jest.fn())

// Assert
expect(mockRes.status).toHaveBeenCalledWith(500)
expect(mockRes.json).toHaveBeenCalledWith({
message: expectedError.message,
})
expect(
MockEncryptSubService.checkFormIsEncryptMode,
).not.toHaveBeenCalled()
// Check cursor retrieval not called.
expect(MockEncryptSubService.getSubmissionCursor).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { RequestHandler } from 'express'
import { ParamsDictionary, Query } from 'express-serve-static-core'
import { 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 { WithForm } from '../../../../types'
import { CaptchaFactory } from '../../../services/captcha/captcha.factory'
import { createReqMeta, getRequestIp } from '../../../utils/request'
import { getFormAfterPermissionChecks } from '../../auth/auth.service'
import { PermissionLevel } from '../../form/admin-form/admin-form.types'
import * as FormService from '../../form/form.service'
import { getPopulatedUserById } from '../../user/user.service'

import {
checkFormIsEncryptMode,
getEncryptedSubmissionData,
getSubmissionCursor,
getSubmissionMetadata,
Expand Down Expand Up @@ -108,26 +111,47 @@ export const handleEncryptedSubmission: RequestHandler = async (

/**
* Handler for GET /:formId([a-fA-F0-9]{24})/adminform/submissions/download
* @security session
*
* @returns 200 with stream of encrypted responses
* @returns 400 if form is not an encrypt mode form
* @returns 400 if req.query.startDate or req.query.endDate is malformed
* @returns 500 if any errors occurs in stream pipeline
* @returns 403 when user does not have read permissions for form
* @returns 404 when form cannot be found
* @returns 410 when form is archived
* @returns 422 when user in session cannot be retrieved from the database
* @returns 500 if any errors occurs in stream pipeline or error retrieving form
*/
export const handleStreamEncryptedResponses: RequestHandler<
ParamsDictionary,
{ formId: string },
unknown,
unknown,
Query & { startDate?: string; endDate?: string; downloadAttachments: boolean }
> = async (req, res) => {
const sessionUserId = (req.session as Express.AuthedSession).user._id
karrui marked this conversation as resolved.
Show resolved Hide resolved
const { formId } = req.params
const { startDate, endDate } = req.query

// TODO (#42): Remove typecast once app has migrated away from middlewares.
const formId = (req as WithForm<typeof req>).form._id

const cursorResult = getSubmissionCursor(formId, {
startDate,
endDate,
})
// Step 1: Retrieve currently logged in user.
// eslint-disable-next-line typesafe/no-await-without-trycatch
const cursorResult = await getPopulatedUserById(sessionUserId)
.andThen((user) =>
// Step 2: Check whether user has read permissions to form
getFormAfterPermissionChecks({
user,
formId,
level: PermissionLevel.Read,
}),
)
// Step 3: Check whether form is encrypt mode.
.andThen(checkFormIsEncryptMode)
// Step 4: Retrieve submissions cursor.
.andThen(() =>
getSubmissionCursor(formId, {
startDate,
endDate,
}),
)

const logMeta = {
action: 'handleStreamEncryptedResponses',
Expand All @@ -137,7 +161,7 @@ export const handleStreamEncryptedResponses: RequestHandler<

if (cursorResult.isErr()) {
logger.error({
message: 'Given date query params are malformed',
message: 'Error occurred whilst retrieving submission cursor',
meta: logMeta,
error: cursorResult.error,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { Transform } from 'stream'
import { aws as AwsConfig } from '../../../../config/config'
import { createLoggerWithLabel } from '../../../../config/logger'
import {
IPopulatedEncryptedForm,
IPopulatedForm,
ResponseMode,
SubmissionCursorData,
SubmissionData,
SubmissionMetadata,
Expand All @@ -15,7 +18,11 @@ import { isMalformedDate } from '../../../utils/date'
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'
import { isFormEncryptMode } from '../../form/form.utils'
import {
ResponseModeError,
SubmissionNotFoundError,
} from '../submission.errors'

const logger = createLoggerWithLabel(module)
const EncryptSubmissionModel = getEncryptSubmissionModel(mongoose)
Expand Down Expand Up @@ -260,3 +267,11 @@ export const getSubmissionMetadataList = (
},
)
}

export const checkFormIsEncryptMode = (
form: IPopulatedForm,
): Result<IPopulatedEncryptedForm, ResponseModeError> => {
return isFormEncryptMode(form)
? ok(form)
: err(new ResponseModeError(ResponseMode.Encrypt, form.responseMode))
}
Loading