142 lines
4.9 KiB
TypeScript
142 lines
4.9 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("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;
|
|
}
|