-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathpreserve-audit-real-driver.integration.test.ts
More file actions
140 lines (120 loc) · 6.56 KB
/
Copy pathpreserve-audit-real-driver.integration.test.ts
File metadata and controls
140 lines (120 loc) · 6.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Real-SQLite end-to-end for the `preserveAudit` seam (#3493 / #3549 / #3556).
*
* A "historical" import (`treatAsHistorical`) writes with `context.preserveAudit`
* so it KEEPS the original `updated_at` and author-declared business `readonly`
* fields (`closed_at`) instead of stamping-now / stripping them. The undo of such
* an import mirrors that flag so restoring the captured pre-import snapshot rolls
* the timeline BACK rather than re-stamping it (#3549 → fixed in #3556).
*
* Each half is already unit-tested in isolation — the engine's audit hook +
* readonly-strip whitelist against a mock driver (objectql `plugin.integration`),
* and the SQL driver's own `updated_at` force-stamp bypass against a directly-
* supplied option (`driver-sql` `sql-driver-timestamp-format`). What NEITHER can
* prove is the SEAM BETWEEN them: that `context.preserveAudit` set on an engine
* write actually threads through `buildDriverOptions` into the driver's options
* and defeats the REAL SQL `updated_at` force-stamp end-to-end. A mock/in-memory
* driver echoes `data.updated_at` and never force-stamps, so it structurally
* cannot catch a break in that thread. This wires the REAL {@link ObjectQL}
* engine to the REAL {@link SqlDriver} (better-sqlite3, on-disk) and reads the
* persisted row back to pin the whole path.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
const ISO_Z = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const HISTORICAL = '2021-03-01T09:00:00.000Z';
const TICKET = {
name: 'ticket',
fields: {
name: { type: 'text' },
// Author-declared business readonly field — a case's close time. Normally
// stripped on a client write; a historical import (preserveAudit) reinstates it.
closed_at: { type: 'datetime', readonly: true },
},
};
describe('preserveAudit end-to-end on a REAL SqlDriver (#3493 / #3549)', () => {
let engine: ObjectQL | null = null;
let dir: string | null = null;
afterEach(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
engine = null;
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
});
async function boot() {
dir = mkdtempSync(join(tmpdir(), 'os-preserveaudit-'));
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true });
await driver.initObjects([TICKET]); // create the real table + audit columns
engine = new ObjectQL();
engine.registerDriver(driver, true);
await engine.init();
engine.registry.registerObject(TICKET as any);
return engine;
}
const readOne = async (id: string): Promise<any> =>
(await (engine as ObjectQL).find('ticket', { where: { id } }))[0];
it('historical write: preserveAudit keeps the supplied updated_at AND the business readonly closed_at, through the SQL force-stamp', async () => {
const e = await boot();
const rec: any = await e.insert('ticket', { name: 'A' });
const id = rec.id;
await e.update(
'ticket',
{ id, name: 'B', updated_at: HISTORICAL, closed_at: HISTORICAL },
{ context: { preserveAudit: true } } as any,
);
const row = await readOne(id);
expect(row.name).toBe('B');
// The engine threaded preserveAudit into the driver options, so the driver's
// updated_at force-stamp was bypassed and the supplied instant survived to disk.
expect(row.updated_at).toBe(HISTORICAL);
// The engine's readonly-strip whitelist admitted the business readonly field,
// and it was actually written by the real driver (not just echoed by a mock).
expect(row.closed_at).toBe(HISTORICAL);
});
it('normal write (control): without preserveAudit the SQL driver force-stamps updated_at and the engine strips readonly closed_at', async () => {
const e = await boot();
const rec: any = await e.insert('ticket', { name: 'A' });
const id = rec.id;
await e.update(
'ticket',
{ id, name: 'B', updated_at: HISTORICAL, closed_at: HISTORICAL },
{ context: {} } as any,
);
const row = await readOne(id);
expect(row.name).toBe('B');
expect(row.updated_at).toMatch(ISO_Z);
expect(row.updated_at).not.toBe(HISTORICAL); // force-stamped "now", not the supplied instant
expect(row.closed_at ?? null).toBeNull(); // readonly business field stripped — never reached the driver
});
it('undo capstone (#3549): restoring a captured pre-import snapshot under preserveAudit rolls updated_at BACK; without the flag it re-stamps now (the corruption #3556 prevents)', async () => {
const e = await boot();
const rec: any = await e.insert('ticket', { name: 'A' });
const id = rec.id;
const original = await readOne(id);
expect(original.updated_at).toMatch(ISO_Z);
// Ensure "now" is measurably later than the original insert stamp.
await new Promise((r) => setTimeout(r, 5));
// A historical import moves the row's recorded timeline to the historical instant.
await e.update('ticket', { id, name: 'B', updated_at: HISTORICAL }, { context: { preserveAudit: true } } as any);
expect((await readOne(id)).updated_at).toBe(HISTORICAL);
// The pre-import snapshot the undo log captured (payload keys only).
const before = { id, name: original.name, updated_at: original.updated_at };
// Undo WITH preserveAudit (the #3556 fix): the original timeline is restored,
// not re-stamped — the driver's force-stamp is bypassed on the restore write too.
await e.update('ticket', before, { context: { preserveAudit: true, skipAutomations: true } } as any);
expect((await readOne(id)).updated_at).toBe(original.updated_at);
// Control — the SAME restore WITHOUT preserveAudit (the pre-#3556 behavior):
// move the timeline again, then undo with a plain context. The SQL driver
// force-stamps now, so the captured original is silently lost. This is exactly
// the #3549 corruption the fix removes.
await e.update('ticket', { id, name: 'B2', updated_at: HISTORICAL }, { context: { preserveAudit: true } } as any);
await e.update('ticket', before, { context: { skipAutomations: true } } as any);
const corrupted = await readOne(id);
expect(corrupted.updated_at).toMatch(ISO_Z);
expect(corrupted.updated_at).not.toBe(original.updated_at); // timeline NOT restored — the bug shape
});
});