170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, test } from "node:test";
|
|
import { NocoBaseClient, NocoBaseError } from "../src/nocobase-client.js";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
function client(token = "personal-secret", dataSource = "main"): NocoBaseClient {
|
|
return new NocoBaseClient(() => ({ apiBaseUrl: "https://nb.example/api", authorizeKey: token }), {
|
|
dataSource,
|
|
});
|
|
}
|
|
|
|
test("sends list requests with the same HTTP contract as nb resource list", async () => {
|
|
let request: Request | undefined;
|
|
globalThis.fetch = async (input, init) => {
|
|
request = new Request(input, init);
|
|
return new Response(JSON.stringify({ data: [], meta: { count: 0 } }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
};
|
|
await client("key", "secondary").listRecords("customers", {
|
|
filter: { status: { $eq: "active" } },
|
|
fields: ["id", "name"],
|
|
page: 2,
|
|
pageSize: 20,
|
|
});
|
|
assert.ok(request);
|
|
assert.equal(request.method, "POST");
|
|
assert.equal(new URL(request.url).pathname, "/api/customers:list");
|
|
assert.equal(
|
|
new URL(request.url).searchParams.get("filter"),
|
|
JSON.stringify({ status: { $eq: "active" } }),
|
|
);
|
|
assert.deepEqual(new URL(request.url).searchParams.getAll("fields"), ["id", "name"]);
|
|
assert.equal(request.headers.get("authorization"), "Bearer key");
|
|
assert.equal(request.headers.get("x-data-source"), "secondary");
|
|
});
|
|
|
|
test("sends aggregation definitions in a JSON body with timezone header", async () => {
|
|
let request: Request | undefined;
|
|
globalThis.fetch = async (input, init) => {
|
|
request = new Request(input, init);
|
|
return new Response(JSON.stringify({ data: [{ count: 3 }] }), { status: 200 });
|
|
};
|
|
await client().queryRecords("customers", {
|
|
measures: [{ field: ["id"], aggregation: "count", alias: "count" }],
|
|
timezone: "Asia/Shanghai",
|
|
});
|
|
assert.ok(request);
|
|
assert.equal(request.headers.get("x-timezone"), "Asia/Shanghai");
|
|
assert.deepEqual(await request.json(), {
|
|
measures: [{ field: ["id"], aggregation: "count", alias: "count" }],
|
|
});
|
|
});
|
|
|
|
test("reads credentials for every request", async () => {
|
|
const seen: string[] = [];
|
|
const keys = ["first", "second"];
|
|
const rotating = new NocoBaseClient(() => ({
|
|
apiBaseUrl: "https://nb.example/api",
|
|
authorizeKey: keys.shift() ?? "missing",
|
|
}));
|
|
globalThis.fetch = async (input, init) => {
|
|
seen.push(new Request(input, init).headers.get("authorization") ?? "");
|
|
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
};
|
|
await rotating.listRecords("customers");
|
|
await rotating.listRecords("customers");
|
|
assert.deepEqual(seen, ["Bearer first", "Bearer second"]);
|
|
});
|
|
|
|
test("redacts Authorize Keys and distinguishes expired auth from ACL denial", async () => {
|
|
for (const [status, body, authenticationFailed] of [
|
|
[401, "Bearer personal-secret INVALID_TOKEN", true],
|
|
[403, "EXPIRED_TOKEN", true],
|
|
[403, "NO_PERMISSIONS", false],
|
|
] as const) {
|
|
globalThis.fetch = async () => new Response(body, { status });
|
|
await assert.rejects(client().listRecords("customers"), (error: unknown) => {
|
|
assert.ok(error instanceof NocoBaseError);
|
|
assert.equal(error.authenticationFailed, authenticationFailed);
|
|
assert.doesNotMatch(error.message, /personal-secret/);
|
|
return true;
|
|
});
|
|
}
|
|
});
|
|
|
|
test("rejects cross-origin redirects before forwarding authentication", async () => {
|
|
globalThis.fetch = async () =>
|
|
new Response(null, { status: 307, headers: { location: "https://evil.example/api" } });
|
|
await assert.rejects(client().listRecords("customers"), /another origin/);
|
|
});
|
|
|
|
test("builds data-model and attachment relation paths safely", async () => {
|
|
const urls: string[] = [];
|
|
globalThis.fetch = async (input) => {
|
|
urls.push(String(input));
|
|
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
|
};
|
|
await client()
|
|
.listCollectionFields("crm customers")
|
|
.then(
|
|
() => assert.fail("invalid collection should fail"),
|
|
() => undefined,
|
|
);
|
|
await client().listCollectionFields("crm_customers");
|
|
await client().addAttachmentIds("../project/7?#", [8]);
|
|
assert.match(urls[0] ?? "", /collections\/crm_customers\/fields:list/);
|
|
assert.match(urls[1] ?? "", /t_projects\/..%2Fproject%2F7%3F%23\/f_projects_attachments:add/);
|
|
await assert.rejects(client().addAttachmentIds("..", [8]), /dot path/);
|
|
});
|
|
|
|
test("rejects nested project write values before sending a request", async () => {
|
|
globalThis.fetch = async () => assert.fail("request must not be sent");
|
|
await assert.rejects(client().createRecord("projects", { unsafe: { nested: true } }, ["unsafe"]), /scalar/);
|
|
});
|
|
|
|
test("checks project ACL before attachment upload", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-upload-"));
|
|
const path = join(directory, "quote.pdf");
|
|
writeFileSync(path, "pdf");
|
|
const requests: Request[] = [];
|
|
globalThis.fetch = async (input, init) => {
|
|
const request = new Request(input, init);
|
|
requests.push(request);
|
|
if (new URL(request.url).pathname.endsWith("/roles:check")) {
|
|
return new Response(
|
|
JSON.stringify({ data: { strategy: { actions: ["view", "create"] }, resources: [] } }),
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
return new Response(JSON.stringify({ data: { id: 42 } }), { status: 200 });
|
|
};
|
|
const uploaded = await client().uploadAttachment(path, "quote.pdf", "application/pdf");
|
|
assert.equal(uploaded.id, 42);
|
|
assert.equal(new URL(requests[0]?.url ?? "").pathname, "/api/roles:check");
|
|
assert.equal(new URL(requests[1]?.url ?? "").pathname, "/api/attachments:create");
|
|
assert.equal(
|
|
new URL(requests[1]?.url ?? "").searchParams.get("attachmentField"),
|
|
"t_projects.f_projects_attachments",
|
|
);
|
|
});
|
|
|
|
test("denies attachment upload before create when project ACL is missing", async () => {
|
|
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-upload-denied-"));
|
|
const path = join(directory, "quote.pdf");
|
|
writeFileSync(path, "pdf");
|
|
let requests = 0;
|
|
globalThis.fetch = async () => {
|
|
requests += 1;
|
|
return new Response(
|
|
JSON.stringify({ data: { strategy: { actions: ["view:own", "trigger:own"] }, resources: [] } }),
|
|
{ status: 200 },
|
|
);
|
|
};
|
|
await assert.rejects(client().uploadAttachment(path), (error: unknown) => {
|
|
assert.ok(error instanceof NocoBaseError);
|
|
assert.equal(error.statusCode, 403);
|
|
return true;
|
|
});
|
|
assert.equal(requests, 1);
|
|
});
|