piper-utils 1.1.66 → 1.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/main.js +253 -1
- package/bin/main.js.map +1 -1
- package/package.json +1 -1
- package/src/audit/audit.js +422 -0
- package/src/audit/audit.test.js +313 -0
- package/src/index.js +16 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attachAudit,
|
|
3
|
+
auditModels,
|
|
4
|
+
bindAuditRequest,
|
|
5
|
+
decrementWithAudit,
|
|
6
|
+
getAuditFilter,
|
|
7
|
+
getAuditModel,
|
|
8
|
+
incrementWithAudit,
|
|
9
|
+
modelOptions
|
|
10
|
+
} from './audit.js';
|
|
11
|
+
|
|
12
|
+
const buildEvent = ({ uid = '42', email = 'kevin@test.com', auditEnabled = true, businessIds = { '1': 'A' } } = {}) => ({
|
|
13
|
+
requestContext: {
|
|
14
|
+
authorizer: {
|
|
15
|
+
claims: {
|
|
16
|
+
'custom:UID': uid,
|
|
17
|
+
'email': email,
|
|
18
|
+
'custom:SET': JSON.stringify({ auditEnabled }),
|
|
19
|
+
'custom:AR': JSON.stringify({ businessIds })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const resetState = () => {
|
|
26
|
+
Object.keys(auditModels).forEach((k) => delete auditModels[k]);
|
|
27
|
+
Object.keys(modelOptions).forEach((k) => delete modelOptions[k]);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('audit', () => {
|
|
31
|
+
describe('bindAuditRequest', () => {
|
|
32
|
+
it('binds user from JWT claims and businessId from arg', () => {
|
|
33
|
+
resetState();
|
|
34
|
+
const Model = { name: 'Customer' };
|
|
35
|
+
const event = buildEvent({ uid: '7', email: 'kevin@test.com', auditEnabled: true });
|
|
36
|
+
|
|
37
|
+
bindAuditRequest(event, 'BIZ-1', [Model]);
|
|
38
|
+
|
|
39
|
+
expect(modelOptions['Customer'].user.username).toEqual('kevin@test.com');
|
|
40
|
+
expect(modelOptions['Customer'].user.id).toEqual(7);
|
|
41
|
+
expect(modelOptions['Customer'].businessId).toEqual('BIZ-1');
|
|
42
|
+
expect(modelOptions['Customer'].auditEnabled).toEqual(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reads auditEnabled=false from custom:SET claim', () => {
|
|
46
|
+
resetState();
|
|
47
|
+
const Model = { name: 'Customer' };
|
|
48
|
+
const event = buildEvent({ auditEnabled: false });
|
|
49
|
+
|
|
50
|
+
bindAuditRequest(event, 'BIZ-1', [Model]);
|
|
51
|
+
|
|
52
|
+
expect(modelOptions['Customer'].auditEnabled).toEqual(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('binds multiple models in one call', () => {
|
|
56
|
+
resetState();
|
|
57
|
+
const A = { name: 'ModelA' };
|
|
58
|
+
const B = { name: 'ModelB' };
|
|
59
|
+
|
|
60
|
+
bindAuditRequest(buildEvent(), 'BIZ-1', [A, B]);
|
|
61
|
+
|
|
62
|
+
expect(modelOptions['ModelA']).toBeDefined();
|
|
63
|
+
expect(modelOptions['ModelB']).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('attachAudit', () => {
|
|
68
|
+
it('defines a <Name>Audit model and wires four hooks', async () => {
|
|
69
|
+
resetState();
|
|
70
|
+
const auditModel = {
|
|
71
|
+
belongsTo: jasmine.createSpy('belongsTo'),
|
|
72
|
+
sync: jasmine.createSpy('sync').and.resolveTo()
|
|
73
|
+
};
|
|
74
|
+
const Model = {
|
|
75
|
+
name: 'Customer',
|
|
76
|
+
addHook: jasmine.createSpy('addHook'),
|
|
77
|
+
sequelize: {
|
|
78
|
+
define: jasmine.createSpy('define').and.returnValue(auditModel),
|
|
79
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await attachAudit(Model);
|
|
84
|
+
|
|
85
|
+
expect(Model.sequelize.define).toHaveBeenCalled();
|
|
86
|
+
const [name, schema, opts] = Model.sequelize.define.calls.mostRecent().args;
|
|
87
|
+
expect(name).toEqual('CustomerAudit');
|
|
88
|
+
expect(schema.changedByUser.allowNull).toEqual(false);
|
|
89
|
+
expect(schema.businessId.allowNull).toEqual(false);
|
|
90
|
+
expect(opts).toEqual({ updatedAt: false, freezeTableName: true });
|
|
91
|
+
|
|
92
|
+
expect(auditModel.belongsTo).toHaveBeenCalledWith(Model, jasmine.objectContaining({ onDelete: 'SET NULL' }));
|
|
93
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterCreate', jasmine.any(Function));
|
|
94
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterUpsert', jasmine.any(Function));
|
|
95
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterUpdate', jasmine.any(Function));
|
|
96
|
+
expect(Model.addHook).toHaveBeenCalledWith('afterDestroy', jasmine.any(Function));
|
|
97
|
+
expect(auditModel.sync).toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('is idempotent — second call is a no-op', async () => {
|
|
101
|
+
resetState();
|
|
102
|
+
const Model = {
|
|
103
|
+
name: 'Customer',
|
|
104
|
+
addHook: jasmine.createSpy('addHook'),
|
|
105
|
+
sequelize: {
|
|
106
|
+
define: jasmine.createSpy('define').and.returnValue({
|
|
107
|
+
belongsTo: () => {},
|
|
108
|
+
sync: () => Promise.resolve()
|
|
109
|
+
}),
|
|
110
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await attachAudit(Model);
|
|
115
|
+
await attachAudit(Model);
|
|
116
|
+
|
|
117
|
+
expect(Model.sequelize.define).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('audit hook (auditMe via attachAudit)', () => {
|
|
122
|
+
const buildModel = (name) => {
|
|
123
|
+
const definedAudit = { belongsTo: () => {}, sync: () => Promise.resolve() };
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
addHook: jasmine.createSpy('addHook'),
|
|
127
|
+
sequelize: {
|
|
128
|
+
define: jasmine.createSpy('define').and.returnValue(definedAudit),
|
|
129
|
+
Sequelize: { STRING: 'STRING_TYPE' }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
const captureHook = async (Model) => {
|
|
134
|
+
await attachAudit(Model);
|
|
135
|
+
const auditModel = auditModels[Model.name];
|
|
136
|
+
auditModel.create = jasmine.createSpy('create').and.resolveTo();
|
|
137
|
+
const hook = Model.addHook.calls.allArgs().find(([h]) => h === 'afterCreate')[1];
|
|
138
|
+
return { hook, auditModel };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
it('writes changedByUser from bound user, no ChangedByUserId field', async () => {
|
|
142
|
+
resetState();
|
|
143
|
+
const Model = buildModel('Widget');
|
|
144
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
145
|
+
modelOptions['Widget'] = {
|
|
146
|
+
user: { username: 'kevin@test.com', id: 7 },
|
|
147
|
+
businessId: 'BIZ-1',
|
|
148
|
+
auditEnabled: true
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
await hook(
|
|
152
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
153
|
+
{ type: 'INITIAL' }
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const writtenRow = auditModel.create.calls.mostRecent().args[0];
|
|
157
|
+
expect(writtenRow.changedByUser).toEqual('kevin@test.com');
|
|
158
|
+
expect(writtenRow.businessId).toEqual('BIZ-1');
|
|
159
|
+
expect(writtenRow.WidgetId).toEqual(99);
|
|
160
|
+
expect(writtenRow.field).toEqual('name');
|
|
161
|
+
expect(writtenRow.valueOld).toEqual('old');
|
|
162
|
+
expect(writtenRow.valueNew).toEqual('new');
|
|
163
|
+
expect(writtenRow.ChangedByUserId).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('skips audit when auditEnabled is false for that model', async () => {
|
|
167
|
+
resetState();
|
|
168
|
+
const Model = buildModel('Widget');
|
|
169
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
170
|
+
modelOptions['Widget'] = {
|
|
171
|
+
user: { username: 'kevin@test.com', id: 7 },
|
|
172
|
+
businessId: 'BIZ-1',
|
|
173
|
+
auditEnabled: false
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
await hook(
|
|
177
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
178
|
+
{ type: 'INITIAL' }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(auditModel.create).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('skips audit when no per-request bind exists for the model', async () => {
|
|
185
|
+
resetState();
|
|
186
|
+
const Model = buildModel('Widget');
|
|
187
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
188
|
+
|
|
189
|
+
await hook(
|
|
190
|
+
{ _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
|
|
191
|
+
{ type: 'INITIAL' }
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(auditModel.create).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('per-model isolation — binding model A does not enable audit for model B', async () => {
|
|
198
|
+
resetState();
|
|
199
|
+
const ModelA = buildModel('A');
|
|
200
|
+
const ModelB = buildModel('B');
|
|
201
|
+
const { hook: hookA, auditModel: auditA } = await captureHook(ModelA);
|
|
202
|
+
const { hook: hookB, auditModel: auditB } = await captureHook(ModelB);
|
|
203
|
+
modelOptions['A'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
204
|
+
|
|
205
|
+
await hookA({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
|
|
206
|
+
await hookB({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
|
|
207
|
+
|
|
208
|
+
expect(auditA.create).toHaveBeenCalled();
|
|
209
|
+
expect(auditB.create).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('masks sensitive fields inside array values', async () => {
|
|
213
|
+
resetState();
|
|
214
|
+
const Model = buildModel('Widget');
|
|
215
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
216
|
+
modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
217
|
+
|
|
218
|
+
await hook(
|
|
219
|
+
{
|
|
220
|
+
_changed: ['tokens'],
|
|
221
|
+
_previousDataValues: { tokens: [{ token: 'secret-old' }] },
|
|
222
|
+
dataValues: { id: 1, tokens: [{ token: 'secret-new' }] }
|
|
223
|
+
},
|
|
224
|
+
{}
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const row = auditModel.create.calls.mostRecent().args[0];
|
|
228
|
+
expect(row.valueOld).not.toContain('secret-old');
|
|
229
|
+
expect(row.valueNew).not.toContain('secret-new');
|
|
230
|
+
expect(row.valueOld).toContain('***************');
|
|
231
|
+
expect(row.valueNew).toContain('***************');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('passes the transaction through to auditModel.create when present', async () => {
|
|
235
|
+
resetState();
|
|
236
|
+
const Model = buildModel('Widget');
|
|
237
|
+
const { hook, auditModel } = await captureHook(Model);
|
|
238
|
+
modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
239
|
+
|
|
240
|
+
await hook(
|
|
241
|
+
{ _changed: ['name'], _previousDataValues: { name: 'a' }, dataValues: { id: 1, name: 'b' } },
|
|
242
|
+
{ transaction: 'TX-1' }
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX-1' });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('incrementWithAudit / decrementWithAudit', () => {
|
|
250
|
+
it('increments and writes an audit row using bound context', async () => {
|
|
251
|
+
resetState();
|
|
252
|
+
const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
|
|
253
|
+
auditModels['Inv'] = auditModel;
|
|
254
|
+
modelOptions['Inv'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
255
|
+
|
|
256
|
+
const incremented = {
|
|
257
|
+
_previousDataValues: { qty: 5 },
|
|
258
|
+
dataValues: { id: 10, qty: 6 }
|
|
259
|
+
};
|
|
260
|
+
const model = { increment: jasmine.createSpy('increment').and.resolveTo(incremented) };
|
|
261
|
+
|
|
262
|
+
const r = await incrementWithAudit('Inv', model, 'qty', { by: 1, transaction: 'TX' });
|
|
263
|
+
|
|
264
|
+
expect(model.increment).toHaveBeenCalledWith('qty', { by: 1, transaction: 'TX' });
|
|
265
|
+
expect(r._changed).toEqual(['qty']);
|
|
266
|
+
expect(auditModel.create).toHaveBeenCalled();
|
|
267
|
+
expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX' });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('decrements and writes an audit row', async () => {
|
|
271
|
+
resetState();
|
|
272
|
+
const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
|
|
273
|
+
auditModels['Rel'] = auditModel;
|
|
274
|
+
modelOptions['Rel'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
|
|
275
|
+
|
|
276
|
+
const decremented = {
|
|
277
|
+
_previousDataValues: { qty: 6 },
|
|
278
|
+
dataValues: { id: 11, qty: 5 }
|
|
279
|
+
};
|
|
280
|
+
const model = { decrement: jasmine.createSpy('decrement').and.resolveTo(decremented) };
|
|
281
|
+
|
|
282
|
+
const r = await decrementWithAudit('Rel', model, 'qty', { by: 1 });
|
|
283
|
+
|
|
284
|
+
expect(model.decrement).toHaveBeenCalledWith('qty', { by: 1 });
|
|
285
|
+
expect(r._changed).toEqual(['qty']);
|
|
286
|
+
expect(auditModel.create).toHaveBeenCalled();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('getAuditModel / getAuditFilter', () => {
|
|
291
|
+
it('returns the audit model registered for a parent', () => {
|
|
292
|
+
resetState();
|
|
293
|
+
const fakeAudit = { name: 'WidgetAudit' };
|
|
294
|
+
auditModels['Widget'] = fakeAudit;
|
|
295
|
+
expect(getAuditModel({ name: 'Widget' })).toBe(fakeAudit);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('builds a where filter scoped to caller businessIds with parent FK', () => {
|
|
299
|
+
resetState();
|
|
300
|
+
const Widget = { name: 'Widget' };
|
|
301
|
+
const event = buildEvent({ businessIds: { 'B1': 'A', 'B2': 'R' } });
|
|
302
|
+
|
|
303
|
+
const filter = getAuditFilter(Widget, 42, { event, offset: 0, limit: 10 });
|
|
304
|
+
|
|
305
|
+
expect(filter.where.WidgetId).toEqual(42);
|
|
306
|
+
expect(filter.where.businessId).toBeDefined();
|
|
307
|
+
expect(filter.include[0].model).toBe(Widget);
|
|
308
|
+
expect(filter.order).toEqual([['createdAt', 'DESC']]);
|
|
309
|
+
expect(filter.offset).toEqual(0);
|
|
310
|
+
expect(filter.limit).toEqual(10);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,15 @@ import { requireCrmAccess as requireCrmAccessImport, requireTicketAccess as requ
|
|
|
34
34
|
import { scopeToOwnBusiness as scopeToOwnBusinessImport, scopeToPartnerBook as scopeToPartnerBookImport, scopeToBookUnionOwn as scopeToBookUnionOwnImport } from './database/dbUtils/partnerAccess/accessScope.js';
|
|
35
35
|
import { assertCanWriteOwnBusiness as assertCanWriteOwnBusinessImport, assertCanWriteBookBusiness as assertCanWriteBookBusinessImport, assertCanWriteBookUnionOwn as assertCanWriteBookUnionOwnImport, stampOwnBusinessId as stampOwnBusinessIdImport } from './database/dbUtils/partnerAccess/accessWrites.js';
|
|
36
36
|
import { createAccessHelpers as createAccessHelpersImport } from './database/dbUtils/partnerAccess/createAccessHelpers.js';
|
|
37
|
+
import {
|
|
38
|
+
attachAudit as attachAuditImport,
|
|
39
|
+
bindAuditRequest as bindAuditRequestImport,
|
|
40
|
+
bindAuditRequestForUser as bindAuditRequestForUserImport,
|
|
41
|
+
decrementWithAudit as decrementWithAuditImport,
|
|
42
|
+
getAuditFilter as getAuditFilterImport,
|
|
43
|
+
getAuditModel as getAuditModelImport,
|
|
44
|
+
incrementWithAudit as incrementWithAuditImport
|
|
45
|
+
} from './audit/audit.js';
|
|
37
46
|
|
|
38
47
|
export const handleFile = handleFileImport;
|
|
39
48
|
export const watchBucket = watchBucketImport;
|
|
@@ -80,3 +89,10 @@ export const assertCanWriteBookBusiness = assertCanWriteBookBusinessImport;
|
|
|
80
89
|
export const assertCanWriteBookUnionOwn = assertCanWriteBookUnionOwnImport;
|
|
81
90
|
export const stampOwnBusinessId = stampOwnBusinessIdImport;
|
|
82
91
|
export const createAccessHelpers = createAccessHelpersImport;
|
|
92
|
+
export const attachAudit = attachAuditImport;
|
|
93
|
+
export const bindAuditRequest = bindAuditRequestImport;
|
|
94
|
+
export const bindAuditRequestForUser = bindAuditRequestForUserImport;
|
|
95
|
+
export const decrementWithAudit = decrementWithAuditImport;
|
|
96
|
+
export const getAuditFilter = getAuditFilterImport;
|
|
97
|
+
export const getAuditModel = getAuditModelImport;
|
|
98
|
+
export const incrementWithAudit = incrementWithAuditImport;
|