@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.
Files changed (64) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +4 -4
  3. package/database/deleteLog.ts +34 -0
  4. package/database/field.crypto.ts +1 -1
  5. package/database/file.ts +3 -3
  6. package/database/mongodbadapter.ts +321 -147
  7. package/database/replication.ts +780 -0
  8. package/database/schema.ts +140 -125
  9. package/database/tenant.ts +4 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +6 -6
  12. package/index.ts +10 -0
  13. package/lib/audit.ts +271 -0
  14. package/lib/auditResult.ts +120 -0
  15. package/lib/define.ts +20 -1
  16. package/lib/files.ts +64 -11
  17. package/lib/lifecycle.ts +141 -0
  18. package/lib/redact.ts +117 -0
  19. package/lib/workflow.ts +1 -1
  20. package/package.json +1 -1
  21. package/server/api.ts +134 -33
  22. package/server/boot.ts +185 -53
  23. package/server/flags.ts +136 -0
  24. package/server/hono.ts +1 -1
  25. package/tests/audit-file.test.ts +296 -0
  26. package/tests/audit-operations.test.ts +184 -0
  27. package/tests/audit-redact.test.ts +233 -0
  28. package/tests/audit-results.test.ts +188 -0
  29. package/tests/audit-retention-config.test.ts +76 -0
  30. package/tests/audit-run-action.test.ts +120 -0
  31. package/tests/audit.test.ts +320 -0
  32. package/tests/crypto.test.ts +17 -17
  33. package/tests/db-cache.test.ts +7 -7
  34. package/tests/field-encryption.test.ts +25 -25
  35. package/tests/file-collection-replication.test.ts +98 -0
  36. package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
  37. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  38. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  39. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  40. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  41. package/tests/fixtures/repl-only/app.ts +26 -0
  42. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  43. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  44. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  45. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  46. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  47. package/tests/index-spec.test.ts +5 -5
  48. package/tests/lifecycle.test.ts +96 -0
  49. package/tests/replication-control.test.ts +128 -0
  50. package/tests/replication-initial-sync.test.ts +86 -0
  51. package/tests/replication-only.test.ts +358 -0
  52. package/tests/replication.test.ts +188 -0
  53. package/tests/upload.test.ts +239 -0
  54. package/tests/vars-api.test.ts +123 -0
  55. package/tests/vars-replication.test.ts +95 -0
  56. package/tests/vars.test.ts +216 -0
  57. package/types/collection.d.ts +3 -0
  58. package/types/config.d.ts +64 -0
  59. package/types/file.d.ts +4 -1
  60. package/types/lifecycle.d.ts +71 -0
  61. package/types/replication.d.ts +199 -0
  62. package/types/tenant.d.ts +23 -0
  63. package/types/vars.d.ts +147 -0
  64. 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
+ });
@@ -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 contenant des sous-champs chiffrés (convention field-level : JSON.stringify)
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
- // sans les champs → sous-champs toujours chiffrés
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"); // pas un ciphertext → inchangé
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 conteneur direct (ex: doc.addresses renvoyé par une requête)
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-chiffré conservé
115
- expect(dec).not.toBe(arr); // copie, pas de mutation
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 dont le plaintext est un array de json
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 par défaut
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 : nouvelle clé active v2, l'ancienne conservée en decrypt-only
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 avec la même instance
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
- // Simule un ciphertext produit AVANT la rotation (format sans octet de version)
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
- // même instance → OK (défaut appliqué au decrypt aussi)
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
- // sans l'AAD config → échec (tag invalide)
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
- // le défaut ne déchiffre pas le chiffré lié à l'explicite
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" }); // chiffré SANS AAD (ancien comportement)
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 avec AAD par défaut
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 fraîche, pas de mode config
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: [] });
@@ -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 avec un ObjectId NATIF (pas un string) — la normalisation String(id) doit matcher l'index
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); // invalidé + refetch
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 } }; // ordre garanti: a(1), b(2), c(3)
172
- await rest.find("items", params, { useCache: true }); // cache peuplé (3 docs)
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
- // le 3e id (c) n'est PAS taggé (maxTags: 2) → pas d'invalidation ciblée
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 conservé
177
+ expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache kept
178
178
 
179
- // le 1er id (a) est taggé → invalidation ciblée
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 }, // champ chiffré
28
- { name: "profile", type: "json", encryption: true }, // objet chiffré
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 : seul address.zip est chiffré
31
- { name: "contacts", type: "json", encryptionOptions: { path: ["phone", "email"] } }, // plusieurs chemins
32
- { name: "addresses", type: "json", encryptionOptions: { path: "zip" } }, // array de json
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
- // réponse API = plaintext
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
- // en base = ciphertext (version byte + IV + data)
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
- // champ non chiffré intact
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
- // lecture = ciphertext (jamais décrypté automatiquement)
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
- // champs non chiffrés intacts
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
- // décryptage MANUEL explicite (utils.crypt.resolve())
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
- // retour = ciphertext (plus de décryptage automatique)
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
- // base = nouveau ciphertext
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
- // lecture = ciphertext, décrypté manuellement
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 sur age (non chiffré) OK
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 sur champ chiffré → rejeté
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 pour les tests suivants
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
- // réponse = plaintext partout
133
+ // response = plaintext everywhere
134
134
  expect(doc.address.zip).toBe("75000");
135
135
  expect(doc.address.city).toBe("Paris");
136
136
 
137
- // en base : seul zip est ciphertext, le reste est en clair
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
- // lecture rest = ciphertext au path, plaintext ailleurs
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
- // décryptage manuel
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 de json : chaque élément chiffré sur son zip
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-chiffré intact
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 du json complet → zip re-chiffré dans le remplacement
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 sur le path exact → valeur chiffrée
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) });