From 09a66505111ac87e3c15fa66c9e20d36b5e80186 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Tue, 21 Apr 2026 16:13:37 +1200 Subject: [PATCH 01/17] Adds audit-logs endpoint --- .../src/controllers/AuditLogController.ts | 39 +++++++ application/backend/src/routes.ts | 55 +++++++++ application/backend/swagger.json | 110 ++++++++++++++++++ .../types/api/audit-logs/getAllAuditLogs.ts | 5 + .../common/types/api/audit-logs/index.ts | 3 + 5 files changed, 212 insertions(+) create mode 100644 application/backend/src/controllers/AuditLogController.ts create mode 100644 application/common/types/api/audit-logs/getAllAuditLogs.ts create mode 100644 application/common/types/api/audit-logs/index.ts diff --git a/application/backend/src/controllers/AuditLogController.ts b/application/backend/src/controllers/AuditLogController.ts new file mode 100644 index 00000000..e8ec5f83 --- /dev/null +++ b/application/backend/src/controllers/AuditLogController.ts @@ -0,0 +1,39 @@ +import { + Controller, + Get, + Middlewares, + Security, + Response, + Route, + Tags, +} from 'tsoa' +import prisma from '../PrismaClient' +import { AuditLog } from '@prisma/client' +import type { + GetAllAuditLogsResponse, +} from 'common/types/api/audit-logs' +import { UnauthorizedErrorResponse } from 'common/types/api/errors' +import { auditLog } from '../middlewares/AuditLog' + +@Route('audit-logs') +@Tags('AuditLogs') +@Response('500', 'Internal Server Error') +@Response('401', 'Unauthorized') +@Security('jwt', ['OrganisationAdmin', 'StudyAdmin']) +@Middlewares(auditLog) +export class AuditLogController extends Controller { + auditLogRepo = prisma.auditLog + + /** + * Get all Audit Log entries + * + * @summary Get all Audit Log entries + */ + @Get('/') + public async getAuditLogEntries(): Promise { + const auditLogs: AuditLog[] = await this.auditLogRepo.findMany({ + }) + const responseData = { data: auditLogs } + return responseData + } +} diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 55620193..972d7a23 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -29,6 +29,8 @@ import { HealthCheckController } from './controllers/HealthCheckController'; import { FamiliesController } from './controllers/FamiliesController'; // 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 import { AuthController } from './controllers/AuthController'; +// 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 +import { AuditLogController } from './controllers/AuditLogController'; import { expressAuthentication } from './authentication'; // @ts-ignore - no great way to install types from subpackage import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; @@ -939,6 +941,29 @@ 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 + "_36_Enums.AuditLogOperation": { + "dataType": "refAlias", + "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["CREATE"]},{"dataType":"enum","enums":["UPDATE"]},{"dataType":"enum","enums":["DELETE"]}],"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 + "DefaultSelection_Prisma._36_AuditLogPayload_": { + "dataType": "refAlias", + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"requestBody":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"userId":{"dataType":"union","subSchemas":[{"dataType":"double"},{"dataType":"enum","enums":[null]}],"required":true},"timestamp":{"dataType":"datetime","required":true},"success":{"dataType":"boolean","required":true},"operation":{"ref":"_36_Enums.AuditLogOperation","required":true},"resource":{"dataType":"string","required":true},"meta":{"dataType":"any","required":true},"id":{"dataType":"double","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 + "AuditLog": { + "dataType": "refAlias", + "type": {"ref":"DefaultSelection_Prisma._36_AuditLogPayload_","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 + "GetAllAuditLogsResponse": { + "dataType": "refObject", + "properties": { + "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"AuditLog"},"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 }; const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true}); @@ -3839,6 +3864,36 @@ export function RegisterRoutes(app: Router,opts?:{multer?:ReturnType = { + }; + app.get('/audit-logs', + authenticateMiddleware([{"jwt":["OrganisationAdmin","StudyAdmin"]}]), + ...(fetchMiddlewares(AuditLogController)), + ...(fetchMiddlewares(AuditLogController.prototype.getAuditLogEntries)), + + async function AuditLogController_getAuditLogEntries(request: ExRequest, response: ExResponse, next: any) { + + // 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 + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args: argsAuditLogController_getAuditLogEntries, request, response }); + + const controller = new AuditLogController(); + + await templateService.apiHandler({ + methodName: 'getAuditLogEntries', + controller, + response, + next, + validatedArgs, + successStatus: undefined, + }); + } catch (err) { + return next(err); + } + }); + // 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 // 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 diff --git a/application/backend/swagger.json b/application/backend/swagger.json index e319f2be..29de8f6b 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -2733,6 +2733,72 @@ ], "type": "object", "additionalProperties": false + }, + "_36_Enums.AuditLogOperation": { + "type": "string", + "enum": [ + "CREATE", + "UPDATE", + "DELETE" + ] + }, + "AuditLog": { + "description": "Model AuditLog", + "properties": { + "requestBody": { + "type": "string", + "nullable": true + }, + "userId": { + "type": "number", + "format": "double", + "nullable": true + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "success": { + "type": "boolean" + }, + "operation": { + "$ref": "#/components/schemas/_36_Enums.AuditLogOperation" + }, + "resource": { + "type": "string" + }, + "meta": {}, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "requestBody", + "userId", + "timestamp", + "success", + "operation", + "resource", + "meta", + "id" + ], + "type": "object" + }, + "GetAllAuditLogsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AuditLog" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": { @@ -8656,6 +8722,50 @@ "security": [], "parameters": [] } + }, + "/audit-logs": { + "get": { + "operationId": "GetAuditLogEntries", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAuditLogsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "description": "Get all Audit Log entries", + "summary": "Get all Audit Log entries", + "tags": [ + "AuditLogs" + ], + "security": [ + { + "jwt": [ + "OrganisationAdmin", + "StudyAdmin" + ] + } + ], + "parameters": [] + } } }, "servers": [ diff --git a/application/common/types/api/audit-logs/getAllAuditLogs.ts b/application/common/types/api/audit-logs/getAllAuditLogs.ts new file mode 100644 index 00000000..78a4ef89 --- /dev/null +++ b/application/common/types/api/audit-logs/getAllAuditLogs.ts @@ -0,0 +1,5 @@ +import { AuditLog } from '@prisma/client' + +export interface GetAllAuditLogsResponse { + data: AuditLog[] +} diff --git a/application/common/types/api/audit-logs/index.ts b/application/common/types/api/audit-logs/index.ts new file mode 100644 index 00000000..650c4821 --- /dev/null +++ b/application/common/types/api/audit-logs/index.ts @@ -0,0 +1,3 @@ +import type { GetAllAuditLogsResponse } from './getAllAuditLogs' + +export { GetAllAuditLogsResponse } From 9071dabf55e8a521d42f201da5a43d24fd38b6a5 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Tue, 21 Apr 2026 16:17:04 +1200 Subject: [PATCH 02/17] Appease the linter --- .../admin-client/src/components/RedcapLogo.tsx | 15 +++++++-------- .../admin-client/src/pages/restore/index.tsx | 2 +- .../admin-client/src/pages/settings/index.tsx | 1 - .../admin-client/src/pages/surveys/edit.tsx | 11 ++++++++++- .../src/controllers/AuditLogController.ts | 17 +++-------------- .../src/controllers/StudiesController.test.ts | 3 +-- .../src/controllers/StudiesController.ts | 1 - application/common/cypress/support/commands.ts | 3 +-- .../user-client/src/components/StudyInvites.tsx | 15 +++++++++++++-- .../user-client/src/pages/ConsentForm.tsx | 11 ++++++++++- application/user-client/src/pages/Register.tsx | 8 ++++++-- 11 files changed, 52 insertions(+), 35 deletions(-) diff --git a/application/admin-client/src/components/RedcapLogo.tsx b/application/admin-client/src/components/RedcapLogo.tsx index f479eddf..913e4f9b 100644 --- a/application/admin-client/src/components/RedcapLogo.tsx +++ b/application/admin-client/src/components/RedcapLogo.tsx @@ -1,13 +1,12 @@ import { ColorModeContext } from '../contexts/color-mode' import { useContext } from 'react' - export const RedcapLogo = () => { - const { mode } = useContext(ColorModeContext) + const { mode } = useContext(ColorModeContext) - return mode === 'dark' ? ( - REDCap Logo - ) : ( - REDCap Logo - ) -} \ No newline at end of file + return mode === 'dark' ? ( + REDCap Logo + ) : ( + REDCap Logo + ) +} diff --git a/application/admin-client/src/pages/restore/index.tsx b/application/admin-client/src/pages/restore/index.tsx index 4e4884c6..38f19613 100644 --- a/application/admin-client/src/pages/restore/index.tsx +++ b/application/admin-client/src/pages/restore/index.tsx @@ -229,7 +229,7 @@ const RestorePage = () => { )} - + ) } diff --git a/application/admin-client/src/pages/settings/index.tsx b/application/admin-client/src/pages/settings/index.tsx index 5be76b58..5b34b259 100644 --- a/application/admin-client/src/pages/settings/index.tsx +++ b/application/admin-client/src/pages/settings/index.tsx @@ -198,7 +198,6 @@ const SettingsPage = () => { - ) } diff --git a/application/admin-client/src/pages/surveys/edit.tsx b/application/admin-client/src/pages/surveys/edit.tsx index 0d43e8c5..15b9c665 100644 --- a/application/admin-client/src/pages/surveys/edit.tsx +++ b/application/admin-client/src/pages/surveys/edit.tsx @@ -139,7 +139,16 @@ export const SurveyEditor = () => { return isLoading ? null : ( - + - {Object.keys(errors) && { }} + {Object.keys(errors) && {}} {Object.values(ContactMethod).map((val, idx) => ( - + {val[0] + val.slice(1).toLowerCase()} ))} From edbcce7861060de4d6534a8f5b2e2f08c474c2e4 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 12:14:21 +1200 Subject: [PATCH 03/17] Adds audit-log UI --- application/admin-client/src/App.tsx | 11 ++ .../src/pages/audit-logs/index.ts | 1 + .../src/pages/audit-logs/list.tsx | 155 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 application/admin-client/src/pages/audit-logs/index.ts create mode 100644 application/admin-client/src/pages/audit-logs/list.tsx diff --git a/application/admin-client/src/App.tsx b/application/admin-client/src/App.tsx index 4e1fd7d1..d59fed99 100644 --- a/application/admin-client/src/App.tsx +++ b/application/admin-client/src/App.tsx @@ -30,6 +30,7 @@ import { authProvider } from './providers/authProvider' import { ParticipantList, ParticipantShow } from './pages/participants' import { SurveyImport, IntegrationsHome, ParticipantImport } from './pages/integrations' import { ResponsesView } from './pages/responses' +import { AuditLogList } from './pages/audit-logs' import { ListAlt, Person, @@ -39,6 +40,7 @@ import { AdminPanelSettings, RestoreFromTrash, LibraryBooks, + History, } from '@mui/icons-material' import { ParticipantEdit } from './pages/participants/edit' import { SetupPage } from './pages/setup' @@ -161,6 +163,14 @@ function App() { parent: 'admin', }, }, + { + name: 'audit-logs', + list: '/audit-logs', + meta: { + icon: , + parent: 'admin', + }, + }, { name: 'restore', list: '/restore', @@ -245,6 +255,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/application/admin-client/src/pages/audit-logs/index.ts b/application/admin-client/src/pages/audit-logs/index.ts new file mode 100644 index 00000000..c1edf1e2 --- /dev/null +++ b/application/admin-client/src/pages/audit-logs/index.ts @@ -0,0 +1 @@ +export * from './list' diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx new file mode 100644 index 00000000..936fa8b2 --- /dev/null +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react' +// import { GetAllAuditLogsResponse } from '@common/types/api/audit-logs' +import { Box, Button, Chip, Collapse, Typography } from '@mui/material' +import { DataGrid, type GridColDef } from '@mui/x-data-grid' +import { DateField, List, useDataGrid } from '@refinedev/mui' + +const ExpandableJsonCell = ({ value }: { value: any }) => { + const [expanded, setExpanded] = useState(false) + + if (!value || Object.keys(value).length === 0) { + return - + } + + return ( + + + + + + {JSON.stringify(value, null, 2)} + + + + ) +} + +export const AuditLogList = () => { + const { dataGridProps } = useDataGrid({ + syncWithLocation: false, + pagination: { mode: 'off' }, + filters: { mode: 'off' }, + sorters: { mode: 'off' }, + resource: 'audit-logs', + }) + + const columns = React.useMemo( + () => [ + { + field: 'id', + headerName: 'ID', + width: 10, + }, + { + field: 'resource', + flex: 1, + headerName: 'Resource', + minWidth: 150, + }, + { + field: 'operation', + headerName: 'Operation', + width: 90, + }, + { + field: 'success', + headerName: 'Success', + width: 80, + }, + { + field: 'timestamp', + headerName: 'Timestamp', + width: 200, + type: 'date', + valueGetter: (value) => { + if (!value) return null + return new Date(value) + }, + renderCell: function render({ value }) { + // ISO format for good sorting properties :) + return + }, + }, + { + field: 'userId', + headerName: 'userId', + width: 70, + }, + { + field: 'meta', + headerName: 'Request Details', + flex: 1, + minWidth: 350, + renderCell: ({ value }) => { + if (!value || !value.method) return '-' + + return ( + + + + {value.url} + + + ) + }, + }, + { + field: 'requestBody', + headerName: 'RequestBody', + flex: 2, + minWidth: 300, + renderCell: ({ value }) => , + }, + ], + [], + ) + return ( + + + 'auto'} + getEstimatedRowHeight={() => 52} + pageSizeOptions={[10, 25, 50]} + initialState={{ + pagination: { paginationModel: { pageSize: 25 } }, + }} + slotProps={{ root: { 'data-cy': 'audit-logs-list' } }} + disableColumnMenu // Optional: matches your cleaner UI style from the other page + /> + + + ) +} From 15e8505ccc0186846311c85c9a6994dde93b1351 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 14:25:47 +1200 Subject: [PATCH 04/17] Adds serverside pagination and sorting for auditlogs --- .../src/pages/audit-logs/list.tsx | 8 ++- .../src/providers/dataProvider.ts | 13 +++- .../src/controllers/AuditLogController.ts | 34 ++++++++--- application/backend/src/routes.ts | 7 ++- application/backend/swagger.json | 60 +++++++++++++++++-- .../{getAllAuditLogs.ts => getAuditLogs.ts} | 3 +- .../common/types/api/audit-logs/index.ts | 4 +- 7 files changed, 107 insertions(+), 22 deletions(-) rename application/common/types/api/audit-logs/{getAllAuditLogs.ts => getAuditLogs.ts} (53%) diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx index 936fa8b2..f7382bba 100644 --- a/application/admin-client/src/pages/audit-logs/list.tsx +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -47,10 +47,14 @@ const ExpandableJsonCell = ({ value }: { value: any }) => { export const AuditLogList = () => { const { dataGridProps } = useDataGrid({ syncWithLocation: false, - pagination: { mode: 'off' }, + // Note: By default Refined uses 'server' mode for pagination, sorting and filtering + // pagination: { mode: 'off' }, + // sorters: { mode: 'off' }, filters: { mode: 'off' }, - sorters: { mode: 'off' }, resource: 'audit-logs', + sorters: { + initial: [{ field: 'timestamp', order: 'desc' }], + }, }) const columns = React.useMemo( diff --git a/application/admin-client/src/providers/dataProvider.ts b/application/admin-client/src/providers/dataProvider.ts index 7569de82..98a7cc3e 100644 --- a/application/admin-client/src/providers/dataProvider.ts +++ b/application/admin-client/src/providers/dataProvider.ts @@ -84,10 +84,21 @@ export const dataProvider = (): DataProvider => { } if (sorters?.at(0)) { - params.append(`orderBy[${sorters[0].field}]`, sorters[0].order) + if (resource === 'audit-logs') { + params.append('sortBy', sorters[0].field) + params.append('sortDirection', sorters[0].order) + } else { + params.append(`orderBy[${sorters[0].field}]`, sorters[0].order) + } } if (studyResources.includes(resource)) url = `/studies/${studyId}/${url}?${params.toString()}` + else if (resource === 'audit-logs') { + const queryString = params.toString() + if (queryString) { + url = `${url}?${queryString}` + } + } const response = await axiosInstance.get(url) const data = response.data.data diff --git a/application/backend/src/controllers/AuditLogController.ts b/application/backend/src/controllers/AuditLogController.ts index 306504af..d57ae81e 100644 --- a/application/backend/src/controllers/AuditLogController.ts +++ b/application/backend/src/controllers/AuditLogController.ts @@ -1,7 +1,6 @@ -import { Controller, Get, Middlewares, Security, Response, Route, Tags } from 'tsoa' +import { Controller, Get, Middlewares, Query, Security, Response, Route, Tags } from 'tsoa' import prisma from '../PrismaClient' -import { AuditLog } from '@prisma/client' -import type { GetAllAuditLogsResponse } from 'common/types/api/audit-logs' +import type { GetAuditLogsResponse } from 'common/types/api/audit-logs' import { UnauthorizedErrorResponse } from 'common/types/api/errors' import { auditLog } from '../middlewares/AuditLog' @@ -15,14 +14,31 @@ export class AuditLogController extends Controller { auditLogRepo = prisma.auditLog /** - * Get all Audit Log entries + * Get Audit Log entries (with pagination) * - * @summary Get all Audit Log entries + * @summary Get Audit Log entries (with pagination) */ @Get('/') - public async getAuditLogEntries(): Promise { - const auditLogs: AuditLog[] = await this.auditLogRepo.findMany({}) - const responseData = { data: auditLogs } - return responseData + public async getAuditLogEntries( + @Query() _start: number = 0, + @Query() _end: number = 25, + @Query() sortBy: string = 'timestamp', + @Query() sortDirection: 'asc' | 'desc' = 'desc', + ): Promise { + const skip = _start + const take = _end - _start + + const [auditLogs, total] = await prisma.$transaction([ + this.auditLogRepo.findMany({ + skip, + take, + orderBy: { [sortBy]: sortDirection }, + }), + this.auditLogRepo.count(), + ]) + return { + data: auditLogs, + total, + } } } diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 972d7a23..d7daad43 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -956,10 +956,11 @@ const models: TsoaRoute.Models = { "type": {"ref":"DefaultSelection_Prisma._36_AuditLogPayload_","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 - "GetAllAuditLogsResponse": { + "GetAuditLogsResponse": { "dataType": "refObject", "properties": { "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"AuditLog"},"required":true}, + "total": {"dataType":"double","required":true}, }, "additionalProperties": false, }, @@ -3865,6 +3866,10 @@ export function RegisterRoutes(app: Router,opts?:{multer?:ReturnType = { + _start: {"default":0,"in":"query","name":"_start","dataType":"double"}, + _end: {"default":25,"in":"query","name":"_end","dataType":"double"}, + sortBy: {"default":"timestamp","in":"query","name":"sortBy","dataType":"string"}, + sortDirection: {"default":"desc","in":"query","name":"sortDirection","dataType":"union","subSchemas":[{"dataType":"enum","enums":["asc"]},{"dataType":"enum","enums":["desc"]}]}, }; app.get('/audit-logs', authenticateMiddleware([{"jwt":["OrganisationAdmin","StudyAdmin"]}]), diff --git a/application/backend/swagger.json b/application/backend/swagger.json index 29de8f6b..d1638007 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -2785,17 +2785,22 @@ ], "type": "object" }, - "GetAllAuditLogsResponse": { + "GetAuditLogsResponse": { "properties": { "data": { "items": { "$ref": "#/components/schemas/AuditLog" }, "type": "array" + }, + "total": { + "type": "number", + "format": "double" } }, "required": [ - "data" + "data", + "total" ], "type": "object", "additionalProperties": false @@ -8732,7 +8737,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GetAllAuditLogsResponse" + "$ref": "#/components/schemas/GetAuditLogsResponse" } } } @@ -8751,8 +8756,8 @@ "description": "Internal Server Error" } }, - "description": "Get all Audit Log entries", - "summary": "Get all Audit Log entries", + "description": "Get Audit Log entries (with pagination)", + "summary": "Get Audit Log entries (with pagination)", "tags": [ "AuditLogs" ], @@ -8764,7 +8769,50 @@ ] } ], - "parameters": [] + "parameters": [ + { + "in": "query", + "name": "_start", + "required": false, + "schema": { + "default": 0, + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "_end", + "required": false, + "schema": { + "default": 25, + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "sortBy", + "required": false, + "schema": { + "default": "timestamp", + "type": "string" + } + }, + { + "in": "query", + "name": "sortDirection", + "required": false, + "schema": { + "default": "desc", + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + } + ] } } }, diff --git a/application/common/types/api/audit-logs/getAllAuditLogs.ts b/application/common/types/api/audit-logs/getAuditLogs.ts similarity index 53% rename from application/common/types/api/audit-logs/getAllAuditLogs.ts rename to application/common/types/api/audit-logs/getAuditLogs.ts index 78a4ef89..e2022678 100644 --- a/application/common/types/api/audit-logs/getAllAuditLogs.ts +++ b/application/common/types/api/audit-logs/getAuditLogs.ts @@ -1,5 +1,6 @@ import { AuditLog } from '@prisma/client' -export interface GetAllAuditLogsResponse { +export interface GetAuditLogsResponse { data: AuditLog[] + total: number } diff --git a/application/common/types/api/audit-logs/index.ts b/application/common/types/api/audit-logs/index.ts index 650c4821..f14748e0 100644 --- a/application/common/types/api/audit-logs/index.ts +++ b/application/common/types/api/audit-logs/index.ts @@ -1,3 +1,3 @@ -import type { GetAllAuditLogsResponse } from './getAllAuditLogs' +import type { GetAuditLogsResponse } from './getAuditLogs' -export { GetAllAuditLogsResponse } +export { GetAuditLogsResponse } From b4a825ab48a41eca32b9582a4cc3aac2a3ac99b6 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 14:34:18 +1200 Subject: [PATCH 05/17] Tiny tweak for center align --- .../admin-client/src/pages/audit-logs/list.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx index f7382bba..7456a32d 100644 --- a/application/admin-client/src/pages/audit-logs/list.tsx +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -151,7 +151,16 @@ export const AuditLogList = () => { pagination: { paginationModel: { pageSize: 25 } }, }} slotProps={{ root: { 'data-cy': 'audit-logs-list' } }} - disableColumnMenu // Optional: matches your cleaner UI style from the other page + disableColumnMenu + sx={{ + '& .MuiDataGrid-columnHeaderTitleContainer': { + justifyContent: 'center', + }, + '& .MuiDataGrid-cell': { + display: 'flex', + alignItems: 'center', + }, + }} /> From 90386a1329b92e6f706fba06fe313738f6f1fa42 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 15:00:15 +1200 Subject: [PATCH 06/17] Fix capilatisation in admin settings panel --- application/admin-client/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/admin-client/src/App.tsx b/application/admin-client/src/App.tsx index d59fed99..bc5bb89e 100644 --- a/application/admin-client/src/App.tsx +++ b/application/admin-client/src/App.tsx @@ -160,6 +160,7 @@ function App() { list: '/studies', meta: { icon: , + label: 'Manage Studies', parent: 'admin', }, }, @@ -168,6 +169,7 @@ function App() { list: '/audit-logs', meta: { icon: , + label: 'Audit Logs', parent: 'admin', }, }, From d890812d44d8f6c9a4c4bff6c81e4a22fd16bb02 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 15:11:14 +1200 Subject: [PATCH 07/17] Do not sort encrypted field --- application/admin-client/src/pages/audit-logs/list.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx index 7456a32d..12cb6b3f 100644 --- a/application/admin-client/src/pages/audit-logs/list.tsx +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -131,6 +131,7 @@ export const AuditLogList = () => { { field: 'requestBody', headerName: 'RequestBody', + sortable: false, flex: 2, minWidth: 300, renderCell: ({ value }) => , From e939ef3cc1ccd6d1f6e777fbf98993ee98e9fa1b Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Wed, 22 Apr 2026 15:33:35 +1200 Subject: [PATCH 08/17] AuditLogController -> AuditLogsController --- .../{AuditLogController.ts => AuditLogsController.ts} | 0 application/backend/src/routes.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename application/backend/src/controllers/{AuditLogController.ts => AuditLogsController.ts} (100%) diff --git a/application/backend/src/controllers/AuditLogController.ts b/application/backend/src/controllers/AuditLogsController.ts similarity index 100% rename from application/backend/src/controllers/AuditLogController.ts rename to application/backend/src/controllers/AuditLogsController.ts diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index d7daad43..07aba8c0 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -30,7 +30,7 @@ import { FamiliesController } from './controllers/FamiliesController'; // 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 import { AuthController } from './controllers/AuthController'; // 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 -import { AuditLogController } from './controllers/AuditLogController'; +import { AuditLogController } from './controllers/AuditLogsController'; import { expressAuthentication } from './authentication'; // @ts-ignore - no great way to install types from subpackage import type { Request as ExRequest, Response as ExResponse, RequestHandler, Router } from 'express'; From ed8ab22fc8eff1693d2e72b07ea747d0d9ab8d5f Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Thu, 23 Apr 2026 09:51:31 +1200 Subject: [PATCH 09/17] Adds list of backend tests --- .../controllers/AuditLogsController.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 application/backend/src/controllers/AuditLogsController.test.ts diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts new file mode 100644 index 00000000..97e3d7f8 --- /dev/null +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -0,0 +1,84 @@ +import request from 'supertest' +import { Api } from '../Api' +import prisma from '../PrismaClient' +import { resetDB } from 'common/testing/TestHelpers' +// import { generateToken, verifyPassword } from '../authentication' +// import type { RegisterRequest } from 'common/types/api/auth' +// import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' +const api = new Api() +const app = api.app + +describe('AuditLogsController', () => { + // let participantToken: string + // let orgAdminToken: string + // let studyAdminToken: string + + beforeAll(async () => { + // participantToken = await generateToken({ userId: PARTICIPANT_UNANSWERED_ID }) + // orgAdminToken = await generateToken({ userId: ORG_ADMIN_ID }) + // studyAdminToken = await generateToken({ userId: STUDY_ADMIN_ID }) + + api.run() + }) + + beforeEach(async () => { + await resetDB() + }) + + afterAll(async () => { + api.stop() + }) + + describe('GET /audit-logs', () => { + it('should return a list of users', async () => { + const response = await request(app).get('/users') + // .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + // const body: GetAllUsersResponse = response.body + // expect(body).toHaveProperty('data') + // expect(body.data).toHaveLength(8) + }) + + it('should return a 500 error if a database error occurs', async () => { + jest.spyOn(prisma.user, 'findMany').mockImplementationOnce(() => { + throw new Error('Internal Server Error') + }) + const response = await request(app).get('/users') + // .set({ Authorization: `Bearer ${orgAdminToken}` }) + + expect(response.status).toBe(500) + + // const body: GetAllUsersResponse = response.body + // expect(body.data).toBe(undefined) + }) + + describe('Authentication and authorisation', () => { + it('should not allow unauthorised access', async () => {}) + it('should not allow Participants to access', async () => {}) + it('should allow Organisation Admins to access', async () => {}) + it('should allow org and study admins to access', async () => {}) + }) + + describe('Default behaviour', () => { + it('should return default sorted data and total count when no query params are provided', async () => {}) + it('should handle empty db table gracefully', async () => {}) + it('should always return accurate total count regardles off _end param', async () => {}) + }) + + describe('Pagination', () => { + it('should accept pagination params and serve correct data', async () => {}) + it('should accept pagination params and handle errors (invalid numbers)', async () => {}) + it('shout return an empty array if _start is greater than total number of records', async () => {}) + }) + + describe('Soring', () => { + it('should accept sorting params and serve correct data (note sorting quirks, like caps)', async () => {}) + it('should accept sorting params and handle errors (incorrect field)', async () => {}) + }) + + describe('Param combinations', () => { + it('should accept combinations of sorting and pagination params and apply them', async () => {}) + }) + }) +}) From 77a7bb677992899b3caa709a7bdb46784e69b06d Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 10:48:29 +1200 Subject: [PATCH 10/17] Adds seedAuditLogs test helper --- .../controllers/AuditLogsController.test.ts | 107 ++++++++++++------ application/common/testing/TestHelpers.ts | 47 ++++++++ 2 files changed, 120 insertions(+), 34 deletions(-) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index 97e3d7f8..d1ffff24 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -1,22 +1,23 @@ import request from 'supertest' import { Api } from '../Api' -import prisma from '../PrismaClient' -import { resetDB } from 'common/testing/TestHelpers' -// import { generateToken, verifyPassword } from '../authentication' +// import prisma from '../PrismaClient' +import { resetDB, seedAuditLogs } from 'common/testing/TestHelpers' +import { generateToken } from '../authentication' +import type { GetAuditLogsResponse } from 'common/types/api/audit-logs' // import type { RegisterRequest } from 'common/types/api/auth' -// import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' +import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' const api = new Api() const app = api.app describe('AuditLogsController', () => { - // let participantToken: string - // let orgAdminToken: string - // let studyAdminToken: string + let participantToken: string + let orgAdminToken: string + let studyAdminToken: string beforeAll(async () => { - // participantToken = await generateToken({ userId: PARTICIPANT_UNANSWERED_ID }) - // orgAdminToken = await generateToken({ userId: ORG_ADMIN_ID }) - // studyAdminToken = await generateToken({ userId: STUDY_ADMIN_ID }) + participantToken = await generateToken({ userId: PARTICIPANT_UNANSWERED_ID }) + orgAdminToken = await generateToken({ userId: ORG_ADMIN_ID }) + studyAdminToken = await generateToken({ userId: STUDY_ADMIN_ID }) api.run() }) @@ -30,39 +31,77 @@ describe('AuditLogsController', () => { }) describe('GET /audit-logs', () => { - it('should return a list of users', async () => { - const response = await request(app).get('/users') - // .set({ Authorization: `Bearer ${orgAdminToken}` }) - expect(response.status).toBe(200) + // it('should return a list of users', async () => { + // const response = await request(app).get('/users') + // // .set({ Authorization: `Bearer ${orgAdminToken}` }) + // expect(response.status).toBe(200) - // const body: GetAllUsersResponse = response.body - // expect(body).toHaveProperty('data') - // expect(body.data).toHaveLength(8) - }) + // // const body: GetAllUsersResponse = response.body + // // expect(body).toHaveProperty('data') + // // expect(body.data).toHaveLength(8) + // }) - it('should return a 500 error if a database error occurs', async () => { - jest.spyOn(prisma.user, 'findMany').mockImplementationOnce(() => { - throw new Error('Internal Server Error') - }) - const response = await request(app).get('/users') - // .set({ Authorization: `Bearer ${orgAdminToken}` }) + // it('should return a 500 error if a database error occurs', async () => { + // jest.spyOn(prisma.user, 'findMany').mockImplementationOnce(() => { + // throw new Error('Internal Server Error') + // }) + // const response = await request(app).get('/users') + // // .set({ Authorization: `Bearer ${orgAdminToken}` }) - expect(response.status).toBe(500) + // expect(response.status).toBe(500) - // const body: GetAllUsersResponse = response.body - // expect(body.data).toBe(undefined) - }) + // // const body: GetAllUsersResponse = response.body + // // expect(body.data).toBe(undefined) + // }) describe('Authentication and authorisation', () => { - it('should not allow unauthorised access', async () => {}) - it('should not allow Participants to access', async () => {}) - it('should allow Organisation Admins to access', async () => {}) - it('should allow org and study admins to access', async () => {}) + it('should not allow unauthorised access', async () => { + const response = await request(app).get('/audit-logs') + expect(response.status).toBe(401) + }) + it('should not allow Participants to access', async () => { + const response = await request(app) + .get('/audit-logs') + .set({ Authorization: `Bearer ${participantToken}` }) + expect(response.status).toBe(401) + }) + it('should allow Organisation Admins to access', async () => { + const response = await request(app) + .get('/audit-logs') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + }) + it('should allow Study Admins to access', async () => { + const response = await request(app) + .get('/audit-logs') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + }) }) describe('Default behaviour', () => { - it('should return default sorted data and total count when no query params are provided', async () => {}) - it('should handle empty db table gracefully', async () => {}) + it('should return default length sorted data and total count when no query params are provided', async () => { + seedAuditLogs(111, 96) + const response = await request(app) + .get('/audit-logs') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data).toHaveLength(25) + expect(body).toHaveProperty('total') + }) + it('should handle empty db table gracefully', async () => { + const response = await request(app) + .get('/audit-logs') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data).toHaveLength(0) + expect(body).toHaveProperty('total') + expect(body.total).toBe(0) + }) it('should always return accurate total count regardles off _end param', async () => {}) }) diff --git a/application/common/testing/TestHelpers.ts b/application/common/testing/TestHelpers.ts index 88ec044f..d9a18987 100644 --- a/application/common/testing/TestHelpers.ts +++ b/application/common/testing/TestHelpers.ts @@ -1,4 +1,5 @@ import prisma from '../../backend/src/PrismaClient' +import { Prisma, AuditLogOperation } from '@prisma/client' import { SurveysController } from '../../backend/src/controllers/SurveysController' import logger from 'common/src/logger' import { PARTICIPANT_UNANSWERED_ID, seedTests } from './seed' @@ -216,3 +217,49 @@ export async function revokeInvite(inviteId: string) { await prisma.invite.update({ where: { id: inviteId }, data: { status: 'REVOKED' } }) return null } + +// Function to generate N audit log entries +export async function seedAuditLogs(count: number, userId: number) { + if (count <= 0) return + + const getRandom = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)] + + const resources = [ + 'studies/surveys/publish', + 'studies/surveys', + 'studies', + 'studies/logo', + 'users', + 'users/make-study-admin', + 'users/remove-study-admin', + 'auth/login', + ] + const operations: AuditLogOperation[] = [ + AuditLogOperation.CREATE, + AuditLogOperation.UPDATE, + AuditLogOperation.DELETE, + ] + const methods = ['POST', 'PATCH', '', 'DELETE'] + + const logsToCreate: Prisma.AuditLogCreateManyInput[] = Array.from({ length: count }).map( + (_, index) => { + const date = new Date() + date.setMinutes(date.getMinutes() - index) + + return { + resource: getRandom(resources), + operation: getRandom(operations), + success: true, + timestamp: date, + userId: userId, + meta: { + method: getRandom(methods), + url: `endpoint/test-${index}`, + } as Prisma.InputJsonObject, + } + }, + ) + await prisma.auditLog.createMany({ + data: logsToCreate, + }) +} From a4a6e30b45480ba0f73919fee94ed3daf2519580 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 14:14:16 +1200 Subject: [PATCH 11/17] Add AllowedAuditLogSortFields to make DRY --- application/backend/src/routes.ts | 7 ++++++- application/backend/swagger.json | 16 ++++++++++++++-- .../common/types/api/audit-logs/getAuditLogs.ts | 16 ++++++++++++++++ application/common/types/api/audit-logs/index.ts | 4 ++-- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 07aba8c0..b1c06b6f 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -965,6 +965,11 @@ 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 + "AllowedAuditLogSortFields": { + "dataType": "refAlias", + "type": {"dataType":"enum","enums":["id","meta","resource","operation","success","timestamp","userId"],"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 }; const templateService = new ExpressTemplateService(models, {"noImplicitAdditionalProperties":"throw-on-extras","bodyCoercion":true}); @@ -3868,7 +3873,7 @@ export function RegisterRoutes(app: Router,opts?:{multer?:ReturnType = { _start: {"default":0,"in":"query","name":"_start","dataType":"double"}, _end: {"default":25,"in":"query","name":"_end","dataType":"double"}, - sortBy: {"default":"timestamp","in":"query","name":"sortBy","dataType":"string"}, + sortBy: {"default":"timestamp","in":"query","name":"sortBy","ref":"AllowedAuditLogSortFields"}, sortDirection: {"default":"desc","in":"query","name":"sortDirection","dataType":"union","subSchemas":[{"dataType":"enum","enums":["asc"]},{"dataType":"enum","enums":["desc"]}]}, }; app.get('/audit-logs', diff --git a/application/backend/swagger.json b/application/backend/swagger.json index d1638007..8b0c04e8 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -2804,6 +2804,19 @@ ], "type": "object", "additionalProperties": false + }, + "AllowedAuditLogSortFields": { + "type": "string", + "enum": [ + "id", + "meta", + "resource", + "operation", + "success", + "timestamp", + "userId" + ], + "nullable": false } }, "securitySchemes": { @@ -8795,8 +8808,7 @@ "name": "sortBy", "required": false, "schema": { - "default": "timestamp", - "type": "string" + "$ref": "#/components/schemas/AllowedAuditLogSortFields" } }, { diff --git a/application/common/types/api/audit-logs/getAuditLogs.ts b/application/common/types/api/audit-logs/getAuditLogs.ts index e2022678..da64adc3 100644 --- a/application/common/types/api/audit-logs/getAuditLogs.ts +++ b/application/common/types/api/audit-logs/getAuditLogs.ts @@ -4,3 +4,19 @@ export interface GetAuditLogsResponse { data: AuditLog[] total: number } + +export type AllowedAuditLogSortFields = keyof Omit + +const sortableFieldsMap: Record = { + id: true, + resource: true, + operation: true, + success: true, + timestamp: true, + userId: true, + meta: true, +} + +export const AUDIT_LOG_SORTABLE_FIELDS = Object.keys( + sortableFieldsMap, +) as AllowedAuditLogSortFields[] diff --git a/application/common/types/api/audit-logs/index.ts b/application/common/types/api/audit-logs/index.ts index f14748e0..772e3d05 100644 --- a/application/common/types/api/audit-logs/index.ts +++ b/application/common/types/api/audit-logs/index.ts @@ -1,3 +1,3 @@ -import type { GetAuditLogsResponse } from './getAuditLogs' +import type { GetAuditLogsResponse, AllowedAuditLogSortFields } from './getAuditLogs' -export { GetAuditLogsResponse } +export { GetAuditLogsResponse, AllowedAuditLogSortFields } From 8216158efeaad276d66e88050c5216b96f03b0a8 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 14:15:46 +1200 Subject: [PATCH 12/17] Add defaultAuditLogsPageSize for DRY config --- application/common/src/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/application/common/src/config.ts b/application/common/src/config.ts index 7f7a6ed9..912028ba 100644 --- a/application/common/src/config.ts +++ b/application/common/src/config.ts @@ -1 +1,2 @@ export const backendPort = 5000 +export const defaultAuditLogsPageSize = 25 From cbcd3ab299f10702151c23275d77c1e6ffd1dc83 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 14:19:15 +1200 Subject: [PATCH 13/17] Use new DRY config and type --- .../src/pages/audit-logs/list.tsx | 21 +++++++----- .../src/controllers/AuditLogsController.ts | 33 ++++++++++++++++--- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx index 12cb6b3f..f93300a8 100644 --- a/application/admin-client/src/pages/audit-logs/list.tsx +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react' -// import { GetAllAuditLogsResponse } from '@common/types/api/audit-logs' +import { defaultAuditLogsPageSize } from '@common/src/config' import { Box, Button, Chip, Collapse, Typography } from '@mui/material' import { DataGrid, type GridColDef } from '@mui/x-data-grid' import { DateField, List, useDataGrid } from '@refinedev/mui' +import { AUDIT_LOG_SORTABLE_FIELDS } from '@common/types/api/audit-logs/getAuditLogs' const ExpandableJsonCell = ({ value }: { value: any }) => { const [expanded, setExpanded] = useState(false) @@ -57,8 +58,8 @@ export const AuditLogList = () => { }, }) - const columns = React.useMemo( - () => [ + const columns = React.useMemo(() => { + const baseColumns: GridColDef[] = [ { field: 'id', headerName: 'ID', @@ -131,14 +132,16 @@ export const AuditLogList = () => { { field: 'requestBody', headerName: 'RequestBody', - sortable: false, flex: 2, minWidth: 300, renderCell: ({ value }) => , }, - ], - [], - ) + ] + return baseColumns.map((col) => ({ + ...col, + sortable: col.sortable ?? AUDIT_LOG_SORTABLE_FIELDS.includes(col.field as any), + })) + }, []) return ( @@ -147,9 +150,9 @@ export const AuditLogList = () => { columns={columns} getRowHeight={() => 'auto'} getEstimatedRowHeight={() => 52} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={[10, defaultAuditLogsPageSize, 50]} initialState={{ - pagination: { paginationModel: { pageSize: 25 } }, + pagination: { paginationModel: { pageSize: defaultAuditLogsPageSize } }, }} slotProps={{ root: { 'data-cy': 'audit-logs-list' } }} disableColumnMenu diff --git a/application/backend/src/controllers/AuditLogsController.ts b/application/backend/src/controllers/AuditLogsController.ts index d57ae81e..3ea2a3ca 100644 --- a/application/backend/src/controllers/AuditLogsController.ts +++ b/application/backend/src/controllers/AuditLogsController.ts @@ -1,7 +1,18 @@ -import { Controller, Get, Middlewares, Query, Security, Response, Route, Tags } from 'tsoa' +import { + Controller, + Get, + Middlewares, + Query, + Security, + Response, + Route, + Tags, + ValidateError, +} from 'tsoa' import prisma from '../PrismaClient' -import type { GetAuditLogsResponse } from 'common/types/api/audit-logs' +import type { GetAuditLogsResponse, AllowedAuditLogSortFields } from 'common/types/api/audit-logs' import { UnauthorizedErrorResponse } from 'common/types/api/errors' +import { defaultAuditLogsPageSize } from 'common/src/config' import { auditLog } from '../middlewares/AuditLog' @Route('audit-logs') @@ -21,13 +32,27 @@ export class AuditLogController extends Controller { @Get('/') public async getAuditLogEntries( @Query() _start: number = 0, - @Query() _end: number = 25, - @Query() sortBy: string = 'timestamp', + @Query() _end: number = defaultAuditLogsPageSize, + @Query() sortBy: AllowedAuditLogSortFields = 'timestamp', @Query() sortDirection: 'asc' | 'desc' = 'desc', ): Promise { const skip = _start const take = _end - _start + // Keep logical bounds checks + if (skip < 0) { + throw new ValidateError( + { _start: { message: '_start cannot be less than 0' } }, + 'Validation Failed', + ) + } + if (take < 0) { + throw new ValidateError( + { _end: { message: '_end cannot be less than _start' } }, + 'Validation Failed', + ) + } + const [auditLogs, total] = await prisma.$transaction([ this.auditLogRepo.findMany({ skip, From 9b5efa2f17ad613312f951817addcf48b9d64527 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 14:29:06 +1200 Subject: [PATCH 14/17] First draft of backend auditlog tests --- .../controllers/AuditLogsController.test.ts | 131 ++++++++++++++++-- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index d1ffff24..c40ddecd 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -2,6 +2,7 @@ import request from 'supertest' import { Api } from '../Api' // import prisma from '../PrismaClient' 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 type { RegisterRequest } from 'common/types/api/auth' @@ -81,15 +82,16 @@ describe('AuditLogsController', () => { describe('Default behaviour', () => { it('should return default length sorted data and total count when no query params are provided', async () => { - seedAuditLogs(111, 96) + await seedAuditLogs(55, 96) const response = await request(app) .get('/audit-logs') .set({ Authorization: `Bearer ${studyAdminToken}` }) expect(response.status).toBe(200) const body: GetAuditLogsResponse = response.body expect(body).toHaveProperty('data') - expect(body.data).toHaveLength(25) + expect(body.data).toHaveLength(defaultAuditLogsPageSize) expect(body).toHaveProperty('total') + expect(body.total).toBe(55) }) it('should handle empty db table gracefully', async () => { const response = await request(app) @@ -102,22 +104,131 @@ describe('AuditLogsController', () => { expect(body).toHaveProperty('total') expect(body.total).toBe(0) }) - it('should always return accurate total count regardles off _end param', async () => {}) + it('should always return accurate total count regardless of _end param', async () => { + await seedAuditLogs(55, 96) + const response = await request(app) + .get(`/audit-logs?_end=${defaultAuditLogsPageSize}`) + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data).toHaveLength(defaultAuditLogsPageSize) + expect(body).toHaveProperty('total') + expect(body.total).toBe(55) + }) }) describe('Pagination', () => { - it('should accept pagination params and serve correct data', async () => {}) - it('should accept pagination params and handle errors (invalid numbers)', async () => {}) - it('shout return an empty array if _start is greater than total number of records', async () => {}) + it('should accept pagination params and serve correct data', async () => { + const seedSize = 55 + const start = 5 + const end = 10 + await seedAuditLogs(seedSize, 96) + const response = await request(app) + .get(`/audit-logs?_start=${start}&_end=${end}`) + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data).toHaveLength(end - start) + expect(body.data[0].id).toBe(1 + start) + expect( + body.data[ + start - 1 // account for 0 index + ].id, + ).toBe(end) + }) + it('should handle non-numeric params gracefully', async () => { + const response = await request(app) + .get('/audit-logs?_start=foo&_end=bar') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(422) + }) + it('should handle negative params gracefully', async () => { + const responseNegativeStart = await request(app) + .get('/audit-logs?_start=-10&_end=20') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(responseNegativeStart.status).toBe(422) + const responseNegativeEnd = await request(app) + .get('/audit-logs?_start=10&_end=-20') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(responseNegativeEnd.status).toBe(422) + }) + it('should handle reversed bounds (_end < _end) gracefully', async () => { + const response = await request(app) + .get('/audit-logs?_start=30&_end=10') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(422) + }) + it('shout return an empty array if _start is greater than total number of records', async () => { + const seedSize = 5 + await seedAuditLogs(seedSize, 96) + const response = await request(app) + .get('/audit-logs?_start=50&_end=100') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data).toHaveLength(0) + expect(body).toHaveProperty('total') + expect(body.total).toBe(seedSize) + }) }) - describe('Soring', () => { - it('should accept sorting params and serve correct data (note sorting quirks, like caps)', async () => {}) - it('should accept sorting params and handle errors (incorrect field)', async () => {}) + describe('Sorting', () => { + // it('should accept sorting params and serve correct data (note sorting quirks, like caps)', async () => { + it('should accept sortDirection asc', async () => { + const seedSize = 5 + await seedAuditLogs(seedSize, 96) + const response = await request(app) + .get('/audit-logs?sortBy=id&sortDirection=asc') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(200) + const body: GetAuditLogsResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0].id).toBe(1) + }) + it('should accept sortDirection desc', async () => { + const seedSize = 5 + await seedAuditLogs(seedSize, 96) + 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].id).toBe(seedSize) + }) + it('should accept sorting params and handle errors (incorrect field)', async () => { + const seedSize = 5 + await seedAuditLogs(seedSize, 96) + const response = await request(app) + .get('/audit-logs?sortBy=totallyFakeField') + .set({ Authorization: `Bearer ${studyAdminToken}` }) + expect(response.status).toBe(422) + }) }) describe('Param combinations', () => { - it('should accept combinations of sorting and pagination params and apply them', async () => {}) + it('should accept combinations of sorting and pagination params and apply them', async () => { + const seedSize = 55 + const start = 5 + const end = 10 + await seedAuditLogs(seedSize, 96) + const response = await request(app) + .get(`/audit-logs?_start=${start}&_end=${end}&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).toHaveLength(end - start) + expect(body.data[0].id).toBe(seedSize - start) + expect( + body.data[ + start - 1 // account for 0 index + ].id, + ).toBe(seedSize - end + 1) // account for 0 index + }) }) }) }) From 14d0677150b7c22f5292f0810de98e98490e0047 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 15:11:50 +1200 Subject: [PATCH 15/17] Randomise audit log test seed userId --- .../controllers/AuditLogsController.test.ts | 39 ++++--------------- application/common/testing/TestHelpers.ts | 5 ++- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index c40ddecd..bee372cf 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -32,29 +32,6 @@ describe('AuditLogsController', () => { }) describe('GET /audit-logs', () => { - // it('should return a list of users', async () => { - // const response = await request(app).get('/users') - // // .set({ Authorization: `Bearer ${orgAdminToken}` }) - // expect(response.status).toBe(200) - - // // const body: GetAllUsersResponse = response.body - // // expect(body).toHaveProperty('data') - // // expect(body.data).toHaveLength(8) - // }) - - // it('should return a 500 error if a database error occurs', async () => { - // jest.spyOn(prisma.user, 'findMany').mockImplementationOnce(() => { - // throw new Error('Internal Server Error') - // }) - // const response = await request(app).get('/users') - // // .set({ Authorization: `Bearer ${orgAdminToken}` }) - - // expect(response.status).toBe(500) - - // // const body: GetAllUsersResponse = response.body - // // expect(body.data).toBe(undefined) - // }) - describe('Authentication and authorisation', () => { it('should not allow unauthorised access', async () => { const response = await request(app).get('/audit-logs') @@ -82,7 +59,7 @@ describe('AuditLogsController', () => { describe('Default behaviour', () => { it('should return default length sorted data and total count when no query params are provided', async () => { - await seedAuditLogs(55, 96) + await seedAuditLogs(55) const response = await request(app) .get('/audit-logs') .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -105,7 +82,7 @@ describe('AuditLogsController', () => { expect(body.total).toBe(0) }) it('should always return accurate total count regardless of _end param', async () => { - await seedAuditLogs(55, 96) + await seedAuditLogs(55) const response = await request(app) .get(`/audit-logs?_end=${defaultAuditLogsPageSize}`) .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -123,7 +100,7 @@ describe('AuditLogsController', () => { const seedSize = 55 const start = 5 const end = 10 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get(`/audit-logs?_start=${start}&_end=${end}`) .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -162,7 +139,7 @@ describe('AuditLogsController', () => { }) it('shout return an empty array if _start is greater than total number of records', async () => { const seedSize = 5 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get('/audit-logs?_start=50&_end=100') .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -179,7 +156,7 @@ describe('AuditLogsController', () => { // it('should accept sorting params and serve correct data (note sorting quirks, like caps)', async () => { it('should accept sortDirection asc', async () => { const seedSize = 5 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get('/audit-logs?sortBy=id&sortDirection=asc') .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -190,7 +167,7 @@ describe('AuditLogsController', () => { }) it('should accept sortDirection desc', async () => { const seedSize = 5 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get('/audit-logs?sortBy=id&sortDirection=desc') .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -201,7 +178,7 @@ describe('AuditLogsController', () => { }) it('should accept sorting params and handle errors (incorrect field)', async () => { const seedSize = 5 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get('/audit-logs?sortBy=totallyFakeField') .set({ Authorization: `Bearer ${studyAdminToken}` }) @@ -214,7 +191,7 @@ describe('AuditLogsController', () => { const seedSize = 55 const start = 5 const end = 10 - await seedAuditLogs(seedSize, 96) + await seedAuditLogs(seedSize) const response = await request(app) .get(`/audit-logs?_start=${start}&_end=${end}&sortBy=id&sortDirection=desc`) .set({ Authorization: `Bearer ${studyAdminToken}` }) diff --git a/application/common/testing/TestHelpers.ts b/application/common/testing/TestHelpers.ts index d9a18987..249119a5 100644 --- a/application/common/testing/TestHelpers.ts +++ b/application/common/testing/TestHelpers.ts @@ -219,11 +219,12 @@ export async function revokeInvite(inviteId: string) { } // Function to generate N audit log entries -export async function seedAuditLogs(count: number, userId: number) { +export async function seedAuditLogs(count: number) { if (count <= 0) return const getRandom = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)] + const testSeedIdArray = [96, 97, 99, 102, 105, 106] // Array from application/common/testing/seed.ts const resources = [ 'studies/surveys/publish', 'studies/surveys', @@ -251,7 +252,7 @@ export async function seedAuditLogs(count: number, userId: number) { operation: getRandom(operations), success: true, timestamp: date, - userId: userId, + userId: getRandom(testSeedIdArray), meta: { method: getRandom(methods), url: `endpoint/test-${index}`, From 12ea1c1246e2e8ca062bde7f81a4d13eda7911f5 Mon Sep 17 00:00:00 2001 From: ignatiusm Date: Fri, 24 Apr 2026 16:33:37 +1200 Subject: [PATCH 16/17] Adds audit-log cypress tests --- application/admin-client/cypress.config.ts | 5 +++ .../admin-client/cypress/e2e/auditLogs.cy.js | 45 +++++++++++++++++++ .../src/pages/audit-logs/list.tsx | 3 +- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 application/admin-client/cypress/e2e/auditLogs.cy.js diff --git a/application/admin-client/cypress.config.ts b/application/admin-client/cypress.config.ts index 9394a07f..a2e7d54b 100644 --- a/application/admin-client/cypress.config.ts +++ b/application/admin-client/cypress.config.ts @@ -8,6 +8,7 @@ import { wipeDB, calculateHash, readCommonFile, + seedAuditLogs, } from 'common/testing/TestHelpers' import { defineConfig } from 'cypress' @@ -49,6 +50,10 @@ export default defineConfig({ readCommonFile(fileName: string) { return readCommonFile(fileName) }, + async seedAuditLogs(count: number) { + await seedAuditLogs(count) + return null + }, }) }, }, diff --git a/application/admin-client/cypress/e2e/auditLogs.cy.js b/application/admin-client/cypress/e2e/auditLogs.cy.js new file mode 100644 index 00000000..e580dcba --- /dev/null +++ b/application/admin-client/cypress/e2e/auditLogs.cy.js @@ -0,0 +1,45 @@ +const { UserType } = require('../../../common/cypress/support/commands') + +before(() => { + cy.task('reset') + cy.task('seedAuditLogs', 55) +}) + +// Note: I've decided against testing the sorting and pagination as this comes from the library + +describe('Audit Logs', () => { + it('Organisation Admin can view Audit Log', () => { + cy.login(UserType.ORG_ADMIN) + cy.visit('/audit-logs') + cy.contains('Audit Logs').should('exist') + }) + + it('Study Amin can view Audit Log', () => { + cy.login(UserType.STUDY_ADMIN) + cy.visit('/audit-logs') + cy.contains('Audit Logs').should('exist') + }) + + it('should toggle the View Payload cell and display JSON', () => { + 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() + + // Assert text changes + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'Hide Payload') + + // Assert JSON appears + // Note: this works because the most recent action is the Study Admin + // logging in at the start of the test :) + cy.get('.MuiCollapse-root').should('be.visible').and('contain.text', UserType.STUDY_ADMIN) + + // Toggle button to hide payload + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'Hide Payload').click() + + cy.get('[data-cy="toggle-payload-view"]').first().should('contain.text', 'View Payload') + + cy.get('[data-cy="payload-viewer"]').should('not.exist') + }) +}) diff --git a/application/admin-client/src/pages/audit-logs/list.tsx b/application/admin-client/src/pages/audit-logs/list.tsx index f93300a8..c3b8bc2f 100644 --- a/application/admin-client/src/pages/audit-logs/list.tsx +++ b/application/admin-client/src/pages/audit-logs/list.tsx @@ -18,13 +18,14 @@ const ExpandableJsonCell = ({ value }: { value: any }) => { size="small" variant="outlined" color="inherit" + data-cy="toggle-payload-view" onClick={() => setExpanded((prev) => !prev)} sx={{ mb: expanded ? 1 : 0, textTransform: 'none' }} > {expanded ? 'Hide Payload' : 'View Payload'} - + Date: Tue, 28 Apr 2026 14:20:43 +1200 Subject: [PATCH 17/17] Fix typos, remove unneeded imports --- .../backend/src/controllers/AuditLogsController.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/application/backend/src/controllers/AuditLogsController.test.ts b/application/backend/src/controllers/AuditLogsController.test.ts index bee372cf..30ff41de 100644 --- a/application/backend/src/controllers/AuditLogsController.test.ts +++ b/application/backend/src/controllers/AuditLogsController.test.ts @@ -1,11 +1,9 @@ import request from 'supertest' import { Api } from '../Api' -// import prisma from '../PrismaClient' 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 type { RegisterRequest } from 'common/types/api/auth' import { ORG_ADMIN_ID, PARTICIPANT_UNANSWERED_ID, STUDY_ADMIN_ID } from 'common/testing/seed' const api = new Api() const app = api.app @@ -131,13 +129,13 @@ describe('AuditLogsController', () => { .set({ Authorization: `Bearer ${studyAdminToken}` }) expect(responseNegativeEnd.status).toBe(422) }) - it('should handle reversed bounds (_end < _end) gracefully', async () => { + it('should handle reversed bounds (_end < _start) gracefully', async () => { const response = await request(app) .get('/audit-logs?_start=30&_end=10') .set({ Authorization: `Bearer ${studyAdminToken}` }) expect(response.status).toBe(422) }) - it('shout return an empty array if _start is greater than total number of records', async () => { + it('should return an empty array if _start is greater than total number of records', async () => { const seedSize = 5 await seedAuditLogs(seedSize) const response = await request(app) @@ -153,7 +151,6 @@ describe('AuditLogsController', () => { }) describe('Sorting', () => { - // it('should accept sorting params and serve correct data (note sorting quirks, like caps)', async () => { it('should accept sortDirection asc', async () => { const seedSize = 5 await seedAuditLogs(seedSize)