piper-utils 1.1.66 → 1.1.68

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.
@@ -0,0 +1,342 @@
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
+ // Pre-populate auditModels so attachAudit's idempotency check skips the
33
+ // sequelize wiring; these tests focus on the bind/state behavior only.
34
+ const preAttachedModel = (name) => {
35
+ auditModels[name] = { create: () => Promise.resolve() };
36
+ return { name };
37
+ };
38
+
39
+ it('binds user from JWT claims and businessId from arg', () => {
40
+ resetState();
41
+ const Model = preAttachedModel('Customer');
42
+ const event = buildEvent({ uid: '7', email: 'kevin@test.com', auditEnabled: true });
43
+
44
+ bindAuditRequest(event, 'BIZ-1', [Model]);
45
+
46
+ expect(modelOptions['Customer'].user.username).toEqual('kevin@test.com');
47
+ expect(modelOptions['Customer'].user.id).toEqual(7);
48
+ expect(modelOptions['Customer'].businessId).toEqual('BIZ-1');
49
+ expect(modelOptions['Customer'].auditEnabled).toEqual(true);
50
+ });
51
+
52
+ it('reads auditEnabled=false from custom:SET claim', () => {
53
+ resetState();
54
+ const Model = preAttachedModel('Customer');
55
+ const event = buildEvent({ auditEnabled: false });
56
+
57
+ bindAuditRequest(event, 'BIZ-1', [Model]);
58
+
59
+ expect(modelOptions['Customer'].auditEnabled).toEqual(false);
60
+ });
61
+
62
+ it('binds multiple models in one call', () => {
63
+ resetState();
64
+ const A = preAttachedModel('ModelA');
65
+ const B = preAttachedModel('ModelB');
66
+
67
+ bindAuditRequest(buildEvent(), 'BIZ-1', [A, B]);
68
+
69
+ expect(modelOptions['ModelA']).toBeDefined();
70
+ expect(modelOptions['ModelB']).toBeDefined();
71
+ });
72
+
73
+ it('lazily calls attachAudit when the model is not yet registered (cold-start regression)', () => {
74
+ resetState();
75
+ const definedAudit = { belongsTo: jasmine.createSpy('belongsTo'), sync: jasmine.createSpy('sync').and.resolveTo() };
76
+ const Model = {
77
+ name: 'Widget',
78
+ addHook: jasmine.createSpy('addHook'),
79
+ sequelize: {
80
+ define: jasmine.createSpy('define').and.returnValue(definedAudit),
81
+ Sequelize: { STRING: 'STRING_TYPE' }
82
+ }
83
+ };
84
+
85
+ bindAuditRequest(buildEvent(), 'BIZ-1', [Model]);
86
+
87
+ // attachAudit ran: hooks wired, model registered
88
+ expect(Model.sequelize.define).toHaveBeenCalled();
89
+ expect(Model.addHook).toHaveBeenCalledWith('afterCreate', jasmine.any(Function));
90
+ expect(auditModels['Widget']).toBeDefined();
91
+ // and the per-request state is set
92
+ expect(modelOptions['Widget']).toBeDefined();
93
+ });
94
+ });
95
+
96
+ describe('attachAudit', () => {
97
+ it('defines a <Name>Audit model and wires four hooks', async () => {
98
+ resetState();
99
+ const auditModel = {
100
+ belongsTo: jasmine.createSpy('belongsTo'),
101
+ sync: jasmine.createSpy('sync').and.resolveTo()
102
+ };
103
+ const Model = {
104
+ name: 'Customer',
105
+ addHook: jasmine.createSpy('addHook'),
106
+ sequelize: {
107
+ define: jasmine.createSpy('define').and.returnValue(auditModel),
108
+ Sequelize: { STRING: 'STRING_TYPE' }
109
+ }
110
+ };
111
+
112
+ await attachAudit(Model);
113
+
114
+ expect(Model.sequelize.define).toHaveBeenCalled();
115
+ const [name, schema, opts] = Model.sequelize.define.calls.mostRecent().args;
116
+ expect(name).toEqual('CustomerAudit');
117
+ expect(schema.changedByUser.allowNull).toEqual(false);
118
+ expect(schema.businessId.allowNull).toEqual(false);
119
+ expect(opts).toEqual({ updatedAt: false, freezeTableName: true });
120
+
121
+ expect(auditModel.belongsTo).toHaveBeenCalledWith(Model, jasmine.objectContaining({ onDelete: 'SET NULL' }));
122
+ expect(Model.addHook).toHaveBeenCalledWith('afterCreate', jasmine.any(Function));
123
+ expect(Model.addHook).toHaveBeenCalledWith('afterUpsert', jasmine.any(Function));
124
+ expect(Model.addHook).toHaveBeenCalledWith('afterUpdate', jasmine.any(Function));
125
+ expect(Model.addHook).toHaveBeenCalledWith('afterDestroy', jasmine.any(Function));
126
+ expect(auditModel.sync).toHaveBeenCalled();
127
+ });
128
+
129
+ it('is idempotent — second call is a no-op', async () => {
130
+ resetState();
131
+ const Model = {
132
+ name: 'Customer',
133
+ addHook: jasmine.createSpy('addHook'),
134
+ sequelize: {
135
+ define: jasmine.createSpy('define').and.returnValue({
136
+ belongsTo: () => {},
137
+ sync: () => Promise.resolve()
138
+ }),
139
+ Sequelize: { STRING: 'STRING_TYPE' }
140
+ }
141
+ };
142
+
143
+ await attachAudit(Model);
144
+ await attachAudit(Model);
145
+
146
+ expect(Model.sequelize.define).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
149
+
150
+ describe('audit hook (auditMe via attachAudit)', () => {
151
+ const buildModel = (name) => {
152
+ const definedAudit = { belongsTo: () => {}, sync: () => Promise.resolve() };
153
+ return {
154
+ name,
155
+ addHook: jasmine.createSpy('addHook'),
156
+ sequelize: {
157
+ define: jasmine.createSpy('define').and.returnValue(definedAudit),
158
+ Sequelize: { STRING: 'STRING_TYPE' }
159
+ }
160
+ };
161
+ };
162
+ const captureHook = async (Model) => {
163
+ await attachAudit(Model);
164
+ const auditModel = auditModels[Model.name];
165
+ auditModel.create = jasmine.createSpy('create').and.resolveTo();
166
+ const hook = Model.addHook.calls.allArgs().find(([h]) => h === 'afterCreate')[1];
167
+ return { hook, auditModel };
168
+ };
169
+
170
+ it('writes changedByUser from bound user, no ChangedByUserId field', async () => {
171
+ resetState();
172
+ const Model = buildModel('Widget');
173
+ const { hook, auditModel } = await captureHook(Model);
174
+ modelOptions['Widget'] = {
175
+ user: { username: 'kevin@test.com', id: 7 },
176
+ businessId: 'BIZ-1',
177
+ auditEnabled: true
178
+ };
179
+
180
+ await hook(
181
+ { _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
182
+ { type: 'INITIAL' }
183
+ );
184
+
185
+ const writtenRow = auditModel.create.calls.mostRecent().args[0];
186
+ expect(writtenRow.changedByUser).toEqual('kevin@test.com');
187
+ expect(writtenRow.businessId).toEqual('BIZ-1');
188
+ expect(writtenRow.WidgetId).toEqual(99);
189
+ expect(writtenRow.field).toEqual('name');
190
+ expect(writtenRow.valueOld).toEqual('old');
191
+ expect(writtenRow.valueNew).toEqual('new');
192
+ expect(writtenRow.ChangedByUserId).toBeUndefined();
193
+ });
194
+
195
+ it('skips audit when auditEnabled is false for that model', async () => {
196
+ resetState();
197
+ const Model = buildModel('Widget');
198
+ const { hook, auditModel } = await captureHook(Model);
199
+ modelOptions['Widget'] = {
200
+ user: { username: 'kevin@test.com', id: 7 },
201
+ businessId: 'BIZ-1',
202
+ auditEnabled: false
203
+ };
204
+
205
+ await hook(
206
+ { _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
207
+ { type: 'INITIAL' }
208
+ );
209
+
210
+ expect(auditModel.create).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('skips audit when no per-request bind exists for the model', async () => {
214
+ resetState();
215
+ const Model = buildModel('Widget');
216
+ const { hook, auditModel } = await captureHook(Model);
217
+
218
+ await hook(
219
+ { _changed: ['name'], _previousDataValues: { name: 'old' }, dataValues: { id: 99, name: 'new' } },
220
+ { type: 'INITIAL' }
221
+ );
222
+
223
+ expect(auditModel.create).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it('per-model isolation — binding model A does not enable audit for model B', async () => {
227
+ resetState();
228
+ const ModelA = buildModel('A');
229
+ const ModelB = buildModel('B');
230
+ const { hook: hookA, auditModel: auditA } = await captureHook(ModelA);
231
+ const { hook: hookB, auditModel: auditB } = await captureHook(ModelB);
232
+ modelOptions['A'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
233
+
234
+ await hookA({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
235
+ await hookB({ _changed: ['x'], _previousDataValues: { x: '1' }, dataValues: { id: 1, x: '2' } }, {});
236
+
237
+ expect(auditA.create).toHaveBeenCalled();
238
+ expect(auditB.create).not.toHaveBeenCalled();
239
+ });
240
+
241
+ it('masks sensitive fields inside array values', async () => {
242
+ resetState();
243
+ const Model = buildModel('Widget');
244
+ const { hook, auditModel } = await captureHook(Model);
245
+ modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
246
+
247
+ await hook(
248
+ {
249
+ _changed: ['tokens'],
250
+ _previousDataValues: { tokens: [{ token: 'secret-old' }] },
251
+ dataValues: { id: 1, tokens: [{ token: 'secret-new' }] }
252
+ },
253
+ {}
254
+ );
255
+
256
+ const row = auditModel.create.calls.mostRecent().args[0];
257
+ expect(row.valueOld).not.toContain('secret-old');
258
+ expect(row.valueNew).not.toContain('secret-new');
259
+ expect(row.valueOld).toContain('***************');
260
+ expect(row.valueNew).toContain('***************');
261
+ });
262
+
263
+ it('passes the transaction through to auditModel.create when present', async () => {
264
+ resetState();
265
+ const Model = buildModel('Widget');
266
+ const { hook, auditModel } = await captureHook(Model);
267
+ modelOptions['Widget'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
268
+
269
+ await hook(
270
+ { _changed: ['name'], _previousDataValues: { name: 'a' }, dataValues: { id: 1, name: 'b' } },
271
+ { transaction: 'TX-1' }
272
+ );
273
+
274
+ expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX-1' });
275
+ });
276
+ });
277
+
278
+ describe('incrementWithAudit / decrementWithAudit', () => {
279
+ it('increments and writes an audit row using bound context', async () => {
280
+ resetState();
281
+ const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
282
+ auditModels['Inv'] = auditModel;
283
+ modelOptions['Inv'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
284
+
285
+ const incremented = {
286
+ _previousDataValues: { qty: 5 },
287
+ dataValues: { id: 10, qty: 6 }
288
+ };
289
+ const model = { increment: jasmine.createSpy('increment').and.resolveTo(incremented) };
290
+
291
+ const r = await incrementWithAudit('Inv', model, 'qty', { by: 1, transaction: 'TX' });
292
+
293
+ expect(model.increment).toHaveBeenCalledWith('qty', { by: 1, transaction: 'TX' });
294
+ expect(r._changed).toEqual(['qty']);
295
+ expect(auditModel.create).toHaveBeenCalled();
296
+ expect(auditModel.create.calls.mostRecent().args[1]).toEqual({ transaction: 'TX' });
297
+ });
298
+
299
+ it('decrements and writes an audit row', async () => {
300
+ resetState();
301
+ const auditModel = { create: jasmine.createSpy('create').and.resolveTo() };
302
+ auditModels['Rel'] = auditModel;
303
+ modelOptions['Rel'] = { user: { username: 'k', id: 1 }, businessId: 'B1', auditEnabled: true };
304
+
305
+ const decremented = {
306
+ _previousDataValues: { qty: 6 },
307
+ dataValues: { id: 11, qty: 5 }
308
+ };
309
+ const model = { decrement: jasmine.createSpy('decrement').and.resolveTo(decremented) };
310
+
311
+ const r = await decrementWithAudit('Rel', model, 'qty', { by: 1 });
312
+
313
+ expect(model.decrement).toHaveBeenCalledWith('qty', { by: 1 });
314
+ expect(r._changed).toEqual(['qty']);
315
+ expect(auditModel.create).toHaveBeenCalled();
316
+ });
317
+ });
318
+
319
+ describe('getAuditModel / getAuditFilter', () => {
320
+ it('returns the audit model registered for a parent', () => {
321
+ resetState();
322
+ const fakeAudit = { name: 'WidgetAudit' };
323
+ auditModels['Widget'] = fakeAudit;
324
+ expect(getAuditModel({ name: 'Widget' })).toBe(fakeAudit);
325
+ });
326
+
327
+ it('builds a where filter scoped to caller businessIds with parent FK', () => {
328
+ resetState();
329
+ const Widget = { name: 'Widget' };
330
+ const event = buildEvent({ businessIds: { 'B1': 'A', 'B2': 'R' } });
331
+
332
+ const filter = getAuditFilter(Widget, 42, { event, offset: 0, limit: 10 });
333
+
334
+ expect(filter.where.WidgetId).toEqual(42);
335
+ expect(filter.where.businessId).toBeDefined();
336
+ expect(filter.include[0].model).toBe(Widget);
337
+ expect(filter.order).toEqual([['createdAt', 'DESC']]);
338
+ expect(filter.offset).toEqual(0);
339
+ expect(filter.limit).toEqual(10);
340
+ });
341
+ });
342
+ });
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;