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