diff --git a/test/gateway-send.test.js b/test/gateway-send.test.js index a2b86d8..fc0a0c9 100644 --- a/test/gateway-send.test.js +++ b/test/gateway-send.test.js @@ -1,5 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; import { GATEWAY_MAX_RESPONSE_BYTES, getTranscriptTail, sendPrompt } from "../src/core/gateway.js"; @@ -13,13 +15,39 @@ function mockGateway(t, send) { }); } -test("sendPrompt refuses a live gateway host in test mode before any request", async (t) => { - const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}", { status: 200 })); +test("test mode: a loopback gateway is served, a live gateway host is refused before any request", async (t) => { + const received = []; + const server = createServer(async (req, res) => { + let text = ""; + for await (const chunk of req) text += chunk; + received.push({ url: req.url, body: JSON.parse(text) }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(req.url === "/api/listAgents" ? roster : { messageId: "m-loop" })); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + const local = { gatewayUrl: "http://127.0.0.1:" + server.address().port, gatewayToken: "t" }; + + const out = await sendPrompt(local, "General", "hi"); + assert.deepEqual(out, { + target: { id: "bot-1", name: "General", title: "", description: "", avatarShape: "", avatarColor: "", isGroup: false, memberIds: [] }, + result: { messageId: "m-loop" }, + delivery: "accepted", + messageId: "m-loop", + }); + assert.deepEqual(received.map((r) => r.url), ["/api/listAgents", "/api/sendPrompt"]); + assert.equal(received[1].body.prompt, "hi"); + + // A non-routable host, and the production escape hatch switched on: production + // policy would let this through, test mode must still refuse it. + process.env.GROK_BOT_ALLOW_ANY_GATEWAY = "1"; + t.after(() => { delete process.env.GROK_BOT_ALLOW_ANY_GATEWAY; }); await assert.rejects( - sendPrompt({ gatewayUrl: "https://box.cursor.sh", gatewayToken: "t" }, "General", "hi"), - /test mode/i, + sendPrompt({ gatewayUrl: "https://gateway.invalid", gatewayToken: "t" }, "General", "hi"), + { message: 'Rejected gateway URL host "gateway.invalid": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.' }, ); - assert.equal(fetchMock.mock.callCount(), 0); + assert.equal(received.length, 2, "the refused send reached no server"); }); test("sendPrompt accepts only a confirmed messageId receipt", async (t) => { diff --git a/test/url-policy.test.js b/test/url-policy.test.js index a37392c..9986af8 100644 --- a/test/url-policy.test.js +++ b/test/url-policy.test.js @@ -108,10 +108,14 @@ test("test mode allows only loopback, for gateway and backend, and ignores escap withEnv({ GROK_BOT_ALLOW_ANY_GATEWAY: "1", GROK_BOT_ALLOW_LOCAL_GATEWAY: null, ...env }, () => { assert.equal(assertAllowedCredentialUrl("http://127.0.0.1:1340/"), "http://127.0.0.1:1340"); assert.equal(assertAllowedCredentialUrl("http://localhost:1340", { kind: "backend" }), "http://localhost:1340"); - assert.throws(() => assertAllowedCredentialUrl("https://box.cursor.sh"), /test mode/i); - assert.throws(() => assertAllowedCredentialUrl("https://api2.cursor.sh", { kind: "backend" }), /test mode/i); - assert.throws(() => assertAllowedCredentialUrl("https://evil.example"), /test mode/i); - assert.throws(() => assertAllowedCredentialUrl("ws://127.0.0.1:1340"), /test mode/i); + assert.throws(() => assertAllowedCredentialUrl("https://box.cursor.sh"), { + message: 'Rejected gateway URL host "box.cursor.sh": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.', + }); + assert.throws(() => assertAllowedCredentialUrl("https://api2.cursor.sh", { kind: "backend" }), { + message: 'Rejected backend URL host "api2.cursor.sh": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.', + }); + assert.throws(() => assertAllowedCredentialUrl("https://evil.example"), /^Error: Rejected gateway URL host "evil.example": test mode/); + assert.throws(() => assertAllowedCredentialUrl("ws://127.0.0.1:1340"), /^Error: Rejected gateway URL host "127.0.0.1": test mode/); }); } }); diff --git a/tests/route-unit/tools.test.ts b/tests/route-unit/tools.test.ts index 76042b7..9f1a5c0 100644 --- a/tests/route-unit/tools.test.ts +++ b/tests/route-unit/tools.test.ts @@ -111,8 +111,6 @@ beforeAll(async () => { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address() as AddressInfo; for (const key of envKeys) savedEnv[key] = process.env[key]; - // rstest.route-unit.config.ts sets this; url-policy then refuses every non-loopback gateway. - expect(process.env.GROK_BOT_TEST).toBe('1'); process.env.GROK_BOT_GATEWAY_URL = `http://127.0.0.1:${port}`; process.env.GROK_BOT_GATEWAY_TOKEN = 'test-token'; process.env.GROK_BOT_ALLOW_LOCAL_GATEWAY = '1'; @@ -398,6 +396,41 @@ describe('grok-bot MCP server', () => { expect(text).not.toContain('test-token'); }); + it('refuses a live gateway host under the test runner and still serves the loopback fake', async () => { + // rstest.route-unit.config.ts sets GROK_BOT_TEST=1; the URL policy then turns any + // non-loopback gateway into a tool error before a request is built. + // Non-routable host plus the production escape hatch: production policy would + // proceed, test mode must refuse, and a regression cannot reach a real gateway. + const loopbackUrl = process.env.GROK_BOT_GATEWAY_URL; + process.env.GROK_BOT_GATEWAY_URL = 'https://gateway.invalid'; + process.env.GROK_BOT_ALLOW_ANY_GATEWAY = '1'; + try { + const live = await invokeMcpTool('gbot_send', { + input: { message: 'must not leave the machine', target: 'General' }, + server: 'grok-bot', + }); + expect(live.isError).toBe(true); + expect(contentText(live.content)).toBe( + 'Rejected gateway URL host "gateway.invalid": test mode (GROK_BOT_TEST / NODE_ENV=test) only allows http(s) loopback gateways.', + ); + expect(calls).toEqual([]); + } finally { + process.env.GROK_BOT_GATEWAY_URL = loopbackUrl; + delete process.env.GROK_BOT_ALLOW_ANY_GATEWAY; + } + const local = await invokeMcpTool('gbot_send', { + input: { message: 'loopback is fine', target: 'General' }, + server: 'grok-bot', + }); + expect(local.structuredContent).toEqual({ + delivery: 'accepted', + messageId: 'm-1', + result: { messageId: 'm-1' }, + target: { id: 'bot-1', kind: 'bot', name: 'General' }, + }); + expect(calls[1]).toMatchObject({ body: { agentId: 'bot-1', prompt: 'loopback is fine' }, method: 'sendPrompt' }); + }); + it('surfaces an unknown target as a tool error without sending anything', async () => { const result = await invokeMcpTool('gbot_send', { input: { message: 'x', target: 'Nobody' },