@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,239 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
3
+ import { createApp } from "../server/hono";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncTenants } from "../database/tenant";
6
+ import { getFileCollection } from "../database/file";
7
+ import { useRest } from "../database/rest";
8
+
9
+ const TENANT = "up-test";
10
+ const DB = "mongodb://localhost:27017/_DB_UP_TEST";
11
+ const SLUG = "photos";
12
+
13
+ let rest: InstanceType<typeof useRest>;
14
+ let server: any;
15
+ let url = "";
16
+
17
+ beforeAll(async () => {
18
+ formatConfig({
19
+ server: { port: 4000 },
20
+ tenants: [{ id: TENANT, dir: "up-tenant", database: { uri: DB } }],
21
+ });
22
+ await syncTenants();
23
+
24
+ // File collection (disk storage) — no syncing from disk, declared directly
25
+ (cfg as any).fileCollections = [{
26
+ _tenant_: TENANT,
27
+ _isFileCollection_: true,
28
+ slug: SLUG,
29
+ fields: [{ name: "label", type: "string" }, { name: "takenAt", type: "date" }],
30
+ upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
31
+ storage: { driver: "disk" },
32
+ api: { access: { "*": true } },
33
+ }];
34
+
35
+ rest = new useRest({ internal: false, tenant_id: TENANT });
36
+ const app = createApp();
37
+ server = Bun.serve({ port: 0, fetch: app.fetch });
38
+ url = server.url.href.replace(/\/$/, "");
39
+ });
40
+
41
+ afterAll(async () => {
42
+ try { await rest.db.collection(SLUG).drop(); } catch {}
43
+ // clean the uploaded files
44
+ try {
45
+ const fs = await import("node:fs/promises");
46
+ await fs.rm(`${process.cwd()}/storage/${TENANT}/${SLUG}`, { recursive: true, force: true });
47
+ } catch {}
48
+ try { server.stop(true); } catch {}
49
+ });
50
+
51
+ function pngFile(name: string) {
52
+ // minimal valid PNG (1x1) so content detection sees a real image
53
+ const bytes = Buffer.from(
54
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
55
+ "base64"
56
+ );
57
+ return new File([bytes], name, { type: "image/png" });
58
+ }
59
+
60
+ describe("upload — audit trail", () => {
61
+ /** Latest audit entry for an action, polling until it lands */
62
+ async function auditEntry(action: string, id?: string) {
63
+ const filter: any = { "operation.action": action };
64
+ if (id) filter["operation.result._id"] = id;
65
+ for (let i = 0; i < 40; i++) {
66
+ const found: any = await rest.db.collection("_audit_").findOne(filter, { sort: { ts: -1 } });
67
+ if (found) return found;
68
+ await Bun.sleep(25);
69
+ }
70
+ return null;
71
+ }
72
+
73
+ it("records an upload with the file metadata and the generated id — never the document", async () => {
74
+ const form = new FormData();
75
+ form.append("file", pngFile("audited.png"));
76
+ form.append("label", "audited");
77
+
78
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
79
+ expect(res.status).toBe(200);
80
+ const body: any = await res.json();
81
+
82
+ const logged: any = await auditEntry("upload", body._id);
83
+ expect(logged).not.toBeNull();
84
+ expect(logged.operation.collection).toBe(SLUG);
85
+ expect(logged.operation.collectionType).toBe("file");
86
+ expect(logged.operation.status).toBe("success");
87
+ expect(logged.internal).toBe(false); // HTTP upload
88
+
89
+ // parameters only: the file's metadata and which custom fields were sent
90
+ expect(logged.operation.input.file.name).toBe("audited.png");
91
+ expect(logged.operation.input.file.mimetype).toBe("image/png");
92
+ expect(typeof logged.operation.input.file.size).toBe("number");
93
+ expect(logged.operation.input.fields).toEqual(["label"]);
94
+ expect(logged.operation.input.label).toBeUndefined(); // the value is NOT stored
95
+
96
+ // and the generated id, never the stored document
97
+ expect(logged.operation.result).toEqual({ _id: body._id });
98
+ });
99
+
100
+ it("records a file deletion", async () => {
101
+ const form = new FormData();
102
+ form.append("file", pngFile("deleted.png"));
103
+ const uploaded: any = await (
104
+ await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form })
105
+ ).json();
106
+
107
+ const res = await fetch(`${url}/files/${TENANT}/${SLUG}/${uploaded._id}`, { method: "DELETE" });
108
+ expect(res.status).toBe(200);
109
+
110
+ const logged: any = await auditEntry("deleteFile");
111
+ expect(logged).not.toBeNull();
112
+ expect(logged.operation.input.id).toBe(uploaded._id);
113
+ expect(logged.operation.input.filename).toBeString();
114
+ expect(logged.operation.result).toEqual({ deleted: true });
115
+ expect(logged.internal).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("upload — single file (unchanged contract)", () => {
120
+ it("returns a single object", async () => {
121
+ const form = new FormData();
122
+ form.append("file", pngFile("one.png"));
123
+ form.append("label", "single");
124
+
125
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
126
+ expect(res.status).toBe(200);
127
+
128
+ const body: any = await res.json();
129
+ expect(Array.isArray(body)).toBe(false); // ← object, not an array
130
+ expect(body._id).toBeString();
131
+ expect(body._file.name).toBe("one.png");
132
+ expect(body._file.mimetype).toBe("image/png");
133
+ expect(body._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
134
+ expect(body.createdAt).toBeString();
135
+ expect(body.updatedAt).toBeString();
136
+
137
+ // metadata persisted on the document
138
+ const doc: any = await rest.findOne(SLUG, body._id);
139
+ expect(doc.label).toBe("single");
140
+
141
+ // Stored as BSON Dates (same end result as collections via toBson)
142
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
143
+ expect(raw.createdAt).toBeInstanceOf(Date);
144
+ expect(raw.updatedAt).toBeInstanceOf(Date);
145
+ });
146
+
147
+ it("accepts the 'upload' field name too", async () => {
148
+ const form = new FormData();
149
+ form.append("upload", pngFile("alt.png"));
150
+
151
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
152
+ expect(res.status).toBe(200);
153
+ const body: any = await res.json();
154
+ expect(Array.isArray(body)).toBe(false);
155
+ expect(body._file.name).toBe("alt.png");
156
+ });
157
+ });
158
+
159
+ describe("upload — multiple files", () => {
160
+ it("returns an array and creates one document per file", async () => {
161
+ const form = new FormData();
162
+ form.append("file", pngFile("a.png"));
163
+ form.append("file", pngFile("b.png"));
164
+ form.append("file", pngFile("c.png"));
165
+ form.append("label", "batch");
166
+
167
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
168
+ expect(res.status).toBe(200);
169
+
170
+ const body: any = await res.json();
171
+ expect(Array.isArray(body)).toBe(true);
172
+ expect(body.length).toBe(3);
173
+ expect(body.map((f: any) => f._file.name).sort()).toEqual(["a.png", "b.png", "c.png"]);
174
+
175
+ // every file is its own document, all ids distinct, metadata shared
176
+ const ids = body.map((f: any) => f._id);
177
+ expect(new Set(ids).size).toBe(3);
178
+ for (const id of ids) {
179
+ const doc: any = await rest.findOne(SLUG, id);
180
+ expect(doc.label).toBe("batch");
181
+ expect(doc._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
182
+ // Uploads carry timestamps (required by the replication cursor)
183
+ expect(doc.createdAt).toBeString();
184
+ expect(doc.updatedAt).toBeString();
185
+ }
186
+ });
187
+
188
+ it("still rejects a request without any file", async () => {
189
+ const form = new FormData();
190
+ form.append("label", "nofile");
191
+
192
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
193
+ expect(res.status).toBe(400);
194
+ const body: any = await res.json();
195
+ expect(body.code).toBe("FILE_REQUIRED");
196
+ });
197
+
198
+ it("rejects the whole batch on an invalid file (per-file validation)", async () => {
199
+ const form = new FormData();
200
+ form.append("file", pngFile("ok.png"));
201
+ form.append("file", new File([Buffer.from("nope")], "bad.exe", { type: "application/x-msdownload" }));
202
+
203
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
204
+ expect(res.status).toBe(400);
205
+ const body: any = await res.json();
206
+ expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
207
+ });
208
+ });
209
+
210
+ describe("upload — custom field types (toBson, like collections)", () => {
211
+ it("converts a `date` field to a BSON Date", async () => {
212
+ const form = new FormData();
213
+ form.append("file", pngFile("dated.png"));
214
+ form.append("label", "dated");
215
+ form.append("takenAt", "2024-05-01T10:00:00.000Z");
216
+
217
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
218
+ expect(res.status).toBe(200);
219
+ const body: any = await res.json();
220
+
221
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
222
+ expect(raw.takenAt).toBeInstanceOf(Date);
223
+ expect(raw.takenAt.toISOString()).toBe("2024-05-01T10:00:00.000Z");
224
+ });
225
+
226
+ it("leaves `_file` untouched, even when a name looks like a date", async () => {
227
+ const form = new FormData();
228
+ form.append("file", pngFile("2024-01-01")); // ← a file literally named like a date
229
+
230
+ const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
231
+ expect(res.status).toBe(200);
232
+ const body: any = await res.json();
233
+ expect(body._file.name).toBe("2024-01-01");
234
+
235
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
236
+ expect(raw._file.name).toBe("2024-01-01"); // NOT converted to a Date
237
+ expect(typeof raw._file.name).toBe("string");
238
+ });
239
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
3
+ import { createApp } from "../server/hono";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncTenants } from "../database/tenant";
6
+ import { syncVars } from "../database/vars";
7
+ import { useRest } from "../database/rest";
8
+
9
+ const TENANT = "vars-api";
10
+ const DB = "mongodb://localhost:27017/_VARS_API_TEST";
11
+ const DIR = "packages/core/tests/fixtures/vars-tenant";
12
+
13
+ let rest: InstanceType<typeof useRest>;
14
+ let server: any;
15
+ let url = "";
16
+
17
+ beforeAll(async () => {
18
+ formatConfig({
19
+ server: { port: 4000 },
20
+ tenants: [{ id: TENANT, dir: DIR, database: { uri: DB } }],
21
+ });
22
+ await syncTenants();
23
+ await syncVars();
24
+
25
+ rest = new useRest({ internal: false, tenant_id: TENANT });
26
+ const app = createApp();
27
+ server = Bun.serve({ port: 0, fetch: app.fetch });
28
+ url = server.url.href.replace(/\/$/, "");
29
+ });
30
+
31
+ afterAll(async () => {
32
+ try { await rest.db.dropDatabase(); } catch (_) {}
33
+ try { server.stop(true); } catch (_) {}
34
+ });
35
+
36
+ const post = (action: string, body: any) =>
37
+ fetch(`${url}/vars/${TENANT}/${action}`, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify(body),
41
+ });
42
+
43
+ const json = async (action: string, body: any) => {
44
+ const res = await post(action, body);
45
+ return { status: res.status, body: await res.json() as any };
46
+ };
47
+
48
+ describe("vars HTTP API — access control", () => {
49
+ it("denies a namespace without api.access", async () => {
50
+ const res = await json("get", { ns: "private", key: "secret" });
51
+ expect(res.status).toBe(401);
52
+ expect(res.body.code).toBe("ACCESS_DENIED");
53
+ });
54
+
55
+ it("rejects an unknown namespace", async () => {
56
+ const res = await json("get", { ns: "nope", key: "x" });
57
+ expect(res.status).toBe(400);
58
+ expect(res.body.code).toBe("NAMESPACE_NOT_FOUND");
59
+ });
60
+
61
+ it("requires the namespace", async () => {
62
+ const res = await json("get", { key: "x" });
63
+ expect(res.status).toBe(400);
64
+ expect(res.body.code).toBe("NAMESPACE_REQUIRED");
65
+ });
66
+ });
67
+
68
+ describe("vars HTTP API — CRUD", () => {
69
+ it("set / get / has / del", async () => {
70
+ expect((await json("set", { ns: "config", key: "plan", value: "pro" })).status).toBe(200);
71
+
72
+ const got = await json("get", { ns: "config", key: "plan" });
73
+ expect(got.body.value).toBe("pro");
74
+
75
+ expect((await json("has", { ns: "config", key: "plan" })).body.exists).toBe(true);
76
+ expect((await json("del", { ns: "config", key: "plan" })).body.ok).toBe(true);
77
+ expect((await json("get", { ns: "config", key: "plan" })).body.value).toBeUndefined();
78
+ });
79
+
80
+ it("setMany / all", async () => {
81
+ await json("setMany", { ns: "config", entries: { a: 1, b: 2 } });
82
+ const all = await json("all", { ns: "config" });
83
+ expect(all.body.vars).toMatchObject({ a: 1, b: 2 });
84
+ });
85
+
86
+ it("entry carries the meta", async () => {
87
+ const id = new ObjectId().toHexString();
88
+ await json("set", { ns: "config", key: "lic", value: "L-1", meta: { note: "hi" }, scope: id });
89
+
90
+ const entry = await json("entry", { ns: "config", key: "lic", scope: id });
91
+ expect(entry.body.entry.value).toBe("L-1");
92
+ expect(entry.body.entry.meta).toEqual({ note: "hi" });
93
+ });
94
+
95
+ it("incr", async () => {
96
+ await json("set", { ns: "config", key: "hits", value: 5 });
97
+ expect((await json("incr", { ns: "config", key: "hits" })).body.value).toBe(6);
98
+ });
99
+
100
+ it("scope keeps values isolated", async () => {
101
+ const id = new ObjectId().toHexString();
102
+ await json("set", { ns: "config", key: "lic", value: "SCOPED", scope: id });
103
+ expect((await json("get", { ns: "config", key: "lic", scope: id })).body.value).toBe("SCOPED");
104
+ });
105
+
106
+ it("TTL round-trips as an expiry", async () => {
107
+ await json("set", { ns: "config", key: "tmp", value: "x", ttl: "1h" });
108
+ const entry = await json("entry", { ns: "config", key: "tmp" });
109
+ expect(typeof entry.body.entry.expiresAt).toBe("string");
110
+ expect((await json("expire", { ns: "config", key: "tmp", ttl: "30m" })).body.ok).toBe(true);
111
+ });
112
+
113
+ it("validates the value against its spec", async () => {
114
+ const res = await json("set", { ns: "config", key: "maxUsers", value: "not-a-number" });
115
+ expect(res.status).toBe(400);
116
+ expect(res.body.code).toBe("VALIDATION_ERROR");
117
+ });
118
+
119
+ it("rejects an unknown action", async () => {
120
+ const res = await json("nope", { ns: "config", key: "x" });
121
+ expect(res.status).toBe(400);
122
+ });
123
+ });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncVars } from "../database/vars";
6
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
7
+
8
+ const TEST_TENANT = "vars-repl";
9
+ const SRC_DB = "mongodb://localhost:27017/_VARS_REPL_SRC";
10
+ const DEST_DB = "mongodb://localhost:27017/_VARS_REPL_DEST";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+ let sourceClient: MongoClient;
14
+ let destClient: MongoClient;
15
+
16
+ beforeAll(async () => {
17
+ formatConfig({
18
+ server: { port: 4000 },
19
+ tenants: [{
20
+ id: TEST_TENANT,
21
+ dir: "packages/core/tests/fixtures/vars-tenant",
22
+ database: { uri: SRC_DB },
23
+ replication: {
24
+ runOnBoot: false,
25
+ schedule: { interval: "1h" },
26
+ destinations: [{ id: "backup", uri: DEST_DB }],
27
+ },
28
+ }],
29
+ });
30
+
31
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
32
+ await sourceClient.connect();
33
+ const tenant: any = cfg.tenants?.[0];
34
+ tenant.database.client = sourceClient;
35
+ tenant.database.db = sourceClient.db();
36
+
37
+ await syncVars();
38
+ await startReplication();
39
+
40
+ rest = new useRest({ tenant_id: TEST_TENANT });
41
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
42
+ await destClient.connect();
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await stopReplication();
47
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
48
+ try { await destClient.db().dropDatabase(); } catch (_) {}
49
+ try { await destClient.close(); } catch (_) {}
50
+ try { await sourceClient.close(); } catch (_) {}
51
+ });
52
+
53
+ const destVar = (key: string, ns = "config") =>
54
+ destClient.db().collection("_vars_").findOne({ ns, key });
55
+
56
+ describe("vars replication", () => {
57
+ it("replicates the `_vars_` collection (all namespaces)", async () => {
58
+ await rest.vars.set("config", "licence", "REPL-1");
59
+ await replicateTenant(TEST_TENANT);
60
+
61
+ expect((await destVar("licence"))?.value).toBe("REPL-1");
62
+
63
+ // Seeded defaults are replicated too
64
+ expect((await destVar("maxUsers"))?.value).toBe(100);
65
+ });
66
+
67
+ it("propagates variable deletion", async () => {
68
+ await rest.vars.set("config", "tmp", "x", { ttl: "1h" });
69
+ await replicateTenant(TEST_TENANT);
70
+ expect(await destVar("tmp")).toBeDefined();
71
+
72
+ await rest.vars.del("config", "tmp");
73
+ await replicateTenant(TEST_TENANT);
74
+ expect(await destVar("tmp")).toBeNull();
75
+ });
76
+
77
+ it("propagates scoped variables", async () => {
78
+ const mine = "aabbccddeeff001122334455";
79
+ await rest.vars.scope(mine).set("config", "licence", "SCOPED-1");
80
+ await replicateTenant(TEST_TENANT);
81
+
82
+ const dest = await destClient.db().collection("_vars_").findOne({
83
+ ns: "config", key: "licence", scope: { $ne: null },
84
+ });
85
+ expect(dest?.value).toBe("SCOPED-1");
86
+ });
87
+
88
+ it("does NOT replicate namespaces that did not opt in", async () => {
89
+ // `private.var.ts` has no `replication` — it must never reach the destination.
90
+ await rest.vars.set("private", "secret", "top-secret");
91
+ await replicateTenant(TEST_TENANT);
92
+
93
+ expect(await destClient.db().collection("_vars_").findOne({ ns: "private" })).toBeNull();
94
+ });
95
+ });
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient, ObjectId } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncVars } from "../database/vars";
6
+ import { define } from "../lib/define";
7
+
8
+ const TEST_TENANT = "vars";
9
+ const DB = "mongodb://localhost:27017/_VARS_TEST";
10
+
11
+ let rest: InstanceType<typeof useRest>;
12
+ let client: MongoClient;
13
+ const raw = () => client.db().collection("_vars_");
14
+
15
+ beforeAll(async () => {
16
+ formatConfig({
17
+ server: { port: 4000 },
18
+ tenants: [{
19
+ id: TEST_TENANT,
20
+ dir: "packages/core/tests/fixtures/vars-tenant",
21
+ database: { uri: DB },
22
+ }],
23
+ });
24
+
25
+ client = new MongoClient(DB, { serverSelectionTimeoutMS: 3000 });
26
+ await client.connect();
27
+ const tenant: any = cfg.tenants?.[0];
28
+ tenant.database.client = client;
29
+ tenant.database.db = client.db();
30
+
31
+ await syncVars();
32
+
33
+ rest = new useRest({ tenant_id: TEST_TENANT });
34
+ });
35
+
36
+ afterAll(async () => {
37
+ try { await client.db().dropDatabase(); } catch (_) {}
38
+ try { await client.close(); } catch (_) {}
39
+ });
40
+
41
+ describe("define.Vars", () => {
42
+ it("marks the definition", () => {
43
+ const v = define.Vars({ namespace: "x", vars: { a: 1 } });
44
+ expect(v._isVars_).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("vars loading & seeding", () => {
49
+ it("loads the definitions from vars/*.var.ts", () => {
50
+ const namespaces = (cfg.vars ?? []).map((v) => v.namespace).sort();
51
+ expect(namespaces).toEqual(["config", "private"]);
52
+ expect((cfg.vars ?? []).every((v) => v._tenant_ === TEST_TENANT)).toBe(true);
53
+ });
54
+
55
+ it("seeds default values at the global scope", async () => {
56
+ expect(await rest.vars.get<string>("config", "licence")).toBe("RDX00");
57
+ expect(await rest.vars.get<number>("config", "maxUsers")).toBe(100);
58
+ });
59
+
60
+ it("never overwrites an existing value when re-seeding", async () => {
61
+ await rest.vars.set("config", "licence", "CHANGED");
62
+ await syncVars();
63
+ expect(await rest.vars.get<string>("config", "licence")).toBe("CHANGED");
64
+ });
65
+
66
+ it("always creates the scope + TTL indexes", async () => {
67
+ const names = (await raw().listIndexes().toArray()).map((i) => i.name);
68
+ expect(names).toContain("scope_1_ns_1");
69
+ expect(names).toContain("expiresAt_1");
70
+ });
71
+ });
72
+
73
+ describe("rest.vars — CRUD", () => {
74
+ it("set / get / has / del", async () => {
75
+ await rest.vars.set("app", "theme", "dark");
76
+ expect(await rest.vars.get<string>("app", "theme")).toBe("dark");
77
+ expect(await rest.vars.has("app", "theme")).toBe(true);
78
+
79
+ expect(await rest.vars.del("app", "theme")).toBe(true);
80
+ expect(await rest.vars.get<string>("app", "theme")).toBeUndefined();
81
+ expect(await rest.vars.has("app", "theme")).toBe(false);
82
+ expect(await rest.vars.del("app", "theme")).toBe(false);
83
+ });
84
+
85
+ it("stores rich values (objects, arrays, booleans)", async () => {
86
+ const value = { max: 10, tags: ["a", "b"], on: true };
87
+ await rest.vars.set("app", "limits", value);
88
+ expect(await rest.vars.get<any>("app", "limits")).toEqual(value);
89
+ });
90
+
91
+ it("setMany / all / clear", async () => {
92
+ await rest.vars.setMany("bulk", { a: 1, b: 2, c: 3 });
93
+ expect(await rest.vars.all("bulk")).toEqual({ a: 1, b: 2, c: 3 });
94
+ expect(await rest.vars.clear("bulk")).toBe(3);
95
+ expect(await rest.vars.all("bulk")).toEqual({});
96
+ });
97
+
98
+ it("incr is atomic (and creates a missing key)", async () => {
99
+ await rest.vars.set("counters", "hits", 5);
100
+ expect(await rest.vars.incr("counters", "hits")).toBe(6);
101
+ expect(await rest.vars.incr("counters", "hits", 10)).toBe(16);
102
+ expect(await rest.vars.incr("counters", "fresh", 3)).toBe(3);
103
+ });
104
+
105
+ it("isolates namespaces", async () => {
106
+ await rest.vars.set("ns1", "k", "a");
107
+ await rest.vars.set("ns2", "k", "b");
108
+ expect(await rest.vars.get<string>("ns1", "k")).toBe("a");
109
+ expect(await rest.vars.get<string>("ns2", "k")).toBe("b");
110
+ });
111
+ });
112
+
113
+ describe("rest.vars — scope (per company)", () => {
114
+ it("keeps a distinct value per scope", async () => {
115
+ const acme = new ObjectId().toHexString();
116
+ const globex = new ObjectId().toHexString();
117
+
118
+ await rest.vars.scope(acme).set("config", "licence", "ACME-001");
119
+ await rest.vars.scope(globex).set("config", "licence", "GLBX-002");
120
+
121
+ expect(await rest.vars.scope(acme).get<string>("config", "licence")).toBe("ACME-001");
122
+ expect(await rest.vars.scope(globex).get<string>("config", "licence")).toBe("GLBX-002");
123
+ // global value untouched
124
+ expect(await rest.vars.get<string>("config", "licence")).not.toBe("ACME-001");
125
+ });
126
+
127
+ it("accepts an explicit scope option", async () => {
128
+ const id = new ObjectId().toHexString();
129
+ await rest.vars.set("config", "licence", "SCOPED", { scope: id });
130
+ expect(await rest.vars.get<string>("config", "licence", { scope: id })).toBe("SCOPED");
131
+ });
132
+ });
133
+
134
+ describe("rest.vars — meta", () => {
135
+ it("stores and returns validated meta", async () => {
136
+ const id = new ObjectId().toHexString();
137
+ const acme = rest.vars.scope(id);
138
+
139
+ await acme.set("config", "licence", "ACME-003", { meta: { note: "Contrat 2026" } });
140
+ const entry = await acme.entry("config", "licence");
141
+
142
+ expect(entry?.value).toBe("ACME-003");
143
+ expect(entry?.meta).toEqual({ note: "Contrat 2026" });
144
+ expect(String(entry?.scope)).toBe(id);
145
+ });
146
+
147
+ it("rejects unknown meta keys", async () => {
148
+ await expect(
149
+ rest.vars.set("config", "licence", "X", { meta: { nope: 1 } }),
150
+ ).rejects.toThrow(/nope/);
151
+ });
152
+
153
+ it("filters entries on meta with `where`", async () => {
154
+ const id = new ObjectId().toHexString();
155
+ const acme = rest.vars.scope(id);
156
+
157
+ await acme.setMany("flags", { a: true, b: false }, { meta: { note: "grp" } });
158
+ const found = await acme.entries("flags", { where: { note: "grp" } });
159
+
160
+ expect(found.length).toBe(2);
161
+ expect(await acme.entries("flags", { where: { note: "other" } })).toEqual([]);
162
+ });
163
+ });
164
+
165
+ describe("rest.vars — validation", () => {
166
+ it("rejects a value that does not match its spec", async () => {
167
+ await expect(rest.vars.set("config", "maxUsers", "not-a-number")).rejects.toThrow(/maxUsers/);
168
+ });
169
+ });
170
+
171
+ describe("rest.vars — TTL", () => {
172
+ it("stores an expiry set per call", async () => {
173
+ await rest.vars.set("ttl", "k", "v", { ttl: "1h" });
174
+ const entry = await rest.vars.entry("ttl", "k");
175
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
176
+ expect(entry!.expiresAt!.getTime()).toBeGreaterThan(Date.now());
177
+ });
178
+
179
+ it("applies the variable's default TTL", async () => {
180
+ await rest.vars.set("config", "session", "s1"); // `session` declares ttl: '1h'
181
+ const entry = await rest.vars.entry("config", "session");
182
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
183
+ });
184
+
185
+ it("`set` without ttl clears an existing one", async () => {
186
+ await rest.vars.set("ttl", "clear", "v", { ttl: "1h" });
187
+ await rest.vars.set("ttl", "clear", "v2"); // no ttl → authoritative reset
188
+ const entry = await rest.vars.entry("ttl", "clear");
189
+ expect(entry?.expiresAt).toBeNull();
190
+ });
191
+
192
+ it("`expire` sets / renews the TTL", async () => {
193
+ await rest.vars.set("ttl", "renew", "v");
194
+ expect(await rest.vars.expire("ttl", "renew", "30m")).toBe(true);
195
+ const entry = await rest.vars.entry("ttl", "renew");
196
+ expect(entry?.expiresAt).toBeInstanceOf(Date);
197
+ });
198
+
199
+ it("hides an expired entry from reads", async () => {
200
+ await raw().insertOne({
201
+ _id: "config:gone",
202
+ ns: "config", key: "gone", scope: null,
203
+ value: "x", meta: {},
204
+ expiresAt: new Date(Date.now() - 1000),
205
+ createdAt: new Date(), updatedAt: new Date(),
206
+ } as any);
207
+
208
+ expect(await rest.vars.get<string>("config", "gone")).toBeUndefined();
209
+ expect(await rest.vars.has("config", "gone")).toBe(false);
210
+ expect(await rest.vars.entry("config", "gone")).toBeUndefined();
211
+ });
212
+
213
+ it("rejects an invalid ttl", async () => {
214
+ await expect(rest.vars.set("ttl", "bad", "v", { ttl: "abc" })).rejects.toThrow(/ttl/);
215
+ });
216
+ });