|
| 1 | +import jwt from "jsonwebtoken"; |
| 2 | +import { type SetupServerApi, setupServer } from "msw/node"; |
| 3 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 4 | +import { createTestRepo, type TestRepo } from "../test/fixtures/api.js"; |
| 5 | +import { createPostHogHandlers } from "../test/mocks/msw-handlers.js"; |
| 6 | +import { AgentServer } from "./agent-server.js"; |
| 7 | +import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt.js"; |
| 8 | + |
| 9 | +function createTestJwt( |
| 10 | + payload: JwtPayload, |
| 11 | + privateKey: string, |
| 12 | + expiresInSeconds = 3600, |
| 13 | +): string { |
| 14 | + return jwt.sign( |
| 15 | + { ...payload, aud: SANDBOX_CONNECTION_AUDIENCE }, |
| 16 | + privateKey, |
| 17 | + { |
| 18 | + algorithm: "RS256", |
| 19 | + expiresIn: expiresInSeconds, |
| 20 | + }, |
| 21 | + ); |
| 22 | +} |
| 23 | + |
| 24 | +// Test RSA key pair (2048-bit, for testing only) |
| 25 | +const TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- |
| 26 | +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDqh94SYMFsvG4C |
| 27 | +Co9BSGjtPr2/OxzuNGr41O4+AMkDQRd9pKO49DhTA4VzwnOvrH8y4eI9N8OQne7B |
| 28 | +wpdoouSn4DoDAS/b3SUfij/RoFUSyZiTQoWz0H6o2Vuufiz0Hf+BzlZEVnhSQ1ru |
| 29 | +vqSf+4l8cWgeMXaFXgdD5kQ8GjvR5uqKxvO2Env1hMJRKeOOEGgCep/0c6SkMUTX |
| 30 | +SeC+VjypVg9+8yPxtIpOQ7XKv+7e/PA0ilqehRQh4fo9BAWjUW1+HnbtsjJAjjfv |
| 31 | +ngzIjpajuQVyMi7G79v8OvijhLMJjJBh3TdbVIfi+RkVj/H94UUfKWRfJA0eLykA |
| 32 | +VvTiFf0nAgMBAAECggEABkLBQWFW2IXBNAm/IEGEF408uH2l/I/mqSTaBUq1EwKq |
| 33 | +U17RRg8y77hg2CHBP9fNf3i7NuIltNcaeA6vRwpOK1MXiVv/QJHLO2fP41Mx4jIC |
| 34 | +gi/c7NtsfiprQaG5pnykhP0SnXlndd65bzUkpOasmWdXnbK5VL8ZV40uliInJafE |
| 35 | +1Eo9qSYCJxHmivU/4AbiBgygOAo1QIiuuUHcx0YGknLrBaMQETuvWJGE3lxVQ30/ |
| 36 | +EuRyA3r6BwN2T0z47PZBzvCpg/C1KeoYuKSMwMyEXfl+a8NclqdROkVaenmZpvVH |
| 37 | +0lAvFDuPrBSDmU4XJbKCEfwfHjRkiWAFaTrKntGQtQKBgQD/ILoK4U9DkJoKTYvY |
| 38 | +9lX7dg6wNO8jGLHNufU8tHhU+QnBMH3hBXrAtIKQ1sGs+D5rq/O7o0Balmct9vwb |
| 39 | +CQZ1EpPfa83Thsv6Skd7lWK0JF7g2vVk8kT4nY/eqkgZUWgkfdMp+OMg2drYiIE8 |
| 40 | +u+sRPTCdq4Tv5miRg0OToX2H/QKBgQDrVR2GXm6ZUyFbCy8A0kttXP1YyXqDVq7p |
| 41 | +L4kqyUq43hmbjzIRM4YDN3EvgZvVf6eub6L/3HfKvWD/OvEhHovTvHb9jkwZ3FO+ |
| 42 | +YQllB/ccAWJs/Dw5jLAsX9O+eIe4lfwROib3vYLnDTAmrXD5VL35R5F0MsdRoxk5 |
| 43 | +lTCq1sYI8wKBgGA9ZjDIgXAJUjJkwkZb1l9/T1clALiKjjf+2AXIRkQ3lXhs5G9H |
| 44 | +8+BRt5cPjAvFsTZIrS6xDIufhNiP/NXt96OeGG4FaqVKihOmhYSW+57cwXWs4zjr |
| 45 | +Mx1dwnHKZlw2m0R4unlwy60OwUFBbQ8ODER6gqZXl1Qv5G5Px+Qe3Q25AoGAUl+s |
| 46 | +wgfz9r9egZvcjBEQTeuq0pVTyP1ipET7YnqrKSK1G/p3sAW09xNFDzfy8DyK2UhC |
| 47 | +agUl+VVoym47UTh8AVWK4R4aDUNOHOmifDbZjHf/l96CxjI0yJOSbq2J9FarsOwG |
| 48 | +D9nKJE49eIxlayD6jnM6us27bxwEDF/odSRQlXkCgYEAxn9l/5kewWkeEA0Afe1c |
| 49 | +Uf+mepHBLw1Pbg5GJYIZPC6e5+wRNvtFjM5J6h5LVhyb7AjKeLBTeohoBKEfUyUO |
| 50 | +rl/ql9qDIh5lJFn3uNh7+r7tmG21Zl2pyh+O8GljjZ25mYhdiwl0uqzVZaINe2Wa |
| 51 | +vbMnD1ZQKgL8LHgb02cbTsc= |
| 52 | +-----END PRIVATE KEY-----`; |
| 53 | + |
| 54 | +const TEST_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- |
| 55 | +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6ofeEmDBbLxuAgqPQUho |
| 56 | +7T69vzsc7jRq+NTuPgDJA0EXfaSjuPQ4UwOFc8Jzr6x/MuHiPTfDkJ3uwcKXaKLk |
| 57 | +p+A6AwEv290lH4o/0aBVEsmYk0KFs9B+qNlbrn4s9B3/gc5WRFZ4UkNa7r6kn/uJ |
| 58 | +fHFoHjF2hV4HQ+ZEPBo70ebqisbzthJ79YTCUSnjjhBoAnqf9HOkpDFE10ngvlY8 |
| 59 | +qVYPfvMj8bSKTkO1yr/u3vzwNIpanoUUIeH6PQQFo1Ftfh527bIyQI43754MyI6W |
| 60 | +o7kFcjIuxu/b/Dr4o4SzCYyQYd03W1SH4vkZFY/x/eFFHylkXyQNHi8pAFb04hX9 |
| 61 | +JwIDAQAB |
| 62 | +-----END PUBLIC KEY-----`; |
| 63 | + |
| 64 | +describe("AgentServer HTTP Mode", () => { |
| 65 | + let repo: TestRepo; |
| 66 | + let server: AgentServer; |
| 67 | + let mswServer: SetupServerApi; |
| 68 | + let appendLogCalls: unknown[][]; |
| 69 | + const port = 3099; |
| 70 | + |
| 71 | + beforeEach(async () => { |
| 72 | + repo = await createTestRepo("agent-server-http"); |
| 73 | + appendLogCalls = []; |
| 74 | + mswServer = setupServer( |
| 75 | + ...createPostHogHandlers({ |
| 76 | + baseUrl: "http://localhost:8000", |
| 77 | + onAppendLog: (entries) => appendLogCalls.push(entries), |
| 78 | + }), |
| 79 | + ); |
| 80 | + mswServer.listen({ onUnhandledRequest: "bypass" }); |
| 81 | + }); |
| 82 | + |
| 83 | + afterEach(async () => { |
| 84 | + if (server) { |
| 85 | + await server.stop(); |
| 86 | + } |
| 87 | + mswServer.close(); |
| 88 | + await repo.cleanup(); |
| 89 | + }); |
| 90 | + |
| 91 | + const createServer = () => { |
| 92 | + server = new AgentServer({ |
| 93 | + port, |
| 94 | + jwtPublicKey: TEST_PUBLIC_KEY, |
| 95 | + repositoryPath: repo.path, |
| 96 | + apiUrl: "http://localhost:8000", |
| 97 | + apiKey: "test-api-key", |
| 98 | + projectId: 1, |
| 99 | + }); |
| 100 | + return server; |
| 101 | + }; |
| 102 | + |
| 103 | + const createToken = (overrides = {}) => { |
| 104 | + return createTestJwt( |
| 105 | + { |
| 106 | + run_id: "test-run-id", |
| 107 | + task_id: "test-task-id", |
| 108 | + team_id: 1, |
| 109 | + user_id: 1, |
| 110 | + distinct_id: "test-distinct-id", |
| 111 | + ...overrides, |
| 112 | + }, |
| 113 | + TEST_PRIVATE_KEY, |
| 114 | + ); |
| 115 | + }; |
| 116 | + |
| 117 | + describe("GET /health", () => { |
| 118 | + it("returns ok status", async () => { |
| 119 | + await createServer().start(); |
| 120 | + |
| 121 | + const response = await fetch(`http://localhost:${port}/health`); |
| 122 | + const body = await response.json(); |
| 123 | + |
| 124 | + expect(response.status).toBe(200); |
| 125 | + expect(body).toEqual({ status: "ok", hasSession: false }); |
| 126 | + }); |
| 127 | + }); |
| 128 | + |
| 129 | + describe("GET /events", () => { |
| 130 | + it("returns 401 without authorization header", async () => { |
| 131 | + await createServer().start(); |
| 132 | + |
| 133 | + const response = await fetch(`http://localhost:${port}/events`); |
| 134 | + const body = await response.json(); |
| 135 | + |
| 136 | + expect(response.status).toBe(401); |
| 137 | + expect(body.error).toBe("Missing authorization header"); |
| 138 | + }); |
| 139 | + |
| 140 | + it("returns 401 with invalid token", async () => { |
| 141 | + await createServer().start(); |
| 142 | + |
| 143 | + const response = await fetch(`http://localhost:${port}/events`, { |
| 144 | + headers: { Authorization: "Bearer invalid-token" }, |
| 145 | + }); |
| 146 | + const body = await response.json(); |
| 147 | + |
| 148 | + expect(response.status).toBe(401); |
| 149 | + expect(body.code).toBe("invalid_signature"); |
| 150 | + }); |
| 151 | + |
| 152 | + it("accepts valid JWT and returns SSE stream", async () => { |
| 153 | + await createServer().start(); |
| 154 | + const token = createToken(); |
| 155 | + |
| 156 | + const response = await fetch(`http://localhost:${port}/events`, { |
| 157 | + headers: { Authorization: `Bearer ${token}` }, |
| 158 | + }); |
| 159 | + |
| 160 | + expect(response.status).toBe(200); |
| 161 | + expect(response.headers.get("content-type")).toBe("text/event-stream"); |
| 162 | + }); |
| 163 | + }); |
| 164 | + |
| 165 | + describe("POST /command", () => { |
| 166 | + it("returns 401 without authorization", async () => { |
| 167 | + await createServer().start(); |
| 168 | + |
| 169 | + const response = await fetch(`http://localhost:${port}/command`, { |
| 170 | + method: "POST", |
| 171 | + headers: { "Content-Type": "application/json" }, |
| 172 | + body: JSON.stringify({ |
| 173 | + jsonrpc: "2.0", |
| 174 | + method: "user_message", |
| 175 | + params: { content: "test" }, |
| 176 | + }), |
| 177 | + }); |
| 178 | + |
| 179 | + expect(response.status).toBe(401); |
| 180 | + }); |
| 181 | + |
| 182 | + it("returns 400 when no session exists", async () => { |
| 183 | + await createServer().start(); |
| 184 | + const token = createToken(); |
| 185 | + |
| 186 | + const response = await fetch(`http://localhost:${port}/command`, { |
| 187 | + method: "POST", |
| 188 | + headers: { |
| 189 | + Authorization: `Bearer ${token}`, |
| 190 | + "Content-Type": "application/json", |
| 191 | + }, |
| 192 | + body: JSON.stringify({ |
| 193 | + jsonrpc: "2.0", |
| 194 | + method: "user_message", |
| 195 | + params: { content: "test" }, |
| 196 | + }), |
| 197 | + }); |
| 198 | + |
| 199 | + expect(response.status).toBe(400); |
| 200 | + const body = await response.json(); |
| 201 | + expect(body.error).toBe("No active session for this run"); |
| 202 | + }); |
| 203 | + }); |
| 204 | + |
| 205 | + describe("404 handling", () => { |
| 206 | + it("returns 404 for unknown routes", async () => { |
| 207 | + await createServer().start(); |
| 208 | + |
| 209 | + const response = await fetch(`http://localhost:${port}/unknown`); |
| 210 | + const body = await response.json(); |
| 211 | + |
| 212 | + expect(response.status).toBe(404); |
| 213 | + expect(body.error).toBe("Not found"); |
| 214 | + }); |
| 215 | + }); |
| 216 | +}); |
0 commit comments