@anteros/core 0.0.39 → 0.0.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/database/audit.ts +193 -0
- package/database/collection.ts +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- 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 +134 -33
- 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/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- 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/index-spec.test.ts +5 -5
- 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 +239 -0
- 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,296 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { useRest } from "../database/rest";
|
|
5
|
+
import { formatConfig, cfg } from "../server/config";
|
|
6
|
+
import { syncTenants, getTenant } from "../database/tenant";
|
|
7
|
+
import { resolveAuditFile, writeAuditFile, flushAuditFile, pruneAuditFiles } from "../lib/audit";
|
|
8
|
+
import type { AuditFileOptions } from "../lib/audit";
|
|
9
|
+
|
|
10
|
+
const DIR = path.join(import.meta.dir, ".tmp-audit-file");
|
|
11
|
+
const TODAY = new Date().toISOString().slice(0, 10);
|
|
12
|
+
|
|
13
|
+
/** Minimal activity document (same shape as `ActivityInput`). */
|
|
14
|
+
function activity(action: string, collection = "items") {
|
|
15
|
+
return {
|
|
16
|
+
internal: true,
|
|
17
|
+
trace: { id: crypto.randomUUID() },
|
|
18
|
+
meta: {},
|
|
19
|
+
operation: {
|
|
20
|
+
tenant: "test",
|
|
21
|
+
action,
|
|
22
|
+
collection,
|
|
23
|
+
status: "success",
|
|
24
|
+
input: null,
|
|
25
|
+
result: null,
|
|
26
|
+
error: null,
|
|
27
|
+
duration: 0,
|
|
28
|
+
transaction: false,
|
|
29
|
+
},
|
|
30
|
+
ts: new Date(),
|
|
31
|
+
} as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readLines(file: string): any[] {
|
|
35
|
+
return fs.readFileSync(file, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function filesOf(dir: string, tenantId: string): string[] {
|
|
39
|
+
try {
|
|
40
|
+
return fs.readdirSync(dir).filter((f) => f.startsWith(`audit-${tenantId}-`)).sort();
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeAll(() => {
|
|
47
|
+
try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
// `cfg.server` is shared by every test file in the same process: never leak
|
|
52
|
+
// the audit sink into the other suites (they would write files too)
|
|
53
|
+
delete (cfg.server as any).audit;
|
|
54
|
+
try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── Disabled by default ────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe("audit file — disabled by default", () => {
|
|
60
|
+
const TENANT = "af-off";
|
|
61
|
+
let rest: InstanceType<typeof useRest>;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
formatConfig({
|
|
65
|
+
server: { port: 4000 },
|
|
66
|
+
tenants: [{ id: TENANT, dir: "src", database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_OFF" } }],
|
|
67
|
+
});
|
|
68
|
+
await syncTenants();
|
|
69
|
+
rest = new useRest({ internal: true, tenant_id: TENANT });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("writes nothing when no config is set anywhere", async () => {
|
|
73
|
+
await rest.audit.addActivities([activity("off")]);
|
|
74
|
+
await flushAuditFile();
|
|
75
|
+
|
|
76
|
+
expect(filesOf(DIR, TENANT)).toEqual([]);
|
|
77
|
+
expect(fs.existsSync(path.join(DIR, TENANT))).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resolves to null", () => {
|
|
81
|
+
expect(resolveAuditFile({ id: TENANT, dir: "src", database: { uri: "" } } as any)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ─── Configured (tenant level and server default) ───────────────────────
|
|
86
|
+
|
|
87
|
+
describe("audit file — writes and rotation", () => {
|
|
88
|
+
const TENANT = "af-tenant";
|
|
89
|
+
const ROT = "af-rot";
|
|
90
|
+
const OPTOUT = "af-optout";
|
|
91
|
+
const DIR_TENANT = path.join(DIR, "tenant");
|
|
92
|
+
const DIR_ROT = path.join(DIR, "rot");
|
|
93
|
+
const DIR_SERVER = path.join(DIR, "server");
|
|
94
|
+
|
|
95
|
+
let rest: InstanceType<typeof useRest>;
|
|
96
|
+
let rot: InstanceType<typeof useRest>;
|
|
97
|
+
let optout: InstanceType<typeof useRest>;
|
|
98
|
+
|
|
99
|
+
beforeAll(async () => {
|
|
100
|
+
formatConfig({
|
|
101
|
+
server: {
|
|
102
|
+
port: 4000,
|
|
103
|
+
// Server-wide default: everything goes to <DIR>/server unless a tenant says otherwise
|
|
104
|
+
audit: { file: { dir: DIR_SERVER } },
|
|
105
|
+
},
|
|
106
|
+
tenants: [
|
|
107
|
+
{
|
|
108
|
+
id: TENANT,
|
|
109
|
+
dir: "src",
|
|
110
|
+
database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_TENANT" },
|
|
111
|
+
// Tenant wins: own dir + 7d retention (default size cap)
|
|
112
|
+
audit: { file: { dir: DIR_TENANT, retention: "7d" } },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: ROT,
|
|
116
|
+
dir: "src",
|
|
117
|
+
database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_ROT" },
|
|
118
|
+
// Cap is set by the rotation test itself, once the real line size is known
|
|
119
|
+
audit: { file: { dir: DIR_ROT } },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: OPTOUT,
|
|
123
|
+
dir: "src",
|
|
124
|
+
database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_OPTOUT" },
|
|
125
|
+
audit: { file: false }, // opt out of the server default
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
await syncTenants();
|
|
130
|
+
rest = new useRest({ internal: true, tenant_id: TENANT });
|
|
131
|
+
rot = new useRest({ internal: true, tenant_id: ROT });
|
|
132
|
+
optout = new useRest({ internal: true, tenant_id: OPTOUT });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("appends one JSONL line per activity, in a dated file", async () => {
|
|
136
|
+
await rest.audit.addActivities([activity("first"), activity("second")]);
|
|
137
|
+
await flushAuditFile();
|
|
138
|
+
|
|
139
|
+
const files = filesOf(DIR_TENANT, TENANT);
|
|
140
|
+
expect(files).toEqual([`audit-${TENANT}-${TODAY}.jsonl`]);
|
|
141
|
+
|
|
142
|
+
const lines = readLines(path.join(DIR_TENANT, files[0]!));
|
|
143
|
+
expect(lines.length).toBe(2);
|
|
144
|
+
expect(lines.map((l) => l.operation.action)).toEqual(["first", "second"]);
|
|
145
|
+
expect(typeof lines[0]!.ts).toBe("string"); // Date serialized as ISO
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("appends (never truncates) on the next call", async () => {
|
|
149
|
+
await rest.audit.addActivities([activity("third")]);
|
|
150
|
+
await flushAuditFile();
|
|
151
|
+
|
|
152
|
+
const lines = readLines(path.join(DIR_TENANT, `audit-${TENANT}-${TODAY}.jsonl`));
|
|
153
|
+
expect(lines.map((l) => l.operation.action)).toEqual(["first", "second", "third"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rotates when the size cap is reached", async () => {
|
|
157
|
+
// First activity reserves the plain file of the day
|
|
158
|
+
await rot.audit.addActivities([activity("rot-0")]);
|
|
159
|
+
await flushAuditFile();
|
|
160
|
+
|
|
161
|
+
const plain = path.join(DIR_ROT, `audit-${ROT}-${TODAY}.jsonl`);
|
|
162
|
+
const lineBytes = fs.statSync(plain).size;
|
|
163
|
+
// Cap sized so exactly two lines fit per file (line size is known now)
|
|
164
|
+
const cap = lineBytes * 2 + 5;
|
|
165
|
+
((getTenant(ROT)!.audit!.file) as AuditFileOptions).maxSize = cap;
|
|
166
|
+
|
|
167
|
+
for (let i = 1; i < 4; i++) {
|
|
168
|
+
await rot.audit.addActivities([activity(`rot-${i}`)]);
|
|
169
|
+
await flushAuditFile();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Chronological split: the plain file holds the two first lines,
|
|
173
|
+
// the `-1` rotation the next two
|
|
174
|
+
const files = filesOf(DIR_ROT, ROT);
|
|
175
|
+
const rotated = `audit-${ROT}-${TODAY}-1.jsonl`;
|
|
176
|
+
const name = `audit-${ROT}-${TODAY}.jsonl`;
|
|
177
|
+
|
|
178
|
+
expect(files.length).toBe(2);
|
|
179
|
+
expect(files).toContain(name);
|
|
180
|
+
expect(files).toContain(rotated);
|
|
181
|
+
|
|
182
|
+
const actions = (file: string) =>
|
|
183
|
+
readLines(path.join(DIR_ROT, file)).map((l) => l.operation.action);
|
|
184
|
+
expect(actions(name)).toEqual(["rot-0", "rot-1"]);
|
|
185
|
+
expect(actions(rotated)).toEqual(["rot-2", "rot-3"]);
|
|
186
|
+
|
|
187
|
+
// every file holds valid JSONL, stays under the cap, and no line is lost
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const raw = fs.readFileSync(path.join(DIR_ROT, file), "utf-8");
|
|
190
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
191
|
+
expect(raw.length).toBeLessThanOrEqual(cap);
|
|
192
|
+
}
|
|
193
|
+
expect([...actions(name), ...actions(rotated)])
|
|
194
|
+
.toEqual(["rot-0", "rot-1", "rot-2", "rot-3"]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("carries the `_id` assigned by MongoDB", async () => {
|
|
198
|
+
const files = filesOf(DIR_TENANT, TENANT);
|
|
199
|
+
const line = readLines(path.join(DIR_TENANT, files[0]!))[0]!;
|
|
200
|
+
expect(line._id).toBeString();
|
|
201
|
+
expect(line._id.length).toBe(24);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("uses the server default when the tenant declares nothing", async () => {
|
|
205
|
+
const inherited = "af-inherit";
|
|
206
|
+
// Same server config, another tenant — resolved from `server.audit.file`
|
|
207
|
+
await rest.audit.addActivities([activity("own")]);
|
|
208
|
+
writeAuditFile({ id: inherited, dir: "src", database: { uri: "" } } as any, [activity("srv")]);
|
|
209
|
+
await flushAuditFile();
|
|
210
|
+
|
|
211
|
+
const files = filesOf(DIR_SERVER, inherited);
|
|
212
|
+
expect(files.length).toBe(1);
|
|
213
|
+
expect(readLines(path.join(DIR_SERVER, files[0]!))[0].operation.action).toBe("srv");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("lets a tenant opt out of the server default with `false`", async () => {
|
|
217
|
+
await optout.audit.addActivities([activity("blocked")]);
|
|
218
|
+
await flushAuditFile();
|
|
219
|
+
|
|
220
|
+
expect(filesOf(DIR_SERVER, OPTOUT)).toEqual([]);
|
|
221
|
+
expect(filesOf(DIR_TENANT, OPTOUT)).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("prunes files older than the retention", async () => {
|
|
225
|
+
const stale = path.join(DIR_TENANT, `audit-${TENANT}-2020-01-01.jsonl`);
|
|
226
|
+
fs.writeFileSync(stale, "{}\n");
|
|
227
|
+
const old = new Date(Date.now() - 60 * 86400_000);
|
|
228
|
+
fs.utimesSync(stale, old, old);
|
|
229
|
+
|
|
230
|
+
const fresh = path.join(DIR_TENANT, `audit-${TENANT}-${TODAY}.jsonl`);
|
|
231
|
+
const before = filesOf(DIR_TENANT, TENANT);
|
|
232
|
+
|
|
233
|
+
const removed = await pruneAuditFiles(DIR_TENANT, TENANT, 7 * 86400_000);
|
|
234
|
+
|
|
235
|
+
expect(removed).toContain(`audit-${TENANT}-2020-01-01.jsonl`);
|
|
236
|
+
expect(fs.existsSync(stale)).toBe(false);
|
|
237
|
+
expect(fs.existsSync(fresh)).toBe(true);
|
|
238
|
+
expect(filesOf(DIR_TENANT, TENANT).length).toBe(before.length - 1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("only touches its own tenant's files", async () => {
|
|
242
|
+
const stale = path.join(DIR_TENANT, `audit-${OPTOUT}-2020-01-01.jsonl`);
|
|
243
|
+
fs.writeFileSync(stale, "{}\n");
|
|
244
|
+
const old = new Date(Date.now() - 60 * 86400_000);
|
|
245
|
+
fs.utimesSync(stale, old, old);
|
|
246
|
+
|
|
247
|
+
await pruneAuditFiles(DIR_TENANT, TENANT, 7 * 86400_000);
|
|
248
|
+
|
|
249
|
+
expect(fs.existsSync(stale)).toBe(true);
|
|
250
|
+
fs.rmSync(stale, { force: true });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("mirrors the database retention when no file retention is set", () => {
|
|
254
|
+
const inherited = resolveAuditFile({
|
|
255
|
+
id: "x", dir: "src", database: { uri: "" }, audit: { retention: "30d", file: true },
|
|
256
|
+
} as any);
|
|
257
|
+
expect(inherited!.retentionMs).toBe(30 * 86400_000);
|
|
258
|
+
expect(inherited!.dir).toBe(path.join(".logs", "audit"));
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ─── Automatic pruning on first write ───────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe("audit file — automatic pruning", () => {
|
|
265
|
+
const TENANT = "af-prune";
|
|
266
|
+
const DIR_PRUNE = path.join(DIR, "prune");
|
|
267
|
+
let rest: InstanceType<typeof useRest>;
|
|
268
|
+
|
|
269
|
+
beforeAll(async () => {
|
|
270
|
+
formatConfig({
|
|
271
|
+
server: { port: 4000 },
|
|
272
|
+
tenants: [{
|
|
273
|
+
id: TENANT,
|
|
274
|
+
dir: "src",
|
|
275
|
+
database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_PRUNE" },
|
|
276
|
+
audit: { file: { dir: DIR_PRUNE, retention: "1d" } },
|
|
277
|
+
}],
|
|
278
|
+
});
|
|
279
|
+
await syncTenants();
|
|
280
|
+
rest = new useRest({ internal: true, tenant_id: TENANT });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("deletes stale files on the first write, without waiting", async () => {
|
|
284
|
+
fs.mkdirSync(DIR_PRUNE, { recursive: true });
|
|
285
|
+
const stale = path.join(DIR_PRUNE, `audit-${TENANT}-2019-12-31.jsonl`);
|
|
286
|
+
fs.writeFileSync(stale, "{}\n");
|
|
287
|
+
const old = new Date(Date.now() - 30 * 86400_000);
|
|
288
|
+
fs.utimesSync(stale, old, old);
|
|
289
|
+
|
|
290
|
+
await rest.audit.addActivities([activity("trigger-prune")]);
|
|
291
|
+
await flushAuditFile();
|
|
292
|
+
|
|
293
|
+
expect(fs.existsSync(stale)).toBe(false);
|
|
294
|
+
expect(filesOf(DIR_PRUNE, TENANT).some((f) => f.includes(TODAY))).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -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}/services/${TENANT}/${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
|
+
});
|