@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,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { MongoClient, ObjectId } from "mongodb";
|
|
3
|
+
import { useRest } from "../database/rest";
|
|
4
|
+
import { formatConfig, cfg } from "../server/config";
|
|
5
|
+
import {
|
|
6
|
+
startReplication,
|
|
7
|
+
stopReplication,
|
|
8
|
+
replicateTenant,
|
|
9
|
+
getReplicationState,
|
|
10
|
+
} from "../database/replication";
|
|
11
|
+
|
|
12
|
+
const TEST_TENANT = "repl";
|
|
13
|
+
const SRC_DB = "mongodb://localhost:27017/_REPL_TEST_SRC";
|
|
14
|
+
const DEST_DB = "mongodb://localhost:27017/_REPL_TEST_DEST";
|
|
15
|
+
const DEST_ID = "backup";
|
|
16
|
+
|
|
17
|
+
let rest: InstanceType<typeof useRest>;
|
|
18
|
+
let sourceClient: MongoClient;
|
|
19
|
+
let destClient: MongoClient;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
formatConfig({
|
|
23
|
+
server: { port: 4000 },
|
|
24
|
+
tenants: [{
|
|
25
|
+
id: TEST_TENANT,
|
|
26
|
+
dir: "src",
|
|
27
|
+
database: { uri: SRC_DB },
|
|
28
|
+
replication: {
|
|
29
|
+
runOnBoot: false,
|
|
30
|
+
schedule: { interval: "1h" },
|
|
31
|
+
destinations: [{ id: DEST_ID, uri: DEST_DB }],
|
|
32
|
+
},
|
|
33
|
+
}],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Connect the source DB manually with a short timeout so a missing MongoDB
|
|
37
|
+
// fails fast and clearly (instead of hanging or process.exit(1)).
|
|
38
|
+
sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
|
|
39
|
+
await sourceClient.connect();
|
|
40
|
+
const tenant: any = cfg.tenants?.[0];
|
|
41
|
+
tenant.database.client = sourceClient;
|
|
42
|
+
tenant.database.db = sourceClient.db();
|
|
43
|
+
|
|
44
|
+
(cfg as any).collections = [
|
|
45
|
+
{
|
|
46
|
+
_tenant_: TEST_TENANT,
|
|
47
|
+
slug: "orders",
|
|
48
|
+
fields: [
|
|
49
|
+
{ name: "title", type: "string" },
|
|
50
|
+
{ name: "status", type: "string" },
|
|
51
|
+
{ name: "customer", type: "relationship", relation: { to: "customers" } },
|
|
52
|
+
{ name: "paidAt", type: "date" },
|
|
53
|
+
],
|
|
54
|
+
replication: { enabled: true },
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
await startReplication();
|
|
59
|
+
|
|
60
|
+
rest = new useRest({ tenant_id: TEST_TENANT });
|
|
61
|
+
destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
|
|
62
|
+
await destClient.connect();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await stopReplication();
|
|
67
|
+
try { await sourceClient.db().dropDatabase(); } catch (_) {}
|
|
68
|
+
try { await destClient.db().dropDatabase(); } catch (_) {}
|
|
69
|
+
try { await destClient.close(); } catch (_) {}
|
|
70
|
+
try { await sourceClient.close(); } catch (_) {}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("replication", () => {
|
|
74
|
+
it("replicates inserted documents", async () => {
|
|
75
|
+
const marker = `ins-${crypto.randomUUID()}`;
|
|
76
|
+
const source: any = await rest.insertOne("orders", { title: marker, status: "new" });
|
|
77
|
+
await replicateTenant(TEST_TENANT);
|
|
78
|
+
|
|
79
|
+
const dest = await destClient.db().collection("orders").findOne({ title: marker });
|
|
80
|
+
expect(dest).toBeDefined();
|
|
81
|
+
expect((dest as any)?.status).toBe("new");
|
|
82
|
+
// The destination keeps the **same `_id`** (the upsert is keyed on it), same BSON type
|
|
83
|
+
expect((dest as any)?._id.toString()).toBe(source._id);
|
|
84
|
+
expect((dest as any)?._id).toBeInstanceOf(ObjectId);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps a custom `_id` type (string) as-is", async () => {
|
|
88
|
+
const marker = `custom-${crypto.randomUUID()}`;
|
|
89
|
+
const customId = `order-${crypto.randomUUID()}`; // not a 24-hex string
|
|
90
|
+
await sourceClient.db().collection("orders").insertOne({
|
|
91
|
+
_id: customId as any,
|
|
92
|
+
title: marker,
|
|
93
|
+
status: "new",
|
|
94
|
+
updatedAt: new Date(),
|
|
95
|
+
});
|
|
96
|
+
await replicateTenant(TEST_TENANT);
|
|
97
|
+
|
|
98
|
+
const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
|
|
99
|
+
expect(dest).toBeDefined();
|
|
100
|
+
expect(dest._id).toBe(customId);
|
|
101
|
+
expect(typeof dest._id).toBe("string");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("writes BSON types on the destination — ObjectId relations and Dates stay typed", async () => {
|
|
105
|
+
const marker = `bson-${crypto.randomUUID()}`;
|
|
106
|
+
const customerId = new ObjectId();
|
|
107
|
+
const paidAt = new Date("2026-03-04T05:06:07.000Z");
|
|
108
|
+
|
|
109
|
+
// Through the framework: the relation string and the ISO date are converted
|
|
110
|
+
// to BSON by `toBson` before hitting MongoDB.
|
|
111
|
+
const source: any = await rest.insertOne("orders", {
|
|
112
|
+
title: marker,
|
|
113
|
+
status: "paid",
|
|
114
|
+
customer: customerId.toHexString(),
|
|
115
|
+
paidAt: paidAt.toISOString(),
|
|
116
|
+
});
|
|
117
|
+
await replicateTenant(TEST_TENANT);
|
|
118
|
+
|
|
119
|
+
// …and the destination must hold the same BSON types, not strings
|
|
120
|
+
const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
|
|
121
|
+
expect(dest).toBeDefined();
|
|
122
|
+
expect(dest._id).toBeInstanceOf(ObjectId);
|
|
123
|
+
expect(dest._id.toString()).toBe(source._id);
|
|
124
|
+
expect(dest.customer).toBeInstanceOf(ObjectId);
|
|
125
|
+
expect(dest.customer.toString()).toBe(customerId.toHexString());
|
|
126
|
+
expect(dest.paidAt).toBeInstanceOf(Date);
|
|
127
|
+
expect(dest.paidAt.getTime()).toBe(paidAt.getTime());
|
|
128
|
+
expect(dest.createdAt).toBeInstanceOf(Date);
|
|
129
|
+
expect(dest.updatedAt).toBeInstanceOf(Date);
|
|
130
|
+
|
|
131
|
+
// The source itself stores the very same types (the copy is faithful)
|
|
132
|
+
const raw: any = await sourceClient.db().collection("orders").findOne({ title: marker });
|
|
133
|
+
expect(raw.customer).toBeInstanceOf(ObjectId);
|
|
134
|
+
expect(raw.paidAt).toBeInstanceOf(Date);
|
|
135
|
+
expect(typeof raw.customer).toBe(typeof dest.customer);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("replicates updates", async () => {
|
|
139
|
+
const marker = `upd-${crypto.randomUUID()}`;
|
|
140
|
+
const doc: any = await rest.insertOne("orders", { title: marker, status: "draft" });
|
|
141
|
+
await replicateTenant(TEST_TENANT);
|
|
142
|
+
|
|
143
|
+
await rest.updateOne("orders", doc._id, { $set: { status: "paid" } });
|
|
144
|
+
await replicateTenant(TEST_TENANT);
|
|
145
|
+
|
|
146
|
+
const dest = await destClient.db().collection("orders").findOne({ title: marker });
|
|
147
|
+
expect((dest as any)?.status).toBe("paid");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("replicates deletes via tombstone", async () => {
|
|
151
|
+
const marker = `del-${crypto.randomUUID()}`;
|
|
152
|
+
const doc: any = await rest.insertOne("orders", { title: marker, status: "x" });
|
|
153
|
+
await replicateTenant(TEST_TENANT);
|
|
154
|
+
|
|
155
|
+
const before = await destClient.db().collection("orders").findOne({ title: marker });
|
|
156
|
+
expect(before).toBeDefined();
|
|
157
|
+
|
|
158
|
+
await rest.deleteOne("orders", doc._id);
|
|
159
|
+
await replicateTenant(TEST_TENANT);
|
|
160
|
+
|
|
161
|
+
const after = await destClient.db().collection("orders").findOne({ title: marker });
|
|
162
|
+
expect(after).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("does not re-copy unchanged documents (cursor is persisted)", async () => {
|
|
166
|
+
const marker = `resume-${crypto.randomUUID()}`;
|
|
167
|
+
await rest.insertOne("orders", { title: marker, status: "new" });
|
|
168
|
+
|
|
169
|
+
const first = await replicateTenant(TEST_TENANT);
|
|
170
|
+
const second = await replicateTenant(TEST_TENANT);
|
|
171
|
+
|
|
172
|
+
const r1 = first.find((r) => r.collection === "orders" && r.destination === DEST_ID);
|
|
173
|
+
const r2 = second.find((r) => r.collection === "orders" && r.destination === DEST_ID);
|
|
174
|
+
expect(r1?.inserted).toBeGreaterThanOrEqual(1);
|
|
175
|
+
expect(r2?.inserted).toBe(0);
|
|
176
|
+
expect(r2?.updated).toBe(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("stores state in _replication_", async () => {
|
|
180
|
+
await rest.insertOne("orders", { title: `state-${crypto.randomUUID()}`, status: "new" });
|
|
181
|
+
await replicateTenant(TEST_TENANT);
|
|
182
|
+
|
|
183
|
+
const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
|
|
184
|
+
expect(state.length).toBe(1);
|
|
185
|
+
expect(state[0]!.status).toBe("success");
|
|
186
|
+
expect(state[0]!.cursor).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
package/tests/upload.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { ObjectId } from "mongodb";
|
|
2
3
|
import { createApp } from "../server/hono";
|
|
3
4
|
import { formatConfig, cfg } from "../server/config";
|
|
4
5
|
import { syncTenants } from "../database/tenant";
|
|
5
6
|
import { getFileCollection } from "../database/file";
|
|
6
7
|
import { useRest } from "../database/rest";
|
|
8
|
+
import { createApi } from "../lib/api";
|
|
7
9
|
|
|
8
10
|
const TENANT = "up-test";
|
|
9
11
|
const DB = "mongodb://localhost:27017/_DB_UP_TEST";
|
|
@@ -25,7 +27,7 @@ beforeAll(async () => {
|
|
|
25
27
|
_tenant_: TENANT,
|
|
26
28
|
_isFileCollection_: true,
|
|
27
29
|
slug: SLUG,
|
|
28
|
-
fields: [{ name: "label", type: "string" }],
|
|
30
|
+
fields: [{ name: "label", type: "string" }, { name: "takenAt", type: "date" }],
|
|
29
31
|
upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
|
|
30
32
|
storage: { driver: "disk" },
|
|
31
33
|
api: { access: { "*": true } },
|
|
@@ -56,13 +58,72 @@ function pngFile(name: string) {
|
|
|
56
58
|
return new File([bytes], name, { type: "image/png" });
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
describe("upload — audit trail", () => {
|
|
62
|
+
/** Latest audit entry for an action, polling until it lands */
|
|
63
|
+
async function auditEntry(action: string, id?: string) {
|
|
64
|
+
const filter: any = { "operation.action": action };
|
|
65
|
+
if (id) filter["operation.result._id"] = id;
|
|
66
|
+
for (let i = 0; i < 40; i++) {
|
|
67
|
+
const found: any = await rest.db.collection("_audit_").findOne(filter, { sort: { ts: -1 } });
|
|
68
|
+
if (found) return found;
|
|
69
|
+
await Bun.sleep(25);
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it("records an upload with the file metadata and the generated id — never the document", async () => {
|
|
75
|
+
const form = new FormData();
|
|
76
|
+
form.append("file", pngFile("audited.png"));
|
|
77
|
+
form.append("label", "audited");
|
|
78
|
+
|
|
79
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
80
|
+
expect(res.status).toBe(200);
|
|
81
|
+
const body: any = await res.json();
|
|
82
|
+
|
|
83
|
+
const logged: any = await auditEntry("upload", body._id);
|
|
84
|
+
expect(logged).not.toBeNull();
|
|
85
|
+
expect(logged.operation.collection).toBe(SLUG);
|
|
86
|
+
expect(logged.operation.collectionType).toBe("file");
|
|
87
|
+
expect(logged.operation.status).toBe("success");
|
|
88
|
+
expect(logged.internal).toBe(false); // HTTP upload
|
|
89
|
+
|
|
90
|
+
// parameters only: the file's metadata and which custom fields were sent
|
|
91
|
+
expect(logged.operation.input.file.name).toBe("audited.png");
|
|
92
|
+
expect(logged.operation.input.file.mimetype).toBe("image/png");
|
|
93
|
+
expect(typeof logged.operation.input.file.size).toBe("number");
|
|
94
|
+
expect(logged.operation.input.fields).toEqual(["label"]);
|
|
95
|
+
expect(logged.operation.input.label).toBeUndefined(); // the value is NOT stored
|
|
96
|
+
|
|
97
|
+
// and the generated id, never the stored document
|
|
98
|
+
expect(logged.operation.result).toEqual({ _id: body._id });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("records a file deletion", async () => {
|
|
102
|
+
const form = new FormData();
|
|
103
|
+
form.append("file", pngFile("deleted.png"));
|
|
104
|
+
const uploaded: any = await (
|
|
105
|
+
await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
|
|
106
|
+
).json();
|
|
107
|
+
|
|
108
|
+
const res = await fetch(`${url}/api/${TENANT}/files/${SLUG}/${uploaded._id}`, { method: "DELETE" });
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
|
|
111
|
+
const logged: any = await auditEntry("deleteFile");
|
|
112
|
+
expect(logged).not.toBeNull();
|
|
113
|
+
expect(logged.operation.input.id).toBe(uploaded._id);
|
|
114
|
+
expect(logged.operation.input.filename).toBeString();
|
|
115
|
+
expect(logged.operation.result).toEqual({ deleted: true });
|
|
116
|
+
expect(logged.internal).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
59
120
|
describe("upload — single file (unchanged contract)", () => {
|
|
60
121
|
it("returns a single object", async () => {
|
|
61
122
|
const form = new FormData();
|
|
62
123
|
form.append("file", pngFile("one.png"));
|
|
63
124
|
form.append("label", "single");
|
|
64
125
|
|
|
65
|
-
const res = await fetch(`${url}/
|
|
126
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
66
127
|
expect(res.status).toBe(200);
|
|
67
128
|
|
|
68
129
|
const body: any = await res.json();
|
|
@@ -70,18 +131,25 @@ describe("upload — single file (unchanged contract)", () => {
|
|
|
70
131
|
expect(body._id).toBeString();
|
|
71
132
|
expect(body._file.name).toBe("one.png");
|
|
72
133
|
expect(body._file.mimetype).toBe("image/png");
|
|
73
|
-
expect(body._file.url).toContain(`/
|
|
134
|
+
expect(body._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
|
|
135
|
+
expect(body.createdAt).toBeString();
|
|
136
|
+
expect(body.updatedAt).toBeString();
|
|
74
137
|
|
|
75
138
|
// metadata persisted on the document
|
|
76
139
|
const doc: any = await rest.findOne(SLUG, body._id);
|
|
77
140
|
expect(doc.label).toBe("single");
|
|
141
|
+
|
|
142
|
+
// Stored as BSON Dates (same end result as collections via toBson)
|
|
143
|
+
const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
|
|
144
|
+
expect(raw.createdAt).toBeInstanceOf(Date);
|
|
145
|
+
expect(raw.updatedAt).toBeInstanceOf(Date);
|
|
78
146
|
});
|
|
79
147
|
|
|
80
148
|
it("accepts the 'upload' field name too", async () => {
|
|
81
149
|
const form = new FormData();
|
|
82
150
|
form.append("upload", pngFile("alt.png"));
|
|
83
151
|
|
|
84
|
-
const res = await fetch(`${url}/
|
|
152
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
85
153
|
expect(res.status).toBe(200);
|
|
86
154
|
const body: any = await res.json();
|
|
87
155
|
expect(Array.isArray(body)).toBe(false);
|
|
@@ -97,7 +165,7 @@ describe("upload — multiple files", () => {
|
|
|
97
165
|
form.append("file", pngFile("c.png"));
|
|
98
166
|
form.append("label", "batch");
|
|
99
167
|
|
|
100
|
-
const res = await fetch(`${url}/
|
|
168
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
101
169
|
expect(res.status).toBe(200);
|
|
102
170
|
|
|
103
171
|
const body: any = await res.json();
|
|
@@ -111,7 +179,10 @@ describe("upload — multiple files", () => {
|
|
|
111
179
|
for (const id of ids) {
|
|
112
180
|
const doc: any = await rest.findOne(SLUG, id);
|
|
113
181
|
expect(doc.label).toBe("batch");
|
|
114
|
-
expect(doc._file.url).toContain(`/
|
|
182
|
+
expect(doc._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
|
|
183
|
+
// Uploads carry timestamps (required by the replication cursor)
|
|
184
|
+
expect(doc.createdAt).toBeString();
|
|
185
|
+
expect(doc.updatedAt).toBeString();
|
|
115
186
|
}
|
|
116
187
|
});
|
|
117
188
|
|
|
@@ -119,7 +190,7 @@ describe("upload — multiple files", () => {
|
|
|
119
190
|
const form = new FormData();
|
|
120
191
|
form.append("label", "nofile");
|
|
121
192
|
|
|
122
|
-
const res = await fetch(`${url}/
|
|
193
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
123
194
|
expect(res.status).toBe(400);
|
|
124
195
|
const body: any = await res.json();
|
|
125
196
|
expect(body.code).toBe("FILE_REQUIRED");
|
|
@@ -130,9 +201,71 @@ describe("upload — multiple files", () => {
|
|
|
130
201
|
form.append("file", pngFile("ok.png"));
|
|
131
202
|
form.append("file", new File([Buffer.from("nope")], "bad.exe", { type: "application/x-msdownload" }));
|
|
132
203
|
|
|
133
|
-
const res = await fetch(`${url}/
|
|
204
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
134
205
|
expect(res.status).toBe(400);
|
|
135
206
|
const body: any = await res.json();
|
|
136
207
|
expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
|
|
137
208
|
});
|
|
138
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("upload — custom field types (toBson, like collections)", () => {
|
|
212
|
+
it("converts a `date` field to a BSON Date", async () => {
|
|
213
|
+
const form = new FormData();
|
|
214
|
+
form.append("file", pngFile("dated.png"));
|
|
215
|
+
form.append("label", "dated");
|
|
216
|
+
form.append("takenAt", "2024-05-01T10:00:00.000Z");
|
|
217
|
+
|
|
218
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
const body: any = await res.json();
|
|
221
|
+
|
|
222
|
+
const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
|
|
223
|
+
expect(raw.takenAt).toBeInstanceOf(Date);
|
|
224
|
+
expect(raw.takenAt.toISOString()).toBe("2024-05-01T10:00:00.000Z");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("leaves `_file` untouched, even when a name looks like a date", async () => {
|
|
228
|
+
const form = new FormData();
|
|
229
|
+
form.append("file", pngFile("2024-01-01")); // ← a file literally named like a date
|
|
230
|
+
|
|
231
|
+
const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
|
|
232
|
+
expect(res.status).toBe(200);
|
|
233
|
+
const body: any = await res.json();
|
|
234
|
+
expect(body._file.name).toBe("2024-01-01");
|
|
235
|
+
|
|
236
|
+
const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
|
|
237
|
+
expect(raw._file.name).toBe("2024-01-01"); // NOT converted to a Date
|
|
238
|
+
expect(typeof raw._file.name).toBe("string");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("api.files — the in-process facade", () => {
|
|
243
|
+
it("builds a file URL, with the transformations the route understands", () => {
|
|
244
|
+
const api = createApi(rest);
|
|
245
|
+
|
|
246
|
+
expect(api.files.url(SLUG, "f1.png")).toBe(`/api/${TENANT}/files/${SLUG}/f1.png`);
|
|
247
|
+
expect(api.files.url(SLUG, "f1.png", { width: 200, format: "webp" }))
|
|
248
|
+
.toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&format=webp`);
|
|
249
|
+
expect(api.files.url(SLUG, "f1.png", { width: 200, height: 100, format: "avif", quality: 70 }))
|
|
250
|
+
.toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&h=100&format=avif&q=70`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("deletes the binary and the document — the same path as the route", async () => {
|
|
254
|
+
const form = new FormData();
|
|
255
|
+
form.append("file", pngFile("to-delete.png"));
|
|
256
|
+
const uploaded: any = await (
|
|
257
|
+
await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
|
|
258
|
+
).json();
|
|
259
|
+
|
|
260
|
+
const fs = await import("node:fs/promises");
|
|
261
|
+
const storage = getFileCollection(SLUG, TENANT)?.storage as any;
|
|
262
|
+
const dir = `${process.cwd()}/${storage?.path ?? "storage"}/${TENANT}/${SLUG}`;
|
|
263
|
+
expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(true);
|
|
264
|
+
|
|
265
|
+
const result = await createApi(rest).files.delete(SLUG, uploaded._id);
|
|
266
|
+
|
|
267
|
+
expect(result).toEqual({ message: "File deleted", ok: true });
|
|
268
|
+
expect(await rest.findOne(SLUG, uploaded._id)).toBeNull();
|
|
269
|
+
expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { syncVars } from "../database/vars";
|
|
7
|
+
import { useRest } from "../database/rest";
|
|
8
|
+
|
|
9
|
+
const TENANT = "vars-api";
|
|
10
|
+
const DB = "mongodb://localhost:27017/_VARS_API_TEST";
|
|
11
|
+
const DIR = "packages/core/tests/fixtures/vars-tenant";
|
|
12
|
+
|
|
13
|
+
let rest: InstanceType<typeof useRest>;
|
|
14
|
+
let server: any;
|
|
15
|
+
let url = "";
|
|
16
|
+
|
|
17
|
+
beforeAll(async () => {
|
|
18
|
+
formatConfig({
|
|
19
|
+
server: { port: 4000 },
|
|
20
|
+
tenants: [{ id: TENANT, dir: DIR, database: { uri: DB } }],
|
|
21
|
+
});
|
|
22
|
+
await syncTenants();
|
|
23
|
+
await syncVars();
|
|
24
|
+
|
|
25
|
+
rest = new useRest({ internal: false, tenant_id: TENANT });
|
|
26
|
+
const app = createApp();
|
|
27
|
+
server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
28
|
+
url = server.url.href.replace(/\/$/, "");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(async () => {
|
|
32
|
+
try { await rest.db.dropDatabase(); } catch (_) {}
|
|
33
|
+
try { server.stop(true); } catch (_) {}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const post = (action: string, body: any) =>
|
|
37
|
+
fetch(`${url}/api/${TENANT}/vars/${action}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const json = async (action: string, body: any) => {
|
|
44
|
+
const res = await post(action, body);
|
|
45
|
+
return { status: res.status, body: await res.json() as any };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("vars HTTP API — access control", () => {
|
|
49
|
+
it("denies a namespace without api.access", async () => {
|
|
50
|
+
const res = await json("get", { ns: "private", key: "secret" });
|
|
51
|
+
expect(res.status).toBe(401);
|
|
52
|
+
expect(res.body.code).toBe("ACCESS_DENIED");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects an unknown namespace", async () => {
|
|
56
|
+
const res = await json("get", { ns: "nope", key: "x" });
|
|
57
|
+
expect(res.status).toBe(400);
|
|
58
|
+
expect(res.body.code).toBe("NAMESPACE_NOT_FOUND");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("requires the namespace", async () => {
|
|
62
|
+
const res = await json("get", { key: "x" });
|
|
63
|
+
expect(res.status).toBe(400);
|
|
64
|
+
expect(res.body.code).toBe("NAMESPACE_REQUIRED");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("vars HTTP API — CRUD", () => {
|
|
69
|
+
it("set / get / has / del", async () => {
|
|
70
|
+
expect((await json("set", { ns: "config", key: "plan", value: "pro" })).status).toBe(200);
|
|
71
|
+
|
|
72
|
+
const got = await json("get", { ns: "config", key: "plan" });
|
|
73
|
+
expect(got.body.value).toBe("pro");
|
|
74
|
+
|
|
75
|
+
expect((await json("has", { ns: "config", key: "plan" })).body.exists).toBe(true);
|
|
76
|
+
expect((await json("del", { ns: "config", key: "plan" })).body.ok).toBe(true);
|
|
77
|
+
expect((await json("get", { ns: "config", key: "plan" })).body.value).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("setMany / all", async () => {
|
|
81
|
+
await json("setMany", { ns: "config", entries: { a: 1, b: 2 } });
|
|
82
|
+
const all = await json("all", { ns: "config" });
|
|
83
|
+
expect(all.body.vars).toMatchObject({ a: 1, b: 2 });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("entry carries the meta", async () => {
|
|
87
|
+
const id = new ObjectId().toHexString();
|
|
88
|
+
await json("set", { ns: "config", key: "lic", value: "L-1", meta: { note: "hi" }, scope: id });
|
|
89
|
+
|
|
90
|
+
const entry = await json("entry", { ns: "config", key: "lic", scope: id });
|
|
91
|
+
expect(entry.body.entry.value).toBe("L-1");
|
|
92
|
+
expect(entry.body.entry.meta).toEqual({ note: "hi" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("incr", async () => {
|
|
96
|
+
await json("set", { ns: "config", key: "hits", value: 5 });
|
|
97
|
+
expect((await json("incr", { ns: "config", key: "hits" })).body.value).toBe(6);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("scope keeps values isolated", async () => {
|
|
101
|
+
const id = new ObjectId().toHexString();
|
|
102
|
+
await json("set", { ns: "config", key: "lic", value: "SCOPED", scope: id });
|
|
103
|
+
expect((await json("get", { ns: "config", key: "lic", scope: id })).body.value).toBe("SCOPED");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("TTL round-trips as an expiry", async () => {
|
|
107
|
+
await json("set", { ns: "config", key: "tmp", value: "x", ttl: "1h" });
|
|
108
|
+
const entry = await json("entry", { ns: "config", key: "tmp" });
|
|
109
|
+
expect(typeof entry.body.entry.expiresAt).toBe("string");
|
|
110
|
+
expect((await json("expire", { ns: "config", key: "tmp", ttl: "30m" })).body.ok).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("validates the value against its spec", async () => {
|
|
114
|
+
const res = await json("set", { ns: "config", key: "maxUsers", value: "not-a-number" });
|
|
115
|
+
expect(res.status).toBe(400);
|
|
116
|
+
expect(res.body.code).toBe("VALIDATION_ERROR");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("rejects an unknown action", async () => {
|
|
120
|
+
const res = await json("nope", { ns: "config", key: "x" });
|
|
121
|
+
expect(res.status).toBe(400);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { MongoClient } from "mongodb";
|
|
3
|
+
import { useRest } from "../database/rest";
|
|
4
|
+
import { formatConfig, cfg } from "../server/config";
|
|
5
|
+
import { syncVars } from "../database/vars";
|
|
6
|
+
import { startReplication, stopReplication, replicateTenant } from "../database/replication";
|
|
7
|
+
|
|
8
|
+
const TEST_TENANT = "vars-repl";
|
|
9
|
+
const SRC_DB = "mongodb://localhost:27017/_VARS_REPL_SRC";
|
|
10
|
+
const DEST_DB = "mongodb://localhost:27017/_VARS_REPL_DEST";
|
|
11
|
+
|
|
12
|
+
let rest: InstanceType<typeof useRest>;
|
|
13
|
+
let sourceClient: MongoClient;
|
|
14
|
+
let destClient: MongoClient;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
formatConfig({
|
|
18
|
+
server: { port: 4000 },
|
|
19
|
+
tenants: [{
|
|
20
|
+
id: TEST_TENANT,
|
|
21
|
+
dir: "packages/core/tests/fixtures/vars-tenant",
|
|
22
|
+
database: { uri: SRC_DB },
|
|
23
|
+
replication: {
|
|
24
|
+
runOnBoot: false,
|
|
25
|
+
schedule: { interval: "1h" },
|
|
26
|
+
destinations: [{ id: "backup", uri: DEST_DB }],
|
|
27
|
+
},
|
|
28
|
+
}],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
|
|
32
|
+
await sourceClient.connect();
|
|
33
|
+
const tenant: any = cfg.tenants?.[0];
|
|
34
|
+
tenant.database.client = sourceClient;
|
|
35
|
+
tenant.database.db = sourceClient.db();
|
|
36
|
+
|
|
37
|
+
await syncVars();
|
|
38
|
+
await startReplication();
|
|
39
|
+
|
|
40
|
+
rest = new useRest({ tenant_id: TEST_TENANT });
|
|
41
|
+
destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
|
|
42
|
+
await destClient.connect();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await stopReplication();
|
|
47
|
+
try { await sourceClient.db().dropDatabase(); } catch (_) {}
|
|
48
|
+
try { await destClient.db().dropDatabase(); } catch (_) {}
|
|
49
|
+
try { await destClient.close(); } catch (_) {}
|
|
50
|
+
try { await sourceClient.close(); } catch (_) {}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const destVar = (key: string, ns = "config") =>
|
|
54
|
+
destClient.db().collection("_vars_").findOne({ ns, key });
|
|
55
|
+
|
|
56
|
+
describe("vars replication", () => {
|
|
57
|
+
it("replicates the `_vars_` collection (all namespaces)", async () => {
|
|
58
|
+
await rest.vars.set("config", "licence", "REPL-1");
|
|
59
|
+
await replicateTenant(TEST_TENANT);
|
|
60
|
+
|
|
61
|
+
expect((await destVar("licence"))?.value).toBe("REPL-1");
|
|
62
|
+
|
|
63
|
+
// Seeded defaults are replicated too
|
|
64
|
+
expect((await destVar("maxUsers"))?.value).toBe(100);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("propagates variable deletion", async () => {
|
|
68
|
+
await rest.vars.set("config", "tmp", "x", { ttl: "1h" });
|
|
69
|
+
await replicateTenant(TEST_TENANT);
|
|
70
|
+
expect(await destVar("tmp")).toBeDefined();
|
|
71
|
+
|
|
72
|
+
await rest.vars.del("config", "tmp");
|
|
73
|
+
await replicateTenant(TEST_TENANT);
|
|
74
|
+
expect(await destVar("tmp")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("propagates scoped variables", async () => {
|
|
78
|
+
const mine = "aabbccddeeff001122334455";
|
|
79
|
+
await rest.vars.scope(mine).set("config", "licence", "SCOPED-1");
|
|
80
|
+
await replicateTenant(TEST_TENANT);
|
|
81
|
+
|
|
82
|
+
const dest = await destClient.db().collection("_vars_").findOne({
|
|
83
|
+
ns: "config", key: "licence", scope: { $ne: null },
|
|
84
|
+
});
|
|
85
|
+
expect(dest?.value).toBe("SCOPED-1");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does NOT replicate namespaces that opt out (`enabled: false`)", async () => {
|
|
89
|
+
// `private.var.ts` declares `replication: { enabled: false }` — it must
|
|
90
|
+
// never reach the destination.
|
|
91
|
+
await rest.vars.set("private", "secret", "top-secret");
|
|
92
|
+
await replicateTenant(TEST_TENANT);
|
|
93
|
+
|
|
94
|
+
expect(await destClient.db().collection("_vars_").findOne({ ns: "private" })).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|