import { isRedirect } from "@sveltejs/kit"; import type { ResolveOptions } from "@sveltejs/kit/hooks"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSecureServer, type Http2Session } from "node:http2"; import type { AddressInfo } from "node:net"; import { Agent, fetch as undiciFetch } from "undici"; import { handle, handleFetch, upstreamPool } from "./hooks.server"; const config = vi.hoisted(() => ({ sitemapUrl: "", bobbinUrl: "https://bobbin.test", avatarUrl: "https://avatar.test", camoUrl: "https://camo.test", upstreamTimeoutMs: 15_000 })); vi.mock("#lib/server/config.ts", () => ({ getConfig: () => config })); const repos = vi.hoisted(() => ({ resolveRepoByRepoDid: vi.fn() })); vi.mock("#lib/api/repo.ts", () => repos); const run = ( path: string, binding?: (request: Request) => Promise, routeId: string | null = null ) => { const resolve = vi.fn(async () => new Response("page")); const url = new URL(`https://next.tangled.org${path}`); const event = { url, route: { id: routeId }, fetch: vi.fn(), request: new Request(url), platform: binding ? { env: { SITEMAP: { fetch: binding } } } : undefined }; return { res: handle({ event, resolve } as unknown as Parameters[0]), resolve }; }; beforeEach(() => { config.sitemapUrl = ""; repos.resolveRepoByRepoDid.mockReset().mockResolvedValue(null); vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => vi.unstubAllGlobals()); describe("handle: legacy redirects", () => { it.each([ ["/@alice.test", 302, "/alice.test"], ["/@alice.test/core/tree/main?x=1", 302, "/alice.test/core/tree/main?x=1"], ["/%40alice.test/core", 302, "/alice.test/core"], [ "/@tangled.org/core/info/refs?service=git-upload-pack", 302, "/tangled.org/core/info/refs?service=git-upload-pack" ], ["/tangled.sh", 301, "/tangled.org"], ["/@tangled.sh", 301, "/tangled.org"], ["/tangled.sh/core/issues?state=open", 301, "/tangled.org/core/issues?state=open"], ["/@tangled.sh/core", 301, "/tangled.org/core"] ])("sends %s to %s %s", async (path, status, location) => { const { res, resolve } = run(path); const out = await res; expect([out.status, out.headers.get("location")]).toEqual([status, location]); expect(resolve).not.toHaveBeenCalled(); }); it.each([ "/tangled.shop/core", "/tangled.org/core", "/did:plc:j5hmlfdrwkvtxm7cjmu7j2is/info/refs", "/@did:plc:j5hmlfdrwkvtxm7cjmu7j2is", "/@", "/@not a handle", "/settings", "/login", "/alice.test/@core", "/%E0%A4%A/core", "/%zz" ])("leaves %s to the routes", async (path) => { const { res, resolve } = run(path); await res; expect(resolve).toHaveBeenCalled(); }); }); describe("handle: repo did permalinks", () => { const repoDid = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; const redirected = async (res: ReturnType) => { try { await res; } catch (thrown) { if (isRedirect(thrown)) return [thrown.status, thrown.location]; throw thrown; } return null; }; beforeEach(() => { repos.resolveRepoByRepoDid.mockImplementation(async (_, did: string) => did === repoDid ? { owner: "tangled.org", name: "core" } : null ); }); it.each([ ["", "/tangled.org/core"], ["/issues", "/tangled.org/core/issues"], ["/issues/12", "/tangled.org/core/issues/12"], ["/tree/main/web/src?x=1", "/tangled.org/core/tree/main/web/src?x=1"] ])("sends the repo did%s to the repo", async (rest, location) => { const { res, resolve } = run(`/${repoDid}${rest}`); expect(await redirected(res)).toEqual([307, location]); expect(resolve).not.toHaveBeenCalled(); }); it.each(["/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", "/did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/core"])( "leaves %s, a did that isn't a repo, to the routes", async (path) => { const { res, resolve } = run(path); expect(await redirected(res)).toBeNull(); expect(resolve).toHaveBeenCalled(); } ); it("leaves git's info/refs at the permalink to its route without a lookup", async () => { const { res, resolve } = run(`/${repoDid}/info/refs`, undefined, "/[handle]/info/refs"); expect(await redirected(res)).toBeNull(); expect(resolve).toHaveBeenCalled(); expect(repos.resolveRepoByRepoDid).not.toHaveBeenCalled(); }); it("doesn't look up a handle", async () => { const { res } = run("/tangled.org/core"); await res; expect(repos.resolveRepoByRepoDid).not.toHaveBeenCalled(); }); }); describe("handle: sitemaps", () => { it("prefers the worker binding", async () => { config.sitemapUrl = "https://sitemap.test"; const binding = vi.fn(async () => new Response("bound")); const network = vi.fn(); vi.stubGlobal("fetch", network); const { res } = run("/sitemap.xml", binding); await expect((await res).text()).resolves.toBe("bound"); expect(network).not.toHaveBeenCalled(); }); it.each(["/sitemap.xml", "/sitemaps/repos-3.xml?v=2"])( "fetches %s from SITEMAP_URL without a binding, dropping the decoded encoding", async (path) => { config.sitemapUrl = "https://sitemap.test"; const network = vi.fn( async (_: string) => new Response("", { headers: { "content-type": "application/xml", "content-encoding": "gzip", "content-length": "9" } }) ); vi.stubGlobal("fetch", network); const { res, resolve } = run(path); const out = await res; expect(network).toHaveBeenCalledWith(`https://sitemap.test${path}`); expect(resolve).not.toHaveBeenCalled(); await expect(out.text()).resolves.toBe(""); expect(out.headers.get("content-type")).toBe("application/xml"); expect(out.headers.get("content-encoding")).toBeNull(); expect(out.headers.get("content-length")).toBeNull(); } ); it("falls through to the app with neither", async () => { const network = vi.fn(); vi.stubGlobal("fetch", network); const { res, resolve } = run("/sitemap.xml"); await expect((await res).text()).resolves.toBe("page"); expect(resolve).toHaveBeenCalledOnce(); expect(network).not.toHaveBeenCalled(); }); }); describe("handle: security headers", () => { const page = async (response: Response) => { const url = new URL("https://next.tangled.org/someone/repo"); const event = { url, request: new Request(url), platform: undefined }; const resolve = vi.fn(async () => response); return handle({ event, resolve } as unknown as Parameters[0]); }; it("adds them to a rendered response", async () => { const res = await page(new Response("page")); expect(Object.fromEntries(res.headers)).toMatchObject({ "x-content-type-options": "nosniff", "referrer-policy": "strict-origin-when-cross-origin", "x-frame-options": "DENY", "content-security-policy": "frame-ancestors 'none'" }); await expect(res.text()).resolves.toBe("page"); }); it("keeps a header the route set itself", async () => { const own = "sandbox; default-src 'none'"; const res = await page( new Response("raw", { headers: { "content-security-policy": own } }) ); expect(res.headers.get("content-security-policy")).toBe(own); expect(res.headers.get("x-frame-options")).toBe("DENY"); }); it("copies a response whose headers can't be changed", async () => { const res = await page(Response.redirect("https://cdn.example.com/a", 302)); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("https://cdn.example.com/a"); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); }); }); describe("handle: page head", () => { it("preconnects to the configured avatar and camo services", async () => { const url = new URL("https://next.tangled.org/someone/repo"); const event = { url, request: new Request(url), platform: undefined }; const resolve = vi.fn(async (_: unknown, opts: ResolveOptions) => { const html = ''; return new Response(await opts.transformPageChunk?.({ html, done: true })); }); const res = await handle({ event, resolve } as unknown as Parameters[0]); await expect(res.text()).resolves.toBe( '' ); }); }); describe("handleFetch", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => { vi.useRealTimers(); config.upstreamTimeoutMs = 15_000; }); const call = async (request: Request, fetch: (request: Request) => Promise) => handleFetch({ request, fetch } as unknown as Parameters[0]); const hanging = (request: Request) => new Promise((_, reject) => request.signal.addEventListener("abort", () => reject(request.signal.reason)) ); it("gives up on a get that has no answer in time", async () => { const out = call(new Request("https://bobbin.test/xrpc/x"), hanging); const failed = expect(out).rejects.toThrow(); await vi.advanceTimersByTimeAsync(15_000); await failed; expect(console.warn).toHaveBeenCalledWith( "[hooks.server] bobbin.test/xrpc/x gave no answer in 15000 ms" ); }); it("leaves a body that is still streaming once the headers came", async () => { let signal: AbortSignal | undefined; const out = await call(new Request("https://bobbin.test/xrpc/x"), async (request) => { signal = request.signal; return new Response("ok"); }); await vi.advanceTimersByTimeAsync(60_000); expect(signal?.aborted).toBe(false); expect(await out.text()).toBe("ok"); }); it("doesn't bound a push or clone post", async () => { const out = call( new Request("https://knot.test/git-receive-pack", { method: "POST" }), hanging ); let settled = false; void out.then( () => (settled = true), () => (settled = true) ); await vi.advanceTimersByTimeAsync(60_000); expect(settled).toBe(false); }); it("waits as long as it takes when the timeout is 0", async () => { config.upstreamTimeoutMs = 0; const out = call(new Request("https://bobbin.test/xrpc/x"), hanging); let settled = false; void out.then( () => (settled = true), () => (settled = true) ); await vi.advanceTimersByTimeAsync(60_000); expect(settled).toBe(false); }); }); // self-signed for 127.0.0.1, only ever served inside this test const TEST_CERT = `-----BEGIN CERTIFICATE----- MIIBmzCCAUGgAwIBAgIUfes5w7WyBUNKaPeKwxWOEvao70IwCgYIKoZIzj0EAwIw FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAyOTIyMzEzNVoYDzIxMjYxMDA1 MjIzMTM1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO PQMBBwNCAASIWcZ5H+k949hDyfpHuWzsHPhx29HL/vLL0G7DMGPIDZ2A00D/Dkl6 1xoFynaNvcMkGTCDSTniIg1R3XIAbSuWo28wbTAdBgNVHQ4EFgQU0cTqOs5nXWSo m1T+lGfPXCk7HMwwHwYDVR0jBBgwFoAU0cTqOs5nXWSom1T+lGfPXCk7HMwwDwYD VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI zj0EAwIDSAAwRQIgIeV3oKSwakMNd+bG3H3XCAU9cJeX6B/t9yoOJd6pWPYCIQCA bgl5XnEX5+i6Sa42SS3HW4bY+vz6nFDK2lDu2MLI8w== -----END CERTIFICATE-----`; const TEST_KEY = `-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgoxzF7/Ony88kSNqq Ap1tbQjKUE2/fYeUqDlFVUBzXeuhRANCAASIWcZ5H+k949hDyfpHuWzsHPhx29HL /vLL0G7DMGPIDZ2A00D/Dkl61xoFynaNvcMkGTCDSTniIg1R3XIAbSuW -----END PRIVATE KEY-----`; describe("upstreamPool", () => { const ourOrigin = (port: number) => `https://127.0.0.1:${port}`; // counts connections and the most requests one connection carried at once const h2Server = async () => { const sessions = new Map(); let most = 0; const server = createSecureServer({ cert: TEST_CERT, key: TEST_KEY }, (req, res) => { const session = req.stream.session!; const now = (sessions.get(session) ?? 0) + 1; sessions.set(session, now); most = Math.max(most, now); setTimeout(() => { sessions.set(session, sessions.get(session)! - 1); res.end("ok"); }, 50); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const port = (server.address() as AddressInfo).port; return { port, stats: () => ({ connections: sessions.size, most }), close: () => server.close() }; }; const burst = async (agent: Agent, url: string, count: number) => { const out = await Promise.all( Array.from({ length: count }, () => undiciFetch(url, { dispatcher: agent }).then((res) => res.text()) ) ); expect(out.every((body) => body === "ok")).toBe(true); }; const agentFor = (ours: string[]) => new Agent({ factory: upstreamPool(new Set(ours)), connect: { rejectUnauthorized: false } }); // a cold burst still opens a connection per request, since none knows it got http/2 yet it("spreads an http/2 host that isn't ours over streams once it's connected", async () => { const server = await h2Server(); const agent = agentFor([]); try { await burst(agent, ourOrigin(server.port), 1); await burst(agent, ourOrigin(server.port), 50); expect(server.stats().connections).toBe(1); expect(server.stats().most).toBeGreaterThan(1); } finally { await agent.close(); server.close(); } }); it("keeps our own services to a few http/2 connections", async () => { const server = await h2Server(); const agent = agentFor([ourOrigin(server.port)]); try { await burst(agent, ourOrigin(server.port), 300); expect(server.stats().connections).toBeLessThanOrEqual(8); } finally { await agent.close(); server.close(); } }); });