Skip to content
Open
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
Expand Up @@ -689,6 +689,7 @@ describe('Multirespondent Submission Model', () => {
version: 3,
attachmentMetadata: { someFileName: 'some url of attachment' },
workflowStep: 0,
encryptedStepToken: 'senderPublicKey;nonce:ciphertext',
})

// Act
Expand All @@ -714,6 +715,7 @@ describe('Multirespondent Submission Model', () => {
'version',
'workflowStep',
'submittedSteps',
'encryptedStepToken',
)
expect(actual).not.toBeNull()
expect(actual?.toJSON()).toEqual(expected)
Expand Down Expand Up @@ -760,4 +762,66 @@ describe('Multirespondent Submission Model', () => {
})
})
})

describe('step-token fields', () => {
it('should persist and retrieve stepTokenHash and encryptedStepToken', async () => {
// Arrange
const validFormId = new ObjectId().toHexString()
const MOCK_STEP_TOKEN_HASH = 'a'.repeat(64)
const MOCK_ENCRYPTED_STEP_TOKEN =
'senderPublicKey;nonce:encryptedStepTokenCiphertext'

// Act
const submission = await MultirespondentSubmission.create({
form: validFormId,
submissionType: SubmissionType.Multirespondent,
form_fields: [],
form_logics: [],
workflow: [],
submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY,
encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY,
encryptedContent: MOCK_ENCRYPTED_CONTENT,
version: 3,
workflowStep: 0,
stepTokenHash: MOCK_STEP_TOKEN_HASH,
encryptedStepToken: MOCK_ENCRYPTED_STEP_TOKEN,
})
const found = await MultirespondentSubmission.findById(submission._id)

// Assert
expect(found?.stepTokenHash).toBe(MOCK_STEP_TOKEN_HASH)
expect(found?.encryptedStepToken).toBe(MOCK_ENCRYPTED_STEP_TOKEN)
})

it('should never expose stepTokenHash or encryptedStepToken in the webhook view', async () => {
// Arrange
const validFormId = new ObjectId().toHexString()
const MOCK_STEP_TOKEN_HASH = 'b'.repeat(64)
const MOCK_ENCRYPTED_STEP_TOKEN = 'senderPublicKey;nonce:ciphertext'
const submission = await MultirespondentSubmission.create({
form: validFormId,
submissionType: SubmissionType.Multirespondent,
form_fields: [],
form_logics: [],
workflow: [],
submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY,
encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY,
encryptedContent: MOCK_ENCRYPTED_CONTENT,
version: 3,
workflowStep: 0,
stepTokenHash: MOCK_STEP_TOKEN_HASH,
encryptedStepToken: MOCK_ENCRYPTED_STEP_TOKEN,
})

// Act
const webhookView = await submission.getWebhookView()

// Assert: step-token fields are ROW-ONLY, never in any webhook payload.
const serialised = JSON.stringify(webhookView)
expect(serialised).not.toContain('stepTokenHash')
expect(serialised).not.toContain('encryptedStepToken')
expect(serialised).not.toContain(MOCK_STEP_TOKEN_HASH)
expect(serialised).not.toContain(MOCK_ENCRYPTED_STEP_TOKEN)
})
})
})
9 changes: 9 additions & 0 deletions apps/backend/src/app/models/submission.server.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,14 @@ export const MultirespondentSubmissionSchema = new Schema<
type: [submittedStepSchema],
default: [],
},
stepTokenHash: {
type: String,
trim: true,
Comment thread
kevin9foong marked this conversation as resolved.
},
encryptedStepToken: {
type: String,
trim: true,
},
})

type MultiRespondentAggregates = Pick<
Expand Down Expand Up @@ -879,6 +887,7 @@ MultirespondentSubmissionSchema.statics.findEncryptedSubmissionById = function (
workflowStep: 1,
mrfVersion: 1,
submittedSteps: 1,
encryptedStepToken: 1,
})
.exec()
}
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/app/modules/core/core.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export enum ErrorCodes {
SUBMISSION_MISSING_SUBMITTER_ID = 100234,
SUBMISSION_ENCRYPTION_VERIFICATION_FAILED = 100235,
SUBMISSION_ENCRYPTION_MISMATCH = 100236,
SUBMISSION_MRF_STEP_TOKEN_INVALID = 100237,
// [100300 - 100399] Receiver Errors (/modules/submission/receiver)
RECEIVER_INITIALISE_MULTIPART_RECEIVER = 100301,
RECEIVER_MULTIPART_CONTENT_LIMIT = 100302,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,44 @@ describe('multirespondent-submision.controller', () => {
})
})

it('resends the SAME step token in the reminder link without rotating it', async () => {
// Arrange: the admin has unwrapped encryptedStepToken client-side and
// passes the RAW token back in the body.
const RAW_STEP_TOKEN = 'raw-step-token-abc'
const mockReq = expressHandler.mockRequest({
params: { formId: mockFormId, submissionId: mockSubmissionId },
body: {
submissionSecretKey: 'mockSubmissionSecretKey',
stepToken: RAW_STEP_TOKEN,
},
session: { user: { _id: MOCK_USER._id } },
})
const mockRes = expressHandler.mockResponse()
const mockNext = jest.fn()

MockMultiRespondentSubmissionService.getPendingStepRecipientEmailsFromSubmittedStepsMeta =
jest.fn().mockReturnValue(
okAsync({
recipientEmails: ['test@example.com'],
reminderStepNumber: 1,
}),
)
MockMultiRespondentSubmissionService.sendNextStepReminderEmail = jest
.fn()
.mockReturnValue(okAsync(true))

// Act
await sendPendingMrfSubmissionReminderForTest(mockReq, mockRes, mockNext)

// Assert: the reminder link carries the exact same token (no rotation).
const call =
MockMultiRespondentSubmissionService.sendNextStepReminderEmail.mock
.calls[0][0]
expect(call.responseUrl).toContain(
`&token=${encodeURIComponent(RAW_STEP_TOKEN)}`,
)
})

it('returns 404 when retrieveFormById encounters FormNotFoundError', async () => {
// Arrange
const formNotFoundError = new FormNotFoundError('Form not found')
Expand Down
Loading
Loading