@geekmidas/audit 0.0.1

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.
Files changed (47) hide show
  1. package/TECHNICAL.md +937 -0
  2. package/dist/Auditor-CZ8lkASv.d.cts +260 -0
  3. package/dist/Auditor-ii62d_pF.d.mts +260 -0
  4. package/dist/Auditor.cjs +0 -0
  5. package/dist/Auditor.d.cts +2 -0
  6. package/dist/Auditor.d.mts +2 -0
  7. package/dist/Auditor.mjs +0 -0
  8. package/dist/DefaultAuditor-1HDUGMub.cjs +124 -0
  9. package/dist/DefaultAuditor-1HDUGMub.cjs.map +1 -0
  10. package/dist/DefaultAuditor-B9Unin1g.d.mts +59 -0
  11. package/dist/DefaultAuditor-BAVnNmRh.mjs +96 -0
  12. package/dist/DefaultAuditor-BAVnNmRh.mjs.map +1 -0
  13. package/dist/DefaultAuditor-Dqc4UZA1.d.cts +59 -0
  14. package/dist/DefaultAuditor.cjs +3 -0
  15. package/dist/DefaultAuditor.d.cts +4 -0
  16. package/dist/DefaultAuditor.d.mts +4 -0
  17. package/dist/DefaultAuditor.mjs +3 -0
  18. package/dist/index.cjs +3 -0
  19. package/dist/index.d.cts +4 -0
  20. package/dist/index.d.mts +4 -0
  21. package/dist/index.mjs +3 -0
  22. package/dist/kysely.cjs +193 -0
  23. package/dist/kysely.cjs.map +1 -0
  24. package/dist/kysely.d.cts +165 -0
  25. package/dist/kysely.d.mts +165 -0
  26. package/dist/kysely.mjs +191 -0
  27. package/dist/kysely.mjs.map +1 -0
  28. package/dist/storage-BtFY7Rha.d.cts +102 -0
  29. package/dist/storage-DnAMfOA7.d.mts +102 -0
  30. package/dist/storage.cjs +0 -0
  31. package/dist/storage.d.cts +3 -0
  32. package/dist/storage.d.mts +3 -0
  33. package/dist/storage.mjs +0 -0
  34. package/dist/types.cjs +0 -0
  35. package/dist/types.d.cts +2 -0
  36. package/dist/types.d.mts +2 -0
  37. package/dist/types.mjs +0 -0
  38. package/package.json +34 -0
  39. package/src/Auditor.ts +157 -0
  40. package/src/DefaultAuditor.ts +141 -0
  41. package/src/__tests__/DefaultAuditor.spec.ts +420 -0
  42. package/src/__tests__/KyselyAuditStorage.integration.spec.ts +517 -0
  43. package/src/__tests__/KyselyAuditStorage.spec.ts +359 -0
  44. package/src/index.ts +23 -0
  45. package/src/kysely.ts +391 -0
  46. package/src/storage.ts +101 -0
  47. package/src/types.ts +147 -0
