@anteros/core 0.0.40 → 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 (59) hide show
  1. package/database/audit.ts +193 -0
  2. package/database/collection.ts +3 -3
  3. package/database/deleteLog.ts +34 -0
  4. package/database/file.ts +3 -3
  5. package/database/mongodbadapter.ts +321 -147
  6. package/database/replication.ts +780 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/vars.ts +405 -0
  10. package/database/workflow.ts +6 -6
  11. package/index.ts +10 -0
  12. package/lib/audit.ts +271 -0
  13. package/lib/auditResult.ts +120 -0
  14. package/lib/define.ts +20 -1
  15. package/lib/files.ts +64 -11
  16. package/lib/lifecycle.ts +141 -0
  17. package/lib/redact.ts +117 -0
  18. package/lib/workflow.ts +1 -1
  19. package/package.json +1 -1
  20. package/server/api.ts +109 -25
  21. package/server/boot.ts +185 -53
  22. package/server/flags.ts +136 -0
  23. package/server/hono.ts +1 -1
  24. package/tests/audit-file.test.ts +296 -0
  25. package/tests/audit-operations.test.ts +184 -0
  26. package/tests/audit-redact.test.ts +233 -0
  27. package/tests/audit-results.test.ts +188 -0
  28. package/tests/audit-retention-config.test.ts +76 -0
  29. package/tests/audit-run-action.test.ts +120 -0
  30. package/tests/audit.test.ts +320 -0
  31. package/tests/db-cache.test.ts +7 -7
  32. package/tests/file-collection-replication.test.ts +98 -0
  33. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  34. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  35. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  36. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  37. package/tests/fixtures/repl-only/app.ts +26 -0
  38. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  39. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  40. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  41. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  42. package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
  43. package/tests/lifecycle.test.ts +96 -0
  44. package/tests/replication-control.test.ts +128 -0
  45. package/tests/replication-initial-sync.test.ts +86 -0
  46. package/tests/replication-only.test.ts +358 -0
  47. package/tests/replication.test.ts +188 -0
  48. package/tests/upload.test.ts +102 -1
  49. package/tests/vars-api.test.ts +123 -0
  50. package/tests/vars-replication.test.ts +95 -0
  51. package/tests/vars.test.ts +216 -0
  52. package/types/collection.d.ts +3 -0
  53. package/types/config.d.ts +64 -0
  54. package/types/file.d.ts +4 -1
  55. package/types/lifecycle.d.ts +71 -0
  56. package/types/replication.d.ts +199 -0
  57. package/types/tenant.d.ts +23 -0
  58. package/types/vars.d.ts +147 -0
  59. package/utils/func.ts +67 -52
