Files
sw-ads-agent/apps/server/tests/app.test.ts
T

51 lines
9.7 KiB
TypeScript

import { mkdtemp, mkdir, copyFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp, type AppServices } from "../src/app.js";
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
import { sampleReport } from "../../../packages/harness/tests/report-fixture.js";
import type { FastifyInstance } from "fastify";
class ServerFakePort implements AgentSessionPort {
readonly modelId = "fake-model"; readonly supportsImages = true; disposed = false; private finish?: () => void;
prompt(): Promise<void> { return new Promise((resolve) => { this.finish = resolve; }); }
async abort(): Promise<void> { this.finish?.(); }
subscribe(): () => void { return () => undefined; }
dispose(): void { this.disposed = true; }
complete(): void { this.finish?.(); }
}
class ServerFakeFactory implements AgentSessionFactory { port = new ServerFakePort(); async create() { return this.port; } }
const apps: FastifyInstance[] = [];
afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! };
}
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
describe("Fastify API", () => {
it("returns health and a request id", async () => { const { app } = await fixture(); const response = await app.inject({ method: "GET", url: "/health" }); expect(response.statusCode).toBe(200); expect(response.headers["x-request-id"]).toMatch(/^req_/); expect(response.json()).toMatchObject({ ok: true }); });
it("uses the unified 404 error envelope", async () => { const { app } = await fixture(); const response = await app.inject({ method: "POST", url: "/api/sessions/missing/cancel" }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ error: { code: "SESSION_NOT_FOUND", message: "Session not found" } }); });
it("returns safe Zod field errors", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "", attachmentIds: [] } }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error.details.fields"); });
it("never returns the API key from model config", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const saved = await configure(app, sessionId); expect(saved.statusCode).toBe(200); expect(saved.body).not.toContain("sk-test-never-return"); const read = await app.inject({ method: "GET", url: `/api/sessions/${sessionId}/model-config` }); expect(read.body).not.toContain("sk-test-never-return"); expect(read.json()).toMatchObject({ configured: true, keyConfigured: true, keyHint: "••••turn" }); });
it("rejects SSRF base URLs", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake", apiKey: "x", supportsImages: false, baseUrl: "https://169.254.169.254/latest" } }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ error: { code: "SSRF_BLOCKED" } }); });
it("returns 202 without waiting for the Agent run", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "hello", attachmentIds: [] } }); expect(response.statusCode).toBe(202); expect(response.json()).toHaveProperty("runId"); factory.port.complete(); });
it("returns 409 for concurrent messages", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "one", attachmentIds: [] } }); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "two", attachmentIds: [] } }); expect(response.statusCode).toBe(409); expect(response.json()).toMatchObject({ error: { code: "SESSION_BUSY" } }); factory.port.complete(); });
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
it("keeps reports downloadable after session deletion and guards artifact routes", async () => {
const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css"));
const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` });
for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); }
const base = artifact.downloads.json.slice(0, -4);
for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); }
}, 60_000);
});
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }