Files
sw-ads-agent/packages/harness/tests/swads.test.ts
T

105 lines
14 KiB
TypeScript

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