Implement hidden fields omission in audit logs and notifications for database operations
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

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.
This commit is contained in:
Tom Butcher 2026-11-07 22:35:50 +00:00
parent fd219700f8
commit 1d8fcdd831
3 changed files with 118 additions and 6 deletions

View File

@ -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);
});
});
});

View File

@ -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) {

View File

@ -101,6 +101,8 @@ const rollupConfigs = [
},
];
printerSchema.statics.hiddenFields = ['alerts', 'pendingSlicerUploads'];
printerSchema.statics.stats = async function () {
const results = await aggregateRollups({
model: this,