@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.
- package/database/audit.ts +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/redact.ts +117 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -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/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -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/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/lifecycle.test.ts +96 -0
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
|
@@ -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}/collections/${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
|
+
});
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { useRest } from "../database/rest";
|
|
3
|
+
import { formatConfig, cfg } from "../server/config";
|
|
4
|
+
import { syncTenants, getTenant } from "../database/tenant";
|
|
5
|
+
import {
|
|
6
|
+
AUDIT_COLLECTION,
|
|
7
|
+
LEGACY_AUDIT_COLLECTION,
|
|
8
|
+
AUDIT_TTL_INDEX,
|
|
9
|
+
migrateAuditCollection,
|
|
10
|
+
syncAuditRetention,
|
|
11
|
+
} from "../database/audit";
|
|
12
|
+
|
|
13
|
+
const TEST_TENANT = "audit";
|
|
14
|
+
const TEST_DB = "mongodb://localhost:27017/_AUDIT_TEST";
|
|
15
|
+
|
|
16
|
+
// Second tenant: proves `tenant.audit.retention` is wired end-to-end
|
|
17
|
+
const TTL_TENANT = "audit-ttl";
|
|
18
|
+
const TTL_DB = "mongodb://localhost:27017/_AUDIT_TTL_TEST";
|
|
19
|
+
|
|
20
|
+
let rest: InstanceType<typeof useRest>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Latest audit entry for a (collection, action) pair.
|
|
24
|
+
*
|
|
25
|
+
* `#logActivity` is fire-and-forget, so we poll — and when the same pair was
|
|
26
|
+
* already logged by an earlier test, `since` (captured *before* the operation)
|
|
27
|
+
* makes sure we wait for the new entry instead of reading the stale one.
|
|
28
|
+
*/
|
|
29
|
+
async function lastActivity(collection: string, action: string, since?: Date) {
|
|
30
|
+
for (let i = 0; i < 40; i++) {
|
|
31
|
+
const found: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
|
|
32
|
+
{
|
|
33
|
+
"operation.collection": collection,
|
|
34
|
+
"operation.action": action,
|
|
35
|
+
...(since ? { ts: { $gte: since } } : {}),
|
|
36
|
+
},
|
|
37
|
+
{ sort: { ts: -1 } },
|
|
38
|
+
);
|
|
39
|
+
if (found) return found;
|
|
40
|
+
await Bun.sleep(25);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Minimal activity document (same shape as `ActivityInput`). */
|
|
46
|
+
function activity(action: string) {
|
|
47
|
+
return {
|
|
48
|
+
internal: true,
|
|
49
|
+
trace: { id: crypto.randomUUID() },
|
|
50
|
+
meta: {},
|
|
51
|
+
operation: {
|
|
52
|
+
tenant: TEST_TENANT,
|
|
53
|
+
action,
|
|
54
|
+
collection: "items",
|
|
55
|
+
status: "success",
|
|
56
|
+
input: null,
|
|
57
|
+
result: null,
|
|
58
|
+
error: null,
|
|
59
|
+
duration: 0,
|
|
60
|
+
transaction: false,
|
|
61
|
+
},
|
|
62
|
+
ts: new Date(),
|
|
63
|
+
} as any;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index document, or undefined when absent. */
|
|
67
|
+
async function index(name: string): Promise<any> {
|
|
68
|
+
return (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
69
|
+
.find((i: any) => i.name === name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
formatConfig({
|
|
74
|
+
server: { port: 4000 },
|
|
75
|
+
tenants: [
|
|
76
|
+
{ id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } },
|
|
77
|
+
{ id: TTL_TENANT, dir: "src", database: { uri: TTL_DB }, audit: { retention: "7d" } },
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
await syncTenants();
|
|
81
|
+
|
|
82
|
+
// Declared collections — needed to exercise the `$lookup` allow-list and `collectionType`
|
|
83
|
+
(cfg as any).collections = [
|
|
84
|
+
{
|
|
85
|
+
_tenant_: TEST_TENANT,
|
|
86
|
+
slug: "items",
|
|
87
|
+
fields: [{ name: "title", type: "string" }],
|
|
88
|
+
api: { access: { "*": true } },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
(cfg as any).fileCollections = [
|
|
92
|
+
{
|
|
93
|
+
_tenant_: TEST_TENANT,
|
|
94
|
+
_isFileCollection_: true,
|
|
95
|
+
slug: "photos",
|
|
96
|
+
fields: [{ name: "label", type: "string" }],
|
|
97
|
+
api: { access: { "*": true } },
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
rest = new useRest({ internal: true, tenant_id: TEST_TENANT });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(async () => {
|
|
105
|
+
try { await rest.db.collection("items").drop(); } catch (_) {}
|
|
106
|
+
try { await rest.db.collection("photos").drop(); } catch (_) {}
|
|
107
|
+
try { await rest.db.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
108
|
+
try { await rest.db.collection(LEGACY_AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
109
|
+
try { await getTenant(TTL_TENANT)?.database?.db?.collection(AUDIT_COLLECTION).drop(); } catch (_) {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("audit collection", () => {
|
|
113
|
+
it("writes to `_audit_`", async () => {
|
|
114
|
+
await rest.audit.addActivities([activity("unit")]);
|
|
115
|
+
|
|
116
|
+
const stored: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
117
|
+
.findOne({ "operation.action": "unit" });
|
|
118
|
+
expect(stored).toBeDefined();
|
|
119
|
+
expect(stored.operation.collection).toBe("items");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads back through getActivities", async () => {
|
|
123
|
+
const logs = await rest.audit.getActivities({
|
|
124
|
+
$match: { "operation.action": "unit" },
|
|
125
|
+
$limit: 10,
|
|
126
|
+
} as any);
|
|
127
|
+
expect(logs.length).toBeGreaterThanOrEqual(1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not use the legacy `_activities_` name anymore", async () => {
|
|
131
|
+
const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
|
|
132
|
+
expect(legacy.length).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is on disk as soon as a mutation resolves (no polling needed)", async () => {
|
|
136
|
+
const doc: any = await rest.insertOne("items", { title: "awaited" });
|
|
137
|
+
|
|
138
|
+
// Deliberately **no** polling: mutations await their audit entry, so it is
|
|
139
|
+
// already there — a successful write can never lose its trace.
|
|
140
|
+
const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne({
|
|
141
|
+
"operation.action": "insertOne",
|
|
142
|
+
"operation.result._id": doc._id,
|
|
143
|
+
});
|
|
144
|
+
expect(logged).not.toBeNull();
|
|
145
|
+
expect(logged.operation.status).toBe("success");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("accepts `_audit_` as a $lookup target from a declared collection", async () => {
|
|
149
|
+
const rows = await rest.aggregate("items", [
|
|
150
|
+
{ $lookup: { from: AUDIT_COLLECTION, localField: "_id", foreignField: "_id", as: "audit" } },
|
|
151
|
+
{ $limit: 1 },
|
|
152
|
+
]);
|
|
153
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("audit collectionType", () => {
|
|
158
|
+
it("labels a document collection as `document`", async () => {
|
|
159
|
+
await rest.insertOne("items", { title: "collectionType" });
|
|
160
|
+
|
|
161
|
+
const logged = await lastActivity("items", "insertOne");
|
|
162
|
+
expect(logged).not.toBeNull();
|
|
163
|
+
expect(logged.operation.collectionType).toBe("document");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("labels a file collection as `file`", async () => {
|
|
167
|
+
await rest.insertOne("photos", { label: "ct", _file: { filename: "ct.png" } });
|
|
168
|
+
|
|
169
|
+
const logged = await lastActivity("photos", "insertOne");
|
|
170
|
+
expect(logged).not.toBeNull();
|
|
171
|
+
expect(logged.operation.collectionType).toBe("file");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("leaves `collectionType` undefined for an internal collection", async () => {
|
|
175
|
+
// `_audit_` is not declared anywhere — no type to report
|
|
176
|
+
const logged: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
177
|
+
.findOne({ "operation.collection": "_locks_" });
|
|
178
|
+
expect(logged?.operation?.collectionType).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("audit read operations", () => {
|
|
183
|
+
it("logs `find` with its parameters, never the result", async () => {
|
|
184
|
+
const since = new Date();
|
|
185
|
+
const docs = await rest.find("items", { $match: { title: "collectionType" }, $limit: 5 });
|
|
186
|
+
expect(docs.length).toBeGreaterThanOrEqual(1); // the read really returned data
|
|
187
|
+
|
|
188
|
+
const logged = await lastActivity("items", "find", since);
|
|
189
|
+
expect(logged).not.toBeNull();
|
|
190
|
+
expect(logged.operation.input.params).toEqual({ $match: { title: "collectionType" }, $limit: 5 });
|
|
191
|
+
expect(logged.operation.result).toBeNull(); // ← nothing returned is stored
|
|
192
|
+
expect(logged.operation.status).toBe("success");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("logs `findOne` with the id", async () => {
|
|
196
|
+
const since = new Date();
|
|
197
|
+
const [first] = await rest.find("items", { $limit: 1 });
|
|
198
|
+
const doc: any = await rest.findOne("items", first!._id);
|
|
199
|
+
expect(doc).not.toBeNull();
|
|
200
|
+
|
|
201
|
+
const logged = await lastActivity("items", "findOne", since);
|
|
202
|
+
expect(logged.operation.input.id).toBeString();
|
|
203
|
+
expect(logged.operation.result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("logs `aggregate` with its pipeline", async () => {
|
|
207
|
+
const since = new Date();
|
|
208
|
+
const pipeline = [{ $match: { title: "collectionType" } }, { $limit: 1 }];
|
|
209
|
+
const rows = await rest.aggregate("items", pipeline);
|
|
210
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
211
|
+
|
|
212
|
+
const logged = await lastActivity("items", "aggregate", since);
|
|
213
|
+
expect(logged.operation.input.pipeline).toEqual(pipeline);
|
|
214
|
+
expect(logged.operation.result).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("logs a failed read with its error and still no result", async () => {
|
|
218
|
+
const since = new Date();
|
|
219
|
+
await expect(rest.aggregate("items", [
|
|
220
|
+
{ $lookup: { from: "not_declared", localField: "_id", foreignField: "_id", as: "x" } },
|
|
221
|
+
])).rejects.toThrow();
|
|
222
|
+
|
|
223
|
+
const logged = await lastActivity("items", "aggregate", since);
|
|
224
|
+
expect(logged.operation.status).toBe("error");
|
|
225
|
+
expect(logged.operation.error.message).toBeString();
|
|
226
|
+
expect(logged.operation.result).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("audit query indexes", () => {
|
|
231
|
+
it("are ensured at boot", async () => {
|
|
232
|
+
const names = (await rest.db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
233
|
+
.map((i: any) => i.name);
|
|
234
|
+
expect(names).toContain("_audit_ts_");
|
|
235
|
+
expect(names).toContain("_audit_collection_ts_");
|
|
236
|
+
expect(names).toContain("_audit_status_ts_");
|
|
237
|
+
expect(names).toContain("_audit_trace_");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("serve the documented sorts and filters", async () => {
|
|
241
|
+
expect((await index("_audit_ts_")).key).toEqual({ ts: -1 });
|
|
242
|
+
expect((await index("_audit_collection_ts_")).key).toEqual({ "operation.collection": 1, ts: -1 });
|
|
243
|
+
expect((await index("_audit_status_ts_")).key).toEqual({ "operation.status": 1, ts: -1 });
|
|
244
|
+
expect((await index("_audit_trace_")).key).toEqual({ "trace.id": 1 });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("audit retention (TTL)", () => {
|
|
249
|
+
it("stays untouched — no TTL index — when retention is not configured", async () => {
|
|
250
|
+
expect(await syncAuditRetention(rest.db, undefined)).toBe("skipped");
|
|
251
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("creates a TTL index when a duration is configured", async () => {
|
|
255
|
+
expect(await syncAuditRetention(rest.db, "90d")).toBe("applied");
|
|
256
|
+
|
|
257
|
+
const ttl = await index(AUDIT_TTL_INDEX);
|
|
258
|
+
expect(ttl.key).toEqual({ ts: 1 });
|
|
259
|
+
expect(ttl.expireAfterSeconds).toBe(90 * 86400);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("is idempotent — the same duration is a no-op", async () => {
|
|
263
|
+
expect(await syncAuditRetention(rest.db, "90d")).toBe("unchanged");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("updates expireAfterSeconds when the duration changes", async () => {
|
|
267
|
+
expect(await syncAuditRetention(rest.db, "24h")).toBe("applied");
|
|
268
|
+
expect((await index(AUDIT_TTL_INDEX)).expireAfterSeconds).toBe(24 * 3600);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("drops the TTL index when retention is explicitly disabled", async () => {
|
|
272
|
+
expect(await syncAuditRetention(rest.db, false)).toBe("dropped");
|
|
273
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("ignores an invalid duration", async () => {
|
|
277
|
+
expect(await syncAuditRetention(rest.db, "banana")).toBe("skipped");
|
|
278
|
+
expect(await index(AUDIT_TTL_INDEX)).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("is wired through `tenant.audit.retention`", async () => {
|
|
282
|
+
const db = getTenant(TTL_TENANT)!.database!.db!;
|
|
283
|
+
const ttl: any = (await db.collection(AUDIT_COLLECTION).listIndexes().toArray())
|
|
284
|
+
.find((i: any) => i.name === AUDIT_TTL_INDEX);
|
|
285
|
+
|
|
286
|
+
expect(ttl).toBeDefined();
|
|
287
|
+
expect(ttl.expireAfterSeconds).toBe(7 * 86400);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("audit collection migration (`_activities_` → `_audit_`)", () => {
|
|
292
|
+
it("renames an existing legacy collection, without losing documents", async () => {
|
|
293
|
+
await rest.db.collection(AUDIT_COLLECTION).drop().catch(() => {});
|
|
294
|
+
await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("legacy") as any);
|
|
295
|
+
|
|
296
|
+
expect(await migrateAuditCollection(rest.db)).toBe(true);
|
|
297
|
+
|
|
298
|
+
const legacy = await rest.db.listCollections({ name: LEGACY_AUDIT_COLLECTION }).toArray();
|
|
299
|
+
expect(legacy.length).toBe(0);
|
|
300
|
+
|
|
301
|
+
const moved: any = await rest.db.collection(AUDIT_COLLECTION)
|
|
302
|
+
.findOne({ "operation.action": "legacy" });
|
|
303
|
+
expect(moved).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("is idempotent — a second run is a no-op", async () => {
|
|
307
|
+
expect(await migrateAuditCollection(rest.db)).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("never touches the legacy collection when `_audit_` already exists", async () => {
|
|
311
|
+
await rest.db.collection(AUDIT_COLLECTION).insertOne(activity("keep") as any);
|
|
312
|
+
await rest.db.collection(LEGACY_AUDIT_COLLECTION).insertOne(activity("orphan") as any);
|
|
313
|
+
|
|
314
|
+
expect(await migrateAuditCollection(rest.db)).toBe(false);
|
|
315
|
+
|
|
316
|
+
// neither merged nor dropped — both collections survive untouched
|
|
317
|
+
expect(await rest.db.collection(AUDIT_COLLECTION).countDocuments({ "operation.action": "orphan" })).toBe(0);
|
|
318
|
+
expect(await rest.db.collection(LEGACY_AUDIT_COLLECTION).countDocuments({})).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
});
|
package/tests/db-cache.test.ts
CHANGED
|
@@ -152,10 +152,10 @@ describe("DB query cache (useCache)", () => {
|
|
|
152
152
|
const r1: any = await rest.find("items", params, { useCache: true });
|
|
153
153
|
expect(r1[0]!.count).toBe(1);
|
|
154
154
|
|
|
155
|
-
// update
|
|
155
|
+
// update with a NATIVE ObjectId (not a string) — String(id) normalization must match the index
|
|
156
156
|
await rest.updateOne("items", new ObjectId(a._id), { $set: { count: 77 } });
|
|
157
157
|
const r2: any = await rest.find("items", params, { useCache: true });
|
|
158
|
-
expect(r2[0]!.count).toBe(77); //
|
|
158
|
+
expect(r2[0]!.count).toBe(77); // invalidated + refetch
|
|
159
159
|
|
|
160
160
|
await rest.db.collection("items").deleteMany({ title: marker });
|
|
161
161
|
});
|
|
@@ -168,15 +168,15 @@ describe("DB query cache (useCache)", () => {
|
|
|
168
168
|
const b = await rest.insertOne("items", { title: marker, count: 2 });
|
|
169
169
|
const c = await rest.insertOne("items", { title: marker, count: 3 });
|
|
170
170
|
|
|
171
|
-
const params = { $match: { title: marker }, $sort: { count: 1 } }; //
|
|
172
|
-
await rest.find("items", params, { useCache: true }); // cache
|
|
171
|
+
const params = { $match: { title: marker }, $sort: { count: 1 } }; // guaranteed order: a(1), b(2), c(3)
|
|
172
|
+
await rest.find("items", params, { useCache: true }); // cache populated (3 docs)
|
|
173
173
|
|
|
174
|
-
//
|
|
174
|
+
// the 3rd id (c) is NOT tagged (maxTags: 2) → no targeted invalidation
|
|
175
175
|
await rest.updateOne("items", c._id, { $set: { count: 33 } });
|
|
176
176
|
const r1: any = await rest.find("items", params, { useCache: true });
|
|
177
|
-
expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache
|
|
177
|
+
expect(r1.find((d: any) => d._id === c._id)!.count).toBe(3); // cache kept
|
|
178
178
|
|
|
179
|
-
//
|
|
179
|
+
// the 1st id (a) is tagged → targeted invalidation
|
|
180
180
|
await rest.updateOne("items", a._id, { $set: { count: 11 } });
|
|
181
181
|
const r2: any = await rest.find("items", params, { useCache: true });
|
|
182
182
|
expect(r2.find((d: any) => d._id === a._id)!.count).toBe(11); // refetch
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { endpoints, FAMILIES, isReservedFamily, patterns, segment } from "../lib/endpoints";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The URL of everything — pinned here so a change of shape is a **deliberate**
|
|
6
|
+
* change (the whole surface moved to `/api/:tenant_id/<family>/…` once already,
|
|
7
|
+
* and every client, doc and skill had to follow).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
describe("endpoints — the server patterns", () => {
|
|
11
|
+
it("keeps every surface under `/api/:tenant_id/<family>`", () => {
|
|
12
|
+
for (const pattern of Object.values(patterns)) {
|
|
13
|
+
expect(pattern.startsWith("/api/:tenant_id/")).toBe(true);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("names a family the builders also use", () => {
|
|
18
|
+
const families = new Set<string>(FAMILIES);
|
|
19
|
+
for (const pattern of Object.values(patterns)) {
|
|
20
|
+
const [, , , family] = pattern.split("/");
|
|
21
|
+
expect(families.has(family!)).toBe(true);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("registers no pattern that could shadow a collection action", () => {
|
|
26
|
+
// A collection lives under `/collections/`, so `services`, `vars` or `login`
|
|
27
|
+
// are usable as collection names — that is the point of the layout.
|
|
28
|
+
expect(patterns.collection.split("/")[3]).toBe("collections");
|
|
29
|
+
expect(patterns.login).toBe("/api/:tenant_id/collections/:collection/login");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("endpoints — the builders", () => {
|
|
34
|
+
it("builds the documented paths", () => {
|
|
35
|
+
expect(endpoints.collection("v1", "orders", "find")).toBe("/api/v1/collections/orders/find");
|
|
36
|
+
expect(endpoints.collection("v1", "users", "login")).toBe("/api/v1/collections/users/login");
|
|
37
|
+
expect(endpoints.service("v1", "billing", "charge")).toBe("/api/v1/services/billing/charge");
|
|
38
|
+
expect(endpoints.vars("v1", "get")).toBe("/api/v1/vars/get");
|
|
39
|
+
expect(endpoints.upload("v1", "photos")).toBe("/api/v1/upload/photos");
|
|
40
|
+
expect(endpoints.file("v1", "photos", "507f1f77.jpg")).toBe("/api/v1/files/photos/507f1f77.jpg");
|
|
41
|
+
expect(endpoints.agent("v1", "support", "generate")).toBe("/api/v1/agents/support/generate");
|
|
42
|
+
expect(endpoints.mcp("v1")).toBe("/api/v1/mcp");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("matches the pattern it maps to", () => {
|
|
46
|
+
const mapped: Array<[string, string]> = [
|
|
47
|
+
[patterns.collection, endpoints.collection("t", "c", "a")],
|
|
48
|
+
[patterns.login, endpoints.collection("t", "c", "login")],
|
|
49
|
+
[patterns.service, endpoints.service("t", "s", "a")],
|
|
50
|
+
[patterns.vars, endpoints.vars("t", "a")],
|
|
51
|
+
[patterns.upload, endpoints.upload("t", "c")],
|
|
52
|
+
[patterns.file, endpoints.file("t", "c", "f")],
|
|
53
|
+
[patterns.agent, endpoints.agent("t", "ag", "a")],
|
|
54
|
+
[patterns.mcp, endpoints.mcp("t")],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
for (const [pattern, path] of mapped) {
|
|
58
|
+
const expected = pattern.split("/").slice(1);
|
|
59
|
+
expect(path.split("/").slice(1)).toHaveLength(expected.length);
|
|
60
|
+
// Same shape: each `:param` is one segment, the literals are identical
|
|
61
|
+
for (const [index, part] of expected.entries()) {
|
|
62
|
+
if (part.startsWith(":")) continue;
|
|
63
|
+
expect(path.split("/").slice(1)[index]).toBe(part);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("encodes a segment, so an id with a slash cannot forge a path", () => {
|
|
69
|
+
expect(segment("a/b c")).toBe("a%2Fb%20c");
|
|
70
|
+
expect(endpoints.file("v1", "photos", "../etc/passwd")).toBe("/api/v1/files/photos/..%2Fetc%2Fpasswd");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("endpoints — reserved families", () => {
|
|
75
|
+
it("recognises what a tenant route must not shadow", () => {
|
|
76
|
+
for (const family of FAMILIES) expect(isReservedFamily(family)).toBe(true);
|
|
77
|
+
expect(isReservedFamily("orders")).toBe(false);
|
|
78
|
+
expect(isReservedFamily("Collections")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|