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