From c7df5df7e6ff3c3f4fbb1aa5b7ce660625c909bb Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 29 Apr 2026 13:53:26 +1200 Subject: [PATCH 01/14] Tidy up yarn lock --- yarn.lock | 182 ------------------------------------------------------ 1 file changed, 182 deletions(-) diff --git a/yarn.lock b/yarn.lock index 53938c24..a7c8eb3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1337,13 +1337,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/aix-ppc64@npm:0.25.12" - conditions: os=aix & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/aix-ppc64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/aix-ppc64@npm:0.25.8" @@ -1365,13 +1358,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/android-arm64@npm:0.25.12" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/android-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm64@npm:0.25.8" @@ -1393,13 +1379,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/android-arm@npm:0.25.12" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@esbuild/android-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-arm@npm:0.25.8" @@ -1421,13 +1400,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/android-x64@npm:0.25.12" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - "@esbuild/android-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/android-x64@npm:0.25.8" @@ -1449,13 +1421,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/darwin-arm64@npm:0.25.12" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/darwin-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-arm64@npm:0.25.8" @@ -1477,13 +1442,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/darwin-x64@npm:0.25.12" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@esbuild/darwin-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/darwin-x64@npm:0.25.8" @@ -1505,13 +1463,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/freebsd-arm64@npm:0.25.12" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/freebsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-arm64@npm:0.25.8" @@ -1533,13 +1484,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/freebsd-x64@npm:0.25.12" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/freebsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/freebsd-x64@npm:0.25.8" @@ -1561,13 +1505,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-arm64@npm:0.25.12" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/linux-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm64@npm:0.25.8" @@ -1589,13 +1526,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-arm@npm:0.25.12" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@esbuild/linux-arm@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-arm@npm:0.25.8" @@ -1617,13 +1547,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-ia32@npm:0.25.12" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/linux-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ia32@npm:0.25.8" @@ -1645,13 +1568,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-loong64@npm:0.25.12" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - "@esbuild/linux-loong64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-loong64@npm:0.25.8" @@ -1673,13 +1589,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-mips64el@npm:0.25.12" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - "@esbuild/linux-mips64el@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-mips64el@npm:0.25.8" @@ -1701,13 +1610,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-ppc64@npm:0.25.12" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/linux-ppc64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-ppc64@npm:0.25.8" @@ -1729,13 +1631,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-riscv64@npm:0.25.12" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - "@esbuild/linux-riscv64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-riscv64@npm:0.25.8" @@ -1757,13 +1652,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-s390x@npm:0.25.12" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - "@esbuild/linux-s390x@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-s390x@npm:0.25.8" @@ -1785,13 +1673,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/linux-x64@npm:0.25.12" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/linux-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/linux-x64@npm:0.25.8" @@ -1813,13 +1694,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/netbsd-arm64@npm:0.25.12" - conditions: os=netbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/netbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-arm64@npm:0.25.8" @@ -1841,13 +1715,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/netbsd-x64@npm:0.25.12" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/netbsd-x64@npm:0.25.8" @@ -1869,13 +1736,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/openbsd-arm64@npm:0.25.12" - conditions: os=openbsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/openbsd-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-arm64@npm:0.25.8" @@ -1897,13 +1757,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/openbsd-x64@npm:0.25.12" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openbsd-x64@npm:0.25.8" @@ -1925,13 +1778,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/openharmony-arm64@npm:0.25.12" - conditions: os=openharmony & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/openharmony-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/openharmony-arm64@npm:0.25.8" @@ -1953,13 +1799,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/sunos-x64@npm:0.25.12" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - "@esbuild/sunos-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/sunos-x64@npm:0.25.8" @@ -1981,13 +1820,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/win32-arm64@npm:0.25.12" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/win32-arm64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-arm64@npm:0.25.8" @@ -2009,13 +1841,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/win32-ia32@npm:0.25.12" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/win32-ia32@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-ia32@npm:0.25.8" @@ -2037,13 +1862,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.25.12": - version: 0.25.12 - resolution: "@esbuild/win32-x64@npm:0.25.12" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@esbuild/win32-x64@npm:0.25.8": version: 0.25.8 resolution: "@esbuild/win32-x64@npm:0.25.8" From e64556895a627b295b6fdfcd42e773996b3aff55 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 29 Apr 2026 14:33:29 +1200 Subject: [PATCH 02/14] Adds token visibility test to studies backend tests --- .../src/controllers/StudiesController.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/application/backend/src/controllers/StudiesController.test.ts b/application/backend/src/controllers/StudiesController.test.ts index d4bf6912..46b63cd6 100644 --- a/application/backend/src/controllers/StudiesController.test.ts +++ b/application/backend/src/controllers/StudiesController.test.ts @@ -57,6 +57,22 @@ describe('StudiesController', () => { expect(body.data.length).toEqual(4) }) + it('should not return any token information', async () => { + const response = await request(app) + .get('/studies') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllStudiesResponse = response.body + expect(Array.isArray(body.data)).toBeTruthy() + expect(body.data.length).toBeGreaterThan(0) + + body.data.forEach((study) => { + expect(study).not.toHaveProperty('redcapURL') + expect(study).not.toHaveProperty('redcapToken') + }) + }) + it('should return a 500 error if a database error occurs', async () => { jest.spyOn(prisma.study, 'findMany').mockImplementationOnce(() => { throw new Error('Internal Server Error') From 81a06eed9d31d1a89d2509cc7bf5e4bda312103e Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 29 Apr 2026 16:45:46 +1200 Subject: [PATCH 03/14] Adds more token visibility test to studies and audit log backend tests --- .../controllers/AuditLogsController.test.ts | 92 +++++++++++++++++++ .../src/controllers/StudiesController.test.ts | 33 +++++++ 2 files changed, 125 insertions(+) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index 30ff41de..bbd812f9 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -1,10 +1,14 @@ import request from 'supertest' import { Api } from '../Api' +import prisma from '../PrismaClient' +import { Role } from '@prisma/client' import { resetDB, seedAuditLogs } from 'common/testing/TestHelpers' import { defaultAuditLogsPageSize } from 'common/src/config' import { generateToken } from '../authentication' import type { GetAuditLogsResponse } from 'common/types/api/audit-logs' import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' +import { UpdateStudyRequest } from 'common/types/api/studies' +import type { RegisterRequest } from 'common/types/api/auth' const api = new Api() const app = api.app @@ -204,5 +208,93 @@ describe('AuditLogsController', () => { ).toBe(seedSize - end + 1) // account for 0 index }) }) + + describe('Sensitive information', () => { + it('should not show sensitive token information in payloads', async () => { + // Update a redcapToken + const studyName: string = 'Test Study' + const testStudyId: number = 1 + // Check test study exists + const existingStudy = await prisma.study.findFirst({ + where: { name: studyName }, + }) + + expect(existingStudy?.name).toBe(studyName) + + const updatedRedcapToken = 'SuperSecretTokenInfo' + + const patchResponse = await request(app) + .patch(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ redcapToken: updatedRedcapToken } as UpdateStudyRequest) + expect(patchResponse.status).toBe(204) + + // Check Audit Logs + const response = await request(app) + .get('/audit-logs?sortBy=id&sortDirection=desc') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0].requestBody).not.toContain(updatedRedcapToken) + }) + + it('should obscure sensitive token information in payloads', async () => { + // Update a redcapToken + const studyName: string = 'Test Study' + const testStudyId: number = 1 + // Check test study exists + const existingStudy = await prisma.study.findFirst({ + where: { name: studyName }, + }) + + expect(existingStudy?.name).toBe(studyName) + + const updatedRedcapToken = 'SuperSecretTokenInfo' + const obscuredRedcapToken = '\"redcapToken\":\"***\"' // eslint-disable-line no-useless-escape + const patchResponse = await request(app) + .patch(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send({ redcapToken: updatedRedcapToken } as UpdateStudyRequest) + expect(patchResponse.status).toBe(204) + + // Check Audit Logs + const response = await request(app) + .get('/audit-logs?sortBy=id&sortDirection=desc') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0].requestBody).toContain(obscuredRedcapToken) + }) + + it('should obscure sensitive password information in payloads', async () => { + // Register a user + const registerRequest: RegisterRequest = { + email: 'johndoe@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'Password1', + role: Role.Participant, + } + + const postResponse = await request(app) + .post('/auth/register') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + .send(registerRequest) + expect(postResponse.status).toEqual(201) + + const obscuredPassword = '\"password\":\"***\"' // eslint-disable-line no-useless-escape + + // Check Audit Logs + const response = await request(app) + .get('/audit-logs?sortBy=id&sortDirection=desc') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0].requestBody).toContain(obscuredPassword) + }) + }) }) }) diff --git a/application/backend/src/controllers/StudiesController.test.ts b/application/backend/src/controllers/StudiesController.test.ts index 46b63cd6..e5d0386a 100644 --- a/application/backend/src/controllers/StudiesController.test.ts +++ b/application/backend/src/controllers/StudiesController.test.ts @@ -111,6 +111,26 @@ describe('StudiesController', () => { expect(body.data.length).toEqual(2) }) + it('should not return any token information', async () => { + const token = await generateToken({ + userId: PARTICIPANT_UNANSWERED_ID, + }) + + const response = await request(app) + .get('/studies/list') + .set({ Authorization: `Bearer ${token}` }) + expect(response.status).toBe(200) + + const body: GetAllStudiesResponse = response.body + expect(Array.isArray(body.data)).toBeTruthy() + expect(body.data.length).toBeGreaterThan(0) + + body.data.forEach((study) => { + expect(study).not.toHaveProperty('redcapURL') + expect(study).not.toHaveProperty('redcapToken') + }) + }) + it('should not return anything for org admin', async () => { const response = await request(app) .get('/studies/list') @@ -146,6 +166,19 @@ describe('StudiesController', () => { expect(body.data.id).toBe(testStudyId) }) + it('should not return any token information', async () => { + const response = await request(app) + .get(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetStudyByIdResponse = response.body + expect(body.data).not.toBeNull() + expect(body.data.id).toBe(testStudyId) + expect(body.data).not.toHaveProperty('redcapURL') + expect(body.data).not.toHaveProperty('redcapToken') + }) + it('should return a 404 error if the study does not exist', async () => { const notExistingStudyId: number = 1234567890 const response = await request(app) From 38fe050ccf2894d5d7c117ba76aee38d3d324c15 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 30 Apr 2026 11:59:01 +1200 Subject: [PATCH 04/14] More token visibility tests in backend --- .../src/controllers/StudiesController.test.ts | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/application/backend/src/controllers/StudiesController.test.ts b/application/backend/src/controllers/StudiesController.test.ts index e5d0386a..2ff2a1b0 100644 --- a/application/backend/src/controllers/StudiesController.test.ts +++ b/application/backend/src/controllers/StudiesController.test.ts @@ -154,19 +154,31 @@ describe('StudiesController', () => { }) }) - describe('GET /studies/:studyId', () => { - it('should return an study by ID', async () => { + describe('GET /studies/deleted', () => { + it('should not return any token information', async () => { + const deleteResponse = await request(app) + .delete(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(deleteResponse.status).toBe(204) + const response = await request(app) - .get(`/studies/${testStudyId}`) + .get('/studies/deleted') .set({ Authorization: `Bearer ${orgAdminToken}` }) expect(response.status).toBe(200) - const body: GetStudyByIdResponse = response.body - expect(body.data).not.toBeNull() - expect(body.data.id).toBe(testStudyId) + const body: GetAllStudiesResponse = response.body + expect(Array.isArray(body.data)).toBeTruthy() + expect(body.data.length).toBeGreaterThan(0) + + body.data.forEach((study) => { + expect(study).not.toHaveProperty('redcapURL') + expect(study).not.toHaveProperty('redcapToken') + }) }) + }) - it('should not return any token information', async () => { + describe('GET /studies/:studyId', () => { + it('should return an study by ID', async () => { const response = await request(app) .get(`/studies/${testStudyId}`) .set({ Authorization: `Bearer ${orgAdminToken}` }) @@ -175,8 +187,16 @@ describe('StudiesController', () => { const body: GetStudyByIdResponse = response.body expect(body.data).not.toBeNull() expect(body.data.id).toBe(testStudyId) - expect(body.data).not.toHaveProperty('redcapURL') - expect(body.data).not.toHaveProperty('redcapToken') + }) + + it('should not return any token information to participant', async () => { + const token = await generateToken({ + userId: PARTICIPANT_UNANSWERED_ID, + }) + const response = await request(app) + .get(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${token}` }) + expect(response.status).toBe(401) }) it('should return a 404 error if the study does not exist', async () => { From 8319f1aa805f1bc6d4a06f10d9a2268ff5168c12 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 30 Apr 2026 12:20:54 +1200 Subject: [PATCH 05/14] Add test to ensure token is obsured even for admin --- .../backend/src/controllers/StudiesController.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/application/backend/src/controllers/StudiesController.test.ts b/application/backend/src/controllers/StudiesController.test.ts index 2ff2a1b0..d2396985 100644 --- a/application/backend/src/controllers/StudiesController.test.ts +++ b/application/backend/src/controllers/StudiesController.test.ts @@ -199,6 +199,17 @@ describe('StudiesController', () => { expect(response.status).toBe(401) }) + it('should not return any token information to admin', async () => { + const response = await request(app) + .get(`/studies/${testStudyId}`) + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetStudyByIdResponse = response.body + expect(body.data).not.toBeNull() + expect(body.data).not.toHaveProperty('redcapToken') // URL is okay but NOT token + }) + it('should return a 404 error if the study does not exist', async () => { const notExistingStudyId: number = 1234567890 const response = await request(app) From 45b1e8bb4f4d392f86dcacd24040bc8efdb8f48b Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 30 Apr 2026 14:23:47 +1200 Subject: [PATCH 06/14] Add cypress tests for sensitive info in audit logs --- .../admin-client/cypress/e2e/auditLogs.cy.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/application/admin-client/cypress/e2e/auditLogs.cy.js b/application/admin-client/cypress/e2e/auditLogs.cy.js index e580dcba..17c44840 100644 --- a/application/admin-client/cypress/e2e/auditLogs.cy.js +++ b/application/admin-client/cypress/e2e/auditLogs.cy.js @@ -42,4 +42,31 @@ describe('Audit Logs', () => { cy.get('[data-cy="payload-viewer"]').should('not.exist') }) + + it('should obscure password fields in JSON payload', () => { + cy.login(UserType.STUDY_ADMIN) + cy.visit('/audit-logs') + + // Toggle button to view payload + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'View Payload').click() + + // Ensure password is obscured (double escapes required) + cy.get('.MuiCollapse-root').should('contain.text', '\\"password\\":\\"***\\"') + }) + + it('should obscure redcapToken fields in JSON payload', () => { + cy.login(UserType.STUDY_ADMIN) + cy.visit('/studies') + cy.get('[data-cy="advanced-toggle"]').eq(0).click() + cy.get('[data-cy="redcapToken"] input').eq(0).type('abc123') + cy.get('[data-cy="settings-apply"]').eq(0).click() + + cy.visit('/audit-logs') + + // Toggle button to view payload + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'View Payload').click() + + // Ensure token is obscured (double escapes required) + cy.get('.MuiCollapse-root').should('contain.text', '\\"redcapToken\\":\\"***\\"') + }) }) From 28d40c1d3a2d968404aa69769ef541d9799c112b Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 30 Apr 2026 14:38:23 +1200 Subject: [PATCH 07/14] Obscure token info from most studies endpoints --- .../src/controllers/StudiesController.ts | 40 +++++++++++++------ .../backend/src/middlewares/AuditLog.ts | 15 +++++-- application/backend/src/routes.ts | 10 ++--- application/backend/swagger.json | 18 ++------- .../common/types/api/studies/getAllStudies.ts | 2 +- 5 files changed, 49 insertions(+), 36 deletions(-) diff --git a/application/backend/src/controllers/StudiesController.ts b/application/backend/src/controllers/StudiesController.ts index 02132b7c..fcbd0ac5 100644 --- a/application/backend/src/controllers/StudiesController.ts +++ b/application/backend/src/controllers/StudiesController.ts @@ -37,6 +37,14 @@ import { auditLog } from '../middlewares/AuditLog' import { Readable } from 'stream' import type { RequestWithAuthentication } from 'authentication' +function sanitiseStudy(study: Study) { + const { redcapToken, redcapURL, ...safeStudyData } = study // eslint-disable-line @typescript-eslint/no-unused-vars + + return { + ...safeStudyData, + } +} + @Route('studies') @Tags('Studies') @Response('500', 'Internal Server Error') @@ -61,9 +69,12 @@ export class StudiesController extends Controller { where: { id: { in: request.user.studies } }, orderBy: { id: 'asc' }, }) - const responseData = { data: studies.map((val) => ({ ...val, logo: Boolean(val.logo) })) } - logger.info({ ...responseData }) - return responseData + return { + data: studies.map((study) => ({ + ...sanitiseStudy(study), + logo: Boolean(study.logo), + })), + } as GetAllStudiesResponse } /** @@ -86,11 +97,12 @@ export class StudiesController extends Controller { select: { study: true }, }) - const responseData = { - data: studies.map((val) => ({ ...val.study, logo: Boolean(val.study.logo) })), - } - logger.info({ ...responseData }) - return responseData + return { + data: studies.map((val) => ({ + ...sanitiseStudy(val.study), + logo: Boolean(val.study.logo), + })), + } as GetAllStudiesResponse } /** @@ -117,8 +129,12 @@ export class StudiesController extends Controller { orderBy: { id: 'asc' }, }) - const responseData = { data: studies.map((val) => ({ ...val, logo: Boolean(val.logo) })) } - return responseData + return { + data: studies.map((study) => ({ + ...sanitiseStudy(study), + logo: Boolean(study.logo), + })), + } as GetAllStudiesResponse } /** @@ -148,8 +164,7 @@ export class StudiesController extends Controller { logger.error({ errorMessage }) throw new NotFoundError(errorMessage) } - const responseData = { data: study } - logger.info({ ...responseData }) + const responseData = { data: study } // TODO: Obscure token return responseData } @@ -179,7 +194,6 @@ export class StudiesController extends Controller { const responseData = { id: newStudy.id, } - logger.info({ ...responseData }) return responseData } diff --git a/application/backend/src/middlewares/AuditLog.ts b/application/backend/src/middlewares/AuditLog.ts index e28fa470..604976dc 100644 --- a/application/backend/src/middlewares/AuditLog.ts +++ b/application/backend/src/middlewares/AuditLog.ts @@ -2,6 +2,9 @@ import { AuditLogOperation } from '@prisma/client' import { Request, Response, NextFunction } from 'express' import prisma from '../PrismaClient' +// SENSITIVE FIELDS THAT WE DO NOT WANT TO SHOW IN AUDIT LOG +const SENSITIVE_FIELDS = ['password', 'redcapToken'] + export async function auditLog(req: Request, res: Response, next: NextFunction) { const userId = req.user?.userId || undefined @@ -40,9 +43,15 @@ export async function auditLog(req: Request, res: Response, next: NextFunction) .filter((val) => !/^-?\d+$/.test(val || '') && val !== 'current' && val) .join('/') const success = 200 <= res.statusCode && res.statusCode <= 299 - const bodyData = req.body - if (bodyData?.password) { - bodyData.password = '***' + + const bodyData = req.body ? structuredClone(req.body) : undefined + + if (bodyData) { + SENSITIVE_FIELDS.forEach((field) => { + if (bodyData[field] !== undefined) { + bodyData[field] = '***' + } + }) } const meta = { resourceId: id, url: req.url, method: req.method } diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index b1c06b6f..0e0e827a 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -393,20 +393,20 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Pick_Study.Exclude_keyofStudy.logo__": { + "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"id":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"redcapURL":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"redcapToken":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailSubject":{"dataType":"string","required":true},"inviteEmailText":{"dataType":"string","required":true},"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}},"validators":{}}, + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"id":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailSubject":{"dataType":"string","required":true},"inviteEmailText":{"dataType":"string","required":true},"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}},"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Omit_Study.logo_": { + "Omit_Study.logo-or-redcapURL-or-redcapToken_": { "dataType": "refAlias", - "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo__","validators":{}}, + "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetAllStudiesResponse": { "dataType": "refObject", "properties": { - "data": {"dataType":"array","array":{"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true}}}]},"required":true}, + "data": {"dataType":"array","array":{"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapURL-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true}}}]},"required":true}, }, "additionalProperties": false, }, diff --git a/application/backend/swagger.json b/application/backend/swagger.json index 8b0c04e8..08928cdb 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -961,7 +961,7 @@ "type": "object", "additionalProperties": false }, - "Pick_Study.Exclude_keyofStudy.logo__": { + "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { "properties": { "name": { "type": "string" @@ -977,14 +977,6 @@ "type": "string", "nullable": true }, - "redcapURL": { - "type": "string", - "nullable": true - }, - "redcapToken": { - "type": "string", - "nullable": true - }, "inviteEmailSubject": { "type": "string" }, @@ -1001,8 +993,6 @@ "id", "deleted", "description", - "redcapURL", - "redcapToken", "inviteEmailSubject", "inviteEmailText", "contactUsEmail" @@ -1010,8 +1000,8 @@ "type": "object", "description": "From T, pick a set of properties whose keys are in the union K" }, - "Omit_Study.logo_": { - "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo__", + "Omit_Study.logo-or-redcapURL-or-redcapToken_": { + "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__", "description": "Construct a type with the properties of T except for those in type K." }, "GetAllStudiesResponse": { @@ -1020,7 +1010,7 @@ "items": { "allOf": [ { - "$ref": "#/components/schemas/Omit_Study.logo_" + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapURL-or-redcapToken_" }, { "properties": { diff --git a/application/common/types/api/studies/getAllStudies.ts b/application/common/types/api/studies/getAllStudies.ts index bed810ef..40f16cb8 100644 --- a/application/common/types/api/studies/getAllStudies.ts +++ b/application/common/types/api/studies/getAllStudies.ts @@ -1,5 +1,5 @@ import { Study } from '@prisma/client' export interface GetAllStudiesResponse { - data: (Omit & { logo: boolean })[] + data: (Omit & { logo: boolean })[] } From ac74025508ab66b757912ce64f3fe357d77880a5 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 30 Apr 2026 17:15:06 +1200 Subject: [PATCH 08/14] Obscure token info from all studies endpoints (separate admin and participant response types) --- .../src/controllers/StudiesController.test.ts | 11 +- .../src/controllers/StudiesController.ts | 32 +++-- application/backend/src/routes.ts | 28 ++-- application/backend/swagger.json | 121 +++++++++++------- .../common/types/api/studies/getAllStudies.ts | 6 +- .../common/types/api/studies/getStudyById.ts | 5 +- application/common/types/api/studies/index.ts | 3 +- 7 files changed, 133 insertions(+), 73 deletions(-) diff --git a/application/backend/src/controllers/StudiesController.test.ts b/application/backend/src/controllers/StudiesController.test.ts index d2396985..f936c1ba 100644 --- a/application/backend/src/controllers/StudiesController.test.ts +++ b/application/backend/src/controllers/StudiesController.test.ts @@ -6,6 +6,7 @@ import { GetStudyByIdResponse, CreateStudyRequest, UpdateStudyRequest, + GetAllStudiesByParticipantResponse, } from 'common/types/api/studies' import { PARTICIPANT_UNANSWERED_ID, @@ -68,7 +69,6 @@ describe('StudiesController', () => { expect(body.data.length).toBeGreaterThan(0) body.data.forEach((study) => { - expect(study).not.toHaveProperty('redcapURL') expect(study).not.toHaveProperty('redcapToken') }) }) @@ -106,7 +106,7 @@ describe('StudiesController', () => { .set({ Authorization: `Bearer ${token}` }) expect(response.status).toBe(200) - const body: GetAllStudiesResponse = response.body + const body: GetAllStudiesByParticipantResponse = response.body expect(Array.isArray(body.data)).toBeTruthy() expect(body.data.length).toEqual(2) }) @@ -121,7 +121,7 @@ describe('StudiesController', () => { .set({ Authorization: `Bearer ${token}` }) expect(response.status).toBe(200) - const body: GetAllStudiesResponse = response.body + const body: GetAllStudiesByParticipantResponse = response.body expect(Array.isArray(body.data)).toBeTruthy() expect(body.data.length).toBeGreaterThan(0) @@ -148,7 +148,7 @@ describe('StudiesController', () => { .set({ Authorization: `Bearer ${token}` }) expect(response.status).toBe(200) - const body: GetAllStudiesResponse = response.body + const body: GetAllStudiesByParticipantResponse = response.body expect(Array.isArray(body.data)).toBeTruthy() expect(body.data.length).toEqual(1) }) @@ -171,8 +171,7 @@ describe('StudiesController', () => { expect(body.data.length).toBeGreaterThan(0) body.data.forEach((study) => { - expect(study).not.toHaveProperty('redcapURL') - expect(study).not.toHaveProperty('redcapToken') + expect(study).not.toHaveProperty('redcapToken') // URL is okay for Admins, just not token }) }) }) diff --git a/application/backend/src/controllers/StudiesController.ts b/application/backend/src/controllers/StudiesController.ts index fcbd0ac5..4105a05e 100644 --- a/application/backend/src/controllers/StudiesController.ts +++ b/application/backend/src/controllers/StudiesController.ts @@ -19,6 +19,7 @@ import { import logger from 'common/src/logger' import type { GetAllStudiesResponse, + GetAllStudiesByParticipantResponse, GetStudyByIdResponse, CreateStudyRequest, CreateStudyResponse, @@ -37,7 +38,16 @@ import { auditLog } from '../middlewares/AuditLog' import { Readable } from 'stream' import type { RequestWithAuthentication } from 'authentication' -function sanitiseStudy(study: Study) { +function sanitiseStudyForAdmin(study: Study) { + const { redcapToken, ...safeStudyData } = study // eslint-disable-line @typescript-eslint/no-unused-vars + + return { + ...safeStudyData, + hasRedcapToken: Boolean(study.redcapToken), + } +} + +function sanitiseStudyForParticipant(study: Study) { const { redcapToken, redcapURL, ...safeStudyData } = study // eslint-disable-line @typescript-eslint/no-unused-vars return { @@ -71,7 +81,7 @@ export class StudiesController extends Controller { }) return { data: studies.map((study) => ({ - ...sanitiseStudy(study), + ...sanitiseStudyForAdmin(study), logo: Boolean(study.logo), })), } as GetAllStudiesResponse @@ -86,7 +96,7 @@ export class StudiesController extends Controller { @Security('jwt', ['Participant']) public async listStudies( @Request() request: RequestWithAuthentication, - ): Promise { + ): Promise { // get profile id from token const participantProfile = await this.profileRepo.findFirstOrThrow({ where: { userId: request.user.userId }, @@ -99,10 +109,9 @@ export class StudiesController extends Controller { return { data: studies.map((val) => ({ - ...sanitiseStudy(val.study), - logo: Boolean(val.study.logo), + ...sanitiseStudyForParticipant(val.study), })), - } as GetAllStudiesResponse + } as GetAllStudiesByParticipantResponse } /** @@ -131,7 +140,7 @@ export class StudiesController extends Controller { return { data: studies.map((study) => ({ - ...sanitiseStudy(study), + ...sanitiseStudyForAdmin(study), logo: Boolean(study.logo), })), } as GetAllStudiesResponse @@ -150,6 +159,7 @@ export class StudiesController extends Controller { }) } + // Note: I think this endpoint is not currently used in Admin Portal /** * Gets a Specific Study by ID * @@ -164,8 +174,12 @@ export class StudiesController extends Controller { logger.error({ errorMessage }) throw new NotFoundError(errorMessage) } - const responseData = { data: study } // TODO: Obscure token - return responseData + return { + data: { + ...sanitiseStudyForAdmin(study), + logo: Boolean(study.logo), + }, + } as GetStudyByIdResponse } /** diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 0e0e827a..19e76c4c 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -393,38 +393,46 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { + "Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__": { "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"id":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailSubject":{"dataType":"string","required":true},"inviteEmailText":{"dataType":"string","required":true},"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}},"validators":{}}, + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"id":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"redcapURL":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailSubject":{"dataType":"string","required":true},"inviteEmailText":{"dataType":"string","required":true},"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}},"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Omit_Study.logo-or-redcapURL-or-redcapToken_": { + "Omit_Study.logo-or-redcapToken_": { "dataType": "refAlias", - "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__","validators":{}}, + "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetAllStudiesResponse": { "dataType": "refObject", "properties": { - "data": {"dataType":"array","array":{"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapURL-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true}}}]},"required":true}, + "data": {"dataType":"array","array":{"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true},"hasRedcapToken":{"dataType":"boolean","required":true}}}]},"required":true}, }, "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "DefaultSelection_Prisma._36_StudyPayload_": { + "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailText":{"dataType":"string","required":true},"inviteEmailSubject":{"dataType":"string","required":true},"redcapToken":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"redcapURL":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"logo":{"dataType":"union","subSchemas":[{"dataType":"buffer"},{"dataType":"enum","enums":[null]}],"required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"deleted":{"dataType":"boolean","required":true},"id":{"dataType":"double","required":true},"name":{"dataType":"string","required":true}},"validators":{}}, + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"id":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true},"description":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"inviteEmailSubject":{"dataType":"string","required":true},"inviteEmailText":{"dataType":"string","required":true},"contactUsEmail":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true}},"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "Study": { + "Omit_Study.logo-or-redcapURL-or-redcapToken_": { "dataType": "refAlias", - "type": {"ref":"DefaultSelection_Prisma._36_StudyPayload_","validators":{}}, + "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__","validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "GetAllStudiesByParticipantResponse": { + "dataType": "refObject", + "properties": { + "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"Omit_Study.logo-or-redcapURL-or-redcapToken_"},"required":true}, + }, + "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetStudyByIdResponse": { "dataType": "refObject", "properties": { - "data": {"ref":"Study","required":true}, + "data": {"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true},"hasRedcapToken":{"dataType":"boolean","required":true}}}],"required":true}, }, "additionalProperties": false, }, diff --git a/application/backend/swagger.json b/application/backend/swagger.json index 08928cdb..6460a93d 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -961,7 +961,7 @@ "type": "object", "additionalProperties": false }, - "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { + "Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__": { "properties": { "name": { "type": "string" @@ -977,6 +977,10 @@ "type": "string", "nullable": true }, + "redcapURL": { + "type": "string", + "nullable": true + }, "inviteEmailSubject": { "type": "string" }, @@ -993,6 +997,7 @@ "id", "deleted", "description", + "redcapURL", "inviteEmailSubject", "inviteEmailText", "contactUsEmail" @@ -1000,8 +1005,8 @@ "type": "object", "description": "From T, pick a set of properties whose keys are in the union K" }, - "Omit_Study.logo-or-redcapURL-or-redcapToken_": { - "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__", + "Omit_Study.logo-or-redcapToken_": { + "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__", "description": "Construct a type with the properties of T except for those in type K." }, "GetAllStudiesResponse": { @@ -1010,16 +1015,20 @@ "items": { "allOf": [ { - "$ref": "#/components/schemas/Omit_Study.logo-or-redcapURL-or-redcapToken_" + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapToken_" }, { "properties": { "logo": { "type": "boolean" + }, + "hasRedcapToken": { + "type": "boolean" } }, "required": [ - "logo" + "logo", + "hasRedcapToken" ], "type": "object" } @@ -1034,65 +1043,87 @@ "type": "object", "additionalProperties": false }, - "Study": { - "description": "Model Study", + "Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__": { "properties": { - "contactUsEmail": { - "type": "string", - "nullable": true - }, - "inviteEmailText": { - "type": "string" - }, - "inviteEmailSubject": { + "name": { "type": "string" }, - "redcapToken": { - "type": "string", - "nullable": true - }, - "redcapURL": { - "type": "string", - "nullable": true + "id": { + "type": "number", + "format": "double" }, - "logo": { - "type": "string", - "format": "byte", - "nullable": true + "deleted": { + "type": "boolean" }, "description": { "type": "string", "nullable": true }, - "deleted": { - "type": "boolean" - }, - "id": { - "type": "number", - "format": "double" + "inviteEmailSubject": { + "type": "string" }, - "name": { + "inviteEmailText": { "type": "string" + }, + "contactUsEmail": { + "type": "string", + "nullable": true } }, "required": [ - "contactUsEmail", - "inviteEmailText", - "inviteEmailSubject", - "redcapToken", - "redcapURL", - "logo", - "description", - "deleted", + "name", "id", - "name" + "deleted", + "description", + "inviteEmailSubject", + "inviteEmailText", + "contactUsEmail" ], - "type": "object" + "type": "object", + "description": "From T, pick a set of properties whose keys are in the union K" + }, + "Omit_Study.logo-or-redcapURL-or-redcapToken_": { + "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__", + "description": "Construct a type with the properties of T except for those in type K." + }, + "GetAllStudiesByParticipantResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapURL-or-redcapToken_" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object", + "additionalProperties": false }, "GetStudyByIdResponse": { "properties": { "data": { - "$ref": "#/components/schemas/Study" + "allOf": [ + { + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapToken_" + }, + { + "properties": { + "logo": { + "type": "boolean" + }, + "hasRedcapToken": { + "type": "boolean" + } + }, + "required": [ + "logo", + "hasRedcapToken" + ], + "type": "object" + } + ] } }, "required": [ @@ -4309,7 +4340,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetAllStudiesResponse" + "$ref": "#/components/schemas/GetAllStudiesByParticipantResponse" } } } diff --git a/application/common/types/api/studies/getAllStudies.ts b/application/common/types/api/studies/getAllStudies.ts index 40f16cb8..7a41fe73 100644 --- a/application/common/types/api/studies/getAllStudies.ts +++ b/application/common/types/api/studies/getAllStudies.ts @@ -1,5 +1,9 @@ import { Study } from '@prisma/client' export interface GetAllStudiesResponse { - data: (Omit & { logo: boolean })[] + data: (Omit & { hasRedcapToken: boolean; logo: boolean })[] +} + +export interface GetAllStudiesByParticipantResponse { + data: Omit[] } diff --git a/application/common/types/api/studies/getStudyById.ts b/application/common/types/api/studies/getStudyById.ts index c44fbd4f..89622c52 100644 --- a/application/common/types/api/studies/getStudyById.ts +++ b/application/common/types/api/studies/getStudyById.ts @@ -1,5 +1,8 @@ import { Study } from '@prisma/client' export interface GetStudyByIdResponse { - data: Study + data: Omit & { + hasRedcapToken: boolean + logo: boolean + } } diff --git a/application/common/types/api/studies/index.ts b/application/common/types/api/studies/index.ts index e416678a..4c27a885 100644 --- a/application/common/types/api/studies/index.ts +++ b/application/common/types/api/studies/index.ts @@ -1,10 +1,11 @@ import type { CreateStudyRequest, CreateStudyResponse } from './createStudy' import type { GetStudyByIdResponse } from './getStudyById' -import type { GetAllStudiesResponse } from './getAllStudies' +import type { GetAllStudiesResponse, GetAllStudiesByParticipantResponse } from './getAllStudies' import type { UpdateStudyRequest } from './updateStudy' export { GetAllStudiesResponse, + GetAllStudiesByParticipantResponse, GetStudyByIdResponse, CreateStudyRequest, CreateStudyResponse, From 440de1b5c6cf2fd1e5f1695f915669de372e6d78 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 1 May 2026 14:00:08 +1200 Subject: [PATCH 09/14] Update study management token UI and add cypress test --- .../cypress/e2e/studyManagement.cy.js | 9 +++++ .../src/components/RedcapImport.tsx | 2 +- .../admin-client/src/pages/studies/index.tsx | 34 ++++++++++++++----- application/admin-client/src/studyStore.ts | 4 +-- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/application/admin-client/cypress/e2e/studyManagement.cy.js b/application/admin-client/cypress/e2e/studyManagement.cy.js index d9dd0cf8..0fce0770 100644 --- a/application/admin-client/cypress/e2e/studyManagement.cy.js +++ b/application/admin-client/cypress/e2e/studyManagement.cy.js @@ -179,4 +179,13 @@ describe('Study management page', () => { cy.contains('Deleted logo').should('exist') cy.get('[data-cy="logo-preview"]').should('not.exist') }) + + it('Should not show token information', () => { + cy.visit('/studies') + cy.get('[data-cy="advanced-toggle"]').eq(1).click() + cy.get('[data-cy="redcapToken"] input').eq(1).type('abc123') + cy.contains('abc123').should('not.exist') // due to SensitiveTextField + cy.get('[data-cy="settings-apply"]').eq(1).click() + cy.contains('abc123').should('not.exist') + }) }) diff --git a/application/admin-client/src/components/RedcapImport.tsx b/application/admin-client/src/components/RedcapImport.tsx index cb27ace6..5ef18ecc 100644 --- a/application/admin-client/src/components/RedcapImport.tsx +++ b/application/admin-client/src/components/RedcapImport.tsx @@ -87,7 +87,7 @@ export const RedcapImport = ({ useEffect(() => { const currentStudy = studies.find((val) => val.id == studyId) - if (!currentStudy?.redcapToken || !currentStudy?.redcapURL) { + if (!currentStudy?.hasRedcapToken || !currentStudy?.redcapURL) { setRedcapIsSetup(false) } else { setRedcapIsSetup(true) diff --git a/application/admin-client/src/pages/studies/index.tsx b/application/admin-client/src/pages/studies/index.tsx index eaa31c02..09812177 100644 --- a/application/admin-client/src/pages/studies/index.tsx +++ b/application/admin-client/src/pages/studies/index.tsx @@ -46,21 +46,21 @@ const StudyCard = ({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [contactUsEmail, setContactUsEmail] = useState(study.contactUsEmail || '') const [redcapURL, setRedcapURL] = useState(study.redcapURL || '') - const [redcapToken, setRedcapToken] = useState(study.redcapToken || '') + const [redcapToken, setRedcapToken] = useState('') const [settingsChanged, setSettingsChanged] = useState(false) const [accordionOpen, setAccordionOpen] = useState(advancedOpen) useEffect(() => { setRedcapURL(study.redcapURL || '') - setRedcapToken(study.redcapToken || '') + setRedcapToken('') setSettingsChanged(false) - }, [study.redcapURL, study.redcapToken, study.contactUsEmail]) + }, [study.redcapURL, study.hasRedcapToken, study.contactUsEmail]) useEffect(() => { setAccordionOpen(advancedOpen) }, [advancedOpen]) - const handleUpdate = (updateData: Partial) => { + const handleUpdate = (updateData: Partial & { redcapToken?: string }) => { axiosInstance .patch(`/studies/${study.id}`, updateData) .then(() => { @@ -69,8 +69,16 @@ const StudyCard = ({ } queryClient.invalidateQueries(['studies']) const newStudies = [...studies] - newStudies[studyIdx] = { ...newStudies[studyIdx], ...updateData } + // Destructure out the token so that it doesn't go into the store + const { redcapToken, ...sanitisedUpdateData } = updateData + + newStudies[studyIdx] = { + ...newStudies[studyIdx], + ...sanitisedUpdateData, + ...(redcapToken ? { hasRedcapToken: true } : {}), + } setStudies(newStudies) + setRedcapToken('') open?.({ type: 'success', message: 'Updated successfully' }) }) .catch((e) => { @@ -114,11 +122,15 @@ const StudyCard = ({ }) return } - handleUpdate({ + const updatePayload: Partial & { redcapToken?: string } = { redcapURL, - redcapToken, contactUsEmail, - }) + } + + if (redcapToken) { + updatePayload.redcapToken = redcapToken + } + handleUpdate(updatePayload) setSettingsChanged(false) } @@ -296,6 +308,12 @@ const StudyCard = ({ name="redcapToken" data-cy="redcapToken" value={redcapToken} + placeholder={'Enter new token here'} + helperText={ + study.hasRedcapToken + ? 'A token has been saved. Enter a new value to overwrite it.' + : 'No token has been saved.' + } onChange={(e: React.ChangeEvent) => { setRedcapToken(e.target.value) setSettingsChanged(true) diff --git a/application/admin-client/src/studyStore.ts b/application/admin-client/src/studyStore.ts index 072efc76..53280e78 100644 --- a/application/admin-client/src/studyStore.ts +++ b/application/admin-client/src/studyStore.ts @@ -5,9 +5,9 @@ export interface StudyEntry { id: number name: string description?: string - logo?: SourceBuffer + logo?: boolean redcapURL?: string - redcapToken?: string + hasRedcapToken?: boolean contactUsEmail?: string } From e6939f9a34d75feda745cf71fe32ef726ced1fa6 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 1 May 2026 14:35:09 +1200 Subject: [PATCH 10/14] Also obscure otp info from Audit Log --- .../controllers/AuditLogsController.test.ts | 32 ++++++++++++++++++- .../backend/src/middlewares/AuditLog.ts | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index bbd812f9..2cc77e53 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -8,7 +8,7 @@ import { generateToken } from '../authentication' import type { GetAuditLogsResponse } from 'common/types/api/audit-logs' import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' import { UpdateStudyRequest } from 'common/types/api/studies' -import type { RegisterRequest } from 'common/types/api/auth' +import type { OTPLoginRequest, RegisterRequest } from 'common/types/api/auth' const api = new Api() const app = api.app @@ -295,6 +295,36 @@ describe('AuditLogsController', () => { expect(body).toHaveProperty('data') expect(body.data[0].requestBody).toContain(obscuredPassword) }) + + it('should obscure sensitive otp information in payloads', async () => { + await prisma.oTPToken.create({ + data: { + code: '1223', + expiresAt: new Date(new Date().getTime() + 1000 * 60), + id: 'abc123', + userId: PARTICIPANT_UNANSWERED_ID, + }, + }) + const loginRequest: OTPLoginRequest = { + otp_code: '1223', + otp_token: 'abc123', + } + const loginResponse = await request(app).post('/auth/login/otp').send(loginRequest) + expect(loginResponse.ok).toBe(true) + + const obscuredCode = '\"otp_code\":\"***\"' // eslint-disable-line no-useless-escape + const obscuredToken = '\"otp_token\":\"***\"' // eslint-disable-line no-useless-escape + + // Check Audit Logs + const response = await request(app) + .get('/audit-logs?sortBy=id&sortDirection=desc') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0].requestBody).toContain(obscuredCode) + expect(body.data[0].requestBody).toContain(obscuredToken) + }) }) }) }) diff --git a/application/backend/src/middlewares/AuditLog.ts b/application/backend/src/middlewares/AuditLog.ts index 604976dc..2509bcfb 100644 --- a/application/backend/src/middlewares/AuditLog.ts +++ b/application/backend/src/middlewares/AuditLog.ts @@ -3,7 +3,7 @@ import { Request, Response, NextFunction } from 'express' import prisma from '../PrismaClient' // SENSITIVE FIELDS THAT WE DO NOT WANT TO SHOW IN AUDIT LOG -const SENSITIVE_FIELDS = ['password', 'redcapToken'] +const SENSITIVE_FIELDS = ['password', 'redcapToken', 'otp_code', 'otp_token'] export async function auditLog(req: Request, res: Response, next: NextFunction) { const userId = req.user?.userId || undefined From 1f0c41e3488f90af12d9f23303fe2c33d2b79384 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Mon, 4 May 2026 12:43:12 +1200 Subject: [PATCH 11/14] Make sanitiseStudyFor function types more explicit --- .../src/controllers/StudiesController.ts | 10 ++-- application/backend/src/routes.ts | 14 +++++- application/backend/swagger.json | 48 +++++++++++-------- .../common/types/api/studies/getAllStudies.ts | 11 ++++- application/common/types/api/studies/index.ts | 9 +++- 5 files changed, 61 insertions(+), 31 deletions(-) diff --git a/application/backend/src/controllers/StudiesController.ts b/application/backend/src/controllers/StudiesController.ts index 4105a05e..56fc40c4 100644 --- a/application/backend/src/controllers/StudiesController.ts +++ b/application/backend/src/controllers/StudiesController.ts @@ -18,6 +18,8 @@ import { } from 'tsoa' import logger from 'common/src/logger' import type { + AdminStudyItem, + ParticipantStudyItem, GetAllStudiesResponse, GetAllStudiesByParticipantResponse, GetStudyByIdResponse, @@ -38,16 +40,17 @@ import { auditLog } from '../middlewares/AuditLog' import { Readable } from 'stream' import type { RequestWithAuthentication } from 'authentication' -function sanitiseStudyForAdmin(study: Study) { +function sanitiseStudyForAdmin(study: Study): AdminStudyItem { const { redcapToken, ...safeStudyData } = study // eslint-disable-line @typescript-eslint/no-unused-vars return { ...safeStudyData, hasRedcapToken: Boolean(study.redcapToken), + logo: Boolean(study.logo), } } -function sanitiseStudyForParticipant(study: Study) { +function sanitiseStudyForParticipant(study: Study): ParticipantStudyItem { const { redcapToken, redcapURL, ...safeStudyData } = study // eslint-disable-line @typescript-eslint/no-unused-vars return { @@ -82,7 +85,6 @@ export class StudiesController extends Controller { return { data: studies.map((study) => ({ ...sanitiseStudyForAdmin(study), - logo: Boolean(study.logo), })), } as GetAllStudiesResponse } @@ -141,7 +143,6 @@ export class StudiesController extends Controller { return { data: studies.map((study) => ({ ...sanitiseStudyForAdmin(study), - logo: Boolean(study.logo), })), } as GetAllStudiesResponse } @@ -177,7 +178,6 @@ export class StudiesController extends Controller { return { data: { ...sanitiseStudyForAdmin(study), - logo: Boolean(study.logo), }, } as GetStudyByIdResponse } diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 19e76c4c..0105ba23 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -403,10 +403,15 @@ const models: TsoaRoute.Models = { "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "AdminStudyItem": { + "dataType": "refAlias", + "type": {"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true},"hasRedcapToken":{"dataType":"boolean","required":true}}}],"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetAllStudiesResponse": { "dataType": "refObject", "properties": { - "data": {"dataType":"array","array":{"dataType":"intersection","subSchemas":[{"ref":"Omit_Study.logo-or-redcapToken_"},{"dataType":"nestedObjectLiteral","nestedProperties":{"logo":{"dataType":"boolean","required":true},"hasRedcapToken":{"dataType":"boolean","required":true}}}]},"required":true}, + "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"AdminStudyItem"},"required":true}, }, "additionalProperties": false, }, @@ -421,10 +426,15 @@ const models: TsoaRoute.Models = { "type": {"ref":"Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "ParticipantStudyItem": { + "dataType": "refAlias", + "type": {"ref":"Omit_Study.logo-or-redcapURL-or-redcapToken_","validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetAllStudiesByParticipantResponse": { "dataType": "refObject", "properties": { - "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"Omit_Study.logo-or-redcapURL-or-redcapToken_"},"required":true}, + "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"ParticipantStudyItem"},"required":true}, }, "additionalProperties": false, }, diff --git a/application/backend/swagger.json b/application/backend/swagger.json index 6460a93d..3b1cebc5 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -1009,30 +1009,33 @@ "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapToken__", "description": "Construct a type with the properties of T except for those in type K." }, + "AdminStudyItem": { + "allOf": [ + { + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapToken_" + }, + { + "properties": { + "logo": { + "type": "boolean" + }, + "hasRedcapToken": { + "type": "boolean" + } + }, + "required": [ + "logo", + "hasRedcapToken" + ], + "type": "object" + } + ] + }, "GetAllStudiesResponse": { "properties": { "data": { "items": { - "allOf": [ - { - "$ref": "#/components/schemas/Omit_Study.logo-or-redcapToken_" - }, - { - "properties": { - "logo": { - "type": "boolean" - }, - "hasRedcapToken": { - "type": "boolean" - } - }, - "required": [ - "logo", - "hasRedcapToken" - ], - "type": "object" - } - ] + "$ref": "#/components/schemas/AdminStudyItem" }, "type": "array" } @@ -1086,11 +1089,14 @@ "$ref": "#/components/schemas/Pick_Study.Exclude_keyofStudy.logo-or-redcapURL-or-redcapToken__", "description": "Construct a type with the properties of T except for those in type K." }, + "ParticipantStudyItem": { + "$ref": "#/components/schemas/Omit_Study.logo-or-redcapURL-or-redcapToken_" + }, "GetAllStudiesByParticipantResponse": { "properties": { "data": { "items": { - "$ref": "#/components/schemas/Omit_Study.logo-or-redcapURL-or-redcapToken_" + "$ref": "#/components/schemas/ParticipantStudyItem" }, "type": "array" } diff --git a/application/common/types/api/studies/getAllStudies.ts b/application/common/types/api/studies/getAllStudies.ts index 7a41fe73..bfe80171 100644 --- a/application/common/types/api/studies/getAllStudies.ts +++ b/application/common/types/api/studies/getAllStudies.ts @@ -1,9 +1,16 @@ import { Study } from '@prisma/client' +export type AdminStudyItem = Omit & { + hasRedcapToken: boolean + logo: boolean +} + +export type ParticipantStudyItem = Omit + export interface GetAllStudiesResponse { - data: (Omit & { hasRedcapToken: boolean; logo: boolean })[] + data: AdminStudyItem[] } export interface GetAllStudiesByParticipantResponse { - data: Omit[] + data: ParticipantStudyItem[] } diff --git a/application/common/types/api/studies/index.ts b/application/common/types/api/studies/index.ts index 4c27a885..f84f526c 100644 --- a/application/common/types/api/studies/index.ts +++ b/application/common/types/api/studies/index.ts @@ -1,9 +1,16 @@ import type { CreateStudyRequest, CreateStudyResponse } from './createStudy' import type { GetStudyByIdResponse } from './getStudyById' -import type { GetAllStudiesResponse, GetAllStudiesByParticipantResponse } from './getAllStudies' +import type { + AdminStudyItem, + ParticipantStudyItem, + GetAllStudiesResponse, + GetAllStudiesByParticipantResponse, +} from './getAllStudies' import type { UpdateStudyRequest } from './updateStudy' export { + AdminStudyItem, + ParticipantStudyItem, GetAllStudiesResponse, GetAllStudiesByParticipantResponse, GetStudyByIdResponse, From 17cecba7a043a3bbb79bd676a9311b968a928619 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Mon, 4 May 2026 14:41:52 +1200 Subject: [PATCH 12/14] Add cypress tests for sensitive info in participant portal --- .../user-client/cypress/e2e/multistudy.cy.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/application/user-client/cypress/e2e/multistudy.cy.js b/application/user-client/cypress/e2e/multistudy.cy.js index 0fa494ad..8ef6cf9d 100644 --- a/application/user-client/cypress/e2e/multistudy.cy.js +++ b/application/user-client/cypress/e2e/multistudy.cy.js @@ -138,4 +138,17 @@ describe('multistudy', () => { }) }) }) + + it('should load study infomation from dashboard but not expose token', () => { + cy.intercept('GET', '**/studies/list').as('getParticipantStudies') + cy.login(TestUsers.PARTICIPANT_UNANSWERED.email) + cy.visit('/') + + cy.wait('@getParticipantStudies').then((interception) => { + const firstStudy = interception.response.body.data[0] + + expect(firstStudy).to.not.have.property('redcapToken') + expect(firstStudy).to.not.have.property('redcapURL') + }) + }) }) From 362c152f52ac2fcf1a8d01ff9194f3aa03b0573d Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Mon, 4 May 2026 15:01:20 +1200 Subject: [PATCH 13/14] Add test to ensure non-sensitive info is not obscured --- application/backend/src/controllers/AuditLogsController.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index 2cc77e53..042d8c69 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -285,6 +285,7 @@ describe('AuditLogsController', () => { expect(postResponse.status).toEqual(201) const obscuredPassword = '\"password\":\"***\"' // eslint-disable-line no-useless-escape + const safeEmail = '\"email\":\"johndoe@example.com\"' // eslint-disable-line no-useless-escape // Check Audit Logs const response = await request(app) @@ -294,6 +295,7 @@ describe('AuditLogsController', () => { const body: GetAuditLogsResponse = response.body expect(body).toHaveProperty('data') expect(body.data[0].requestBody).toContain(obscuredPassword) + expect(body.data[0].requestBody).toContain(safeEmail) }) it('should obscure sensitive otp information in payloads', async () => { From bef5a4d5b4108bdc8f7995d9131d1d0975a493be Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Tue, 5 May 2026 10:01:09 +1200 Subject: [PATCH 14/14] Add auditlog middlewares unit test --- .../backend/src/middlewares/AuditLog.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 application/backend/src/middlewares/AuditLog.test.ts diff --git a/application/backend/src/middlewares/AuditLog.test.ts b/application/backend/src/middlewares/AuditLog.test.ts new file mode 100644 index 00000000..0e06d508 --- /dev/null +++ b/application/backend/src/middlewares/AuditLog.test.ts @@ -0,0 +1,73 @@ +import request from 'supertest' +import express, { Request, Response } from 'express' +import { auditLog } from './AuditLog' +import prisma from '../PrismaClient' + +jest.mock('../PrismaClient', () => ({ + __esModule: true, + default: { + auditLog: { + create: jest.fn(), + }, + }, +})) + +describe('AuditLog Middleware', () => { + let app: express.Application + + beforeAll(() => { + app = express() // Isolateed Express app just for this test + app.use(express.json()) + app.use((req: any, _res, next) => { + req.user = { userId: 123 } // Dummy test user + next() + }) + + app.use(auditLog) + + // Dummy endpoint that just echoes back the body + app.post('/dummy-endpoint', (req: Request, res: Response) => { + res.status(200).json({ receivedBody: req.body }) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should only obscure sensitive fields', async () => { + const testPayload = { + email: 'notsecret@example.com', + password: 'SuperSecretPassword123!', + redcapURL: 'https://notsecret.com/123', + redcapToken: 'SuperSecretToken123', + otp_code: '321123', + otp_token: '1234-1234-1234-1234', + contactUsEmail: 'not@secret.com', + } + + const response = await request(app).post('/dummy-endpoint').send(testPayload) + + expect(response.status).toBe(200) + expect(response.body.receivedBody.password).toBe(testPayload.password) + expect(response.body.receivedBody.redcapToken).toBe(testPayload.redcapToken) + + // Ensure logs are only recorded once (or fail) + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1) + + const prismaCallArgs = (prisma.auditLog.create as jest.Mock).mock.calls[0][0] + const savedBody = JSON.parse(prismaCallArgs.data.requestBody) + + // Test that sensitive fields are obscured + expect(savedBody.password).not.toBe('SuperSecretPassword123!') + expect(savedBody.password).toBe('***') + expect(savedBody.redcapToken).toBe('***') + expect(savedBody.otp_code).toBe('***') + expect(savedBody.otp_token).toBe('***') + + // Test that non-sensitive fields are visible + expect(savedBody.email).toBe('notsecret@example.com') + expect(savedBody.redcapURL).toBe('https://notsecret.com/123') + expect(savedBody.contactUsEmail).toBe('not@secret.com') + }) +})