@anteros/core 0.0.40 → 0.0.42

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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,184 @@
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 = "ops-test";
9
+ const DB = "mongodb://localhost:27017/_AUDIT_OPS_TEST";
10
+ const SLUG = "orders";
11
+ const SERVICE = "billing";
12
+
13
+ let internalRest: InstanceType<typeof useRest>;
14
+ let server: any;
15
+ let url = "";
16
+
17
+ /** Latest audited entry for an action, written *after* `since` (fire-and-forget logging) */
18
+ async function lastActivity(action: string, since?: Date) {
19
+ for (let i = 0; i < 40; i++) {
20
+ const found: any = await internalRest.db.collection(AUDIT_COLLECTION).findOne(
21
+ { "operation.action": action, ...(since ? { ts: { $gte: since } } : {}) },
22
+ { sort: { ts: -1 } },
23
+ );
24
+ if (found) return found;
25
+ await Bun.sleep(25);
26
+ }
27
+ return null;
28
+ }
29
+
30
+ beforeAll(async () => {
31
+ formatConfig({
32
+ server: { port: 4000 },
33
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
34
+ });
35
+ await syncTenants();
36
+
37
+ (cfg as any).collections = [{
38
+ _tenant_: TENANT,
39
+ slug: SLUG,
40
+ fields: [{ name: "ref", type: "string" }],
41
+ api: { access: { "*": true } },
42
+ }];
43
+ (cfg as any).services = [{
44
+ _tenant_: TENANT,
45
+ name: SERVICE,
46
+ enabled: true,
47
+ actions: {
48
+ charge: async ({ data }: any) => ({ charged: data?.amount }),
49
+ boom: async () => { throw new Error("service-kaboom") },
50
+ },
51
+ api: { access: { "*": true } },
52
+ }];
53
+
54
+ internalRest = new useRest({ internal: true, tenant_id: TENANT });
55
+
56
+ await internalRest.insertMany(SLUG, [{ ref: "A-1" }, { ref: "A-2" }]);
57
+
58
+ const app = createApp();
59
+ server = Bun.serve({ port: 0, fetch: app.fetch });
60
+ url = server.url.href.replace(/\/$/, "");
61
+ });
62
+
63
+ afterAll(async () => {
64
+ try { await internalRest.db.collection(SLUG).drop(); } catch (_) {}
65
+ try { await internalRest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
66
+ try { server.stop(true); } catch {}
67
+ });
68
+
69
+ describe("audit — streams", () => {
70
+ it("audits `findStream` with its parameters and no result", async () => {
71
+ const since = new Date();
72
+ const docs: any[] = [];
73
+ for await (const doc of internalRest.findStream(SLUG, { $match: { ref: "A-1" } }, { batchSize: 10 })) {
74
+ docs.push(doc);
75
+ }
76
+ expect(docs.length).toBe(1); // the consumer really got the documents
77
+
78
+ const logged = await lastActivity("findStream", since);
79
+ expect(logged).not.toBeNull();
80
+ expect(logged.operation.input.params).toEqual({ $match: { ref: "A-1" } });
81
+ expect(logged.operation.input.options).toEqual({ batchSize: 10 });
82
+ expect(logged.operation.result).toBeNull();
83
+ expect(logged.operation.status).toBe("success");
84
+ expect(typeof logged.operation.duration).toBe("number");
85
+ });
86
+
87
+ it("audits `aggregateStream` with its pipeline and no result", async () => {
88
+ const since = new Date();
89
+ const pipeline = [{ $match: { ref: "A-2" } }];
90
+ const rows: any[] = [];
91
+ for await (const doc of internalRest.aggregateStream(SLUG, pipeline, { batchSize: 10 })) {
92
+ rows.push(doc);
93
+ }
94
+ expect(rows.length).toBe(1);
95
+
96
+ const logged = await lastActivity("aggregateStream", since);
97
+ expect(logged).not.toBeNull();
98
+ expect(logged.operation.input.pipeline).toEqual(pipeline);
99
+ expect(logged.operation.result).toBeNull();
100
+ });
101
+
102
+ it("audits a stream that breaks early (the entry is written on `finally`)", async () => {
103
+ const since = new Date();
104
+ for await (const _doc of internalRest.findStream(SLUG, {}, { batchSize: 1 })) {
105
+ break; // consumer stops after the first document
106
+ }
107
+
108
+ const logged = await lastActivity("findStream", since);
109
+ expect(logged).not.toBeNull();
110
+ expect(logged.operation.status).toBe("success");
111
+ });
112
+
113
+ it("audits `watch` with its parameters and no result", async () => {
114
+ const since = new Date();
115
+ const pipeline = [{ $match: { "fullDocument.ref": "A-1" } }];
116
+ const stream = await internalRest.watch(SLUG, pipeline, {});
117
+ expect(stream).toBeDefined();
118
+
119
+ const logged = await lastActivity("watch", since);
120
+ expect(logged).not.toBeNull();
121
+ expect(logged.operation.input.pipeline).toEqual(pipeline);
122
+ expect(logged.operation.result).toBeNull();
123
+
124
+ await stream.close().catch(() => {});
125
+ });
126
+ });
127
+
128
+ describe("audit — services", () => {
129
+ it("audits an internal `rest.runService` call and marks it as internal", async () => {
130
+ const since = new Date();
131
+ const result: any = await internalRest.runService(SERVICE, "charge", { amount: 42 });
132
+ expect(result).toEqual({ charged: 42 }); // the caller still gets the payload
133
+
134
+ const logged = await lastActivity("runService", since);
135
+ expect(logged).not.toBeNull();
136
+ expect(logged.operation.collection).toBe(SERVICE);
137
+ expect(logged.operation.input.action).toBe("charge");
138
+ expect(logged.operation.input.data).toEqual({ amount: 42 });
139
+ expect(logged.operation.result).toBeNull();
140
+ expect(logged.internal).toBe(true);
141
+ });
142
+
143
+ it("audits the HTTP route once — not twice — and marks it as not internal", async () => {
144
+ const since = new Date();
145
+ const before = await internalRest.db.collection(AUDIT_COLLECTION)
146
+ .countDocuments({ "operation.action": "runService" });
147
+
148
+ const res = await fetch(`${url}/api/${TENANT}/services/${SERVICE}/charge`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ data: { amount: 7 } }),
152
+ });
153
+ expect(res.status).toBe(200);
154
+ expect(await res.json()).toEqual({ charged: 7 });
155
+
156
+ await lastActivity("runService", since);
157
+ const after = await internalRest.db.collection(AUDIT_COLLECTION)
158
+ .countDocuments({ "operation.action": "runService" });
159
+
160
+ expect(after - before).toBe(1); // exactly one entry — the route no longer logs its own
161
+ const logged = await lastActivity("runService", since);
162
+ expect(logged.internal).toBe(false);
163
+ expect(logged.operation.input.data).toEqual({ amount: 7 });
164
+ });
165
+
166
+ it("records a failing service action with its error, and rethrows it unchanged", async () => {
167
+ const since = new Date();
168
+ await expect(internalRest.runService(SERVICE, "boom")).rejects.toThrow("service-kaboom");
169
+
170
+ const logged = await lastActivity("runService", since);
171
+ expect(logged.operation.status).toBe("error");
172
+ expect(logged.operation.error.message).toBe("service-kaboom");
173
+ expect(logged.operation.result).toBeNull();
174
+ });
175
+
176
+ it("records an unknown service", async () => {
177
+ const since = new Date();
178
+ await expect(internalRest.runService("nope", "charge")).rejects.toThrow();
179
+
180
+ const logged = await lastActivity("runService", since);
181
+ expect(logged.operation.status).toBe("error");
182
+ expect(logged.operation.error.code).toBe("SERVICE_NOT_FOUND");
183
+ });
184
+ });
@@ -0,0 +1,259 @@
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
+ // The URL shape, pinned here — every surface moved to `/api/:tenant_id/<family>/…`
81
+ describe("API layout", () => {
82
+ it("serves nothing on a pre-refactor path", async () => {
83
+ // `/api/<tenant>/<collection>/<action>` used to be a collection action. No alias
84
+ // is mounted, so it is a plain 404 — never a silent misroute on another handler.
85
+ const legacy = await fetch(`${url}/api/${TENANT}/${SLUG}/find`, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ $limit: 1 }),
89
+ });
90
+ expect(legacy.status).toBe(404);
91
+
92
+ // Same for a surface that moved with its own family
93
+ expect((await fetch(`${url}/services/${TENANT}/billing/charge`, { method: "POST" })).status).toBe(404);
94
+ expect((await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST" })).status).toBe(404);
95
+
96
+ // …and the new shape is what answers
97
+ const current = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/find`, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({ $limit: 1 }),
101
+ });
102
+ expect(current.status).toBe(200);
103
+ });
104
+ });
105
+
106
+ describe("audit redaction — defaults", () => {
107
+ it("masks the password of a login payload (success path)", async () => {
108
+ const since = new Date();
109
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/login`, {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({ payload: { email: "ada@example.com", password: "SuperSecret123!" } }),
113
+ });
114
+ expect(res.status).toBe(200);
115
+
116
+ const logged = await lastActivity("login", since);
117
+ expect(logged.operation.status).toBe("success");
118
+ expect(logged.operation.input.payload.password).toBe(REDACTED);
119
+ expect(logged.operation.input.payload.email).toBe("ada@example.com"); // untouched
120
+ });
121
+
122
+ it("masks the password of a login payload (failure path)", async () => {
123
+ const since = new Date();
124
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/login`, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ payload: { email: "bad", password: "WrongOne!" } }),
128
+ });
129
+ // unknown user is not the point here — whatever the status, the payload is logged
130
+ expect(res.status).toBeGreaterThanOrEqual(200);
131
+
132
+ const logged = await lastActivity("login", since);
133
+ expect(logged.operation.input.payload.password).toBe(REDACTED);
134
+ });
135
+
136
+ it("masks keys at any depth, in arrays, case-insensitively — and stores no result data", async () => {
137
+ const since = new Date();
138
+ const doc: any = await rest.insertOne(SLUG, {
139
+ name: "John",
140
+ password: "p4ss",
141
+ profile: { apiKey: "k-123", nested: { token: "tok", ok: "keep" } },
142
+ list: [{ key: "secret-key", label: "visible" }],
143
+ });
144
+
145
+ const logged = await lastActivity("insertOne", since);
146
+ const input = logged.operation.input.data;
147
+ expect(input.name).toBe("John");
148
+ expect(input.password).toBe(REDACTED);
149
+ expect(input.profile.apiKey).toBe(REDACTED);
150
+ expect(input.profile.nested.token).toBe(REDACTED);
151
+ expect(input.profile.nested.ok).toBe("keep");
152
+ expect(input.list[0].key).toBe(REDACTED);
153
+ expect(input.list[0].label).toBe("visible");
154
+
155
+ // The result holds the generated identifier only — no document, and
156
+ // therefore no value to leak in the first place.
157
+ expect(logged.operation.result).toEqual({ _id: doc._id });
158
+
159
+ // …while the caller still received the real values
160
+ expect(doc.password).toBe("p4ss");
161
+ });
162
+
163
+ it("never alters non-plain values (Date, ObjectId) or the payload reference when nothing matches", async () => {
164
+ // Direct check on the redactor: non-plain objects pass through untouched
165
+ const when = new Date();
166
+ const id = new ObjectId();
167
+ const clean = { name: "x", when, id, list: [when] };
168
+ expect(redactPayload(clean)).toBe(clean); // same reference — no allocation
169
+
170
+ const dirty = redactPayload({ when, id, password: "p" });
171
+ expect(dirty.when).toBe(when);
172
+ expect(dirty.id).toBe(id);
173
+ expect(dirty.password).toBe(REDACTED);
174
+
175
+ // …and on a real entry: only the generated id comes back
176
+ const since = new Date();
177
+ await rest.insertOne(SLUG, { name: "dates" });
178
+ const logged = await lastActivity("insertOne", since);
179
+ expect(logged.operation.result._id).toBeString();
180
+ expect(typeof logged.operation.duration).toBe("number");
181
+ });
182
+
183
+ it("redacts the request headers and query string", async () => {
184
+ const since = new Date();
185
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/find?token=query-secret&page=2`, {
186
+ method: "POST",
187
+ headers: {
188
+ "Content-Type": "application/json",
189
+ Authorization: "Bearer fake.jwt",
190
+ "X-API-Key": "api-key-secret",
191
+ "X-Trace": "keep-me",
192
+ },
193
+ body: JSON.stringify({ $limit: 1 }),
194
+ });
195
+ expect(res.status).toBe(200);
196
+
197
+ const logged = await lastActivity("find", since);
198
+ // `authorization` and `cookie` are dropped by the request middleware
199
+ expect(logged.request.headers.authorization).toBeUndefined();
200
+ // …the rest goes through the redactor: `x-api-key` → `api_key`, `token` → `token`
201
+ expect(logged.request.headers["x-api-key"]).toBe(REDACTED);
202
+ expect(logged.request.query.token).toBe(REDACTED);
203
+ // innocuous values are kept
204
+ expect(logged.request.headers["x-trace"]).toBe("keep-me");
205
+ expect(logged.request.query.page).toBe("2");
206
+ });
207
+
208
+ it("masks the headers-like `authorization` key of a custom activity", async () => {
209
+ await rest.audit.addActivities([{
210
+ internal: true,
211
+ trace: { id: crypto.randomUUID() },
212
+ meta: { authorization: "Bearer abc" },
213
+ operation: {
214
+ tenant: TENANT, action: "manual", collection: SLUG,
215
+ status: "success", input: { authorization: "Bearer xyz", token: "t" },
216
+ result: null, error: null, duration: 0, transaction: false,
217
+ },
218
+ ts: new Date(),
219
+ } as any]);
220
+
221
+ const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne({ "operation.action": "manual" });
222
+ expect(logged.operation.input.authorization).toBe(REDACTED);
223
+ expect(logged.operation.input.token).toBe(REDACTED);
224
+ expect(logged.meta.authorization).toBe(REDACTED);
225
+ });
226
+ });
227
+
228
+ describe("audit redaction — configuration", () => {
229
+ it("uses a custom key list instead of the defaults", async () => {
230
+ (cfg.server as any).audit = { redact: ["ssn"] };
231
+
232
+ const since = new Date();
233
+ await rest.insertOne(SLUG, { name: "custom", password: "keep-me", ssn: "123-45-6789" });
234
+
235
+ const logged = await lastActivity("insertOne", since);
236
+ expect(logged.operation.input.data.ssn).toBe(REDACTED);
237
+ expect(logged.operation.input.data.password).toBe("keep-me");
238
+ });
239
+
240
+ it("can be disabled with `redact: false`", async () => {
241
+ (cfg.server as any).audit = { redact: false };
242
+
243
+ const since = new Date();
244
+ await rest.insertOne(SLUG, { name: "off", password: "clear" });
245
+
246
+ const logged = await lastActivity("insertOne", since);
247
+ expect(logged.operation.input.data.password).toBe("clear");
248
+ });
249
+
250
+ it("falls back to the defaults when unset", async () => {
251
+ delete (cfg.server as any).audit;
252
+
253
+ const since = new Date();
254
+ await rest.insertOne(SLUG, { name: "back", password: "masked-again" });
255
+
256
+ const logged = await lastActivity("insertOne", since);
257
+ expect(logged.operation.input.data.password).toBe(REDACTED);
258
+ });
259
+ });
@@ -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
+ });