Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions tests/unit/test_next_backend_proxy_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from __future__ import annotations

from pathlib import Path


ROOT = Path(__file__).resolve().parents[2]
API_ROOT = ROOT / "web" / "src" / "app" / "api"


def _route_files() -> list[Path]:
return sorted(
path
for path in API_ROOT.rglob("route.ts")
if "_utils" not in path.parts
)


def test_next_api_routes_use_shared_backend_proxy_helpers() -> None:
offenders: list[str] = []

for file_path in _route_files():
text = file_path.read_text(encoding="utf-8")
rel = file_path.relative_to(ROOT)

if "fetch(" in text:
offenders.append(f"{rel}: direct fetch")

if "process.env.PAPERBOT_API_BASE_URL" in text or "process.env.BACKEND_BASE_URL" in text:
offenders.append(f"{rel}: inlined backend base URL")

assert not offenders, "\n".join(offenders)


def test_dead_runbook_proxy_routes_are_removed() -> None:
assert not (API_ROOT / "runbook" / "runs" / "[runId]" / "route.ts").exists()
assert not (API_ROOT / "runbook" / "smoke" / "route.ts").exists()
7 changes: 5 additions & 2 deletions web/src/app/api/_utils/auth-headers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { auth } from "@/auth"

export function backendBaseUrl(): string {
return process.env.BACKEND_BASE_URL || "http://127.0.0.1:8000"
return (
process.env.BACKEND_BASE_URL ||
process.env.PAPERBOT_API_BASE_URL ||
"http://127.0.0.1:8000"
)
}

export async function withBackendAuth(
Expand Down Expand Up @@ -35,4 +39,3 @@ export async function withBackendAuth(
}
return headers
}

155 changes: 155 additions & 0 deletions web/src/app/api/_utils/backend-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import {
apiBaseUrl,
proxyBinary,
proxyJson,
proxyStream,
proxyText,
} from "./backend-proxy"
import { withBackendAuth } from "./auth-headers"

vi.mock("./auth-headers", () => ({
backendBaseUrl: () => "http://backend.test",
withBackendAuth: vi.fn(async (_req: Request, base: HeadersInit = {}) => {
const headers = new Headers(base)
headers.set("authorization", "Bearer test-token")
return headers
}),
}))

describe("backend-proxy", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn())
})

afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})

it("uses the shared backend base URL", () => {
expect(apiBaseUrl()).toBe("http://backend.test")
})

it("proxies JSON with backend auth when requested", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 201,
headers: { "content-type": "application/json" },
}),
)

const req = new Request("http://app.test/api/auth/me", {
body: JSON.stringify({ display_name: "PaperBot" }),
headers: { "content-type": "application/json" },
method: "PATCH",
})

const res = await proxyJson(req, "http://backend.test/api/auth/me", "PATCH", {
auth: true,
})

expect(withBackendAuth).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledTimes(1)
const [, init] = vi.mocked(fetch).mock.calls[0]
const headers = new Headers((init as RequestInit).headers)
expect(headers.get("authorization")).toBe("Bearer test-token")
expect(await res.json()).toEqual({ ok: true })
})

it("supports custom error handlers for text responses", async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error("boom"))

const res = await proxyText(
new Request("http://app.test/api/newsletter/unsubscribe/token"),
"http://backend.test/api/newsletter/unsubscribe/token",
"GET",
{
onError: () => new Response("<html>fallback</html>", {
headers: { "Content-Type": "text/html" },
status: 502,
}),
responseContentType: "text/html",
},
)

expect(res.status).toBe(502)
expect(res.headers.get("content-type")).toContain("text/html")
expect(await res.text()).toContain("fallback")
})

it("preserves binary download headers", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
new Response(new Uint8Array([1, 2, 3]), {
headers: {
"content-disposition": 'attachment; filename="papers.csv"',
"content-type": "text/csv",
},
status: 200,
}),
)

const res = await proxyBinary(
new Request("http://app.test/api/papers/export"),
"http://backend.test/api/research/papers/export",
"GET",
{ accept: "*/*" },
)

expect(res.headers.get("content-disposition")).toContain("papers.csv")
expect(res.headers.get("content-type")).toContain("text/csv")
})

it("passes through SSE streams and JSON fallbacks", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(
new Response("data: ping\n\n", {
headers: { "content-type": "text/event-stream" },
status: 200,
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ done: true }), {
headers: { "content-type": "application/json" },
status: 200,
}),
)

const streamReq = new Request("http://app.test/api/research/paperscool/daily", {
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
method: "POST",
})
const streamRes = await proxyStream(
streamReq,
"http://backend.test/api/research/paperscool/daily",
"POST",
{
auth: true,
passthroughNonStreamResponse: true,
responseContentType: "text/event-stream",
},
)
expect(streamRes.headers.get("content-type")).toContain("text/event-stream")
expect(await streamRes.text()).toContain("data: ping")

const jsonReq = new Request("http://app.test/api/research/paperscool/daily", {
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
method: "POST",
})
const jsonRes = await proxyStream(
jsonReq,
"http://backend.test/api/research/paperscool/daily",
"POST",
{
auth: true,
passthroughNonStreamResponse: true,
responseContentType: "text/event-stream",
},
)
expect(jsonRes.headers.get("content-type")).toContain("application/json")
expect(await jsonRes.json()).toEqual({ done: true })
})
})
Loading
Loading