Skip to content
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
27 changes: 27 additions & 0 deletions application/admin-client/cypress/e2e/auditLogs.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\\":\\"***\\"')
})
})
9 changes: 9 additions & 0 deletions application/admin-client/cypress/e2e/studyManagement.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
2 changes: 1 addition & 1 deletion application/admin-client/src/components/RedcapImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 26 additions & 8 deletions application/admin-client/src/pages/studies/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<StudyEntry>) => {
const handleUpdate = (updateData: Partial<StudyEntry> & { redcapToken?: string }) => {
axiosInstance
.patch(`/studies/${study.id}`, updateData)
.then(() => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -114,11 +122,15 @@ const StudyCard = ({
})
return
}
handleUpdate({
const updatePayload: Partial<StudyEntry> & { redcapToken?: string } = {
redcapURL,
redcapToken,
contactUsEmail,
})
}

if (redcapToken) {
updatePayload.redcapToken = redcapToken
}
handleUpdate(updatePayload)
setSettingsChanged(false)
}

Expand Down Expand Up @@ -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<HTMLInputElement>) => {
setRedcapToken(e.target.value)
setSettingsChanged(true)
Expand Down
4 changes: 2 additions & 2 deletions application/admin-client/src/studyStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
124 changes: 124 additions & 0 deletions application/backend/src/controllers/AuditLogsController.test.ts
Original file line number Diff line number Diff line change
@@ -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 { OTPLoginRequest, RegisterRequest } from 'common/types/api/auth'
const api = new Api()
const app = api.app

Expand Down Expand Up @@ -204,5 +208,125 @@ 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
const safeEmail = '\"email\":\"johndoe@example.com\"' // 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)
expect(body.data[0].requestBody).toContain(safeEmail)
})

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)
})
})
})
})
Loading
Loading