Files
nocobase-mcp/test/credentials-config.test.ts

155 lines
5.6 KiB
TypeScript

import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { DEFAULT_CONFIG, loadConfig } from "../src/config.js";
import {
createCredentialProvider,
loadCredentials,
loadStoredCredentials,
NOCOBASE_API_BASE_URL,
saveCredentials,
saveOAuthCredentials,
} from "../src/credentials.js";
test("stores only the Key and always loads the fixed NocoBase endpoint", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credentials-"));
const path = join(directory, "private", "credentials.json");
saveCredentials({ authorizeKey: "secret" }, path);
assert.deepEqual(loadCredentials(path), {
apiBaseUrl: NOCOBASE_API_BASE_URL,
authorizeKey: "secret",
});
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { authorizeKey: "secret" });
writeFileSync(path, JSON.stringify({ apiBaseUrl: "https://legacy.example/api", authorizeKey: "secret" }));
assert.equal(loadCredentials(path).apiBaseUrl, NOCOBASE_API_BASE_URL);
if (process.platform !== "win32") {
chmodSync(path, 0o644);
assert.throws(() => loadCredentials(path), /0600/);
}
});
test("stores OAuth credentials privately and refreshes them once for concurrent requests", async () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-oauth-"));
const path = join(directory, "private", "credentials.json");
saveOAuthCredentials(
{
type: "oauth",
issuer: "https://justconn.top/api",
clientId: "desktop-client",
accessToken: "old-access",
refreshToken: "old-refresh",
tokenType: "Bearer",
scope: "api offline_access",
expiresAt: 900,
},
path,
);
assert.equal(loadCredentials(path).authorizeKey, "old-access");
let refreshes = 0;
const provider = createCredentialProvider({
path,
now: () => 1_000,
refresh: async (credentials) => {
refreshes += 1;
await Promise.resolve();
return { ...credentials, accessToken: "new-access", refreshToken: "new-refresh", expiresAt: 100_000 };
},
});
const results = await Promise.all([provider(), provider(), provider()]);
assert.equal(refreshes, 1);
assert.deepEqual(
results.map((result) => result.authorizeKey),
["new-access", "new-access", "new-access"],
);
const stored = loadStoredCredentials(path);
assert.equal(stored.type, "oauth");
if (stored.type === "oauth") {
assert.equal(stored.accessToken, "new-access");
assert.equal(stored.refreshToken, "new-refresh");
}
if (process.platform !== "win32") assert.equal(statMode(path), 0o600);
});
test("credential provider can reload credentials saved by browser login without restarting MCP", async () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-credential-reload-"));
const path = join(directory, "private", "credentials.json");
saveCredentials({ authorizeKey: "old-key" }, path);
const provider = createCredentialProvider({ path });
assert.equal((await provider()).authorizeKey, "old-key");
saveCredentials({ authorizeKey: "new-key" }, path);
assert.equal((await provider()).authorizeKey, "old-key");
provider.reset();
assert.equal((await provider()).authorizeKey, "new-key");
});
test("backs off after a refresh failure while the current access token remains valid", async () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-oauth-backoff-"));
const path = join(directory, "private", "credentials.json");
saveOAuthCredentials(
{
type: "oauth",
issuer: "https://justconn.top/api",
clientId: "desktop-client",
accessToken: "still-valid-access",
refreshToken: "refresh-token",
tokenType: "Bearer",
scope: "api offline_access",
expiresAt: 50_000,
},
path,
);
let currentTime = 10_000;
let refreshes = 0;
const provider = createCredentialProvider({
path,
now: () => currentTime,
refresh: async () => {
refreshes += 1;
throw new Error("temporary network failure");
},
});
assert.equal((await provider()).authorizeKey, "still-valid-access");
assert.equal((await provider()).authorizeKey, "still-valid-access");
assert.equal(refreshes, 1);
currentTime += 30_000;
assert.equal((await provider()).authorizeKey, "still-valid-access");
assert.equal(refreshes, 2);
});
test("loads camelCase and snake_case config aliases without mutating defaults", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-config-"));
const path = join(directory, "config.json");
writeFileSync(
path,
JSON.stringify({
data_source: "secondary",
timezone: "UTC",
collections: { customers: { resource: "crm_customers", owner_field: "assignee" } },
projects: { nameField: "project_title" },
}),
);
const loaded = loadConfig(path);
assert.equal(loaded.dataSource, "secondary");
assert.equal(loaded.customer.resource, "crm_customers");
assert.equal(loaded.customer.ownerField, "assignee");
assert.equal(loaded.project.nameField, "project_title");
assert.equal(DEFAULT_CONFIG.customer.resource, "customers");
});
test("rejects malformed config types before any NocoBase request", () => {
const directory = mkdtempSync(join(tmpdir(), "nocobase-mcp-invalid-config-"));
const path = join(directory, "config.json");
writeFileSync(path, JSON.stringify({ collections: { customers: { fields: "id,name" } } }));
assert.throws(() => loadConfig(path), /array of strings/);
});
function statMode(path: string): number {
return statSync(path).mode & 0o777;
}