@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,233 @@
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 { useRest } from "../database/rest";
7
+ import { AUDIT_COLLECTION } from "../database/audit";
8
+ import { REDACTED, redactPayload } from "../lib/redact";
9
+
10
+ const TENANT = "redact-test";
11
+ const DB = "mongodb://localhost:27017/_AUDIT_REDACT_TEST";
12
+ const SLUG = "patients";
13
+
14
+ let rest: InstanceType<typeof useRest>;
15
+ let server: any;
16
+ let url = "";
17
+
18
+ /** Latest audited entry for an action, written *after* `since` (fire-and-forget logging) */
19
+ async function lastActivity(action: string, since?: Date) {
20
+ for (let i = 0; i < 40; i++) {
21
+ const found: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
22
+ { "operation.action": action, ...(since ? { ts: { $gte: since } } : {}) },
23
+ { sort: { ts: -1 } },
24
+ );
25
+ if (found) return found;
26
+ await Bun.sleep(25);
27
+ }
28
+ return null;
29
+ }
30
+
31
+ const PNG = Buffer.from(
32
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
33
+ "base64",
34
+ );
35
+
36
+ beforeAll(async () => {
37
+ formatConfig({
38
+ server: { port: 4000, jwt: { secret: "redact-secret" } },
39
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
40
+ });
41
+ await syncTenants();
42
+
43
+ (cfg as any).collections = [{
44
+ _tenant_: TENANT,
45
+ slug: SLUG,
46
+ fields: [
47
+ { name: "name", type: "string" },
48
+ { name: "password", type: "string" },
49
+ { name: "ssn", type: "string" },
50
+ { name: "profile", type: "json" },
51
+ { name: "list", type: "json" },
52
+ ],
53
+ api: {
54
+ access: { "*": true },
55
+ auth: {
56
+ enabled: true,
57
+ onLogin: async ({ payload, jwt }: any) => ({
58
+ // `jwt.sign` is async — must be awaited, or the token fails verification
59
+ token: await jwt.sign({ sub: payload.email }),
60
+ data: { email: payload.email },
61
+ }),
62
+ },
63
+ },
64
+ }];
65
+
66
+ rest = new useRest({ internal: false, tenant_id: TENANT });
67
+
68
+ const app = createApp();
69
+ server = Bun.serve({ port: 0, fetch: app.fetch });
70
+ url = server.url.href.replace(/\/$/, "");
71
+ });
72
+
73
+ afterAll(async () => {
74
+ try { await rest.db.collection(SLUG).drop(); } catch (_) {}
75
+ try { await rest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
76
+ try { server.stop(true); } catch {}
77
+ delete (cfg.server as any).audit;
78
+ });
79
+
80
+ describe("audit redaction — defaults", () => {
81
+ it("masks the password of a login payload (success path)", async () => {
82
+ const since = new Date();
83
+ const res = await fetch(`${url}/api/${TENANT}/${SLUG}/login`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ payload: { email: "ada@example.com", password: "SuperSecret123!" } }),
87
+ });
88
+ expect(res.status).toBe(200);
89
+
90
+ const logged = await lastActivity("login", since);
91
+ expect(logged.operation.status).toBe("success");
92
+ expect(logged.operation.input.payload.password).toBe(REDACTED);
93
+ expect(logged.operation.input.payload.email).toBe("ada@example.com"); // untouched
94
+ });
95
+
96
+ it("masks the password of a login payload (failure path)", async () => {
97
+ const since = new Date();
98
+ const res = await fetch(`${url}/api/${TENANT}/${SLUG}/login`, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ payload: { email: "bad", password: "WrongOne!" } }),
102
+ });
103
+ // unknown user is not the point here — whatever the status, the payload is logged
104
+ expect(res.status).toBeGreaterThanOrEqual(200);
105
+
106
+ const logged = await lastActivity("login", since);
107
+ expect(logged.operation.input.payload.password).toBe(REDACTED);
108
+ });
109
+
110
+ it("masks keys at any depth, in arrays, case-insensitively — and stores no result data", async () => {
111
+ const since = new Date();
112
+ const doc: any = await rest.insertOne(SLUG, {
113
+ name: "John",
114
+ password: "p4ss",
115
+ profile: { apiKey: "k-123", nested: { token: "tok", ok: "keep" } },
116
+ list: [{ key: "secret-key", label: "visible" }],
117
+ });
118
+
119
+ const logged = await lastActivity("insertOne", since);
120
+ const input = logged.operation.input.data;
121
+ expect(input.name).toBe("John");
122
+ expect(input.password).toBe(REDACTED);
123
+ expect(input.profile.apiKey).toBe(REDACTED);
124
+ expect(input.profile.nested.token).toBe(REDACTED);
125
+ expect(input.profile.nested.ok).toBe("keep");
126
+ expect(input.list[0].key).toBe(REDACTED);
127
+ expect(input.list[0].label).toBe("visible");
128
+
129
+ // The result holds the generated identifier only — no document, and
130
+ // therefore no value to leak in the first place.
131
+ expect(logged.operation.result).toEqual({ _id: doc._id });
132
+
133
+ // …while the caller still received the real values
134
+ expect(doc.password).toBe("p4ss");
135
+ });
136
+
137
+ it("never alters non-plain values (Date, ObjectId) or the payload reference when nothing matches", async () => {
138
+ // Direct check on the redactor: non-plain objects pass through untouched
139
+ const when = new Date();
140
+ const id = new ObjectId();
141
+ const clean = { name: "x", when, id, list: [when] };
142
+ expect(redactPayload(clean)).toBe(clean); // same reference — no allocation
143
+
144
+ const dirty = redactPayload({ when, id, password: "p" });
145
+ expect(dirty.when).toBe(when);
146
+ expect(dirty.id).toBe(id);
147
+ expect(dirty.password).toBe(REDACTED);
148
+
149
+ // …and on a real entry: only the generated id comes back
150
+ const since = new Date();
151
+ await rest.insertOne(SLUG, { name: "dates" });
152
+ const logged = await lastActivity("insertOne", since);
153
+ expect(logged.operation.result._id).toBeString();
154
+ expect(typeof logged.operation.duration).toBe("number");
155
+ });
156
+
157
+ it("redacts the request headers and query string", async () => {
158
+ const since = new Date();
159
+ const res = await fetch(`${url}/api/${TENANT}/${SLUG}/find?token=query-secret&page=2`, {
160
+ method: "POST",
161
+ headers: {
162
+ "Content-Type": "application/json",
163
+ Authorization: "Bearer fake.jwt",
164
+ "X-API-Key": "api-key-secret",
165
+ "X-Trace": "keep-me",
166
+ },
167
+ body: JSON.stringify({ $limit: 1 }),
168
+ });
169
+ expect(res.status).toBe(200);
170
+
171
+ const logged = await lastActivity("find", since);
172
+ // `authorization` and `cookie` are dropped by the request middleware
173
+ expect(logged.request.headers.authorization).toBeUndefined();
174
+ // …the rest goes through the redactor: `x-api-key` → `api_key`, `token` → `token`
175
+ expect(logged.request.headers["x-api-key"]).toBe(REDACTED);
176
+ expect(logged.request.query.token).toBe(REDACTED);
177
+ // innocuous values are kept
178
+ expect(logged.request.headers["x-trace"]).toBe("keep-me");
179
+ expect(logged.request.query.page).toBe("2");
180
+ });
181
+
182
+ it("masks the headers-like `authorization` key of a custom activity", async () => {
183
+ await rest.audit.addActivities([{
184
+ internal: true,
185
+ trace: { id: crypto.randomUUID() },
186
+ meta: { authorization: "Bearer abc" },
187
+ operation: {
188
+ tenant: TENANT, action: "manual", collection: SLUG,
189
+ status: "success", input: { authorization: "Bearer xyz", token: "t" },
190
+ result: null, error: null, duration: 0, transaction: false,
191
+ },
192
+ ts: new Date(),
193
+ } as any]);
194
+
195
+ const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne({ "operation.action": "manual" });
196
+ expect(logged.operation.input.authorization).toBe(REDACTED);
197
+ expect(logged.operation.input.token).toBe(REDACTED);
198
+ expect(logged.meta.authorization).toBe(REDACTED);
199
+ });
200
+ });
201
+
202
+ describe("audit redaction — configuration", () => {
203
+ it("uses a custom key list instead of the defaults", async () => {
204
+ (cfg.server as any).audit = { redact: ["ssn"] };
205
+
206
+ const since = new Date();
207
+ await rest.insertOne(SLUG, { name: "custom", password: "keep-me", ssn: "123-45-6789" });
208
+
209
+ const logged = await lastActivity("insertOne", since);
210
+ expect(logged.operation.input.data.ssn).toBe(REDACTED);
211
+ expect(logged.operation.input.data.password).toBe("keep-me");
212
+ });
213
+
214
+ it("can be disabled with `redact: false`", async () => {
215
+ (cfg.server as any).audit = { redact: false };
216
+
217
+ const since = new Date();
218
+ await rest.insertOne(SLUG, { name: "off", password: "clear" });
219
+
220
+ const logged = await lastActivity("insertOne", since);
221
+ expect(logged.operation.input.data.password).toBe("clear");
222
+ });
223
+
224
+ it("falls back to the defaults when unset", async () => {
225
+ delete (cfg.server as any).audit;
226
+
227
+ const since = new Date();
228
+ await rest.insertOne(SLUG, { name: "back", password: "masked-again" });
229
+
230
+ const logged = await lastActivity("insertOne", since);
231
+ expect(logged.operation.input.data.password).toBe(REDACTED);
232
+ });
233
+ });
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { formatConfig, cfg } from "../server/config";
3
+ import { syncTenants } from "../database/tenant";
4
+ import { useRest } from "../database/rest";
5
+ import { AUDIT_COLLECTION } from "../database/audit";
6
+ import { IDS_MAX } from "../lib/auditResult";
7
+
8
+ const TENANT = "results-test";
9
+ const DB = "mongodb://localhost:27017/_AUDIT_RESULTS_TEST";
10
+ const SLUG = "orders";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+
14
+ /** Latest audited entry for an action, written *after* `since` (fire-and-forget logging) */
15
+ async function lastActivity(action: string, since?: Date) {
16
+ for (let i = 0; i < 40; i++) {
17
+ const found: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
18
+ { "operation.action": action, ...(since ? { ts: { $gte: since } } : {}) },
19
+ { sort: { ts: -1 } },
20
+ );
21
+ if (found) return found;
22
+ await Bun.sleep(25);
23
+ }
24
+ return null;
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ formatConfig({
29
+ server: { port: 4000 },
30
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
31
+ });
32
+ await syncTenants();
33
+
34
+ (cfg as any).collections = [{
35
+ _tenant_: TENANT,
36
+ slug: SLUG,
37
+ fields: [
38
+ { name: "ref", type: "string" },
39
+ { name: "total", type: "number" },
40
+ ],
41
+ api: { access: { "*": true } },
42
+ }];
43
+
44
+ rest = new useRest({ internal: false, tenant_id: TENANT });
45
+ });
46
+
47
+ afterAll(async () => {
48
+ try { await rest.db.collection(SLUG).drop(); } catch (_) {}
49
+ try { await rest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
50
+ delete (cfg.server as any).audit;
51
+ });
52
+
53
+ describe("audit results — default (`summary`)", () => {
54
+ it("keeps only the generated `_id` for an insert — no document", async () => {
55
+ const since = new Date();
56
+ const doc: any = await rest.insertOne(SLUG, { ref: "TOPSECRET-REF", total: 10 });
57
+
58
+ const logged = await lastActivity("insertOne", since);
59
+ expect(logged.operation.result).toEqual({ _id: doc._id });
60
+ // the payload is nowhere in the result
61
+ expect(JSON.stringify(logged.operation.result)).not.toContain("TOPSECRET-REF");
62
+ // …but the input still carries the parameters (that is the audit trail)
63
+ expect(logged.operation.input.data.ref).toBe("TOPSECRET-REF");
64
+ });
65
+
66
+ it("keeps `count` + `insertedIds` for a batch insert", async () => {
67
+ const since = new Date();
68
+ const docs: any[] = await rest.insertMany(SLUG, [
69
+ { ref: "B-1", total: 1 },
70
+ { ref: "B-2", total: 2 },
71
+ { ref: "B-3", total: 3 },
72
+ ]);
73
+
74
+ const logged = await lastActivity("insertMany", since);
75
+ expect(logged.operation.result.count).toBe(3);
76
+ expect(logged.operation.result.insertedIds).toEqual(docs.map((d) => d._id));
77
+ expect(logged.operation.result.truncated).toBeUndefined();
78
+ // the input of insertMany only holds the count — the ids are the trace
79
+ expect(logged.operation.input).toEqual({ count: 3 });
80
+ });
81
+
82
+ it("caps the identifiers of a huge batch", async () => {
83
+ const since = new Date();
84
+ const batch = Array.from({ length: IDS_MAX + 5 }, (_, i) => ({ ref: `big-${i}`, total: i }));
85
+ await rest.insertMany(SLUG, batch);
86
+
87
+ const logged = await lastActivity("insertMany", since);
88
+ expect(logged.operation.result.count).toBe(IDS_MAX + 5);
89
+ expect(logged.operation.result.insertedIds.length).toBe(IDS_MAX);
90
+ expect(logged.operation.result.truncated).toBe(true);
91
+ });
92
+
93
+ it("keeps the operational counters of updates, deletes and meta operations", async () => {
94
+ const [first] = await rest.find(SLUG, { $limit: 1 });
95
+
96
+ let since = new Date();
97
+ const one: any = await rest.updateOne(SLUG, first!._id, { $set: { total: 99 } });
98
+ expect(one.total).toBe(99); // the caller gets the document…
99
+ const updated = await lastActivity("updateOne", since);
100
+ // …but the audit keeps the identifier only (`updateOne` returns a document)
101
+ expect(updated.operation.result).toEqual({ _id: one._id });
102
+ // the parameters are there too
103
+ expect(updated.operation.input.update.$set.total).toBe(99);
104
+
105
+ since = new Date();
106
+ const many: any = await rest.updateMany(SLUG, [first!._id], { $set: { total: 100 } });
107
+ expect((await lastActivity("updateMany", since)).operation.result.modifiedCount).toBe(many.modifiedCount);
108
+
109
+ since = new Date();
110
+ await rest.countDocuments(SLUG, {});
111
+ expect(typeof (await lastActivity("countDocuments", since)).operation.result).toBe("number");
112
+
113
+ since = new Date();
114
+ const removed: any = await rest.deleteOne(SLUG, first!._id);
115
+ expect(removed).not.toBeNull();
116
+ expect((await lastActivity("deleteOne", since)).operation.result).toEqual({ deleted: true });
117
+
118
+ // a delete that matched nothing reports nothing
119
+ since = new Date();
120
+ await rest.deleteOne(SLUG, first!._id);
121
+ expect((await lastActivity("deleteOne", since)).operation.result).toBeNull();
122
+
123
+ // a document-producing operation never leaks the document — just its id
124
+ const fresh: any = await rest.insertOne(SLUG, { ref: "FOU-1", total: 5 });
125
+ since = new Date();
126
+ const found: any = await rest.findOneAndUpdate(SLUG, { _id: fresh._id }, { $set: { total: 6 } });
127
+ expect(found.total).toBe(6); // the caller gets the document…
128
+ expect((await lastActivity("findOneAndUpdate", since)).operation.result).toEqual({ _id: found._id });
129
+ });
130
+
131
+ it("never stores a document, whatever the operation", async () => {
132
+ const entries: any[] = await rest.db.collection(AUDIT_COLLECTION)
133
+ .find({ "operation.collection": SLUG })
134
+ .toArray();
135
+
136
+ for (const entry of entries) {
137
+ // a document would carry the collection's own fields
138
+ expect(entry.operation.result?.total).toBeUndefined();
139
+ expect(entry.operation.result?.ref).toBeUndefined();
140
+ }
141
+ });
142
+ });
143
+
144
+ describe("audit results — `none`", () => {
145
+ it("stores no result at all, except the generated identifiers", async () => {
146
+ (cfg.server as any).audit = { results: "none" };
147
+
148
+ const since = new Date();
149
+ const doc: any = await rest.insertOne(SLUG, { ref: "N-1", total: 1 });
150
+ expect((await lastActivity("insertOne", since)).operation.result).toEqual({ _id: doc._id });
151
+
152
+ let since2 = new Date();
153
+ await rest.updateMany(SLUG, [doc._id], { $set: { total: 2 } });
154
+ expect((await lastActivity("updateMany", since2)).operation.result).toBeNull();
155
+
156
+ since2 = new Date();
157
+ await rest.countDocuments(SLUG, {});
158
+ expect((await lastActivity("countDocuments", since2)).operation.result).toBeNull();
159
+
160
+ since2 = new Date();
161
+ await rest.deleteOne(SLUG, doc._id);
162
+ expect((await lastActivity("deleteOne", since2)).operation.result).toBeNull();
163
+ });
164
+ });
165
+
166
+ describe("audit results — `full`", () => {
167
+ it("stores the raw result, documents included", async () => {
168
+ (cfg.server as any).audit = { results: "full" };
169
+
170
+ const since = new Date();
171
+ const doc: any = await rest.insertOne(SLUG, { ref: "F-1", total: 1 });
172
+ const inserted = await lastActivity("insertOne", since);
173
+ expect(inserted.operation.result.ref).toBe("F-1");
174
+
175
+ const since2 = new Date();
176
+ await rest.deleteOne(SLUG, doc._id);
177
+ const deleted = await lastActivity("deleteOne", since2);
178
+ expect(deleted.operation.result.ref).toBe("F-1"); // the deleted document is kept
179
+ });
180
+
181
+ it("still never stores a result for reads and custom actions", async () => {
182
+ (cfg.server as any).audit = { results: "full" };
183
+
184
+ const since = new Date();
185
+ await rest.find(SLUG, { $limit: 1 });
186
+ expect((await lastActivity("find", since)).operation.result).toBeNull();
187
+ });
188
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { formatConfig, cfg } from "../server/config";
3
+ import { syncTenants, getTenant } from "../database/tenant";
4
+ import { AUDIT_COLLECTION, AUDIT_TTL_INDEX, resolveAuditRetention } from "../database/audit";
5
+
6
+ const SERVER_RETENTION = "30d";
7
+
8
+ const INHERIT = "ret-inherit";
9
+ const OVERRIDE = "ret-override";
10
+ const OFF = "ret-off";
11
+
12
+ const DBS = {
13
+ [INHERIT]: "mongodb://localhost:27017/_AUDIT_RET_INHERIT",
14
+ [OVERRIDE]: "mongodb://localhost:27017/_AUDIT_RET_OVERRIDE",
15
+ [OFF]: "mongodb://localhost:27017/_AUDIT_RET_OFF",
16
+ } as const;
17
+
18
+ /** TTL index of a tenant's `_audit_` collection, or undefined. */
19
+ async function ttlIndex(tenantId: string): Promise<any> {
20
+ const db = getTenant(tenantId)!.database!.db!;
21
+ return (await db.collection(AUDIT_COLLECTION).listIndexes().toArray())
22
+ .find((i: any) => i.name === AUDIT_TTL_INDEX);
23
+ }
24
+
25
+ beforeAll(async () => {
26
+ formatConfig({
27
+ server: {
28
+ port: 4000,
29
+ // Server-wide default — every tenant inherits it unless it declares its own
30
+ audit: { retention: SERVER_RETENTION },
31
+ },
32
+ tenants: [
33
+ { id: INHERIT, dir: "src", database: { uri: DBS[INHERIT] } },
34
+ { id: OVERRIDE, dir: "src", database: { uri: DBS[OVERRIDE] }, audit: { retention: "1h" } },
35
+ { id: OFF, dir: "src", database: { uri: DBS[OFF] }, audit: { retention: false } },
36
+ ],
37
+ });
38
+
39
+ await syncTenants();
40
+ });
41
+
42
+ afterAll(async () => {
43
+ for (const id of Object.keys(DBS)) {
44
+ try { await getTenant(id)?.database?.db?.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
45
+ }
46
+ // `cfg.server` is shared by every test file in the same process: the server-wide
47
+ // retention would otherwise leak into the other suites
48
+ delete (cfg.server as any).audit;
49
+ });
50
+
51
+ describe("audit retention precedence", () => {
52
+ it("resolves: the tenant wins, the server default is the fallback", () => {
53
+ const base = { dir: "src", database: { uri: "" } };
54
+
55
+ expect(resolveAuditRetention({ id: INHERIT, ...base } as any)).toBe(SERVER_RETENTION);
56
+ expect(resolveAuditRetention({ id: OVERRIDE, ...base, audit: { retention: "1h" } } as any)).toBe("1h");
57
+ // `false` is a value, not an absence — it must override the server default
58
+ expect(resolveAuditRetention({ id: OFF, ...base, audit: { retention: false } } as any)).toBe(false);
59
+ });
60
+
61
+ it("applies the server default to a tenant without its own config", async () => {
62
+ const ttl = await ttlIndex(INHERIT);
63
+ expect(ttl).toBeDefined();
64
+ expect(ttl.expireAfterSeconds).toBe(30 * 86400);
65
+ });
66
+
67
+ it("lets the tenant override the server default", async () => {
68
+ const ttl = await ttlIndex(OVERRIDE);
69
+ expect(ttl).toBeDefined();
70
+ expect(ttl.expireAfterSeconds).toBe(3600);
71
+ });
72
+
73
+ it("lets the tenant opt out of the server default with `false`", async () => {
74
+ expect(await ttlIndex(OFF)).toBeUndefined();
75
+ });
76
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { createApp } from "../server/hono";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { useRest } from "../database/rest";
6
+ import { AUDIT_COLLECTION } from "../database/audit";
7
+
8
+ const TENANT = "act-test";
9
+ const DB = "mongodb://localhost:27017/_AUDIT_ACTION_TEST";
10
+ const SLUG = "invoices";
11
+
12
+ let internalRest: InstanceType<typeof useRest>;
13
+ let server: any;
14
+ let url = "";
15
+
16
+ /**
17
+ * Latest audited `runAction` entry.
18
+ * `#logActivity` is fire-and-forget, and `since` (captured *before* the call)
19
+ * makes sure we wait for the new entry instead of reading the previous one.
20
+ */
21
+ async function lastRunAction(since?: Date) {
22
+ for (let i = 0; i < 40; i++) {
23
+ const found: any = await internalRest.db.collection(AUDIT_COLLECTION).findOne(
24
+ { "operation.action": "runAction", ...(since ? { ts: { $gte: since } } : {}) },
25
+ { sort: { ts: -1 } },
26
+ );
27
+ if (found) return found;
28
+ await Bun.sleep(25);
29
+ }
30
+ return null;
31
+ }
32
+
33
+ beforeAll(async () => {
34
+ formatConfig({
35
+ server: { port: 4000 },
36
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
37
+ });
38
+ await syncTenants();
39
+
40
+ (cfg as any).collections = [{
41
+ _tenant_: TENANT,
42
+ slug: SLUG,
43
+ fields: [{ name: "ref", type: "string" }],
44
+ actions: {
45
+ greet: async ({ data }: any) => ({ hello: data?.name }),
46
+ boom: async () => { throw new Error("kaboom") },
47
+ },
48
+ api: { access: { "*": true } },
49
+ }];
50
+
51
+ internalRest = new useRest({ internal: true, tenant_id: TENANT });
52
+
53
+ const app = createApp();
54
+ server = Bun.serve({ port: 0, fetch: app.fetch });
55
+ url = server.url.href.replace(/\/$/, "");
56
+ });
57
+
58
+ afterAll(async () => {
59
+ try { await internalRest.db.collection(SLUG).drop(); } catch (_) {}
60
+ try { await internalRest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
61
+ try { server.stop(true); } catch {}
62
+ });
63
+
64
+ describe("audit — custom actions (runAction)", () => {
65
+ it("audits an internal call with the action name and its data, never the payload", async () => {
66
+ const since = new Date();
67
+ const result: any = await internalRest.runAction(SLUG, "greet", { name: "Ada" });
68
+
69
+ // the caller still receives the action's payload…
70
+ expect(result).toEqual({ hello: "Ada" });
71
+
72
+ // …but the audit only holds the parameters
73
+ const logged = await lastRunAction(since);
74
+ expect(logged).not.toBeNull();
75
+ expect(logged.operation.input.action).toBe("greet");
76
+ expect(logged.operation.input.data).toEqual({ name: "Ada" });
77
+ expect(logged.operation.result).toBeNull();
78
+ expect(logged.operation.status).toBe("success");
79
+ expect(logged.internal).toBe(true);
80
+ });
81
+
82
+ it("audits the HTTP route as well, and marks it as not internal", async () => {
83
+ const since = new Date();
84
+ const res = await fetch(`${url}/api/${TENANT}/${SLUG}/greet`, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ data: { name: "Grace" } }),
88
+ });
89
+
90
+ expect(res.status).toBe(200);
91
+ expect(await res.json()).toEqual({ hello: "Grace" });
92
+
93
+ const logged = await lastRunAction(since);
94
+ expect(logged.operation.input.action).toBe("greet");
95
+ expect(logged.operation.input.data).toEqual({ name: "Grace" });
96
+ expect(logged.operation.result).toBeNull();
97
+ expect(logged.internal).toBe(false);
98
+ });
99
+
100
+ it("records a failing action with its error, and rethrows it unchanged", async () => {
101
+ const since = new Date();
102
+
103
+ // `wrapErrors: false` — a custom action's own error reaches the caller as-is
104
+ await expect(internalRest.runAction(SLUG, "boom")).rejects.toThrow("kaboom");
105
+
106
+ const logged = await lastRunAction(since);
107
+ expect(logged.operation.status).toBe("error");
108
+ expect(logged.operation.error.message).toBe("kaboom");
109
+ expect(logged.operation.result).toBeNull();
110
+ });
111
+
112
+ it("records an unknown action", async () => {
113
+ const since = new Date();
114
+ await expect(internalRest.runAction(SLUG, "nope")).rejects.toThrow();
115
+
116
+ const logged = await lastRunAction(since);
117
+ expect(logged.operation.status).toBe("error");
118
+ expect(logged.operation.error.code).toBe("ACTION_NOT_FOUND");
119
+ });
120
+ });