diff --git a/src/api/v2/conversations/conversations.router.ts b/src/api/v2/conversations/conversations.router.ts index b4590429..69a4ee27 100644 --- a/src/api/v2/conversations/conversations.router.ts +++ b/src/api/v2/conversations/conversations.router.ts @@ -1,6 +1,9 @@ import { Router } from "express"; import { requireAccount } from "@/middleware/auth"; -import { agentParticipationLimiter } from "@/middleware/rateLimit"; +import { + agentParticipationLimiter, + spaceUpstreamLimiter, +} from "@/middleware/rateLimit"; import { conversationAbilitiesGetHandler } from "./handlers/abilities-get"; import { conversationAbilityDeleteHandler } from "./handlers/ability-delete"; import { conversationAbilityPutHandler } from "./handlers/ability-put"; @@ -8,6 +11,7 @@ import { getParticipationHandler, participationHandler, } from "./handlers/participation"; +import { spaceUpstreamHandler } from "./handlers/space-upstream"; // /v2/conversations — conversation-scoped surfaces. Mounted behind // authMiddleware in src/api/v2/index.ts; every route here applies @@ -15,6 +19,13 @@ import { // Conversation table). export const conversationsRouter = Router(); +conversationsRouter.post( + "/:conversationId/debug/space-upstream", + spaceUpstreamLimiter, + requireAccount, + spaceUpstreamHandler, +); + // How much the agents in this conversation may speak. `requireAccount` for the // same reason as /agents/join: an account-less JWT is an authorization failure, // not a stale token. The product rule is that any member may change the level, diff --git a/src/api/v2/conversations/handlers/space-upstream.ts b/src/api/v2/conversations/handlers/space-upstream.ts new file mode 100644 index 00000000..3bde9d32 --- /dev/null +++ b/src/api/v2/conversations/handlers/space-upstream.ts @@ -0,0 +1,241 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { + getAssistantApiKey, + getAssistantApiUrl, +} from "@/api/v2/agents/handlers/assistant-config"; +import { resolveVariantWorkerOrigin } from "@/api/v2/agents/lib/variant-routing"; + +const SPACE_UPSTREAM_FETCH_TIMEOUT_MS = 50_000; +const ERROR_BODY_LOG_LIMIT = 200; + +const conversationIdSchema = z + .string() + .trim() + .min(1, "conversationId is required") + .max(256); + +const paramsSchema = z.object({ + conversationId: conversationIdSchema, +}); + +const querySchema = z.object({ + variantId: z.string().trim().min(1).max(64).optional(), +}); + +const resultCountsSchema = { + wrote: z.number().int().nonnegative(), + deleted: z.number().int().nonnegative(), + refusedCount: z.number().int().nonnegative(), +}; + +const spaceUpstreamResultSchema = z.discriminatedUnion("outcome", [ + z.object({ + conversationId: conversationIdSchema, + outcome: z.literal("pull_request"), + prUrl: z.string().url(), + prNumber: z.number().int().positive(), + branch: z.string().min(1), + commitSha: z.string().min(1), + forkCommitSha: z.string().min(1), + ...resultCountsSchema, + }), + z.object({ + conversationId: conversationIdSchema, + outcome: z.literal("unchanged"), + forkCommitSha: z.string().min(1), + ...resultCountsSchema, + }), +]); + +const upstreamErrorSchema = z.object({ + error: z.string().min(1).max(500), + code: z.string().min(1).max(64), +}); + +type PublicError = { + status: number; + error: string; + message: string; +}; + +const ERRORS = { + INVALID_REQUEST: { + status: 400, + error: "INVALID_REQUEST", + message: "Invalid Space PR proposal request", + }, + VARIANT_UNAVAILABLE: { + status: 409, + error: "VARIANT_UNAVAILABLE", + message: "The selected agent variant is unavailable", + }, + SPACE_NOT_FOUND: { + status: 404, + error: "SPACE_NOT_FOUND", + message: "No Space was found for this conversation", + }, + SPACE_REPOSITORY_UNAVAILABLE: { + status: 409, + error: "SPACE_REPOSITORY_UNAVAILABLE", + message: "This Space does not have a repository", + }, + SPACE_UPSTREAM_NOT_ARMED: { + status: 503, + error: "SPACE_UPSTREAM_NOT_ARMED", + message: "The selected Space deployment is not armed for PR proposals", + }, + SPACE_UPSTREAM_UNAVAILABLE: { + status: 503, + error: "SPACE_UPSTREAM_UNAVAILABLE", + message: "Space PR proposals are unavailable", + }, + SPACE_UPSTREAM_REFUSED: { + status: 422, + error: "SPACE_UPSTREAM_REFUSED", + message: "The Space changes could not be proposed safely", + }, + SPACE_UPSTREAM_GITHUB_FAILED: { + status: 502, + error: "SPACE_UPSTREAM_GITHUB_FAILED", + message: "GitHub rejected the Space PR proposal; please try again", + }, + SPACE_UPSTREAM_FAILED: { + status: 502, + error: "SPACE_UPSTREAM_FAILED", + message: "The Space PR proposal failed", + }, + SPACE_UPSTREAM_TIMEOUT: { + status: 504, + error: "SPACE_UPSTREAM_TIMEOUT", + message: "The Space PR proposal timed out", + }, +} as const satisfies Record; + +const UPSTREAM_ERRORS = { + space_upstream_not_armed: ERRORS.SPACE_UPSTREAM_NOT_ARMED, + space_not_found: ERRORS.SPACE_NOT_FOUND, + space_repository_unavailable: ERRORS.SPACE_REPOSITORY_UNAVAILABLE, + space_repository_provider_unavailable: ERRORS.SPACE_UPSTREAM_UNAVAILABLE, + space_upstream_refused: ERRORS.SPACE_UPSTREAM_REFUSED, + space_upstream_github_failed: ERRORS.SPACE_UPSTREAM_GITHUB_FAILED, + space_upstream_failed: ERRORS.SPACE_UPSTREAM_FAILED, + space_upstream_timeout: ERRORS.SPACE_UPSTREAM_TIMEOUT, +} as const satisfies Record; + +function sendError(res: Response, value: PublicError): void { + const { status, ...body } = value; + res.status(status).json({ success: false, ...body }); +} + +function translateUpstreamError(raw: unknown): PublicError { + const parsed = upstreamErrorSchema.safeParse(raw); + if (!parsed.success) return ERRORS.SPACE_UPSTREAM_FAILED; + + const { code, error: message } = parsed.data; + if (!(code in UPSTREAM_ERRORS)) return ERRORS.SPACE_UPSTREAM_FAILED; + const publicError = UPSTREAM_ERRORS[code as keyof typeof UPSTREAM_ERRORS]; + return code === "space_upstream_refused" + ? { ...publicError, message } + : publicError; +} + +/** + * Handler for POST /api/v2/conversations/:conversationId/debug/space-upstream + * + * Relays an authenticated Space PR proposal to the assistant Worker. The + * client never receives the shared Worker credential; it receives the standard + * v2 success or coded-error envelope instead. + */ +export async function spaceUpstreamHandler(req: Request, res: Response) { + const parsedParams = paramsSchema.safeParse(req.params); + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedParams.success || !parsedQuery.success) { + sendError(res, ERRORS.INVALID_REQUEST); + return; + } + + const conversationId = parsedParams.data.conversationId; + const variantId = parsedQuery.data.variantId; + + let assistantOrigin: string; + if (variantId !== undefined) { + // This mutation can create a GitHub branch and PR from variant-specific + // code, so it must not silently fall back to the default Worker. + const resolvedOrigin = await resolveVariantWorkerOrigin(variantId); + if (!resolvedOrigin) { + sendError(res, ERRORS.VARIANT_UNAVAILABLE); + return; + } + assistantOrigin = resolvedOrigin; + } else { + assistantOrigin = getAssistantApiUrl(); + } + + const assistantApiKey = getAssistantApiKey(); + const assistantBaseUrl = assistantOrigin.replace(/\/+$/, ""); + if (!assistantApiKey) { + req.log.error("Space upstream Worker is not configured"); + sendError(res, ERRORS.SPACE_UPSTREAM_UNAVAILABLE); + return; + } + + try { + const upstream = await fetch( + `${assistantBaseUrl}/api/conversations/${encodeURIComponent(conversationId)}/space-upstream`, + { + method: "POST", + headers: { Authorization: `Bearer ${assistantApiKey}` }, + signal: AbortSignal.timeout(SPACE_UPSTREAM_FETCH_TIMEOUT_MS), + }, + ); + + if (!upstream.ok) { + const text = await upstream.text(); + const bodyPreview = text.substring(0, ERROR_BODY_LOG_LIMIT); + req.log.error( + { status: upstream.status, bodyPreview }, + "Space upstream Worker request failed", + ); + + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + raw = null; + } + sendError(res, translateUpstreamError(raw)); + return; + } + + let raw: unknown; + try { + raw = await upstream.json(); + } catch { + raw = null; + } + const result = spaceUpstreamResultSchema.safeParse(raw); + if (!result.success) { + req.log.error( + { issues: result.error.issues }, + "Invalid Space upstream Worker response", + ); + sendError(res, ERRORS.SPACE_UPSTREAM_FAILED); + return; + } + + res.status(200).json({ success: true, ...result.data }); + } catch (error) { + if (error instanceof DOMException && error.name === "TimeoutError") { + req.log.error("Space upstream Worker request timed out"); + sendError(res, ERRORS.SPACE_UPSTREAM_TIMEOUT); + return; + } + + req.log.error( + { error, stack: error instanceof Error ? error.stack : undefined }, + "Space upstream Worker request failed", + ); + sendError(res, ERRORS.SPACE_UPSTREAM_FAILED); + } +} diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index afd26705..b7b4550d 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -57,6 +57,20 @@ export const agentParticipationLimiter = rateLimit({ }, }); +// Space-to-starter proposals can update a GitHub branch and draft pull request, +// so keep retries bounded independently of the cheaper participation controls. +export const spaceUpstreamLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + limit: 10, + legacyHeaders: false, + standardHeaders: "draft-8", + message: { + success: false, + error: "RATE_LIMITED", + message: "Too many Space PR proposals; retry shortly", + }, +}); + // Rate limiting for asset renewal endpoint (10 batch requests per hour per device) export const assetRenewalLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour diff --git a/tests/conversations-space-upstream.test.ts b/tests/conversations-space-upstream.test.ts new file mode 100644 index 00000000..3989b672 --- /dev/null +++ b/tests/conversations-space-upstream.test.ts @@ -0,0 +1,449 @@ +import express, { + type Request as ExpressRequest, + type Response as ExpressResponse, + type NextFunction, +} from "express"; +import request from "supertest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { __setAssistantConfigOverridesForTests } from "@/api/v2/agents/handlers/assistant-config"; +import { conversationsRouter } from "@/api/v2/conversations/conversations.router"; +import { authMiddleware } from "@/middleware/auth"; +import { pinoMiddleware } from "@/middleware/pino"; +import { createJwtToken, validateJWTKeys } from "@/utils/jwt"; +import { prisma } from "@/utils/prisma"; + +const DEFAULT_URL = "https://assistants.test.local"; +const ASSISTANT_KEY = "test-space-upstream-key"; +const ACCOUNT_ID = "11111111-1111-4111-8111-111111111111"; +const GOOD_VARIANT = "pr-test-space-upstream"; +const GOOD_VARIANT_URL = `https://ephemeral-${GOOD_VARIANT}.convos.fun`; + +const pullRequestResult = { + conversationId: "conversation_abc", + outcome: "pull_request", + prUrl: "https://github.com/xmtplabs/convos-assistants/pull/123", + prNumber: 123, + branch: "space-upstream/conversation_abc", + commitSha: "commit-sha", + forkCommitSha: "fork-commit-sha", + wrote: 4, + deleted: 1, + refusedCount: 2, +} as const; + +const unchangedResult = { + conversationId: "conversation_abc", + outcome: "unchanged", + forkCommitSha: "fork-commit-sha", + wrote: 0, + deleted: 0, + refusedCount: 0, +} as const; + +type FetchCall = { url: string; init?: RequestInit }; +let fetchCalls: FetchCall[] = []; +let fetchImpl: (url: string, init?: RequestInit) => Promise; +const originalFetch = globalThis.fetch; +let nextIpOctet = 1; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function accountMiddleware( + _req: ExpressRequest, + res: ExpressResponse, + next: NextFunction, +) { + res.locals.accountId = ACCOUNT_ID; + next(); +} + +function buildApp(options?: { + auth?: boolean; + errorLog?: ReturnType; +}) { + const app = express(); + app.set("trust proxy", 1); + if (options?.errorLog) { + app.use((req, _res, next) => { + req.log = { + error: options.errorLog, + warn: vi.fn(), + info: vi.fn(), + } as unknown as ExpressRequest["log"]; + next(); + }); + } else { + app.use(pinoMiddleware); + } + app.use( + "/api/v2/conversations", + options?.auth ? authMiddleware : accountMiddleware, + conversationsRouter, + ); + return app; +} + +function proposal( + app: ReturnType, + path = "/api/v2/conversations/CONVERSATION_ABC/debug/space-upstream", + ip?: string, +) { + const selectedIp = ip ?? `198.51.100.${nextIpOctet++}`; + return request(app).post(path).set("X-Forwarded-For", selectedIp); +} + +function responseBody(response: { body: unknown }): Record { + return response.body as Record; +} + +beforeAll(async () => { + await validateJWTKeys(); +}); + +afterAll(() => { + globalThis.fetch = originalFetch; + __setAssistantConfigOverridesForTests({}); +}); + +beforeEach(() => { + vi.restoreAllMocks(); + fetchCalls = []; + __setAssistantConfigOverridesForTests({ + assistantApiUrl: DEFAULT_URL, + assistantApiKey: ASSISTANT_KEY, + }); + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve(jsonResponse(200, pullRequestResult)); + }; + globalThis.fetch = (url: string | URL | Request, init?: RequestInit) => { + const urlString = + typeof url === "string" ? url : url instanceof URL ? url.href : url.url; + return fetchImpl(urlString, init); + }; + vi.spyOn(prisma.agentVariant, "findFirst").mockImplementation((args) => { + const slug = (args?.where as { slug?: string } | undefined)?.slug; + const assistantWorkerUrl = slug === GOOD_VARIANT ? GOOD_VARIANT_URL : null; + return Promise.resolve( + assistantWorkerUrl ? { assistantWorkerUrl } : null, + ) as never; + }); +}); + +describe("POST /conversations/:conversationId/debug/space-upstream", () => { + test("uses JWT auth and requires an account", async () => { + const app = buildApp({ auth: true }); + + const missing = await proposal(app); + expect(missing.status).toBe(401); + + const accountlessToken = await createJwtToken({ deviceId: "device-only" }); + const accountless = await proposal(app).set( + "X-Convos-AuthToken", + accountlessToken, + ); + expect(accountless.status).toBe(403); + expect(accountless.body).toEqual({ error: "Account required" }); + + const accountToken = await createJwtToken({ + deviceId: "device-account", + accountId: ACCOUNT_ID, + }); + const authenticated = await proposal(app).set( + "X-Convos-AuthToken", + accountToken, + ); + expect(authenticated.status).toBe(200); + expect(fetchCalls).toHaveLength(1); + }); + + test("forwards the bounded conversation ID verbatim and sends only the shared key", async () => { + const res = await proposal(buildApp()); + expect(res.status).toBe(200); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.url).toBe( + `${DEFAULT_URL}/api/conversations/CONVERSATION_ABC/space-upstream`, + ); + expect(fetchCalls[0]?.init).toMatchObject({ + method: "POST", + headers: { Authorization: `Bearer ${ASSISTANT_KEY}` }, + }); + expect(fetchCalls[0]?.init?.body).toBeUndefined(); + expect(fetchCalls[0]?.init?.headers).toEqual({ + Authorization: `Bearer ${ASSISTANT_KEY}`, + }); + }); + + test.each([ + ["blank", "%20"], + ["overlong", "a".repeat(257)], + ])("rejects an %s conversation ID before fetch", async (_label, id) => { + const res = await proposal( + buildApp(), + `/api/v2/conversations/${id}/debug/space-upstream`, + ); + expect(res.status).toBe(400); + expect(res.body).toEqual({ + success: false, + error: "INVALID_REQUEST", + message: "Invalid Space PR proposal request", + }); + expect(fetchCalls).toHaveLength(0); + }); + + test.each([ + ["empty", "variantId="], + ["array", "variantId=one&variantId=two"], + ["overlong", `variantId=${"a".repeat(65)}`], + ])("rejects an %s provided variant", async (_label, query) => { + const res = await proposal( + buildApp(), + `/api/v2/conversations/conversation_abc/debug/space-upstream?${query}`, + ); + expect(res.status).toBe(400); + expect(responseBody(res).error).toBe("INVALID_REQUEST"); + expect(fetchCalls).toHaveLength(0); + }); + + test("ignores unrelated query keys and uses the default Worker", async () => { + const res = await proposal( + buildApp(), + "/api/v2/conversations/conversation_abc/debug/space-upstream?future=value", + ); + expect(res.status).toBe(200); + expect(fetchCalls[0]?.url).toBe( + `${DEFAULT_URL}/api/conversations/conversation_abc/space-upstream`, + ); + }); + + test("passes the parsed variant slug to the registry and uses its exact origin", async () => { + const res = await proposal( + buildApp(), + `/api/v2/conversations/conversation_abc/debug/space-upstream?variantId=${GOOD_VARIANT}`, + ); + expect(res.status).toBe(200); + expect(fetchCalls[0]?.url).toBe( + `${GOOD_VARIANT_URL}/api/conversations/conversation_abc/space-upstream`, + ); + }); + + test("fails a non-allowed variant closed without fetching", async () => { + const res = await proposal( + buildApp(), + "/api/v2/conversations/conversation_abc/debug/space-upstream?variantId=unknown-space-variant", + ); + expect(res.status).toBe(409); + expect(res.body).toEqual({ + success: false, + error: "VARIANT_UNAVAILABLE", + message: "The selected agent variant is unavailable", + }); + expect(fetchCalls).toHaveLength(0); + }); + + test("uses a 50-second upstream AbortSignal", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); + try { + const res = await proposal(buildApp()); + expect(res.status).toBe(200); + expect(timeoutSpy).toHaveBeenCalledWith(50_000); + } finally { + timeoutSpy.mockRestore(); + } + }); + + test("returns unavailable without the optional shared key", async () => { + __setAssistantConfigOverridesForTests({ + assistantApiUrl: DEFAULT_URL, + assistantApiKey: "", + }); + const res = await proposal(buildApp()); + expect(res.status).toBe(503); + expect(responseBody(res).error).toBe("SPACE_UPSTREAM_UNAVAILABLE"); + expect(fetchCalls).toHaveLength(0); + }); + + test.each([ + [403, "space_upstream_not_armed", 503, "SPACE_UPSTREAM_NOT_ARMED"], + [404, "space_not_found", 404, "SPACE_NOT_FOUND"], + [409, "space_repository_unavailable", 409, "SPACE_REPOSITORY_UNAVAILABLE"], + [ + 503, + "space_repository_provider_unavailable", + 503, + "SPACE_UPSTREAM_UNAVAILABLE", + ], + [422, "space_upstream_refused", 422, "SPACE_UPSTREAM_REFUSED"], + [502, "space_upstream_github_failed", 502, "SPACE_UPSTREAM_GITHUB_FAILED"], + [502, "space_upstream_failed", 502, "SPACE_UPSTREAM_FAILED"], + [504, "space_upstream_timeout", 504, "SPACE_UPSTREAM_TIMEOUT"], + [401, "unauthorized", 502, "SPACE_UPSTREAM_FAILED"], + ])( + "maps Worker %i %s to %i %s", + async (workerStatus, workerCode, expectedStatus, expectedCode) => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve( + jsonResponse(workerStatus, { + error: "Safe upstream detail", + code: workerCode, + }), + ); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(expectedStatus); + expect(responseBody(res).error).toBe(expectedCode); + if (workerCode === "space_upstream_refused") { + expect(responseBody(res).message).toBe("Safe upstream detail"); + } + }, + ); + + test.each([ + ["uncoded old-route 404", 404, { error: "Not found" }], + ["unexpected code", 418, { error: "No", code: "unexpected" }], + ])("maps %s to the generic failure", async (_label, status, body) => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve(jsonResponse(status, body)); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(502); + expect(responseBody(res).error).toBe("SPACE_UPSTREAM_FAILED"); + }); + + test("accepts additive fields in a coded Worker error", async () => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve( + jsonResponse(404, { + error: "Not found", + code: "space_not_found", + extra: true, + }), + ); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(404); + expect(res.body).toEqual({ + success: false, + error: "SPACE_NOT_FOUND", + message: "No Space was found for this conversation", + }); + }); + + test("separates timeout and network failures", async () => { + fetchImpl = () => + Promise.reject(new DOMException("Timed out", "TimeoutError")); + const timedOut = await proposal(buildApp()); + expect(timedOut.status).toBe(504); + expect(responseBody(timedOut).error).toBe("SPACE_UPSTREAM_TIMEOUT"); + + fetchImpl = () => Promise.reject(new TypeError("network unavailable")); + const networkFailure = await proposal(buildApp()); + expect(networkFailure.status).toBe(502); + expect(responseBody(networkFailure).error).toBe("SPACE_UPSTREAM_FAILED"); + }); + + test.each([pullRequestResult, unchangedResult])( + "returns a valid $outcome result transparently", + async (result) => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve(jsonResponse(200, result)); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, ...result }); + }, + ); + + test.each([ + ["malformed JSON", "not-json"], + ["unknown outcome", { ...unchangedResult, outcome: "queued" }], + [ + "missing required field", + { ...unchangedResult, forkCommitSha: undefined }, + ], + ])("rejects a %s success response", async (_label, body) => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + if (typeof body === "string") { + return Promise.resolve( + new Response(body, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return Promise.resolve(jsonResponse(200, body)); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(502); + expect(responseBody(res).error).toBe("SPACE_UPSTREAM_FAILED"); + }); + + test("accepts additive fields in a valid Worker result", async () => { + fetchImpl = (url, init) => { + fetchCalls.push({ url, init }); + return Promise.resolve( + jsonResponse(200, { + ...unchangedResult, + futureField: true, + }), + ); + }; + const res = await proposal(buildApp()); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, ...unchangedResult }); + }); + + test("caps upstream body diagnostics", async () => { + const errorLog = vi.fn(); + fetchImpl = () => + Promise.resolve( + new Response("x".repeat(1_000), { + status: 500, + headers: { "Content-Type": "text/plain" }, + }), + ); + const res = await proposal(buildApp({ errorLog })); + expect(res.status).toBe(502); + expect(errorLog).toHaveBeenCalledWith( + { status: 500, bodyPreview: "x".repeat(200) }, + "Space upstream Worker request failed", + ); + }); + + test("returns the exact IP-keyed mutation limit response", async () => { + const app = buildApp(); + const ip = "203.0.113.77"; + for (let index = 0; index < 10; index += 1) { + const allowed = await proposal(app, undefined, ip); + expect(allowed.status).toBe(200); + } + const limited = await proposal(app, undefined, ip); + expect(limited.status).toBe(429); + expect(limited.body).toEqual({ + success: false, + error: "RATE_LIMITED", + message: "Too many Space PR proposals; retry shortly", + }); + + const otherIp = await proposal(app, undefined, "203.0.113.78"); + expect(otherIp.status).toBe(200); + }); +});