From 1d8fcdd83180a26bb28d79bf885b004ec71c216f Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 7 Nov 2026 22:35:50 +0000 Subject: [PATCH] Implement hidden fields omission in audit logs and notifications for database operations This commit introduces a utility function, `omitHiddenFields`, to exclude specified schema hidden fields from audit logs and notifications during create, edit, and delete operations in the database module. The `printerSchema` is updated to define hidden fields, ensuring sensitive information is not logged. Corresponding tests are added to verify the correct omission of these fields in audit logs and notifications, enhancing data privacy and compliance. --- src/database/__tests__/database.test.js | 89 ++++++++++++++++++- src/database/database.js | 33 +++++-- .../schemas/production/printer.schema.js | 2 + 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/database/__tests__/database.test.js b/src/database/__tests__/database.test.js index 185efbd..6849582 100644 --- a/src/database/__tests__/database.test.js +++ b/src/database/__tests__/database.test.js @@ -75,7 +75,8 @@ jest.unstable_mockModule('../schemas/management/file.schema.js', () => ({ // Now import the database utilities const { listObjects, getObject, newObject, editObject, deleteObject } = await import('../database.js'); -const { editAuditLog, editNotification } = await import('../../utils.js'); +const { deleteAuditLog, deleteNotification, editAuditLog, editNotification, newAuditLog } = + await import('../../utils.js'); describe('Database Utilities (CRUD)', () => { let mockModel; @@ -151,6 +152,26 @@ describe('Database Utilities (CRUD)', () => { expect(mockModel.create).toHaveBeenCalledWith(newData); expect(result).toEqual(createdData); }); + + it('omits schema hidden fields from the create audit log', async () => { + mockModel.hiddenFields = ['alerts', 'pendingSlicerUploads']; + const newData = { + name: 'Printer', + alerts: [{ type: 'error', message: 'hotend' }], + pendingSlicerUploads: [{ file: 'file-1' }], + }; + mockModel.create.mockResolvedValue({ + toObject: () => ({ _id: '456', ...newData }), + _id: '456', + }); + + const result = await newObject({ model: mockModel, newData }); + + expect(mockModel.create).toHaveBeenCalledWith(newData); + expect(newAuditLog).toHaveBeenCalledWith({ name: 'Printer' }, '456', 'TestModel', null); + expect(result.alerts).toEqual(newData.alerts); + expect(result.pendingSlicerUploads).toEqual(newData.pendingSlicerUploads); + }); }); describe('editObject', () => { @@ -199,6 +220,46 @@ describe('Database Utilities (CRUD)', () => { ); expect(editNotification).not.toHaveBeenCalled(); }); + + it('omits schema hidden fields from edit audit logs and notifications', async () => { + mockModel.hiddenFields = ['alerts', 'pendingSlicerUploads']; + mockModel.lean.mockResolvedValue({ + _id: '123', + name: 'Old', + alerts: [{ type: 'info', message: 'before' }], + pendingSlicerUploads: [], + }); + + const result = await editObject({ + model: mockModel, + id: '123', + updateData: { + name: 'Updated', + alerts: [{ type: 'error', message: 'after' }], + pendingSlicerUploads: [{ file: 'file-1' }], + }, + user: { _id: 'user-1' }, + }); + + const visiblePrevious = { _id: '123', name: 'Old' }; + const visibleUpdated = { _id: '123', name: 'Updated' }; + expect(editAuditLog).toHaveBeenCalledWith( + visiblePrevious, + visibleUpdated, + '123', + 'TestModel', + { _id: 'user-1' } + ); + expect(editNotification).toHaveBeenCalledWith( + visiblePrevious, + visibleUpdated, + '123', + 'TestModel', + { _id: 'user-1' } + ); + expect(result.alerts).toEqual([{ type: 'error', message: 'after' }]); + expect(result.pendingSlicerUploads).toEqual([{ file: 'file-1' }]); + }); }); describe('deleteObject', () => { @@ -214,5 +275,31 @@ describe('Database Utilities (CRUD)', () => { expect(mockModel.findByIdAndDelete).toHaveBeenCalledWith(id); expect(result).toEqual({ deleted: true, object: mockData }); }); + + it('omits schema hidden fields from delete audit logs and notifications', async () => { + mockModel.hiddenFields = ['alerts', 'pendingSlicerUploads']; + const mockData = { + _id: '123', + name: 'To be deleted', + alerts: [{ type: 'error', message: 'hotend' }], + pendingSlicerUploads: [{ file: 'file-1' }], + }; + mockModel.findByIdAndDelete.mockResolvedValue({ + toObject: () => mockData, + }); + + const result = await deleteObject({ + model: mockModel, + id: '123', + user: { _id: 'user-1' }, + }); + + const visible = { _id: '123', name: 'To be deleted' }; + expect(deleteAuditLog).toHaveBeenCalledWith(visible, '123', 'TestModel', { _id: 'user-1' }); + expect(deleteNotification).toHaveBeenCalledWith(visible, '123', 'TestModel', { + _id: 'user-1', + }); + expect(result.object).toEqual(mockData); + }); }); }); diff --git a/src/database/database.js b/src/database/database.js index 9432135..7ac97e3 100644 --- a/src/database/database.js +++ b/src/database/database.js @@ -34,6 +34,24 @@ import { convertObjectIdStringsInFilter } from './utils.js'; const BASIC_OBJECT_FIELDS = '_id _reference name color state'; +// Schema statics such as printerSchema.statics.hiddenFields are copied onto the model. +// Drop them from snapshots that become audit logs or notifications; the stored +// document and live updates still include them. +const omitHiddenFields = (model, value) => { + const hiddenFields = model?.hiddenFields; + if ( + !Array.isArray(hiddenFields) || + hiddenFields.length === 0 || + value == null || + typeof value !== 'object' || + Array.isArray(value) + ) { + return value; + } + + return _.omit(value, hiddenFields); +}; + const logger = log4js.getLogger('Database'); logger.level = config.server.logLevel; @@ -1221,8 +1239,11 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc } } + const previousForAudit = omitHiddenFields(model, previousExpandedObject); + const updatedForAudit = omitHiddenFields(model, updatedObject); + // Audit log before update - await editAuditLog(previousExpandedObject, updatedObject, id, parentType, user); + await editAuditLog(previousForAudit, updatedForAudit, id, parentType, user); if ( user !== 'system' && @@ -1231,7 +1252,7 @@ export const editObject = async ({ model, id, updateData, user, populate, recalc parentType !== 'userNotifier' && parentType !== 'objectView' ) { - await editNotification(previousExpandedObject, updatedObject, id, parentType, user); + await editNotification(previousForAudit, updatedForAudit, id, parentType, user); } // Distribute update await distributeUpdate(updateData, id, parentType); @@ -1310,7 +1331,7 @@ export const newObject = async ( typeof result.toObject === 'function' ? result.toObject() : result.$toObject(); const created = expandObjectIds(serialized); - await newAuditLog(newData, created._id, parentType, user); + await newAuditLog(omitHiddenFields(model, newData), created._id, parentType, user); if (distributeChanges == true) { await distributeNew(created, parentType); @@ -1587,8 +1608,10 @@ export const deleteObject = async ( typeof result.toObject === 'function' ? result.toObject() : result.$toObject(); const deleted = expandObjectIds(serialized); + const deletedForAudit = omitHiddenFields(model, deleted); + // Audit log the deletion - await deleteAuditLog(deleted, id.toString(), parentType, user); + await deleteAuditLog(deletedForAudit, id.toString(), parentType, user); if ( user !== 'system' && @@ -1597,7 +1620,7 @@ export const deleteObject = async ( parentType !== 'userNotifier' && parentType !== 'objectView' ) { - await deleteNotification(deleted, id.toString(), parentType, user); + await deleteNotification(deletedForAudit, id.toString(), parentType, user); } if (distributeChanges == true) { diff --git a/src/database/schemas/production/printer.schema.js b/src/database/schemas/production/printer.schema.js index 750d003..fe5bd4b 100644 --- a/src/database/schemas/production/printer.schema.js +++ b/src/database/schemas/production/printer.schema.js @@ -101,6 +101,8 @@ const rollupConfigs = [ }, ]; +printerSchema.statics.hiddenFields = ['alerts', 'pendingSlicerUploads']; + printerSchema.statics.stats = async function () { const results = await aggregateRollups({ model: this,