Files

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);
});