import { afterEach, describe, expect, it } from "vitest"; import { request } from "node:http"; import { mkdirSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { startFixtureServer, type FixtureServer } from "../../runner/fixtureServer"; import { makeTempRepo, type TempRepo } from "../helpers/tempRepo"; let repo: TempRepo; let server: FixtureServer | undefined; afterEach(async () => { await server?.close(); server = undefined; repo?.cleanup(); }); async function serve(files: Record = {}) { repo = makeTempRepo({ git: false }); mkdirSync(join(repo.dir, "site/sub"), { recursive: true }); repo.write("secret.txt", "TOP SECRET"); for (const [name, body] of Object.entries({ "hello.html": "

hi

", "style.css": "p{}", "app.js": "1", "data.json": "{}", "sub/index.html": "

index

", "pic.png": "x", ...files })) repo.write(`site/${name}`, body); server = await startFixtureServer({ root: join(repo.dir, "site"), port: 0 }); return server; } /** Raw request, so paths are sent exactly as written (fetch would tidy ../ away). */ const raw = (port: number, path: string, method = "GET") => new Promise<{ status: number; headers: Record; body: string }>((resolve, reject) => { const req = request({ host: "127.0.0.1", port, path, method }, (res) => { let body = ""; res.on("data", (c) => (body += c)); res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body })); }); req.on("error", reject); req.end(); }); describe("startFixtureServer", () => { it("serves a file with the right content type, uncached", async () => { const s = await serve(); const r = await raw(s.port, "/hello.html"); expect(r.status).toBe(200); expect(r.body).toBe("

hi

"); expect(r.headers["content-type"]).toBe("text/html; charset=utf-8"); expect(r.headers["cache-control"]).toBe("no-store"); }); it.each([["/style.css", "text/css; charset=utf-8"], ["/app.js", "text/javascript; charset=utf-8"], ["/data.json", "application/json; charset=utf-8"], ["/pic.png", "image/png"]])("types %s as %s", async (path, type) => { const s = await serve(); expect((await raw(s.port, path)).headers["content-type"]).toBe(type); }); it("serves index.html for a folder", async () => { const s = await serve(); expect((await raw(s.port, "/sub/")).body).toBe("

index

"); expect((await raw(s.port, "/sub")).body).toBe("

index

"); }); it("ignores a query string and a fragment", async () => { const s = await serve(); expect((await raw(s.port, "/hello.html?x=1")).status).toBe(200); }); it("answers HEAD with headers and no body", async () => { const s = await serve(); const r = await raw(s.port, "/hello.html", "HEAD"); expect(r.status).toBe(200); expect(r.body).toBe(""); }); it("answers 404 with an empty body for a missing file or an empty folder", async () => { const s = await serve(); mkdirSync(join(repo.dir, "site/empty")); for (const path of ["/nope.html", "/empty/", "/sub/nope"]) { const r = await raw(s.port, path); expect(r.status, path).toBe(404); expect(r.body).toBe(""); } }); it.each(["POST", "PUT", "DELETE", "PATCH"])("refuses %s with 405", async (method) => { const s = await serve(); const r = await raw(s.port, "/hello.html", method); expect(r.status).toBe(405); expect(r.headers.allow).toBe("GET, HEAD"); }); }); describe("startFixtureServer: it never serves anything outside its folder", () => { it.each([ "/../secret.txt", "/%2e%2e/secret.txt", "/%2E%2E/secret.txt", "/..%2fsecret.txt", "/sub/../../secret.txt", "/sub/%2e%2e/%2e%2e/secret.txt", "//../secret.txt", "/..\\secret.txt", "/%5c..%5csecret.txt", "/....//secret.txt", "/%252e%252e/secret.txt", ])("%s is not served", async (path) => { const s = await serve(); const r = await raw(s.port, path); expect(r.body).not.toContain("TOP SECRET"); expect([400, 404]).toContain(r.status); }); it("refuses a path with a NUL byte or invalid escapes", async () => { const s = await serve(); for (const path of ["/hello.html%00.png", "/%zz", "/%c0%ae%c0%ae/secret.txt"]) { const r = await raw(s.port, path); expect(r.body).not.toContain("TOP SECRET"); expect([400, 404]).toContain(r.status); } }); it("does not follow a symlink that points outside", async () => { const s = await serve(); symlinkSync(join(repo.dir, "secret.txt"), join(repo.dir, "site/link.txt")); const r = await raw(s.port, "/link.txt"); expect(r.body).not.toContain("TOP SECRET"); expect(r.status).toBe(404); }); it("still serves a symlink that stays inside", async () => { const s = await serve(); symlinkSync(join(repo.dir, "site/hello.html"), join(repo.dir, "site/alias.html")); expect((await raw(s.port, "/alias.html")).body).toBe("

hi

"); }); it("does not serve dot files", async () => { const s = await serve({ ".env": "KEY=1", "sub/.hidden": "x" }); expect((await raw(s.port, "/.env")).status).toBe(404); expect((await raw(s.port, "/sub/.hidden")).status).toBe(404); }); }); describe("startFixtureServer: address and lifecycle", () => { it("listens on 127.0.0.1 only", async () => { const s = await serve(); expect(s.origin).toBe(`http://127.0.0.1:${s.port}`); }); it("refuses a root that is not a folder", async () => { repo = makeTempRepo({ git: false }); await expect(startFixtureServer({ root: join(repo.dir, "missing"), port: 0 })).rejects.toThrowError(/not a folder/); repo.write("file.txt", "x"); await expect(startFixtureServer({ root: join(repo.dir, "file.txt"), port: 0 })).rejects.toThrowError(/not a folder/); }); it("refuses a port that is taken, saying which", async () => { const a = await serve(); await expect(startFixtureServer({ root: join(repo.dir, "site"), port: a.port })).rejects.toThrowError(new RegExp(`port ${a.port} is already in use`)); }); it("frees the port on close, and close can be called twice", async () => { const a = await serve(); const port = a.port; await a.close(); await a.close(); server = await startFixtureServer({ root: join(repo.dir, "site"), port }); expect(server.port).toBe(port); }); it("keeps serving after a bad request", async () => { const s = await serve(); await raw(s.port, "/%zz"); expect((await raw(s.port, "/hello.html")).status).toBe(200); }); it("tells the onRequest hook the decoded path of each request, including paths that are not there", async () => { repo = makeTempRepo({ git: false }); mkdirSync(join(repo.dir, "site"), { recursive: true }); repo.write("site/a.html", "x"); const seen: string[] = []; server = await startFixtureServer({ root: join(repo.dir, "site"), port: 0, onRequest: (p) => seen.push(p) }); await raw(server.port, "/a.html?x=1"); expect((await raw(server.port, "/beacon/clicked%20it")).status).toBe(404); expect(seen).toEqual(["/a.html", "/beacon/clicked it"]); }); });