@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.
- package/database/audit.ts +193 -0
- package/database/collection.ts +3 -3
- package/database/deleteLog.ts +34 -0
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +109 -25
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +102 -1
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- 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
|
+
});
|