@@ -0,0 +1,188 @@
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 {
6
+ startReplication,
7
+ stopReplication,
8
+ replicateTenant,
9
+ getReplicationState,
10
+ } from "../database/replication";
11
+
12
+ const TEST_TENANT = "repl";
13
+ const SRC_DB = "mongodb://localhost:27017/_REPL_TEST_SRC";
14
+ const DEST_DB = "mongodb://localhost:27017/_REPL_TEST_DEST";
15
+ const DEST_ID = "backup";
16
+
17
+ let rest: InstanceType<typeof useRest>;
18
+ let sourceClient: MongoClient;
19
+ let destClient: MongoClient;
20
+
21
+ beforeAll(async () => {
22
+ formatConfig({
23
+ server: { port: 4000 },
24
+ tenants: [{
25
+ id: TEST_TENANT,
26
+ dir: "src",
27
+ database: { uri: SRC_DB },
28
+ replication: {
29
+ runOnBoot: false,
30
+ schedule: { interval: "1h" },
31
+ destinations: [{ id: DEST_ID, uri: DEST_DB }],
32
+ },
33
+ }],
34
+ });
35
+
36
+ // Connect the source DB manually with a short timeout so a missing MongoDB
37
+ // fails fast and clearly (instead of hanging or process.exit(1)).
38
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
39
+ await sourceClient.connect();
40
+ const tenant: any = cfg.tenants?.[0];
41
+ tenant.database.client = sourceClient;
42
+ tenant.database.db = sourceClient.db();
43
+
44
+ (cfg as any).collections = [
45
+ {
46
+ _tenant_: TEST_TENANT,
47
+ slug: "orders",
48
+ fields: [
49
+ { name: "title", type: "string" },
50
+ { name: "status", type: "string" },
51
+ { name: "customer", type: "relationship", relation: { to: "customers" } },
52
+ { name: "paidAt", type: "date" },
53
+ ],
54
+ replication: { enabled: true },
55
+ },
56
+ ];
57
+
58
+ await startReplication();
59
+
60
+ rest = new useRest({ tenant_id: TEST_TENANT });
61
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
62
+ await destClient.connect();
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await stopReplication();
67
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
68
+ try { await destClient.db().dropDatabase(); } catch (_) {}
69
+ try { await destClient.close(); } catch (_) {}
70
+ try { await sourceClient.close(); } catch (_) {}
71
+ });
72
+
73
+ describe("replication", () => {
74
+ it("replicates inserted documents", async () => {
75
+ const marker = `ins-${crypto.randomUUID()}`;
76
+ const source: any = await rest.insertOne("orders", { title: marker, status: "new" });
77
+ await replicateTenant(TEST_TENANT);
78
+
79
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
80
+ expect(dest).toBeDefined();
81
+ expect((dest as any)?.status).toBe("new");
82
+ // The destination keeps the **same `_id`** (the upsert is keyed on it), same BSON type
83
+ expect((dest as any)?._id.toString()).toBe(source._id);
84
+ expect((dest as any)?._id).toBeInstanceOf(ObjectId);
85
+ });
86
+
87
+ it("keeps a custom `_id` type (string) as-is", async () => {
88
+ const marker = `custom-${crypto.randomUUID()}`;
89
+ const customId = `order-${crypto.randomUUID()}`; // not a 24-hex string
90
+ await sourceClient.db().collection("orders").insertOne({
91
+ _id: customId as any,
92
+ title: marker,
93
+ status: "new",
94
+ updatedAt: new Date(),
95
+ });
96
+ await replicateTenant(TEST_TENANT);
97
+
98
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
99
+ expect(dest).toBeDefined();
100
+ expect(dest._id).toBe(customId);
101
+ expect(typeof dest._id).toBe("string");
102
+ });
103
+
104
+ it("writes BSON types on the destination — ObjectId relations and Dates stay typed", async () => {
105
+ const marker = `bson-${crypto.randomUUID()}`;
106
+ const customerId = new ObjectId();
107
+ const paidAt = new Date("2026-03-04T05:06:07.000Z");
108
+
109
+ // Through the framework: the relation string and the ISO date are converted
110
+ // to BSON by `toBson` before hitting MongoDB.
111
+ const source: any = await rest.insertOne("orders", {
112
+ title: marker,
113
+ status: "paid",
114
+ customer: customerId.toHexString(),
115
+ paidAt: paidAt.toISOString(),
116
+ });
117
+ await replicateTenant(TEST_TENANT);
118
+
119
+ // …and the destination must hold the same BSON types, not strings
120
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
121
+ expect(dest).toBeDefined();
122
+ expect(dest._id).toBeInstanceOf(ObjectId);
123
+ expect(dest._id.toString()).toBe(source._id);
124
+ expect(dest.customer).toBeInstanceOf(ObjectId);
125
+ expect(dest.customer.toString()).toBe(customerId.toHexString());
126
+ expect(dest.paidAt).toBeInstanceOf(Date);
127
+ expect(dest.paidAt.getTime()).toBe(paidAt.getTime());
128
+ expect(dest.createdAt).toBeInstanceOf(Date);
129
+ expect(dest.updatedAt).toBeInstanceOf(Date);
130
+
131
+ // The source itself stores the very same types (the copy is faithful)
132
+ const raw: any = await sourceClient.db().collection("orders").findOne({ title: marker });
133
+ expect(raw.customer).toBeInstanceOf(ObjectId);
134
+ expect(raw.paidAt).toBeInstanceOf(Date);
135
+ expect(typeof raw.customer).toBe(typeof dest.customer);
136
+ });
137
+
138
+ it("replicates updates", async () => {
139
+ const marker = `upd-${crypto.randomUUID()}`;
140
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "draft" });
141
+ await replicateTenant(TEST_TENANT);
142
+
143
+ await rest.updateOne("orders", doc._id, { $set: { status: "paid" } });
144
+ await replicateTenant(TEST_TENANT);
145
+
146
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
147
+ expect((dest as any)?.status).toBe("paid");
148
+ });
149
+
150
+ it("replicates deletes via tombstone", async () => {
151
+ const marker = `del-${crypto.randomUUID()}`;
152
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "x" });
153
+ await replicateTenant(TEST_TENANT);
154
+
155
+ const before = await destClient.db().collection("orders").findOne({ title: marker });
156
+ expect(before).toBeDefined();
157
+
158
+ await rest.deleteOne("orders", doc._id);
159
+ await replicateTenant(TEST_TENANT);
160
+
161
+ const after = await destClient.db().collection("orders").findOne({ title: marker });
162
+ expect(after).toBeNull();
163
+ });
164
+
165
+ it("does not re-copy unchanged documents (cursor is persisted)", async () => {
166
+ const marker = `resume-${crypto.randomUUID()}`;
167
+ await rest.insertOne("orders", { title: marker, status: "new" });
168
+
169
+ const first = await replicateTenant(TEST_TENANT);
170
+ const second = await replicateTenant(TEST_TENANT);
171
+
172
+ const r1 = first.find((r) => r.collection === "orders" && r.destination === DEST_ID);
173
+ const r2 = second.find((r) => r.collection === "orders" && r.destination === DEST_ID);
174
+ expect(r1?.inserted).toBeGreaterThanOrEqual(1);
175
+ expect(r2?.inserted).toBe(0);
176
+ expect(r2?.updated).toBe(0);
177
+ });
178
+
179
+ it("stores state in _replication_", async () => {
180
+ await rest.insertOne("orders", { title: `state-${crypto.randomUUID()}`, status: "new" });
181
+ await replicateTenant(TEST_TENANT);
182
+
183
+ const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
184
+ expect(state.length).toBe(1);
185
+ expect(state[0]!.status).toBe("success");
186
+ expect(state[0]!.cursor).toBeDefined();
187
+ });
188
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
2
3
  import { createApp } from "../server/hono";
