@nkmc/agent-fs 0.1.0
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/dist/chunk-7LIZT7L3.js +966 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +419 -0
- package/dist/rpc-D1IHpjF_.d.cts +330 -0
- package/dist/rpc-D1IHpjF_.d.ts +330 -0
- package/dist/testing.cjs +842 -0
- package/dist/testing.d.cts +29 -0
- package/dist/testing.d.ts +29 -0
- package/dist/testing.js +10 -0
- package/package.json +25 -0
- package/src/agent-fs.ts +151 -0
- package/src/backends/http.ts +835 -0
- package/src/backends/memory.ts +183 -0
- package/src/backends/rpc.ts +456 -0
- package/src/index.ts +36 -0
- package/src/mount.ts +84 -0
- package/src/parser.ts +162 -0
- package/src/server.ts +158 -0
- package/src/testing.ts +3 -0
- package/src/types.ts +52 -0
- package/test/agent-fs.test.ts +325 -0
- package/test/http-204.test.ts +102 -0
- package/test/http-auth-prefix.test.ts +79 -0
- package/test/http-cloudflare.test.ts +533 -0
- package/test/http-form-encoding.test.ts +119 -0
- package/test/http-github.test.ts +580 -0
- package/test/http-listkey.test.ts +128 -0
- package/test/http-oauth2.test.ts +174 -0
- package/test/http-pagination.test.ts +200 -0
- package/test/http-param-styles.test.ts +98 -0
- package/test/http-passthrough.test.ts +282 -0
- package/test/http-retry.test.ts +132 -0
- package/test/http.test.ts +360 -0
- package/test/memory.test.ts +120 -0
- package/test/mount.test.ts +94 -0
- package/test/parser.test.ts +100 -0
- package/test/rpc-crud.test.ts +627 -0
- package/test/rpc-evm.test.ts +390 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +8 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
HttpBackend,
|
|
5
|
+
type HttpBackendConfig,
|
|
6
|
+
type HttpResource,
|
|
7
|
+
} from "../src/backends/http.js";
|
|
8
|
+
|
|
9
|
+
// --- Mock Cloudflare API data ---
|
|
10
|
+
|
|
11
|
+
interface MockRecord {
|
|
12
|
+
id: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const zones: MockRecord[] = [
|
|
17
|
+
{ id: "z1", name: "example.com", status: "active" },
|
|
18
|
+
{ id: "z2", name: "test.dev", status: "active" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const dnsRecords: Record<string, MockRecord[]> = {
|
|
22
|
+
z1: [
|
|
23
|
+
{ id: "r1", type: "A", name: "example.com", content: "1.2.3.4" },
|
|
24
|
+
{ id: "r2", type: "CNAME", name: "www.example.com", content: "example.com" },
|
|
25
|
+
],
|
|
26
|
+
z2: [
|
|
27
|
+
{ id: "r3", type: "A", name: "test.dev", content: "5.6.7.8" },
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const zoneSettings: Record<string, MockRecord[]> = {
|
|
32
|
+
z1: [
|
|
33
|
+
{ id: "ssl", value: "full" },
|
|
34
|
+
{ id: "minify", value: "on" },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const workerScripts: MockRecord[] = [
|
|
39
|
+
{ id: "my-worker", script: "addEventListener('fetch', ...)" },
|
|
40
|
+
{ id: "api-gateway", script: "export default { fetch() {} }" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const kvNamespaces: MockRecord[] = [
|
|
44
|
+
{ id: "ns1", title: "MY_KV" },
|
|
45
|
+
{ id: "ns2", title: "SESSIONS" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const kvKeys: Record<string, MockRecord[]> = {
|
|
49
|
+
ns1: [
|
|
50
|
+
{ name: "user:1", metadata: {} },
|
|
51
|
+
{ name: "user:2", metadata: {} },
|
|
52
|
+
],
|
|
53
|
+
ns2: [
|
|
54
|
+
{ name: "sess:abc", metadata: {} },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const r2Buckets: MockRecord[] = [
|
|
59
|
+
{ name: "assets", creation_date: "2024-01-01" },
|
|
60
|
+
{ name: "backups", creation_date: "2024-06-01" },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// --- Mock Cloudflare API server ---
|
|
64
|
+
|
|
65
|
+
function json(res: ServerResponse, status: number, data: unknown) {
|
|
66
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
67
|
+
res.end(JSON.stringify(data));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cfResult(data: unknown) {
|
|
71
|
+
return { result: data, success: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createMockCfServer(): Promise<{ server: Server; baseUrl: string }> {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const srv = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
77
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
78
|
+
const path = url.pathname;
|
|
79
|
+
const method = req.method ?? "GET";
|
|
80
|
+
|
|
81
|
+
// Verify auth (Cloudflare uses Bearer token)
|
|
82
|
+
const auth = req.headers.authorization;
|
|
83
|
+
if (auth !== "Bearer cf-test-token") {
|
|
84
|
+
json(res, 401, { success: false, errors: [{ message: "Unauthorized" }] });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Read body
|
|
89
|
+
const chunks: Buffer[] = [];
|
|
90
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
91
|
+
req.on("end", () => {
|
|
92
|
+
const body = chunks.length
|
|
93
|
+
? JSON.parse(Buffer.concat(chunks).toString())
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
// --- Zones ---
|
|
97
|
+
// GET /zones
|
|
98
|
+
if (path === "/zones" && method === "GET") {
|
|
99
|
+
return json(res, 200, cfResult(zones));
|
|
100
|
+
}
|
|
101
|
+
// POST /zones
|
|
102
|
+
if (path === "/zones" && method === "POST") {
|
|
103
|
+
const newZone = { id: `z${zones.length + 1}`, ...body };
|
|
104
|
+
zones.push(newZone);
|
|
105
|
+
return json(res, 201, newZone);
|
|
106
|
+
}
|
|
107
|
+
// GET/DELETE /zones/:id
|
|
108
|
+
const zoneMatch = path.match(/^\/zones\/([^/]+)$/);
|
|
109
|
+
if (zoneMatch) {
|
|
110
|
+
const [, zoneId] = zoneMatch;
|
|
111
|
+
if (method === "GET") {
|
|
112
|
+
const zone = zones.find((z) => z.id === zoneId);
|
|
113
|
+
if (!zone) return json(res, 404, { success: false });
|
|
114
|
+
return json(res, 200, zone);
|
|
115
|
+
}
|
|
116
|
+
if (method === "DELETE") {
|
|
117
|
+
const idx = zones.findIndex((z) => z.id === zoneId);
|
|
118
|
+
if (idx === -1) return json(res, 404, { success: false });
|
|
119
|
+
zones.splice(idx, 1);
|
|
120
|
+
return json(res, 200, { id: zoneId });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- DNS Records (nested under zones) ---
|
|
125
|
+
// GET /zones/:id/dns_records
|
|
126
|
+
const dnsListMatch = path.match(/^\/zones\/([^/]+)\/dns_records$/);
|
|
127
|
+
if (dnsListMatch && method === "GET") {
|
|
128
|
+
const [, zoneId] = dnsListMatch;
|
|
129
|
+
const records = dnsRecords[zoneId] ?? [];
|
|
130
|
+
return json(res, 200, cfResult(records));
|
|
131
|
+
}
|
|
132
|
+
// POST /zones/:id/dns_records
|
|
133
|
+
if (dnsListMatch && method === "POST") {
|
|
134
|
+
const [, zoneId] = dnsListMatch;
|
|
135
|
+
if (!dnsRecords[zoneId]) dnsRecords[zoneId] = [];
|
|
136
|
+
const newRecord = { id: `r${Date.now()}`, ...body };
|
|
137
|
+
dnsRecords[zoneId].push(newRecord);
|
|
138
|
+
return json(res, 201, newRecord);
|
|
139
|
+
}
|
|
140
|
+
// GET/PUT/DELETE /zones/:id/dns_records/:rid
|
|
141
|
+
const dnsItemMatch = path.match(/^\/zones\/([^/]+)\/dns_records\/([^/]+)$/);
|
|
142
|
+
if (dnsItemMatch) {
|
|
143
|
+
const [, zoneId, recordId] = dnsItemMatch;
|
|
144
|
+
const records = dnsRecords[zoneId] ?? [];
|
|
145
|
+
if (method === "GET") {
|
|
146
|
+
const record = records.find((r) => r.id === recordId);
|
|
147
|
+
if (!record) return json(res, 404, { success: false });
|
|
148
|
+
return json(res, 200, record);
|
|
149
|
+
}
|
|
150
|
+
if (method === "PUT") {
|
|
151
|
+
const idx = records.findIndex((r) => r.id === recordId);
|
|
152
|
+
if (idx === -1) return json(res, 404, { success: false });
|
|
153
|
+
records[idx] = { ...records[idx], ...body, id: recordId };
|
|
154
|
+
return json(res, 200, records[idx]);
|
|
155
|
+
}
|
|
156
|
+
if (method === "DELETE") {
|
|
157
|
+
const idx = records.findIndex((r) => r.id === recordId);
|
|
158
|
+
if (idx === -1) return json(res, 404, { success: false });
|
|
159
|
+
records.splice(idx, 1);
|
|
160
|
+
return json(res, 200, { id: recordId });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Zone Settings (nested under zones) ---
|
|
165
|
+
const settingsListMatch = path.match(/^\/zones\/([^/]+)\/settings$/);
|
|
166
|
+
if (settingsListMatch && method === "GET") {
|
|
167
|
+
const [, zoneId] = settingsListMatch;
|
|
168
|
+
const settings = zoneSettings[zoneId] ?? [];
|
|
169
|
+
return json(res, 200, cfResult(settings));
|
|
170
|
+
}
|
|
171
|
+
const settingsItemMatch = path.match(/^\/zones\/([^/]+)\/settings\/([^/]+)$/);
|
|
172
|
+
if (settingsItemMatch && method === "GET") {
|
|
173
|
+
const [, zoneId, settingId] = settingsItemMatch;
|
|
174
|
+
const settings = zoneSettings[zoneId] ?? [];
|
|
175
|
+
const setting = settings.find((s) => s.id === settingId);
|
|
176
|
+
if (!setting) return json(res, 404, { success: false });
|
|
177
|
+
return json(res, 200, setting);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Workers Scripts (account-scoped) ---
|
|
181
|
+
const workersMatch = path.match(/^\/accounts\/([^/]+)\/workers\/scripts$/);
|
|
182
|
+
if (workersMatch && method === "GET") {
|
|
183
|
+
return json(res, 200, cfResult(workerScripts));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- KV Namespaces (account-scoped) ---
|
|
187
|
+
const kvNsMatch = path.match(/^\/accounts\/([^/]+)\/storage\/kv\/namespaces$/);
|
|
188
|
+
if (kvNsMatch && method === "GET") {
|
|
189
|
+
return json(res, 200, cfResult(kvNamespaces));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- KV Keys (deeply nested) ---
|
|
193
|
+
const kvKeysMatch = path.match(
|
|
194
|
+
/^\/accounts\/([^/]+)\/storage\/kv\/namespaces\/([^/]+)\/keys$/,
|
|
195
|
+
);
|
|
196
|
+
if (kvKeysMatch && method === "GET") {
|
|
197
|
+
const [, , nsId] = kvKeysMatch;
|
|
198
|
+
const keys = kvKeys[nsId] ?? [];
|
|
199
|
+
return json(res, 200, cfResult(keys));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- R2 Buckets (account-scoped) ---
|
|
203
|
+
const r2Match = path.match(/^\/accounts\/([^/]+)\/r2\/buckets$/);
|
|
204
|
+
if (r2Match && method === "GET") {
|
|
205
|
+
return json(res, 200, cfResult(r2Buckets));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
json(res, 404, { success: false, errors: [{ message: "Not found" }] });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
srv.listen(0, () => {
|
|
213
|
+
const addr = srv.address();
|
|
214
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
215
|
+
resolve({ server: srv, baseUrl: `http://localhost:${port}` });
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- Cloudflare resource configuration ---
|
|
221
|
+
|
|
222
|
+
function createCfResources(): HttpResource[] {
|
|
223
|
+
return [
|
|
224
|
+
{
|
|
225
|
+
name: "zones",
|
|
226
|
+
listKey: "result",
|
|
227
|
+
children: [
|
|
228
|
+
{
|
|
229
|
+
name: "dns_records",
|
|
230
|
+
listKey: "result",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "settings",
|
|
234
|
+
listKey: "result",
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "workers_scripts",
|
|
240
|
+
apiPath: "/accounts/:accountId/workers/scripts",
|
|
241
|
+
listKey: "result",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: "kv_namespaces",
|
|
245
|
+
apiPath: "/accounts/:accountId/storage/kv/namespaces",
|
|
246
|
+
listKey: "result",
|
|
247
|
+
children: [
|
|
248
|
+
{
|
|
249
|
+
name: "keys",
|
|
250
|
+
listKey: "result",
|
|
251
|
+
idField: "name",
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "r2_buckets",
|
|
257
|
+
apiPath: "/accounts/:accountId/r2/buckets",
|
|
258
|
+
listKey: "result",
|
|
259
|
+
idField: "name",
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- Tests ---
|
|
265
|
+
|
|
266
|
+
describe("HttpBackend — Cloudflare nested resources", () => {
|
|
267
|
+
let server: Server;
|
|
268
|
+
let backend: HttpBackend;
|
|
269
|
+
|
|
270
|
+
beforeAll(async () => {
|
|
271
|
+
const mock = await createMockCfServer();
|
|
272
|
+
server = mock.server;
|
|
273
|
+
|
|
274
|
+
backend = new HttpBackend({
|
|
275
|
+
baseUrl: mock.baseUrl,
|
|
276
|
+
auth: { type: "bearer", token: "cf-test-token" },
|
|
277
|
+
resources: createCfResources(),
|
|
278
|
+
params: { accountId: "acct-123" },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
afterAll(
|
|
283
|
+
() => new Promise<void>((resolve) => { server.close(() => resolve()); }),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// --- Root listing ---
|
|
287
|
+
|
|
288
|
+
describe("root listing", () => {
|
|
289
|
+
it("should list all top-level resources", async () => {
|
|
290
|
+
const result = await backend.list("/");
|
|
291
|
+
expect(result).toEqual([
|
|
292
|
+
"zones/",
|
|
293
|
+
"workers_scripts/",
|
|
294
|
+
"kv_namespaces/",
|
|
295
|
+
"r2_buckets/",
|
|
296
|
+
]);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// --- Flat resource CRUD (zones) ---
|
|
301
|
+
|
|
302
|
+
describe("flat resource CRUD — zones", () => {
|
|
303
|
+
it("should list zones", async () => {
|
|
304
|
+
const result = await backend.list("/zones/");
|
|
305
|
+
expect(result).toContain("z1.json");
|
|
306
|
+
expect(result).toContain("z2.json");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should read a single zone", async () => {
|
|
310
|
+
const result = (await backend.read("/zones/z1.json")) as { name: string };
|
|
311
|
+
expect(result.name).toBe("example.com");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("should create a zone", async () => {
|
|
315
|
+
const result = await backend.write("/zones/", {
|
|
316
|
+
name: "new-zone.io",
|
|
317
|
+
status: "pending",
|
|
318
|
+
});
|
|
319
|
+
expect(result.id).toBeDefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("should delete a zone", async () => {
|
|
323
|
+
// Delete the zone we just created
|
|
324
|
+
const list = await backend.list("/zones/");
|
|
325
|
+
const lastZone = list[list.length - 1].replace(".json", "");
|
|
326
|
+
await backend.remove(`/zones/${lastZone}.json`);
|
|
327
|
+
const listAfter = await backend.list("/zones/");
|
|
328
|
+
expect(listAfter).not.toContain(`${lastZone}.json`);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// --- Nested resource CRUD (zones/z1/dns_records) ---
|
|
333
|
+
|
|
334
|
+
describe("nested resource CRUD — dns_records", () => {
|
|
335
|
+
it("should list DNS records under a zone", async () => {
|
|
336
|
+
const result = await backend.list("/zones/z1/dns_records/");
|
|
337
|
+
expect(result).toContain("r1.json");
|
|
338
|
+
expect(result).toContain("r2.json");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("should read a single DNS record", async () => {
|
|
342
|
+
const result = (await backend.read("/zones/z1/dns_records/r1.json")) as {
|
|
343
|
+
type: string;
|
|
344
|
+
content: string;
|
|
345
|
+
};
|
|
346
|
+
expect(result.type).toBe("A");
|
|
347
|
+
expect(result.content).toBe("1.2.3.4");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("should create a DNS record", async () => {
|
|
351
|
+
const result = await backend.write("/zones/z1/dns_records/", {
|
|
352
|
+
type: "MX",
|
|
353
|
+
name: "example.com",
|
|
354
|
+
content: "mail.example.com",
|
|
355
|
+
});
|
|
356
|
+
expect(result.id).toBeDefined();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should update a DNS record", async () => {
|
|
360
|
+
const result = await backend.write("/zones/z1/dns_records/r1.json", {
|
|
361
|
+
content: "10.0.0.1",
|
|
362
|
+
});
|
|
363
|
+
expect(result.id).toBe("r1");
|
|
364
|
+
|
|
365
|
+
const updated = (await backend.read("/zones/z1/dns_records/r1.json")) as {
|
|
366
|
+
content: string;
|
|
367
|
+
};
|
|
368
|
+
expect(updated.content).toBe("10.0.0.1");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("should delete a DNS record", async () => {
|
|
372
|
+
await backend.remove("/zones/z1/dns_records/r2.json");
|
|
373
|
+
const list = await backend.list("/zones/z1/dns_records/");
|
|
374
|
+
expect(list).not.toContain("r2.json");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("should list DNS records under a different zone", async () => {
|
|
378
|
+
const result = await backend.list("/zones/z2/dns_records/");
|
|
379
|
+
expect(result).toContain("r3.json");
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// --- Intermediate node directory listing ---
|
|
384
|
+
|
|
385
|
+
describe("intermediate node listing", () => {
|
|
386
|
+
it("should list child resources of a zone", async () => {
|
|
387
|
+
const result = await backend.list("/zones/z1/");
|
|
388
|
+
expect(result).toEqual(["dns_records/", "settings/"]);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// --- Account-scoped resources ---
|
|
393
|
+
|
|
394
|
+
describe("account-scoped resources", () => {
|
|
395
|
+
it("should list worker scripts", async () => {
|
|
396
|
+
const result = await backend.list("/workers_scripts/");
|
|
397
|
+
expect(result).toContain("my-worker.json");
|
|
398
|
+
expect(result).toContain("api-gateway.json");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("should list R2 buckets", async () => {
|
|
402
|
+
const result = await backend.list("/r2_buckets/");
|
|
403
|
+
expect(result).toContain("assets.json");
|
|
404
|
+
expect(result).toContain("backups.json");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("should list KV namespaces", async () => {
|
|
408
|
+
const result = await backend.list("/kv_namespaces/");
|
|
409
|
+
expect(result).toContain("ns1.json");
|
|
410
|
+
expect(result).toContain("ns2.json");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// --- Deep nesting (kv_namespaces/ns1/keys) ---
|
|
415
|
+
|
|
416
|
+
describe("deep nesting — KV keys", () => {
|
|
417
|
+
it("should list keys in a KV namespace", async () => {
|
|
418
|
+
const result = await backend.list("/kv_namespaces/ns1/keys/");
|
|
419
|
+
expect(result).toContain("user:1.json");
|
|
420
|
+
expect(result).toContain("user:2.json");
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("should list keys in another KV namespace", async () => {
|
|
424
|
+
const result = await backend.list("/kv_namespaces/ns2/keys/");
|
|
425
|
+
expect(result).toContain("sess:abc.json");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("should list child resources of a KV namespace", async () => {
|
|
429
|
+
const result = await backend.list("/kv_namespaces/ns1/");
|
|
430
|
+
expect(result).toEqual(["keys/"]);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// --- Static param injection ---
|
|
435
|
+
|
|
436
|
+
describe("static param injection", () => {
|
|
437
|
+
it("should resolve :accountId in API paths", async () => {
|
|
438
|
+
// The workers_scripts resource uses apiPath "/accounts/:accountId/workers/scripts"
|
|
439
|
+
// With params { accountId: "acct-123" }, it should resolve to the correct URL
|
|
440
|
+
// If this fails, the mock server won't match and we'd get an error
|
|
441
|
+
const result = await backend.list("/workers_scripts/");
|
|
442
|
+
expect(result.length).toBeGreaterThan(0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("should resolve params for deeply nested account-scoped resources", async () => {
|
|
446
|
+
const result = await backend.list("/kv_namespaces/ns1/keys/");
|
|
447
|
+
expect(result.length).toBeGreaterThan(0);
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// --- Auth pass-through ---
|
|
452
|
+
|
|
453
|
+
describe("auth pass-through", () => {
|
|
454
|
+
it("should fail with wrong auth token", async () => {
|
|
455
|
+
const badBackend = new HttpBackend({
|
|
456
|
+
baseUrl: `http://localhost:${(server.address() as { port: number }).port}`,
|
|
457
|
+
auth: { type: "bearer", token: "wrong-token" },
|
|
458
|
+
resources: createCfResources(),
|
|
459
|
+
params: { accountId: "acct-123" },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// The mock returns 401 and non-array body, list returns []
|
|
463
|
+
const result = await badBackend.list("/zones/");
|
|
464
|
+
expect(result).toEqual([]);
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// --- Zone settings (second child resource) ---
|
|
469
|
+
|
|
470
|
+
describe("zone settings", () => {
|
|
471
|
+
it("should list settings for a zone", async () => {
|
|
472
|
+
const result = await backend.list("/zones/z1/settings/");
|
|
473
|
+
expect(result).toContain("ssl.json");
|
|
474
|
+
expect(result).toContain("minify.json");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("should read a single setting", async () => {
|
|
478
|
+
const result = (await backend.read("/zones/z1/settings/ssl.json")) as {
|
|
479
|
+
id: string;
|
|
480
|
+
value: string;
|
|
481
|
+
};
|
|
482
|
+
expect(result.id).toBe("ssl");
|
|
483
|
+
expect(result.value).toBe("full");
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// --- AgentFs integration ---
|
|
488
|
+
|
|
489
|
+
describe("AgentFs integration", () => {
|
|
490
|
+
it("should work as a mount in AgentFs", async () => {
|
|
491
|
+
const { AgentFs } = await import("../src/agent-fs.js");
|
|
492
|
+
|
|
493
|
+
const agentFs = new AgentFs({
|
|
494
|
+
mounts: [{ path: "/cf", backend }],
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// List mount root
|
|
498
|
+
const lsRoot = await agentFs.execute("ls /");
|
|
499
|
+
expect(lsRoot.ok).toBe(true);
|
|
500
|
+
if (lsRoot.ok) {
|
|
501
|
+
expect(lsRoot.data).toContain("cf/");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// List resources
|
|
505
|
+
const lsCf = await agentFs.execute("ls /cf/");
|
|
506
|
+
expect(lsCf.ok).toBe(true);
|
|
507
|
+
if (lsCf.ok) {
|
|
508
|
+
expect(lsCf.data).toContain("zones/");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// List zones
|
|
512
|
+
const lsZones = await agentFs.execute("ls /cf/zones/");
|
|
513
|
+
expect(lsZones.ok).toBe(true);
|
|
514
|
+
if (lsZones.ok) {
|
|
515
|
+
expect((lsZones.data as string[]).some((e) => e.endsWith(".json"))).toBe(true);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Read a zone
|
|
519
|
+
const catZone = await agentFs.execute("cat /cf/zones/z1.json");
|
|
520
|
+
expect(catZone.ok).toBe(true);
|
|
521
|
+
if (catZone.ok) {
|
|
522
|
+
expect((catZone.data as { name: string }).name).toBe("example.com");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Navigate nested resource
|
|
526
|
+
const lsDns = await agentFs.execute("ls /cf/zones/z1/dns_records/");
|
|
527
|
+
expect(lsDns.ok).toBe(true);
|
|
528
|
+
if (lsDns.ok) {
|
|
529
|
+
expect((lsDns.data as string[]).some((e) => e.endsWith(".json"))).toBe(true);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { HttpBackend, encodeFormBody } from "../src/backends/http.js";
|
|
3
|
+
|
|
4
|
+
// --- encodeFormBody unit tests ---
|
|
5
|
+
|
|
6
|
+
describe("encodeFormBody", () => {
|
|
7
|
+
it("should encode flat key-value pairs", () => {
|
|
8
|
+
const result = encodeFormBody({ name: "Alice", age: "30" });
|
|
9
|
+
expect(result).toBe("name=Alice&age=30");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should encode nested objects with bracket notation", () => {
|
|
13
|
+
const result = encodeFormBody({ metadata: { key: "value", foo: "bar" } });
|
|
14
|
+
expect(result).toContain("metadata%5Bkey%5D=value");
|
|
15
|
+
expect(result).toContain("metadata%5Bfoo%5D=bar");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should encode arrays with [] suffix", () => {
|
|
19
|
+
const result = encodeFormBody({ items: ["a", "b", "c"] });
|
|
20
|
+
expect(result).toBe("items%5B%5D=a&items%5B%5D=b&items%5B%5D=c");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should URL-encode special characters", () => {
|
|
24
|
+
const result = encodeFormBody({ q: "hello world&more=yes" });
|
|
25
|
+
expect(result).toBe("q=hello%20world%26more%3Dyes");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should handle empty object", () => {
|
|
29
|
+
expect(encodeFormBody({})).toBe("");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should handle null and undefined", () => {
|
|
33
|
+
expect(encodeFormBody(null)).toBe("");
|
|
34
|
+
expect(encodeFormBody(undefined)).toBe("");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should skip undefined values", () => {
|
|
38
|
+
const result = encodeFormBody({ a: "1", b: undefined, c: "3" });
|
|
39
|
+
expect(result).toBe("a=1&c=3");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should handle deeply nested objects", () => {
|
|
43
|
+
const result = encodeFormBody({
|
|
44
|
+
card: { address: { city: "Tokyo" } },
|
|
45
|
+
});
|
|
46
|
+
expect(result).toBe("card%5Baddress%5D%5Bcity%5D=Tokyo");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// --- HttpBackend bodyEncoding integration tests ---
|
|
51
|
+
|
|
52
|
+
describe("HttpBackend bodyEncoding", () => {
|
|
53
|
+
it("should send Content-Type: application/json by default", async () => {
|
|
54
|
+
let capturedHeaders: Record<string, string> = {};
|
|
55
|
+
let capturedBody = "";
|
|
56
|
+
|
|
57
|
+
const mockFetch = async (_url: string, init: RequestInit) => {
|
|
58
|
+
capturedHeaders = Object.fromEntries(
|
|
59
|
+
Object.entries(init.headers as Record<string, string>),
|
|
60
|
+
);
|
|
61
|
+
capturedBody = init.body as string;
|
|
62
|
+
return new Response(JSON.stringify({ id: "1" }), { status: 200 });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const backend = new HttpBackend({
|
|
66
|
+
baseUrl: "https://api.example.com",
|
|
67
|
+
fetch: mockFetch as any,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await backend.write("/", { name: "test" });
|
|
71
|
+
expect(capturedHeaders["Content-Type"]).toBe("application/json");
|
|
72
|
+
expect(capturedBody).toBe(JSON.stringify({ name: "test" }));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should send Content-Type: application/x-www-form-urlencoded when bodyEncoding=form", async () => {
|
|
76
|
+
let capturedHeaders: Record<string, string> = {};
|
|
77
|
+
let capturedBody = "";
|
|
78
|
+
|
|
79
|
+
const mockFetch = async (_url: string, init: RequestInit) => {
|
|
80
|
+
capturedHeaders = Object.fromEntries(
|
|
81
|
+
Object.entries(init.headers as Record<string, string>),
|
|
82
|
+
);
|
|
83
|
+
capturedBody = init.body as string;
|
|
84
|
+
return new Response(JSON.stringify({ id: "cus_123" }), { status: 200 });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const backend = new HttpBackend({
|
|
88
|
+
baseUrl: "https://api.stripe.com/v1",
|
|
89
|
+
bodyEncoding: "form",
|
|
90
|
+
fetch: mockFetch as any,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await backend.write("/", { name: "Alice", email: "alice@example.com" });
|
|
94
|
+
expect(capturedHeaders["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
|
95
|
+
expect(capturedBody).toBe("name=Alice&email=alice%40example.com");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should encode nested objects in form mode", async () => {
|
|
99
|
+
let capturedBody = "";
|
|
100
|
+
|
|
101
|
+
const mockFetch = async (_url: string, init: RequestInit) => {
|
|
102
|
+
capturedBody = init.body as string;
|
|
103
|
+
return new Response(JSON.stringify({ id: "sub_1" }), { status: 200 });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const backend = new HttpBackend({
|
|
107
|
+
baseUrl: "https://api.stripe.com/v1",
|
|
108
|
+
bodyEncoding: "form",
|
|
109
|
+
fetch: mockFetch as any,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await backend.write("/", {
|
|
113
|
+
customer: "cus_123",
|
|
114
|
+
metadata: { order_id: "42" },
|
|
115
|
+
});
|
|
116
|
+
expect(capturedBody).toContain("customer=cus_123");
|
|
117
|
+
expect(capturedBody).toContain("metadata%5Border_id%5D=42");
|
|
118
|
+
});
|
|
119
|
+
});
|