@@ -0,0 +1,517 @@
1
+ import {
2
+ CamelCasePlugin,
3
+ type Generated,
4
+ Kysely,
5
+ PostgresDialect,
6
+ sql,
7
+ } from 'kysely';
8
+ import pg from 'pg';
9
+ import {
10
+ afterAll,
11
+ afterEach,
12
+ beforeAll,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ it,
17
+ } from 'vitest';
18
+ import { TEST_DATABASE_CONFIG } from '../../../testkit/test/globalSetup';
19
+ import { DefaultAuditor } from '../DefaultAuditor';
20
+ import { KyselyAuditStorage, type AuditLogTable } from '../kysely';
21
+ import type { AuditableAction } from '../types';
22
+
23
+ interface TestDatabase {
24
+ auditLogs: AuditLogTable;
25
+ users: {
26
+ id: Generated<number>;
27
+ name: string;
28
+ email: string;
29
+ };
30
+ }
31
+
32
+ // Define test audit actions
33
+ type TestAuditAction =
34
+ | AuditableAction<'user.created', { userId: number; email: string }>
35
+ | AuditableAction<'user.updated', { userId: number; changes: string[] }>
36
+ | AuditableAction<'user.deleted', { userId: number }>;
37
+
38
+ describe('KyselyAuditStorage Integration Tests', () => {
39
+ let db: Kysely<TestDatabase>;
40
+ let storage: KyselyAuditStorage<TestDatabase>;
41
+
42
+ beforeAll(async () => {
43
+ db = new Kysely<TestDatabase>({
44
+ dialect: new PostgresDialect({
45
+ pool: new pg.Pool({
46
+ ...TEST_DATABASE_CONFIG,
47
+ database: 'postgres',
48
+ }),
49
+ }),
50
+ plugins: [new CamelCasePlugin()],
51
+ });
52
+
53
+ // Create audit_logs table
54
+ await db.schema
55
+ .createTable('auditLogs')
56
+ .ifNotExists()
57
+ .addColumn('id', 'varchar(32)', (col) => col.primaryKey())
58
+ .addColumn('type', 'varchar', (col) => col.notNull())
59
+ .addColumn('operation', 'varchar', (col) => col.notNull())
60
+ .addColumn('table', 'varchar')
61
+ .addColumn('entityId', 'varchar')
62
+ .addColumn('oldValues', 'jsonb')
63
+ .addColumn('newValues', 'jsonb')
64
+ .addColumn('payload', 'jsonb')
65
+ .addColumn('timestamp', 'timestamp', (col) =>
66
+ col.defaultTo(sql`now()`).notNull(),
67
+ )
68
+ .addColumn('actorId', 'varchar')
69
+ .addColumn('actorType', 'varchar')
70
+ .addColumn('actorData', 'jsonb')
71
+ .addColumn('metadata', 'jsonb')
72
+ .execute();
73
+
74
+ // Create users table for testing audit integration
75
+ await db.schema
76
+ .createTable('users')
77
+ .ifNotExists()
78
+ .addColumn('id', 'serial', (col) => col.primaryKey())
79
+ .addColumn('name', 'varchar', (col) => col.notNull())
80
+ .addColumn('email', 'varchar', (col) => col.notNull().unique())
81
+ .execute();
82
+
83
+ storage = new KyselyAuditStorage({
84
+ db,
85
+ tableName: 'auditLogs',
86
+ });
87
+ });
88
+
89
+ afterEach(async () => {
90
+ // Clean up data after each test
91
+ await db.deleteFrom('auditLogs').execute();
92
+ await db.deleteFrom('users').execute();
93
+ });
94
+
95
+ afterAll(async () => {
96
+ // Drop tables and close connection
97
+ await db.schema.dropTable('auditLogs').ifExists().execute();
98
+ await db.schema.dropTable('users').ifExists().execute();
99
+ await db.destroy();
100
+ });
101
+
102
+ describe('write', () => {
103
+ it('should write audit records to database', async () => {
104
+ const auditor = new DefaultAuditor<TestAuditAction>({
105
+ actor: { id: 'user-123', type: 'user' },
106
+ storage,
107
+ metadata: { requestId: 'req-456' },
108
+ });
109
+
110
+ auditor.audit('user.created', { userId: 1, email: 'test@example.com' });
111
+
112
+ await auditor.flush();
113
+
114
+ // Verify record was written
115
+ const records = await db
116
+ .selectFrom('auditLogs')
117
+ .selectAll()
118
+ .execute();
119
+
120
+ expect(records).toHaveLength(1);
121
+ expect(records[0].type).toBe('user.created');
122
+ expect(records[0].actorId).toBe('user-123');
123
+ expect(records[0].actorType).toBe('user');
124
+ expect(records[0].payload).toEqual({
125
+ userId: 1,
126
+ email: 'test@example.com',
127
+ });
128
+ expect(records[0].metadata).toEqual({
129
+ requestId: 'req-456',
130
+ });
131
+ });
132
+
133
+ it('should write multiple audit records in batch', async () => {
134
+ const auditor = new DefaultAuditor<TestAuditAction>({
135
+ actor: { id: 'admin-1', type: 'admin' },
136
+ storage,
137
+ });
138
+
139
+ auditor.audit('user.created', { userId: 1, email: 'user1@example.com' });
140
+ auditor.audit('user.created', { userId: 2, email: 'user2@example.com' });
141
+ auditor.audit('user.updated', { userId: 1, changes: ['name'] });
142
+
143
+ await auditor.flush();
144
+
145
+ const records = await db
146
+ .selectFrom('auditLogs')
147
+ .selectAll()
148
+ .orderBy('timestamp', 'asc')
149
+ .execute();
150
+
151
+ expect(records).toHaveLength(3);
152
+ expect(records.map((r) => r.type)).toEqual([
153
+ 'user.created',
154
+ 'user.created',
155
+ 'user.updated',
156
+ ]);
157
+ });
158
+
159
+ it('should write audit records within transaction', async () => {
160
+ const auditor = new DefaultAuditor<TestAuditAction>({
161
+ actor: { id: 'user-123', type: 'user' },
162
+ storage,
163
+ });
164
+
165
+ await db.transaction().execute(async (trx) => {
166
+ // Insert user
167
+ const user = await trx
168
+ .insertInto('users')
169
+ .values({ name: 'Test User', email: 'test@example.com' })
170
+ .returningAll()
171
+ .executeTakeFirstOrThrow();
172
+
173
+ // Audit the creation
174
+ auditor.audit(
175
+ 'user.created',
176
+ { userId: user.id, email: user.email },
177
+ { entityId: String(user.id), table: 'users', operation: 'INSERT' },
178
+ );
179
+
180
+ // Flush within transaction
181
+ await auditor.flush(trx);
182
+ });
183
+
184
+ // Verify both user and audit record exist
185
+ const users = await db.selectFrom('users').selectAll().execute();
186
+ const audits = await db.selectFrom('auditLogs').selectAll().execute();
187
+
188
+ expect(users).toHaveLength(1);
189
+ expect(audits).toHaveLength(1);
190
+ expect(audits[0].entityId).toBe(String(users[0].id));
191
+ expect(audits[0].table).toBe('users');
192
+ expect(audits[0].operation).toBe('INSERT');
193
+ });
194
+
195
+ it('should rollback audit records when transaction fails', async () => {
196
+ const auditor = new DefaultAuditor<TestAuditAction>({
197
+ actor: { id: 'user-123', type: 'user' },
198
+ storage,
199
+ });
200
+
201
+ const transactionPromise = db.transaction().execute(async (trx) => {
202
+ // Insert user
203
+ const user = await trx
204
+ .insertInto('users')
205
+ .values({ name: 'Rollback User', email: 'rollback@example.com' })
206
+ .returningAll()
207
+ .executeTakeFirstOrThrow();
208
+
209
+ // Audit the creation
210
+ auditor.audit('user.created', { userId: user.id, email: user.email });
211
+
212
+ // Flush within transaction
213
+ await auditor.flush(trx);
214
+
215
+ // Throw error to rollback
216
+ throw new Error('Transaction should rollback');
217
+ });
218
+
219
+ await expect(transactionPromise).rejects.toThrow(
220
+ 'Transaction should rollback',
221
+ );
222
+
223
+ // Verify both user and audit record were rolled back
224
+ const users = await db.selectFrom('users').selectAll().execute();
225
+ const audits = await db.selectFrom('auditLogs').selectAll().execute();
226
+
227
+ expect(users).toHaveLength(0);
228
+ expect(audits).toHaveLength(0);
229
+ });
230
+ });
231
+
232
+ describe('query', () => {
233
+ beforeEach(async () => {
234
+ // Insert test audit records
235
+ const auditor1 = new DefaultAuditor<TestAuditAction>({
236
+ actor: { id: 'user-1', type: 'user' },
237
+ storage,
238
+ metadata: { endpoint: '/users' },
239
+ });
240
+
241
+ const auditor2 = new DefaultAuditor<TestAuditAction>({
242
+ actor: { id: 'admin-1', type: 'admin' },
243
+ storage,
244
+ metadata: { endpoint: '/admin/users' },
245
+ });
246
+
247
+ auditor1.audit(
248
+ 'user.created',
249
+ { userId: 1, email: 'user1@example.com' },
250
+ { entityId: '1', table: 'users' },
251
+ );
252
+ auditor1.audit(
253
+ 'user.updated',
254
+ { userId: 1, changes: ['name'] },
255
+ { entityId: '1', table: 'users' },
256
+ );
257
+ auditor2.audit(
258
+ 'user.deleted',
259
+ { userId: 2 },
260
+ { entityId: '2', table: 'users' },
261
+ );
262
+
263
+ await auditor1.flush();
264
+ await auditor2.flush();
265
+ });
266
+
267
+ it('should query all records', async () => {
268
+ const records = await storage.query({});
269
+
270
+ expect(records).toHaveLength(3);
271
+ });
272
+
273
+ it('should filter by type', async () => {
274
+ const records = await storage.query({ type: 'user.created' });
275
+
276
+ expect(records).toHaveLength(1);
277
+ expect(records[0].type).toBe('user.created');
278
+ });
279
+
280
+ it('should filter by multiple types', async () => {
281
+ const records = await storage.query({
282
+ type: ['user.created', 'user.updated'],
283
+ });
284
+
285
+ expect(records).toHaveLength(2);
286
+ });
287
+
288
+ it('should filter by actorId', async () => {
289
+ const records = await storage.query({ actorId: 'admin-1' });
290
+
291
+ expect(records).toHaveLength(1);
292
+ expect(records[0].type).toBe('user.deleted');
293
+ });
294
+
295
+ it('should filter by entityId', async () => {
296
+ const records = await storage.query({ entityId: '1' });
297
+
298
+ expect(records).toHaveLength(2);
299
+ });
300
+
301
+ it('should filter by table', async () => {
302
+ const records = await storage.query({ table: 'users' });
303
+
304
+ expect(records).toHaveLength(3);
305
+ });
306
+
307
+ it('should apply pagination', async () => {
308
+ const page1 = await storage.query({ limit: 2, offset: 0 });
309
+ const page2 = await storage.query({ limit: 2, offset: 2 });
310
+
311
+ expect(page1).toHaveLength(2);
312
+ expect(page2).toHaveLength(1);
313
+ });
314
+
315
+ it('should order by timestamp descending by default', async () => {
316
+ const records = await storage.query({});
317
+
318
+ // Records should be in descending order (newest first)
319
+ for (let i = 1; i < records.length; i++) {
320
+ expect(records[i - 1].timestamp.getTime()).toBeGreaterThanOrEqual(
321
+ records[i].timestamp.getTime(),
322
+ );
323
+ }
324
+ });
325
+
326
+ it('should order by type ascending', async () => {
327
+ const records = await storage.query({
328
+ orderBy: 'type',
329
+ orderDirection: 'asc',
330
+ });
331
+
332
+ const types = records.map((r) => r.type);
333
+ expect(types).toEqual([...types].sort());
334
+ });
335
+ });
336
+
337
+ describe('count', () => {
338
+ beforeEach(async () => {
339
+ const auditor = new DefaultAuditor<TestAuditAction>({
340
+ actor: { id: 'user-1', type: 'user' },
341
+ storage,
342
+ });
343
+
344
+ auditor.audit('user.created', { userId: 1, email: 'user1@example.com' });
345
+ auditor.audit('user.created', { userId: 2, email: 'user2@example.com' });
346
+ auditor.audit('user.updated', { userId: 1, changes: ['name'] });
347
+
348
+ await auditor.flush();
349
+ });
350
+
351
+ it('should count all records', async () => {
352
+ const count = await storage.count({});
353
+
354
+ expect(count).toBe(3);
355
+ });
356
+
357
+ it('should count with type filter', async () => {
358
+ const count = await storage.count({ type: 'user.created' });
359
+
360
+ expect(count).toBe(2);
361
+ });
362
+
363
+ it('should count with actorId filter', async () => {
364
+ const count = await storage.count({ actorId: 'user-1' });
365
+
366
+ expect(count).toBe(3);
367
+ });
368
+ });
369
+
370
+ describe('complex scenarios', () => {
371
+ it('should handle audit trail for user lifecycle', async () => {
372
+ // Create user
373
+ const createAuditor = new DefaultAuditor<TestAuditAction>({
374
+ actor: { id: 'admin-1', type: 'admin' },
375
+ storage,
376
+ metadata: { endpoint: '/users', method: 'POST' },
377
+ });
378
+
379
+ const user = await db
380
+ .insertInto('users')
381
+ .values({ name: 'John Doe', email: 'john@example.com' })
382
+ .returningAll()
383
+ .executeTakeFirstOrThrow();
384
+
385
+ createAuditor.audit(
386
+ 'user.created',
387
+ { userId: user.id, email: user.email },
388
+ {
389
+ entityId: String(user.id),
390
+ table: 'users',
391
+ operation: 'INSERT',
392
+ newValues: { name: 'John Doe', email: 'john@example.com' },
393
+ },
394
+ );
395
+ await createAuditor.flush();
396
+
397
+ // Update user
398
+ const updateAuditor = new DefaultAuditor<TestAuditAction>({
399
+ actor: { id: 'user-1', type: 'user' },
400
+ storage,
401
+ metadata: { endpoint: '/users/1', method: 'PATCH' },
402
+ });
403
+
404
+ await db
405
+ .updateTable('users')
406
+ .set({ name: 'John Smith' })
407
+ .where('id', '=', user.id)
408
+ .execute();
409
+
410
+ updateAuditor.audit(
411
+ 'user.updated',
412
+ { userId: user.id, changes: ['name'] },
413
+ {
414
+ entityId: String(user.id),
415
+ table: 'users',
416
+ operation: 'UPDATE',
417
+ oldValues: { name: 'John Doe' },
418
+ newValues: { name: 'John Smith' },
419
+ },
420
+ );
421
+ await updateAuditor.flush();
422
+
423
+ // Delete user
424
+ const deleteAuditor = new DefaultAuditor<TestAuditAction>({
425
+ actor: { id: 'admin-1', type: 'admin' },
426
+ storage,
427
+ metadata: { endpoint: '/users/1', method: 'DELETE' },
428
+ });
429
+
430
+ await db.deleteFrom('users').where('id', '=', user.id).execute();
431
+
432
+ deleteAuditor.audit(
433
+ 'user.deleted',
434
+ { userId: user.id },
435
+ {
436
+ entityId: String(user.id),
437
+ table: 'users',
438
+ operation: 'DELETE',
439
+ oldValues: { name: 'John Smith', email: 'john@example.com' },
440
+ },
441
+ );
442
+ await deleteAuditor.flush();
443
+
444
+ // Query audit trail for this user
445
+ const auditTrail = await storage.query({
446
+ entityId: String(user.id),
447
+ orderDirection: 'asc',
448
+ });
449
+
450
+ expect(auditTrail).toHaveLength(3);
451
+ expect(auditTrail.map((r) => r.type)).toEqual([
452
+ 'user.created',
453
+ 'user.updated',
454
+ 'user.deleted',
455
+ ]);
456
+ expect(auditTrail.map((r) => r.operation)).toEqual([
457
+ 'INSERT',
458
+ 'UPDATE',
459
+ 'DELETE',
460
+ ]);
461
+
462
+ // Verify old/new values tracking
463
+ expect(auditTrail[1].oldValues).toEqual({ name: 'John Doe' });
464
+ expect(auditTrail[1].newValues).toEqual({ name: 'John Smith' });
465
+ });
466
+
467
+ it('should handle complex entity IDs', async () => {
468
+ const auditor = new DefaultAuditor<TestAuditAction>({
469
+ actor: { id: 'user-1', type: 'user' },
470
+ storage,
471
+ });
472
+
473
+ // Record with composite entity ID
474
+ auditor.record({
475
+ type: 'relation.created',
476
+ operation: 'INSERT',
477
+ entityId: { userId: 1, roleId: 'admin' },
478
+ table: 'user_roles',
479
+ payload: { granted: true },
480
+ });
481
+
482
+ await auditor.flush();
483
+
484
+ // Query by composite entity ID
485
+ const records = await storage.query({
486
+ entityId: { userId: 1, roleId: 'admin' },
487
+ });
488
+
489
+ expect(records).toHaveLength(1);
490
+ expect(records[0].entityId).toEqual({ userId: 1, roleId: 'admin' });
491
+ });
492
+
493
+ it('should preserve actor extra properties', async () => {
494
+ const auditor = new DefaultAuditor<TestAuditAction>({
495
+ actor: {
496
+ id: 'user-1',
497
+ type: 'user',
498
+ email: 'user@example.com',
499
+ roles: ['admin', 'editor'],
500
+ },
501
+ storage,
502
+ });
503
+
504
+ auditor.audit('user.created', { userId: 1, email: 'new@example.com' });
505
+ await auditor.flush();
506
+
507
+ const records = await storage.query({});
508
+
509
+ expect(records[0].actor).toEqual({
510
+ id: 'user-1',
511
+ type: 'user',
512
+ email: 'user@example.com',
513
+ roles: ['admin', 'editor'],
514
+ });
515
+ });
516
+ });
517
+ });