3
4
  import { formatConfig, cfg } from "../server/config";
4
5
  import { syncTenants } from "../database/tenant";
@@ -25,7 +26,7 @@ beforeAll(async () => {
25
26
  _tenant_: TENANT,
26
27
  _isFileCollection_: true,
27
28
  slug: SLUG,
28
- fields: [{ name: "label", type: "string" }],
29
+ fields: [{ name: "label", type: "string" }, { name: "takenAt", type: "date" }],
29
30
  upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
30
31
  storage: { driver: "disk" },
31
32
  api: { access: { "*": true } },
@@ -56,6 +57,65 @@ function pngFile(name: string) {
56
57
  return new File([bytes], name, { type: "image/png" });
57
58
  }
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
+
59
119
  describe("upload — single file (unchanged contract)", () => {
60
120
  it("returns a single object", async () => {
61
121
  const form = new FormData();
@@ -71,10 +131,17 @@ describe("upload — single file (unchanged contract)", () => {
71
131
  expect(body._file.name).toBe("one.png");
72
132
  expect(body._file.mimetype).toBe("image/png");
73
133
  expect(body._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
134
+ expect(body.createdAt).toBeString();
135
+ expect(body.updatedAt).toBeString();
74
136
 
75
137
  // metadata persisted on the document
76
138
  const doc: any = await rest.findOne(SLUG, body._id);
77
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);
78
145
  });
79
146
 
80
147
  it("accepts the 'upload' field name too", async () => {
@@ -112,6 +179,9 @@ describe("upload — multiple files", () => {
112
179
  const doc: any = await rest.findOne(SLUG, id);
113
180
  expect(doc.label).toBe("batch");
114
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();
115
185
  }
116
186
  });
117
187
 
@@ -136,3 +206,34 @@ describe("upload — multiple files", () => {
136
206
  expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
137
207
  });
138
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
+ });