import { describe, expect, it, vi } from "vitest"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { SwadsGateway, gatewayConfig, mcpConnector, MAX_GATEWAY_BYTES, type GatewayConnection } from "../src/swads-gateway.js"; import { SwadsReportTools, commands, validateCli } from "../src/swads-tools.js"; import type { ReportStore } from "../src/report-store.js"; import { sampleReport } from "./report-fixture.js"; const signal = () => new AbortController().signal; const token = "private-sentinel-swads-value"; const config = () => gatewayConfig({ SWADS_MCP_TOKEN: token, SWADS_MCP_TIMEOUT_MS: "1000" }); const capabilities = (): Tool[] => Object.values(commands).filter((name) => name !== "tools/list").map((name) => ({ name, inputSchema: { type: "object" }, annotations: { readOnlyHint: true, destructiveHint: false } })); function fixture(tools = capabilities()) { const connection: GatewayConnection = { list: vi.fn(async () => tools), call: vi.fn(async (name) => ({ content: [{ type: "text", text: JSON.stringify(name === "metrics_semantic_query" ? { columns: [{ name: "spend" }], rows: [[100]] } : { platform: "tiktok", account_id: "demo", metrics: ["spend"], echo: token }) }] })), close: vi.fn(async () => undefined) }; const gateway = new SwadsGateway(config(), async () => connection); const create = vi.fn(async () => ({ ok: true })); let runId = "run_1"; const toolsFacade = new SwadsReportTools(gateway, { create } as unknown as ReportStore, () => ({ runId, signal: signal() })); return { connection, gateway, tools: toolsFacade, create, nextRun: () => { runId = "run_2"; } }; } async function preflight(tools: SwadsReportTools) { await tools.cli({ command: "capabilities", arguments: {} }, signal()); await tools.cli({ command: "whoami", arguments: {} }, signal()); await tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); await tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal()); } describe("swads CLI boundary", () => { it("rejects unknown commands, arbitrary control parameters and oversized/deep inputs", () => { for (const input of [{ command: "tools/call", arguments: {} }, { command: "whoami", arguments: { url: "https://evil" } }, { command: "metrics.catalog", arguments: { platform: "vk" } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { platform: "vk" } } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { headers: { x: "y" } } } }, { command: "whoami", arguments: { name: "x" } }]) expect(() => validateCli(input)).toThrow(); let nested: unknown = {}; for (let i = 0; i < 12; i++) nested = { child: nested }; expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: nested } })).toThrow(); expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: { large: "x".repeat(70_000) } } })).toThrow(); }); it("maps only fixed tools, unwraps JSON, caches capabilities and redacts token before model delivery", async () => { const f = fixture(); const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); expect(result).toMatchObject({ echo: "[REDACTED]" }); await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); expect(f.connection.list).toHaveBeenCalledTimes(1); expect(f.connection.call).toHaveBeenCalledWith("metrics_catalog", { platform: "tiktok" }, expect.any(AbortSignal)); expect(JSON.stringify(f.tools.definitions())).not.toContain(token); }); it("compacts large identity results before they enter model context", async () => { const f = fixture(); const account = { platform_account_binding_id: "binding", tenant_id: "tenant", platform: "tiktok", external_account_id: "account", account_name: "Store", access_profile: "reader", permissions: Array(100).fill("private-permission"), is_current: false, reporting_timezone: "Asia/Kuala_Lumpur", reporting_utc_offset_minutes: 480, account_currency: "MYR" }; vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { access: { user_id: "user", current_account: { ...account, is_current: true }, accounts: Array.from({ length: 57 }, (_, index) => ({ ...account, external_account_id: `account-${index}` })) }, orgs: { current_org_id: "org", organizations: [{ org_id: "org", name: "Org", slug: "org", role: "member" }] } } }); const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); const serialized = JSON.stringify(result); expect(serialized).not.toContain("private-permission"); expect(Buffer.byteLength(serialized)).toBeLessThan(50 * 1024); expect((result as { access: { accounts: unknown[] } }).access.accounts).toHaveLength(57); }); it("refuses missing tools, non-read-only annotations and remote input drift", async () => { const f = fixture(capabilities().filter((tool) => tool.name !== "metrics_semantic_query")); await expect(f.tools.cli({ command: "capabilities", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_MISSING" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" }); expect(f.create).not.toHaveBeenCalled(); const list = capabilities(); list.find((tool) => tool.name === "runtime_findings")!.annotations = { readOnlyHint: false, destructiveHint: true }; const unsafe = fixture(list); await expect(unsafe.tools.cli({ command: "runtime.findings", arguments: { platform: "tiktok", external_account_id: "demo" } }, signal())).rejects.toMatchObject({ code: "SWADS_NOT_READ_ONLY" }); expect(unsafe.connection.call).not.toHaveBeenCalled(); const drift = capabilities(); drift.find((tool) => tool.name === "metrics_catalog")!.inputSchema = { type: "object", properties: { platform: { enum: ["vk", "yandex"] } } }; await expect(fixture(drift).tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_DRIFT" }); }); it("requires successful real preflight and rows in this run to render", async () => { const f = fixture(); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow(); await preflight(f.tools); const report = sampleReport(); report.notes.push(token); await f.tools.render(report, signal()); expect(JSON.stringify(vi.mocked(f.create).mock.calls)).not.toContain(token); f.nextRun(); await expect(f.tools.render(report, signal())).rejects.toThrow(); }); it("renders a deterministic fallback from four collected query kinds", async () => { const f = fixture(); const result = (data: unknown) => ({ structuredContent: data }); await f.tools.cli({ command: "capabilities", arguments: {} }, signal()); vi.mocked(f.connection.call).mockResolvedValueOnce(result({ access: { current_account: { account_name: "Store", external_account_id: "account", account_currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur" } }, orgs: {} })); await f.tools.cli({ command: "whoami", arguments: {} }, signal()); vi.mocked(f.connection.call).mockResolvedValueOnce(result({ metrics: [{ name: "spend" }] })); await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); const query = async (query_kind: string, columns: string[], rows: unknown[][]) => { vi.mocked(f.connection.call).mockResolvedValueOnce(result({ columns: columns.map((name) => ({ name })), rows, row_count: rows.length, account_context: { external_account_id: "account", account_currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur" } })); await f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind, date_from: "2026-09-06", date_to: "2026-09-06" } } }, signal()); }; await query("summary", ["spend", "gross_revenue", "orders", "ctr"], [[10, 20, 2, 0.01]]); await query("timeseries", ["date", "spend", "gross_revenue"], [["2026-09-05", 10, 15], ["2026-09-06", 10, 20]]); await query("campaign_breakdown", ["external_campaign_id", "external_campaign_name", "spend", "gross_revenue", "orders"], [["campaign", "GMV Max", 10, 20, 2]]); await query("creative_breakdown", ["external_creative_id", "external_creative_name", "creative_creator_name", "spend", "gross_revenue", "orders", "product_clicks", "product_ctr"], [["creative", "", "", 10, 20, 2, 3, 0.02]]); const fallback = await f.tools.renderCollected(signal()); expect(fallback.markdown).toContain("SW Ads 日报"); expect(f.create).toHaveBeenCalledOnce(); expect(vi.mocked(f.create).mock.calls[0]?.[0]).toMatchObject({ meta: { report_date: "2026-09-06" }, summary: { spend: 10, revenue: 20, roas: 2 }, creative: { source_groups: [{ label: "未知创作者" }], winners: [{ id: "creative", name: "creative", creator: "未知创作者" }] } }); }); it("accepts an empty entity breakdown when the observed account queries have data", async () => { const f = fixture(); await preflight(f.tools); vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { columns: [], rows: [], row_count: 0 } }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind: "campaign_breakdown" } } }, signal())).resolves.toMatchObject({ rows: [], row_count: 0 }); await expect(f.tools.render(sampleReport(), signal())).resolves.toEqual({ ok: true }); }); it("preserves preflight for queued queries but invalidates render evidence after empty account data", async () => { const f = fixture(); await preflight(f.tools); vi.mocked(f.connection.call).mockResolvedValueOnce({ structuredContent: { columns: [], rows: [] } }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { query_kind: "summary" } } }, signal())).rejects.toMatchObject({ code: "SWADS_NO_DATA" }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal())).resolves.toMatchObject({ rows: [[100]] }); await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" }); }); it("classifies upstream permission envelopes and response limits without their contents", async () => { for (const [status, code] of [[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"]] as const) { const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { error_code: "upstream", upstream_status: status, detail: token } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code, message: code }); } const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { data: "x".repeat(MAX_GATEWAY_BYTES + 1) } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" }); }); it("validates startup URL but defers missing credentials to first call", async () => { expect(() => gatewayConfig({ SWADS_MCP_URL: "file:///tmp/x" })).toThrow(); expect(() => gatewayConfig({ SWADS_MCP_URL: "https://u:p@example.test" })).toThrow(); const gateway = new SwadsGateway(gatewayConfig({})); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_NOT_CONFIGURED" }); }); it("cancels and times out blocked transport calls, closing connections", async () => { const connection: GatewayConnection = { list: async () => [], call: async () => new Promise(() => undefined), close: vi.fn(async () => undefined) }; const gateway = new SwadsGateway({ ...config(), timeoutMs: 100 }, async () => connection); await expect(gateway.execute((conn, s) => conn.call("x", {}, s), signal())).rejects.toMatchObject({ code: "SWADS_TIMEOUT" }); const controller = new AbortController(); const pending = gateway.execute((conn, s) => conn.call("x", {}, s), controller.signal); controller.abort(); await expect(pending).rejects.toMatchObject({ code: "SWADS_ABORTED" }); expect(connection.close).toHaveBeenCalled(); }); }); describe("official MCP Streamable HTTP transport with fake fetch", () => { it("initializes, lists and calls over MCP with server-only Authorization", async () => { const methods: string[] = []; const fetcher: typeof fetch = async (_url, init) => { expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`); if (init?.method === "GET") return new Response(null, { status: 405 }); const message = JSON.parse(String(init?.body)); methods.push(message.method); if (message.id === undefined) return new Response(null, { status: 202 }); const result = message.method === "initialize" ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } : message.method === "tools/list" ? { tools: capabilities() } : { content: [{ type: "text", text: '{"ok":true}' }] }; return Response.json({ jsonrpc: "2.0", id: message.id, result }); }; const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await gateway.execute(async (conn, s) => { expect(await conn.list(s)).toHaveLength(8); await conn.call("swads_whoami", {}, s); }, signal()); expect(methods).toEqual(expect.arrayContaining(["initialize", "notifications/initialized", "tools/list", "tools/call"])); }); it.each([[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"], [503, "SWADS_UNAVAILABLE"]])("classifies HTTP %s without leaking body", async (status, code) => { const fetcher: typeof fetch = async () => new Response(token, { status: Number(status) }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code, message: code }); }); it("bounds response bytes before JSON parsing", async () => { const fetcher: typeof fetch = async () => new Response("x".repeat(MAX_GATEWAY_BYTES + 1), { headers: { "content-type": "application/json", "content-length": String(MAX_GATEWAY_BYTES + 1) } }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" }); }); });