Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
261 lines
11 KiB
JavaScript
261 lines
11 KiB
JavaScript
/**
|
|
* Public MCP mount — security-invariant tests (NO-SIGNUP-MCP §B2/B3).
|
|
*
|
|
* These assert the two gates the v1 design got wrong, at the handler-dispatch
|
|
* level, WITHOUT a DB: the gated `public.js` loaders are mocked so we can prove
|
|
* the *wiring* fails closed. The loaders' own cross-space `null` behaviour is
|
|
* covered by `backend/routes/v3/__tests__/public.test.js` against a real DB.
|
|
*/
|
|
|
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock the gated loaders so the handlers' fail-closed wiring is testable in
|
|
// isolation (and so importing publicMcp doesn't drag in express/db).
|
|
vi.mock('../../../routes/v3/public.js', () => ({
|
|
default: {},
|
|
loadPublicTable: vi.fn(),
|
|
loadPublicTableSchema: vi.fn(),
|
|
fetchPublicRows: vi.fn(),
|
|
fetchPublicRowById: vi.fn(),
|
|
clampPagination: vi.fn(() => ({ limit: 50, offset: 0 })),
|
|
loadPublicTree: vi.fn(),
|
|
loadPublicDocumentsRegistry: vi.fn(),
|
|
loadPublicDocument: vi.fn(),
|
|
listPublicDashboardWidgets: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../agent-tools/memory-tools.js', () => ({
|
|
memoryToolHandlers: { memory_recall: vi.fn(async () => ({ success: true, memories: [] })) },
|
|
}));
|
|
|
|
import * as loaders from '../../../routes/v3/public.js';
|
|
import { memoryToolHandlers } from '../../agent-tools/memory-tools.js';
|
|
import {
|
|
PUBLIC_MCP_HANDLERS,
|
|
PUBLIC_ALLOWLIST,
|
|
PUBLIC_MEMORY_BANK,
|
|
upsellError,
|
|
assertAllowlistAdvertised,
|
|
mcpPublicHandler,
|
|
} from '../publicMcp.js';
|
|
import { buildToolList } from '../buildToolList.js';
|
|
|
|
const SPACE = { id: 7773, name: 'GOD CRM Public', settings: null };
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('capability isolation (allow-set)', () => {
|
|
test('allow-set is exactly the gated read tools', () => {
|
|
expect([...PUBLIC_ALLOWLIST].sort()).toEqual([
|
|
'get_dashboard_widgets',
|
|
'get_document_content',
|
|
'get_table_row',
|
|
'get_table_schema',
|
|
'list_documents',
|
|
'list_projects',
|
|
'list_tables',
|
|
'memory_recall',
|
|
'query_table_data',
|
|
]);
|
|
});
|
|
|
|
test('boot invariant: every allow-set name has a gated handler', () => {
|
|
for (const name of PUBLIC_ALLOWLIST) {
|
|
expect(typeof PUBLIC_MCP_HANDLERS[name]).toBe('function');
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
'list_spaces', 'global_search', 'analyze_table_data',
|
|
'list_conversations', 'get_conversation_messages', 'get_workspace_info',
|
|
'delete_table_row', 'send_telegram_message', 'add_table_row',
|
|
'memory_retain', 'printer_start',
|
|
])('dropped/dangerous tool %s is not public', (name) => {
|
|
expect(PUBLIC_ALLOWLIST.has(name)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('legibility: tools/list advertises EXACTLY the allow-set (Option A)', () => {
|
|
// The advertised surface (tools/list filtered by PUBLIC_ALLOWLIST) must equal
|
|
// the callable surface — collapsing the advertise-77 / allow-9 divergence that
|
|
// read as a tool-poisoning fingerprint to scanners (MCP-DOOR-LEGIBILITY §B6 v2).
|
|
const advertised = buildToolList().filter((t) => PUBLIC_ALLOWLIST.has(t.name));
|
|
|
|
test('advertised set is exactly the 9 allow-set tools', () => {
|
|
expect(advertised.map((t) => t.name).sort()).toEqual([...PUBLIC_ALLOWLIST].sort());
|
|
});
|
|
|
|
test('advertised set leaks no write/destructive verb', () => {
|
|
const writeVerb = /^(delete_|add_|update_|create_|send_|batch_|move_|copy_|printer_)/;
|
|
expect(advertised.filter((t) => writeVerb.test(t.name))).toEqual([]);
|
|
});
|
|
|
|
test('every advertised tool carries an inputSchema (valid MCP tool shape)', () => {
|
|
for (const t of advertised) expect(t.inputSchema).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('boot guardrail #2: allow-set ⊆ advertised (symmetric assertion)', () => {
|
|
test('passes for the shipped (allow-set, buildToolList) pair', () => {
|
|
expect(() => assertAllowlistAdvertised(PUBLIC_ALLOWLIST, buildToolList())).not.toThrow();
|
|
});
|
|
|
|
test('throws when an allow-set name is not advertised (drift caught at boot)', () => {
|
|
const drifted = new Set([...PUBLIC_ALLOWLIST, 'renamed_away_tool']);
|
|
expect(() => assertAllowlistAdvertised(drifted, buildToolList()))
|
|
.toThrow(/renamed_away_tool/);
|
|
});
|
|
});
|
|
|
|
describe('fail-closed: gated loader null → not_found', () => {
|
|
test('query_table_data → not_found when table is outside the pinned space', async () => {
|
|
loaders.loadPublicTable.mockResolvedValue(null); // cross-space id → null
|
|
const out = await PUBLIC_MCP_HANDLERS.query_table_data({ table_id: 1708 }, SPACE);
|
|
expect(out).toEqual({ error: 'not_found' });
|
|
// The only scope source is space.id — never the caller's args.
|
|
expect(loaders.loadPublicTable).toHaveBeenCalledWith(7773, 1708);
|
|
expect(loaders.fetchPublicRows).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('get_table_row → not_found when table is cross-space', async () => {
|
|
loaders.loadPublicTable.mockResolvedValue(null);
|
|
const out = await PUBLIC_MCP_HANDLERS.get_table_row({ table_id: 1708, row_id: 5 }, SPACE);
|
|
expect(out).toEqual({ error: 'not_found' });
|
|
expect(loaders.fetchPublicRowById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('get_table_schema → not_found when loader returns null', async () => {
|
|
loaders.loadPublicTableSchema.mockResolvedValue(null);
|
|
const out = await PUBLIC_MCP_HANDLERS.get_table_schema({ table_id: 1708 }, SPACE);
|
|
expect(out).toEqual({ error: 'not_found' });
|
|
expect(loaders.loadPublicTableSchema).toHaveBeenCalledWith(7773, 1708);
|
|
});
|
|
|
|
test('get_document_content → not_found when loader returns null', async () => {
|
|
loaders.loadPublicDocument.mockResolvedValue(null);
|
|
const out = await PUBLIC_MCP_HANDLERS.get_document_content({ document_id: 999 }, SPACE);
|
|
expect(out).toEqual({ error: 'not_found' });
|
|
expect(loaders.loadPublicDocument).toHaveBeenCalledWith(7773, 999);
|
|
});
|
|
|
|
test('get_dashboard_widgets → not_found when dashboard is cross-space', async () => {
|
|
loaders.listPublicDashboardWidgets.mockResolvedValue(null);
|
|
const out = await PUBLIC_MCP_HANDLERS.get_dashboard_widgets({ dashboard_id: 42 }, SPACE);
|
|
expect(out).toEqual({ error: 'not_found' });
|
|
});
|
|
});
|
|
|
|
describe('happy path: in-space read returns data', () => {
|
|
test('query_table_data returns rows for an in-space table', async () => {
|
|
loaders.loadPublicTable.mockResolvedValue({ id: 100 });
|
|
loaders.fetchPublicRows.mockResolvedValue({ rows: [{ id: 1 }], total: 1 });
|
|
const out = await PUBLIC_MCP_HANDLERS.query_table_data({ table_id: 100 }, SPACE);
|
|
expect(out).toEqual({ rows: [{ id: 1 }], total: 1 });
|
|
expect(loaders.fetchPublicRows).toHaveBeenCalledWith(100, { limit: 50, offset: 0 });
|
|
});
|
|
|
|
test('list_tables flattens the gated tree and filters by project_id', async () => {
|
|
loaders.loadPublicTree.mockResolvedValue({
|
|
projects: [
|
|
{ id: 1, name: 'A', icon: null, tables: [{ id: 10, name: 't10', icon: null }] },
|
|
{ id: 2, name: 'B', icon: null, tables: [{ id: 20, name: 't20', icon: null }] },
|
|
],
|
|
});
|
|
const all = await PUBLIC_MCP_HANDLERS.list_tables({}, SPACE);
|
|
expect(all.tables.map(t => t.id)).toEqual([10, 20]);
|
|
const filtered = await PUBLIC_MCP_HANDLERS.list_tables({ project_id: 2 }, SPACE);
|
|
expect(filtered.tables.map(t => t.id)).toEqual([20]);
|
|
});
|
|
});
|
|
|
|
describe('memory_recall is pinned to the public bank', () => {
|
|
test('caller bank_id cannot redirect to the private bank', async () => {
|
|
await PUBLIC_MCP_HANDLERS.memory_recall({ query: 'comics', bank_id: 'godcrm-main', limit: 5 }, SPACE);
|
|
expect(memoryToolHandlers.memory_recall).toHaveBeenCalledTimes(1);
|
|
const [args] = memoryToolHandlers.memory_recall.mock.calls[0];
|
|
expect(args.bank_id).toBe(PUBLIC_MEMORY_BANK);
|
|
expect(args.bank_id).not.toBe('godcrm-main');
|
|
expect(args.query).toBe('comics');
|
|
});
|
|
});
|
|
|
|
describe('ADR-157 scope router wired into public memory_recall', () => {
|
|
test('a confident keyword query applies the routed room before search', async () => {
|
|
memoryToolHandlers.memory_recall.mockResolvedValueOnce({
|
|
success: true, count: 1, memories: [{ text: 'jwt session login token rotation' }],
|
|
});
|
|
await PUBLIC_MCP_HANDLERS.memory_recall(
|
|
{ query: 'how does the jwt login token session work' }, SPACE);
|
|
expect(memoryToolHandlers.memory_recall).toHaveBeenCalledTimes(1);
|
|
const [args] = memoryToolHandlers.memory_recall.mock.calls[0];
|
|
expect(args.room).toBe('auth'); // narrowed to the Collection
|
|
expect(args.bank_id).toBe(PUBLIC_MEMORY_BANK);
|
|
});
|
|
|
|
test('a low-confidence query widens (no room) — search the domain unscoped', async () => {
|
|
await PUBLIC_MCP_HANDLERS.memory_recall({ query: 'tell me about the weather' }, SPACE);
|
|
const [args] = memoryToolHandlers.memory_recall.mock.calls[0];
|
|
expect(args.room).toBeUndefined(); // widen-by-one-level fallback
|
|
});
|
|
|
|
test('a confident route that returns empty retries unscoped (widen-after-empty)', async () => {
|
|
memoryToolHandlers.memory_recall
|
|
.mockResolvedValueOnce({ success: true, count: 0, memories: [] }) // routed → empty
|
|
.mockResolvedValueOnce({ success: true, count: 2, memories: [{ text: 'x' }] }); // widened
|
|
await PUBLIC_MCP_HANDLERS.memory_recall(
|
|
{ query: 'the nginx server pm2 docker host infra' }, SPACE);
|
|
expect(memoryToolHandlers.memory_recall).toHaveBeenCalledTimes(2);
|
|
expect(memoryToolHandlers.memory_recall.mock.calls[0][0].room).toBe('infrastructure');
|
|
expect(memoryToolHandlers.memory_recall.mock.calls[1][0].room).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('upsellError shape (NO-SIGNUP-MCP §B4)', () => {
|
|
test('account_required, names the tool, points at signup', () => {
|
|
const e = upsellError('delete_table_row');
|
|
expect(e.error).toBe('account_required');
|
|
expect(e.message).toContain('delete_table_row');
|
|
expect(e.signup_url).toBe('https://godcrm.ai');
|
|
});
|
|
});
|
|
|
|
describe('Lever 2: GET probe → benign banner before any transport/DB (MCP-DOOR-LEGIBILITY)', () => {
|
|
// A GreyNoise/Censys/Shodan probe hits the mount with GET, not MCP JSON-RPC.
|
|
// The handler must short-circuit to a fingerprintable benign banner BEFORE it
|
|
// builds an MCP Server/transport or calls any gated loader — so the scanner
|
|
// catalogues a known-benign service, not a context-less 4xx that reads hostile.
|
|
const mockRes = () => ({
|
|
statusCode: null,
|
|
body: null,
|
|
status(code) { this.statusCode = code; return this; },
|
|
json(payload) { this.body = payload; return this; },
|
|
});
|
|
|
|
test('GET returns a 200 benign banner that signals intentional anonymous read-only access', async () => {
|
|
const res = mockRes();
|
|
await mcpPublicHandler({ method: 'GET', publicSpace: SPACE }, res);
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
service: 'godcrm-public-mcp',
|
|
access: 'anonymous-read-only',
|
|
intentional: true,
|
|
});
|
|
});
|
|
|
|
test('banner advertises no tools and leaks no write/destructive verb', async () => {
|
|
const res = mockRes();
|
|
await mcpPublicHandler({ method: 'GET', publicSpace: SPACE }, res);
|
|
expect(res.body.tools).toBeUndefined();
|
|
const writeVerb = /(delete_|add_|update_|create_|send_|batch_|move_|copy_|printer_)/;
|
|
expect(writeVerb.test(JSON.stringify(res.body))).toBe(false);
|
|
});
|
|
|
|
test('short-circuits before any gated loader or transport is touched', async () => {
|
|
const res = mockRes();
|
|
await mcpPublicHandler({ method: 'GET', publicSpace: SPACE }, res);
|
|
expect(loaders.loadPublicTree).not.toHaveBeenCalled();
|
|
expect(loaders.loadPublicTable).not.toHaveBeenCalled();
|
|
});
|
|
});
|