@anteros/core 0.0.39 → 0.0.41
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/database/audit.ts +193 -0
- package/database/collection.ts +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +134 -33
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/index-spec.test.ts +5 -5
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +239 -0
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- package/utils/func.ts +67 -52
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { useRest } from "../database/rest";
|
|
3
|
+
import { formatConfig, cfg } from "../server/config";
|
|
4
|
+
import { syncTenants, getTenant } from "../database/tenant";
|
|
5
|
+
import {
|
|
6
|
+
AUDIT_COLLECTION,
|
|
7
|
+
LEGACY_AUDIT_COLLECTION,
|
|
8
|
+
AUDIT_TTL_INDEX,
|
|
9
|
+
migrateAuditCollection,
|
|
10
|
+
syncAuditRetention,
|
|
11
|
+
} from "../database/audit";
|
|
12
|
+
|
|
13
|
+
const TEST_TENANT = "audit";
|
|
14
|
+
const TEST_DB = "mongodb://localhost:27017/_AUDIT_TEST";
|
|
15
|
+
|
|
16
|
+
// Second tenant: proves `tenant.audit.retention` is wired end-to-end
|
|
17
|
+
const TTL_TENANT = "audit-ttl";
|
|
18
|
+
const TTL_DB = "mongodb://localhost:27017/_AUDIT_TTL_TEST";
|
|
19
|
+
|
|
20
|
+
let rest: InstanceType<typeof useRest>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Latest audit entry for a (collection, action) pair.
|
|
24
|
+
*
|
|
25
|
+
* `#logActivity` is fire-and-forget, so we poll — and when the same pair was
|
|
26
|
+
* already logged by an earlier test, `since` (captured *before* the operation)
|
|
27
|
+
* makes sure we wait for the new entry instead of reading the stale one.
|
|
28
|
+
*/
|
|
29
|
+
async function lastActivity(collection: string, action: string, since?: Date) {
|
|
30
|
+
for (let i = 0; i < 40; i++) {
|
|
31
|
+
const found: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
|
|
32
|
+
{
|
|
33
|
+
"operation.collection": collection,
|
|
34
|
+
"operation.action": action,
|
|
35
|
+
...(since ? { ts: { $gte: since } } : {}),
|
|
36
|
+
},
|
|
37
|
+
{ sort: { ts: -1 } },
|
|
38
|
+
);
|
|
39
|
+
if (found) return found;
|
|
40
|
+
await Bun.sleep(25);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal activity document (same shape as `ActivityInput`). */
|
|
46
|
+
function activity(action: string) {
|
|
47
|
+
return {
|
|
48
|
+
internal: true,
|
|
49
|
+
trace: { id: crypto.randomUUID() },
|
|
50
|
+
meta: {},
|
|
51
|
+
operation: {
|
|
52
|
+
tenant: TEST_TENANT,
|
|
53
|
+
action,
|
|
54
|
+
collection: "items",
|
|
55
|
+
status: "success",
|
|
56
|
+
input: null,
|
|
57
|
+
result: null,
|
|
58
|
+
error: null,
|
|
59
|
+
duration: 0,
|
|
60
|
+
transaction: false,
|
|
61
|
+
},
|
|
62
|
+
ts: new Date(),
|
|
63
|
+
} as any;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index document, or undefined when absent. */
|
|
67
|
+
async function index(name: string): Promise<any> {
|
|
68
|
+
return (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
69
|
+
.find((i: any) => i.name === name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
formatConfig({
|
|
74
|
+
server: { port: 4000 },
|
|
75
|
+
tenants: [
|
|
76
|
+
{ id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } },
|
|
77
|
+
{ id: TTL_TENANT, dir: "src", database: { uri: TTL_DB }, audit: { retention: "7d" } },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
await syncTenants();
|
|
81
|
+
|
|
82
|
+
// Declared collections — needed to exercise the `$lookup` allow-list and `collectionType`
|
|
83
|
+
(cfg as any).collections = [
|
|
84
|
+
{
|
|
85
|
+
_tenant_: TEST_TENANT,
|
|
86
|
+
slug: "items",
|
|
87
|
+
fields: [{ name: "title", type: "string" }],
|
|
88
|
+
api: { access: { "*": true } },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
(cfg as any).fileCollections = [
|
|
92
|
+
{
|
|
93
|
+
_tenant_: TEST_TENANT,
|
|
94
|
+
_isFileCollection_: true,
|
|
95
|
+
slug: "photos",
|
|
96
|
+
fields: [{ name: "label", type: "string" }],
|
|
97
|
+
api: { access: { "*": true } },
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
rest = new useRest({ internal: true, tenant_id: TEST_TENANT });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(async () => {
|
|
105
|
+
try { await rest.db.collection("items").drop(); } catch (_) {}
|
|
106
|
+
try { await rest.db.collection("photos").drop(); } catch (_) {}
|
|
107
|
+
try { await rest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
108
|
+
try { await rest.db.collection(LEGACY_AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
109
|
+
try { await getTenant(TTL_TENANT)?.database?.db?.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("audit collection", () => {
|
|
113
|
+
it("writes to `_audit_`", async () => {
|
|
114
|
+
await rest.audit.addActivities([activity("unit")]);
|
|
115
|
+
|
|
116
|
+
const stored: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
117
|
+
.findOne({ "operation.action": "unit" });
|
|
118
|
+
expect(stored).toBeDefined();
|
|
119
|
+
expect(stored.operation.collection).toBe("items");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads back through getActivities", async () => {
|
|
123
|
+
const logs = await rest.audit.getActivities({
|
|
124
|
+
$match: { "operation.action": "unit" },
|
|
125
|
+
$limit: 10,
|
|
126
|
+
} as any);
|
|
127
|
+
expect(logs.length).toBeGreaterThanOrEqual(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not use the legacy `_activities_` name anymore", async () => {
|
|
131
|
+
const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
|
|
132
|
+
expect(legacy.length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is on disk as soon as a mutation resolves (no polling needed)", async () => {
|
|
136
|
+
const doc: any = await rest.insertOne("items", { title: "awaited" });
|
|
137
|
+
|
|
138
|
+
// Deliberately **no** polling: mutations await their audit entry, so it is
|
|
139
|
+
// already there — a successful write can never lose its trace.
|
|
140
|
+
const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne({
|
|
141
|
+
"operation.action": "insertOne",
|
|
142
|
+
"operation.result._id": doc._id,
|
|
143
|
+
});
|
|
144
|
+
expect(logged).not.toBeNull();
|
|
145
|
+
expect(logged.operation.status).toBe("success");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("accepts `_audit_` as a $lookup target from a declared collection", async () => {
|
|
149
|
+
const rows = await rest.aggregate("items", [
|
|
150
|
+
{ $lookup: { from: AUDIT_COLLECTION, localField: "_id", foreignField: "_id", as: "audit" } },
|
|
151
|
+
{ $limit: 1 },
|
|
152
|
+
]);
|
|
153
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("audit collectionType", () => {
|
|
158
|
+
it("labels a document collection as `document`", async () => {
|
|
159
|
+
await rest.insertOne("items", { title: "collectionType" });
|
|
160
|
+
|
|
161
|
+
const logged = await lastActivity("items", "insertOne");
|
|
162
|
+
expect(logged).not.toBeNull();
|
|
163
|
+
expect(logged.operation.collectionType).toBe("document");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("labels a file collection as `file`", async () => {
|
|
167
|
+
await rest.insertOne("photos", { label: "ct", _file: { filename: "ct.png" } });
|
|
168
|
+
|
|
169
|
+
const logged = await lastActivity("photos", "insertOne");
|
|
170
|
+
expect(logged).not.toBeNull();
|
|
171
|
+
expect(logged.operation.collectionType).toBe("file");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("leaves `collectionType` undefined for an internal collection", async () => {
|
|
175
|
+
// `_audit_` is not declared anywhere — no type to report
|
|
176
|
+
const logged: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
177
|
+
.findOne({ "operation.collection": "_locks_" });
|
|
178
|
+
expect(logged?.operation?.collectionType).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("audit read operations", () => {
|
|
183
|
+
it("logs `find` with its parameters, never the result", async () => {
|
|
184
|
+
const since = new Date();
|
|
185
|
+
const docs = await rest.find("items", { $match: { title: "collectionType" }, $limit: 5 });
|
|
186
|
+
expect(docs.length).toBeGreaterThanOrEqual(1); // the read really returned data
|
|
187
|
+
|
|
188
|
+
const logged = await lastActivity("items", "find", since);
|
|
189
|
+
expect(logged).not.toBeNull();
|
|
190
|
+
expect(logged.operation.input.params).toEqual({ $match: { title: "collectionType" }, $limit: 5 });
|
|
191
|
+
expect(logged.operation.result).toBeNull(); // ← nothing returned is stored
|
|
192
|
+
expect(logged.operation.status).toBe("success");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("logs `findOne` with the id", async () => {
|
|
196
|
+
const since = new Date();
|
|
197
|
+
const [first] = await rest.find("items", { $limit: 1 });
|
|
198
|
+
const doc: any = await rest.findOne("items", first!._id);
|
|
199
|
+
expect(doc).not.toBeNull();
|
|
200
|
+
|
|
201
|
+
const logged = await lastActivity("items", "findOne", since);
|
|
202
|
+
expect(logged.operation.input.id).toBeString();
|
|
203
|
+
expect(logged.operation.result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("logs `aggregate` with its pipeline", async () => {
|
|
207
|
+
const since = new Date();
|
|
208
|
+
const pipeline = [{ $match: { title: "collectionType" } }, { $limit: 1 }];
|
|
209
|
+
const rows = await rest.aggregate("items", pipeline);
|
|
210
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
211
|
+
|
|
212
|
+
const logged = await lastActivity("items", "aggregate", since);
|
|
213
|
+
expect(logged.operation.input.pipeline).toEqual(pipeline);
|
|
214
|
+
expect(logged.operation.result).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("logs a failed read with its error and still no result", async () => {
|
|
218
|
+
const since = new Date();
|
|
219
|
+
await expect(rest.aggregate("items", [
|
|
220
|
+
{ $lookup: { from: "not_declared", localField: "_id", foreignField: "_id", as: "x" } },
|
|
221
|
+
])).rejects.toThrow();
|
|
222
|
+
|
|
223
|
+
const logged = await lastActivity("items", "aggregate", since);
|
|
224
|
+
expect(logged.operation.status).toBe("error");
|
|
225
|
+
expect(logged.operation.error.message).toBeString();
|
|
226
|
+
expect(logged.operation.result).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("audit query indexes", () => {
|
|
231
|
+
it("are ensured at boot", async () => {
|
|
232
|
+
const names = (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
233
|
+
.map((i: any) => i.name);
|
|
234
|
+
expect(names).toContain("_audit_ts_");
|
|
235
|
+
expect(names).toContain("_audit_collection_ts_");
|
|
236
|
+
expect(names).toContain("_audit_status_ts_");
|
|
237
|
+
expect(names).toContain("_audit_trace_");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("serve the documented sorts and filters", async () => {
|
|
241
|
+
expect((await index("_audit_ts_")).key).toEqual({ ts: -1 });
|
|
242
|
+
expect((await index("_audit_collection_ts_")).key).toEqual({ "operation.collection": 1, ts: -1 });
|
|
243
|
+
expect((await index("_audit_status_ts_")).key).toEqual({ "operation.status": 1, ts: -1 });
|
|
244
|
+
expect((await index("_audit_trace_")).key).toEqual({ "trace.id": 1 });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("audit retention (TTL)", () => {
|
|
249
|
+
it("stays untouched — no TTL index — when retention is not configured", async () => {
|
|
250
|
+
expect(await syncAuditRetention(rest.db, undefined)).toBe("skipped");
|
|
251
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("creates a TTL index when a duration is configured", async () => {
|
|
255
|
+
expect(await syncAuditRetention(rest.db, "90d")).toBe("applied");
|
|
256
|
+
|
|
257
|
+
const ttl = await index(AUDIT_TTL_INDEX);
|
|
258
|
+
expect(ttl.key).toEqual({ ts: 1 });
|
|
259
|
+
expect(ttl.expireAfterSeconds).toBe(90 * 86400);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("is idempotent — the same duration is a no-op", async () => {
|
|
263
|
+
expect(await syncAuditRetention(rest.db, "90d")).toBe("unchanged");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("updates expireAfterSeconds when the duration changes", async () => {
|
|
267
|
+
expect(await syncAuditRetention(rest.db, "24h")).toBe("applied");
|
|
268
|
+
expect((await index(AUDIT_TTL_INDEX)).expireAfterSeconds).toBe(24 * 3600);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("drops the TTL index when retention is explicitly disabled", async () => {
|
|
272
|
+
expect(await syncAuditRetention(rest.db, false)).toBe("dropped");
|
|
273
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("ignores an invalid duration", async () => {
|
|
277
|
+
expect(await syncAuditRetention(rest.db, "banana")).toBe("skipped");
|
|
278
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("is wired through `tenant.audit.retention`", async () => {
|
|
282
|
+
const db = getTenant(TTL_TENANT)!.database!.db!;
|
|
283
|
+
const ttl: any = (await db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
284
|
+
.find((i: any) => i.name === AUDIT_TTL_INDEX);
|
|
285
|
+
|
|
286
|
+
expect(ttl).toBeDefined();
|
|
287
|
+
expect(ttl.expireAfterSeconds).toBe(7 * 86400);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("audit collection migration (`_activities_` → `_audit_`)", () => {
|
|
292
|
+
it("renames an existing legacy collection, without losing documents", async () => {
|
|
293
|
+
await rest.db.collection(AUDIT_COLLECTION).drop().catch(() => {});
|
|
294
|
+
await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("legacy") as any);
|
|
295
|
+
|
|
296
|
+
expect(await migrateAuditCollection(rest.db)).toBe(true);
|
|
297
|
+
|
|
298
|
+
const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
|
|
299
|
+
expect(legacy.length).toBe(0);
|
|
300
|
+
|
|
301
|
+
const moved: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
302
|
+
.findOne({ "operation.action": "legacy" });
|
|
303
|
+
expect(moved).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("is idempotent — a second run is a no-op", async () => {
|
|
307
|
+
expect(await migrateAuditCollection(rest.db)).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("never touches the legacy collection when `_audit_` already exists", async () => {
|
|
311
|
+
await rest.db.collection(AUDIT_COLLECTION).insertOne(activity("keep") as any);
|
|
312
|
+
await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("orphan") as any);
|
|
313
|
+
|
|
314
|
+
expect(await migrateAuditCollection(rest.db)).toBe(false);
|
|
315
|
+
|
|
316
|
+
// neither merged nor dropped — both collections survive untouched
|
|
317
|
+
expect(await rest.db.collection(AUDIT_COLLECTION).countDocuments({ "operation.action": "orphan" })).toBe(0);
|
|
318
|
+
expect(await rest.db.collection(LEGACY_AUDIT_COLLECTION).countDocuments({})).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
});
|
package/tests/crypto.test.ts
CHANGED
|
@@ -62,7 +62,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
62
62
|
|
|
63
63
|
it("decrypt(cipher, true, fields) decrypts the listed sub-fields inside the JSON", async () => {
|
|
64
64
|
const c = useSymCrypt({ secret: "s" });
|
|
65
|
-
// json
|
|
65
|
+
// json containing encrypted sub-fields (field-level convention: JSON.stringify)
|
|
66
66
|
const encZip = await c.encrypt(JSON.stringify("75000"));
|
|
67
67
|
const encPhone = await c.encrypt(JSON.stringify("0600000000"));
|
|
68
68
|
const container = await c.encrypt({ city: "Paris", zip: encZip, contact: { phone: encPhone } });
|
|
@@ -72,7 +72,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
72
72
|
expect(dec.zip).toBe("75000");
|
|
73
73
|
expect(dec.contact.phone).toBe("0600000000");
|
|
74
74
|
|
|
75
|
-
//
|
|
75
|
+
// without the fields → sub-fields stay encrypted
|
|
76
76
|
const raw = await c.decrypt<any>(container, true);
|
|
77
77
|
expect(raw.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
78
78
|
expect(raw.city).toBe("Paris");
|
|
@@ -94,7 +94,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
94
94
|
const json = { city: "Paris", note: "plain text", zip: await c.encrypt(JSON.stringify("75000")) };
|
|
95
95
|
const dec = await c.decrypt<any>(json, true, ["zip", "note", "missing"]);
|
|
96
96
|
expect(dec.city).toBe("Paris");
|
|
97
|
-
expect(dec.note).toBe("plain text"); //
|
|
97
|
+
expect(dec.note).toBe("plain text"); // not a ciphertext → kept as-is
|
|
98
98
|
expect(dec.zip).toBe("75000");
|
|
99
99
|
});
|
|
100
100
|
|
|
@@ -103,7 +103,7 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
103
103
|
const encZip = await c.encrypt(JSON.stringify("69001"));
|
|
104
104
|
const encOther = await c.encrypt(JSON.stringify("69002"));
|
|
105
105
|
|
|
106
|
-
// array
|
|
106
|
+
// direct array container (e.g. doc.addresses returned by a query)
|
|
107
107
|
const arr = [
|
|
108
108
|
{ city: "Lyon", zip: encZip },
|
|
109
109
|
{ city: "Villeurbanne", zip: encOther },
|
|
@@ -111,11 +111,11 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
111
111
|
const dec = await c.decrypt<any>(arr, true, ["zip"]);
|
|
112
112
|
expect(dec[0].zip).toBe("69001");
|
|
113
113
|
expect(dec[1].zip).toBe("69002");
|
|
114
|
-
expect(dec[0].city).toBe("Lyon"); // non-
|
|
115
|
-
expect(dec).not.toBe(arr); //
|
|
114
|
+
expect(dec[0].city).toBe("Lyon"); // non-encrypted value kept
|
|
115
|
+
expect(dec).not.toBe(arr); // copy, no mutation
|
|
116
116
|
expect(arr[0]!.zip).toMatch(/^[A-Za-z0-9+/=]+$/); // input intact
|
|
117
117
|
|
|
118
|
-
// ciphertext
|
|
118
|
+
// ciphertext whose plaintext is an array of json
|
|
119
119
|
const encContainer = await c.encrypt([{ zip: encZip }, { zip: encOther }]);
|
|
120
120
|
const dec2 = await c.decrypt<any>(encContainer, true, ["zip"]);
|
|
121
121
|
expect(dec2[0].zip).toBe("69001");
|
|
@@ -125,10 +125,10 @@ describe("crypto — symmetric AES-256-GCM", () => {
|
|
|
125
125
|
|
|
126
126
|
describe("crypto — key rotation (versioned secrets)", () => {
|
|
127
127
|
it("decrypts old ciphertexts after rotating the secret", async () => {
|
|
128
|
-
const old = useSymCrypt({ secret: "v1-secret" }); // version 1
|
|
128
|
+
const old = useSymCrypt({ secret: "v1-secret" }); // version 1 by default
|
|
129
129
|
const enc = await old.encrypt({ apiKey: "sk-rot" });
|
|
130
130
|
|
|
131
|
-
// Rotation
|
|
131
|
+
// Rotation: new active key v2, the old one kept for decrypt-only
|
|
132
132
|
const rotated = useSymCrypt({ secret: "v2-secret", version: 2, previousSecrets: { 1: "v1-secret" } });
|
|
133
133
|
const dec = await rotated.decrypt<{ apiKey: string }>(enc, true);
|
|
134
134
|
expect(dec.apiKey).toBe("sk-rot");
|
|
@@ -139,7 +139,7 @@ describe("crypto — key rotation (versioned secrets)", () => {
|
|
|
139
139
|
const enc = await c.encrypt("data");
|
|
140
140
|
const buf = Buffer.from(enc, "base64");
|
|
141
141
|
expect(buf[0]).toBe(2);
|
|
142
|
-
// round-trip
|
|
142
|
+
// round-trip with the same instance
|
|
143
143
|
const dec = await c.decrypt(enc);
|
|
144
144
|
expect(dec).toBe("data");
|
|
145
145
|
});
|
|
@@ -147,7 +147,7 @@ describe("crypto — key rotation (versioned secrets)", () => {
|
|
|
147
147
|
it("still decrypts legacy ciphertexts (no version byte)", async () => {
|
|
148
148
|
const c = useSymCrypt({ secret: "s" });
|
|
149
149
|
const enc = await c.encrypt("legacy");
|
|
150
|
-
//
|
|
150
|
+
// Simulates a ciphertext produced BEFORE the rotation (format without a version byte)
|
|
151
151
|
const legacy = Buffer.from(Buffer.from(enc, "base64").subarray(1)).toString("base64");
|
|
152
152
|
const dec = await c.decrypt(legacy);
|
|
153
153
|
expect(dec).toBe("legacy");
|
|
@@ -164,11 +164,11 @@ describe("crypto — key rotation (versioned secrets)", () => {
|
|
|
164
164
|
const c = useSymCrypt({ secret: "s", aad: "myapp:prod" });
|
|
165
165
|
const enc = await c.encrypt({ apiKey: "sk-aad" });
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// same instance → OK (default applied on decrypt too)
|
|
168
168
|
const dec = await c.decrypt<{ apiKey: string }>(enc, true);
|
|
169
169
|
expect(dec.apiKey).toBe("sk-aad");
|
|
170
170
|
|
|
171
|
-
//
|
|
171
|
+
// without the config AAD → fails (invalid tag)
|
|
172
172
|
const other = useSymCrypt({ secret: "s" });
|
|
173
173
|
await expect(other.decrypt(enc, true)).rejects.toThrow();
|
|
174
174
|
});
|
|
@@ -178,15 +178,15 @@ describe("crypto — key rotation (versioned secrets)", () => {
|
|
|
178
178
|
const enc = await c.encrypt("x", "explicit:ctx");
|
|
179
179
|
const dec = await c.decrypt(enc, false, "explicit:ctx");
|
|
180
180
|
expect(dec).toBe("x");
|
|
181
|
-
//
|
|
181
|
+
// the default cannot decrypt the ciphertext bound to the explicit AAD
|
|
182
182
|
await expect(c.decrypt(enc, false, "default:ctx")).rejects.toThrow();
|
|
183
183
|
});
|
|
184
184
|
|
|
185
185
|
it("legacy ciphertexts (no AAD) still decrypt after a default AAD is configured", async () => {
|
|
186
|
-
const legacy = useSymCrypt({ secret: "s" }); //
|
|
186
|
+
const legacy = useSymCrypt({ secret: "s" }); // encrypted WITHOUT AAD (legacy behavior)
|
|
187
187
|
const enc = await legacy.encrypt("old-data");
|
|
188
188
|
|
|
189
|
-
const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config
|
|
189
|
+
const c = useSymCrypt({ secret: "s", aad: "myapp:prod" }); // config with a default AAD
|
|
190
190
|
const dec = await c.decrypt(enc);
|
|
191
191
|
expect(dec).toBe("old-data");
|
|
192
192
|
});
|
|
@@ -223,7 +223,7 @@ describe("crypto — encryption mode (server.encryption.mode)", () => {
|
|
|
223
223
|
it("resolve() returns the instance matching the configured mode", async () => {
|
|
224
224
|
delete (cfg.server as any).encryption;
|
|
225
225
|
formatConfig({ server: { port: 4000 }, tenants: [] });
|
|
226
|
-
const priv = await useAsymCrypt().exportPrivateKey(); // pair
|
|
226
|
+
const priv = await useAsymCrypt().exportPrivateKey(); // fresh pair, no configured mode
|
|
227
227
|
|
|
228
228
|
delete (cfg.server as any).encryption;
|
|
229
229
|
formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "cfg" } }, tenants: [] });
|
package/tests/db-cache.test.ts
CHANGED
|
@@ -152,10 +152,10 @@ describe("DB query cache (useCache)", () => {
|
|
|
152
152
|
const r1: any = await rest.find("items", params, { useCache: true });
|
|
153
153
|
expect(r1[0]!.count).toBe(1);
|
|
154
154
|
|
|
155
|
-
// update
|
|
155
|
+
// update with a NATIVE ObjectId (not a string) — String(id) normalization must match the index
|
|
156
156
|
await rest.updateOne("items", new ObjectId(a._id), { $set: { count: 77 } });
|
|
157
157
|
const r2: any = await rest.find("items", params, { useCache: true });
|
|
158
|
-
expect(r2[0]!.count).toBe(77); //
|
|
158
|
+
expect(r2[0]!.count).toBe(77); // invalidated + refetch
|
|
159
159
|
|
|
160
160
|
await rest.db.collection("items").deleteMany({ title: marker });
|
|
161
161
|
});
|
|
@@ -168,15 +168,15 @@ describe("DB query cache (useCache)", () => {
|
|
|
168
168
|
const b = await rest.insertOne("items", { title: marker, count: 2 });
|
|
169
169
|
const c = await rest.insertOne("items", { title: marker, count: 3 });
|
|
170
170
|
|
|
171
|
-
const params = { $match: { title: marker }, $sort: { count: 1 } }; //
|
|
172
|
-
await rest.find("items", params, { useCache: true }); // cache
|
|
171
|
+
const params = { $match: { title: marker }, $sort: { count: 1 } }; // guaranteed order: a(1), b(2), c(3)
|
|
172
|
+
await rest.find("items", params, { useCache: true }); // cache populated (3 docs)
|
|
173
173
|
|
|
174
|
-
//
|
|
174
|
+
// the 3rd id (c) is NOT tagged (maxTags: 2) → no targeted invalidation
|
|
175
175
|
await rest.updateOne("items", c._id, { $set: { count: 33 } });
|
|
176
176
|
const r1: any = await rest.find("items", params, { useCache: true });
|
|
177
|
-
expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache
|
|
177
|
+
expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache kept
|
|
178
178
|
|
|
179
|
-
//
|
|
179
|
+
// the 1st id (a) is tagged → targeted invalidation
|
|
180
180
|
await rest.updateOne("items", a._id, { $set: { count: 11 } });
|
|
181
181
|
const r2: any = await rest.find("items", params, { useCache: true });
|
|
182
182
|
expect(r2.find((d: any) => d._id === a._id)!.count).toBe(11); // refetch
|
|
@@ -24,12 +24,12 @@ beforeAll(async () => {
|
|
|
24
24
|
await syncCollections();
|
|
25
25
|
(cfg as any).collections = [encCollection([
|
|
26
26
|
{ name: "name", type: "string" },
|
|
27
|
-
{ name: "ssn", type: "string", encryption: true }, //
|
|
28
|
-
{ name: "profile", type: "json", encryption: true }, //
|
|
27
|
+
{ name: "ssn", type: "string", encryption: true }, // whole value encrypted
|
|
28
|
+
{ name: "profile", type: "json", encryption: true }, // whole json encrypted
|
|
29
29
|
{ name: "age", type: "number" },
|
|
30
|
-
{ name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json
|
|
31
|
-
{ name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, //
|
|
32
|
-
{ name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array
|
|
30
|
+
{ name: "address", type: "json", encryptionOptions: { path: "zip" } }, // json: only address.zip encrypted
|
|
31
|
+
{ name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // several paths
|
|
32
|
+
{ name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array of json
|
|
33
33
|
])];
|
|
34
34
|
rest = new useRest({ internal: false, tenant_id: TENANT });
|
|
35
35
|
});
|
|
@@ -44,35 +44,35 @@ describe("field-level encryption", () => {
|
|
|
44
44
|
it("insert stores ciphertext in Mongo but returns plaintext", async () => {
|
|
45
45
|
const doc: any = await rest.insertOne("patients", { name: "John", ssn: "123-45-6789", profile: { city: "Paris", zip: 75000 }, age: 42 });
|
|
46
46
|
|
|
47
|
-
//
|
|
47
|
+
// API response = plaintext
|
|
48
48
|
expect(doc.ssn).toBe("123-45-6789");
|
|
49
49
|
expect(doc.profile).toEqual({ city: "Paris", zip: 75000 });
|
|
50
50
|
|
|
51
|
-
//
|
|
51
|
+
// in Mongo = ciphertext (version byte + IV + data)
|
|
52
52
|
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
53
53
|
expect(raw).not.toBeNull();
|
|
54
54
|
expect(raw!.ssn).not.toBe("123-45-6789");
|
|
55
55
|
expect(typeof raw!.ssn).toBe("string");
|
|
56
56
|
expect(raw!.ssn).toMatch(/^[A-Za-z0-9+/=]+$/); // base64
|
|
57
57
|
expect(raw!.profile).not.toEqual({ city: "Paris" });
|
|
58
|
-
//
|
|
58
|
+
// non-encrypted field untouched
|
|
59
59
|
expect(raw!.name).toBe("John");
|
|
60
60
|
expect(raw!.age).toBe(42);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
it("find / findOne return ciphertext (no decrypt on output)", async () => {
|
|
64
64
|
const list: any = await rest.find("patients", { $match: { name: "John" } });
|
|
65
|
-
//
|
|
65
|
+
// read = ciphertext (never decrypted automatically)
|
|
66
66
|
expect(list[0].ssn).not.toBe("123-45-6789");
|
|
67
67
|
expect(list[0].ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
68
68
|
expect(list[0].profile).not.toEqual({ city: "Paris", zip: 75000 });
|
|
69
|
-
//
|
|
69
|
+
// non-encrypted fields untouched
|
|
70
70
|
expect(list[0].name).toBe("John");
|
|
71
71
|
|
|
72
72
|
const one: any = await rest.findOne("patients", list[0]._id);
|
|
73
73
|
expect(one.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
74
74
|
|
|
75
|
-
//
|
|
75
|
+
// explicit MANUAL decryption (utils.crypt.resolve())
|
|
76
76
|
const cipher = resolve();
|
|
77
77
|
const dec = await cipher.decrypt<string>(one.ssn, true);
|
|
78
78
|
expect(dec).toBe("123-45-6789");
|
|
@@ -82,10 +82,10 @@ describe("field-level encryption", () => {
|
|
|
82
82
|
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
|
|
83
83
|
const updated: any = await rest.updateOne("patients", doc._id, { $set: { ssn: "987-65-4321" } });
|
|
84
84
|
|
|
85
|
-
//
|
|
85
|
+
// response = ciphertext (no automatic decryption anymore)
|
|
86
86
|
expect(updated.ssn).not.toBe("987-65-4321");
|
|
87
87
|
expect(updated.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
88
|
-
//
|
|
88
|
+
// stored = new ciphertext
|
|
89
89
|
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
90
90
|
expect(raw).not.toBeNull();
|
|
91
91
|
expect(raw!.ssn).not.toBe("987-65-4321");
|
|
@@ -99,17 +99,17 @@ describe("field-level encryption", () => {
|
|
|
99
99
|
const raw = await rest.db.collection("patients").find({ name: { $in: ["A", "B"] } }).toArray();
|
|
100
100
|
for (const r of raw) expect(r.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
101
101
|
const list: any = await rest.find("patients", { $match: { name: { $in: ["A", "B"] } } });
|
|
102
|
-
//
|
|
102
|
+
// read = ciphertext, decrypted manually
|
|
103
103
|
for (const d of list) expect(d.ssn).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
it("rejects operators that cannot work on ciphertext ($inc on an encrypted field)", async () => {
|
|
107
107
|
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "John" } }))[0]!._id);
|
|
108
108
|
await expect(
|
|
109
|
-
rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc
|
|
109
|
+
rest.updateOne("patients", doc._id, { $set: { ssn: "x" }, $inc: { age: 1 } }) // $inc on age (not encrypted) OK
|
|
110
110
|
).resolves.toBeTruthy();
|
|
111
111
|
await expect(
|
|
112
|
-
rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc
|
|
112
|
+
rest.updateOne("patients", doc._id, { $set: { ssn: "y" }, $inc: { ssn: 1 } as any }) // $inc on an encrypted field → rejected
|
|
113
113
|
).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
|
|
114
114
|
});
|
|
115
115
|
|
|
@@ -119,7 +119,7 @@ describe("field-level encryption", () => {
|
|
|
119
119
|
await expect(rest.insertOne("patients", { name: "NoCfg", ssn: "000-00-0000" }))
|
|
120
120
|
.rejects.toThrow(/ENCRYPTION_NOT_CONFIGURED|server\.encryption/);
|
|
121
121
|
|
|
122
|
-
// restore config
|
|
122
|
+
// restore the config for the following tests
|
|
123
123
|
formatConfig({ server: { port: 4000, encryption: { mode: "symmetric", secret: "field-enc-secret" } }, tenants: [] });
|
|
124
124
|
});
|
|
125
125
|
});
|
|
@@ -130,22 +130,22 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
|
|
|
130
130
|
name: "Alice",
|
|
131
131
|
address: { street: "Rue X", city: "Paris", zip: "75000" },
|
|
132
132
|
});
|
|
133
|
-
//
|
|
133
|
+
// response = plaintext everywhere
|
|
134
134
|
expect(doc.address.zip).toBe("75000");
|
|
135
135
|
expect(doc.address.city).toBe("Paris");
|
|
136
136
|
|
|
137
|
-
//
|
|
137
|
+
// in Mongo: only zip is ciphertext, the rest stays in clear
|
|
138
138
|
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
139
139
|
expect(raw!.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
140
140
|
expect(raw!.address.city).toBe("Paris");
|
|
141
141
|
expect(raw!.address.street).toBe("Rue X");
|
|
142
142
|
|
|
143
|
-
//
|
|
143
|
+
// rest read = ciphertext at the path, plaintext elsewhere
|
|
144
144
|
const list: any = await rest.find("patients", { $match: { name: "Alice" } });
|
|
145
145
|
expect(list[0].address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
146
146
|
expect(list[0].address.city).toBe("Paris");
|
|
147
147
|
|
|
148
|
-
//
|
|
148
|
+
// manual decryption
|
|
149
149
|
const cipher = resolve();
|
|
150
150
|
const zip = await cipher.decrypt<string>(list[0].address.zip, true);
|
|
151
151
|
expect(zip).toBe("75000");
|
|
@@ -158,10 +158,10 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
|
|
|
158
158
|
addresses: [{ zip: "69001" }, { zip: "69002", city: "Lyon" }],
|
|
159
159
|
});
|
|
160
160
|
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|
|
161
|
-
// array
|
|
161
|
+
// array of json: every element encrypted on its zip
|
|
162
162
|
expect(raw!.addresses[0].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
163
163
|
expect(raw!.addresses[1].zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
164
|
-
expect(raw!.addresses[1].city).toBe("Lyon"); // non-
|
|
164
|
+
expect(raw!.addresses[1].city).toBe("Lyon"); // non-encrypted value untouched
|
|
165
165
|
// multiple paths
|
|
166
166
|
expect(raw!.contacts.phone).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
167
167
|
expect(raw!.contacts.email).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
@@ -171,12 +171,12 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
|
|
|
171
171
|
it("encrypts the path on $set replacement and dot-notation $set", async () => {
|
|
172
172
|
const doc: any = await rest.findOne("patients", (await rest.find("patients", { $match: { name: "Alice" } }))[0]!._id);
|
|
173
173
|
|
|
174
|
-
// $set
|
|
174
|
+
// $set of the whole json → zip re-encrypted inside the replacement
|
|
175
175
|
const up1: any = await rest.updateOne("patients", doc._id, { $set: { address: { city: "Nice", zip: "06000" } } });
|
|
176
176
|
expect(up1.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
177
177
|
expect(up1.address.city).toBe("Nice");
|
|
178
178
|
|
|
179
|
-
// dot-notation $set
|
|
179
|
+
// dot-notation $set on the exact path → value encrypted
|
|
180
180
|
const up2: any = await rest.updateOne("patients", doc._id, { $set: { "address.zip": "06100" } });
|
|
181
181
|
expect(up2.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
|
|
182
182
|
const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
|