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<Response>,
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<typeof handle>[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<typeof handle>) => {
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("<urlset/>", {
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("<urlset/>");
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<typeof handle>[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 = '<link href="%tangled.avatar%" /><link href="%tangled.camo%" />';
return new Response(await opts.transformPageChunk?.({ html, done: true }));
});
const res = await handle({ event, resolve } as unknown as Parameters<typeof handle>[0]);
await expect(res.text()).resolves.toBe(
'<link href="https://avatar.test" /><link href="https://camo.test" />'
);
});
});
describe("handleFetch", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
config.upstreamTimeoutMs = 15_000;
});
const call = async (request: Request, fetch: (request: Request) => Promise<Response>) =>
handleFetch({ request, fetch } as unknown as Parameters<typeof handleFetch>[0]);
const hanging = (request: Request) =>
new Promise<Response>((_, 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<Http2Session, number>();
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<void>((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();
}
});
});