/** * Browser-level cookie isolation, driven over the Chrome DevTools Protocol with a throwaway headless profile. * * SITES_E2E_URL=http://localhost:3317 bun run e2e/sites-browser.e2e.ts * * Chrome resolves `*.localhost` to loopback, so site hosts work without hosts-file entries. */ import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createScratchRepo, d1, deployScratch, FORGE, gitPush, log, waitForSite } from "./helpers"; const CHROME = process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; const START_ATTEMPTS = 50; const PORT = 9400 + Math.floor(Math.random() * 500); const profile = mkdtempSync(join(tmpdir(), "sites-e2e-")); const chrome = spawn( CHROME, ["--headless=new", `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, "--no-first-run", "about:blank"], { stdio: "ignore" }, ); async function waitForChrome(): Promise { for (let attempt = 0; attempt < START_ATTEMPTS; attempt++) { try { const targets = (await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()) as { type: string; webSocketDebuggerUrl: string; }[]; const target = targets.find((item) => item.type === "page"); if (target) return target.webSocketDebuggerUrl; } catch { // Chrome is still starting. } await Bun.sleep(200); } throw new Error("Chrome did not start"); } const socket = new WebSocket(await waitForChrome()); await new Promise((resolve) => socket.addEventListener("open", resolve, { once: true })); let nextId = 0; const pending = new Map void>(); const loads: (() => void)[] = []; socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)) as { id?: number; result?: unknown; method?: string }; if (message.id !== undefined) pending.get(message.id)?.(message.result); if (message.method === "Page.loadEventFired") loads.shift()?.(); }); function cdp(method: string, params: Record = {}): Promise { const id = ++nextId; socket.send(JSON.stringify({ id, method, params })); return new Promise((resolve) => pending.set(id, resolve)); } async function visit(url: string): Promise { const loaded = new Promise((resolve) => loads.push(resolve)); await cdp("Page.navigate", { url }); await loaded; return evaluate("document.body.innerText"); } async function evaluate(expression: string): Promise { const result = (await cdp("Runtime.evaluate", { expression, returnByValue: true })) as { result: { value: unknown } }; return String(result.result.value); } let failures = 0; function check(name: string, ok: boolean, detail = ""): void { log(`${ok ? "ok " : "FAIL"} ${name}${ok || !detail ? "" : ` -- ${detail}`}`); if (!ok) failures += 1; } try { await cdp("Page.enable"); const siteA = await deployScratch("browser-a"); const siteB = await deployScratch("browser-b"); const origin = (host: string) => `http://${host}:${FORGE.port}`; await visit(new URL("/", FORGE).toString()); await evaluate(`document.cookie = "forge_cookie=1; path=/"`); await evaluate(`document.cookie = "forge_parent=1; path=/; domain=localhost"`); check("forge sets its cookie", (await evaluate("document.cookie")).includes("forge_cookie=1")); await visit(`${origin(siteA.host)}/set`); const onA = JSON.parse(await visit(`${origin(siteA.host)}/echo`)) as { cookie: string | null }; check("a site sees its own cookie", (onA.cookie ?? "").includes("site_token="), String(onA.cookie)); check( "a cookie set on the forge is not visible on the site", !(onA.cookie ?? "").includes("forge_"), String(onA.cookie), ); check("the site can't read forge cookies from script", !(await evaluate("document.cookie")).includes("forge_")); const onB = JSON.parse(await visit(`${origin(siteB.host)}/echo`)) as { cookie: string | null }; check("a site can't read another site's cookies", !(onB.cookie ?? "").includes("site_token"), String(onB.cookie)); check("another site can't read them from script either", !(await evaluate("document.cookie")).includes("site_token")); await visit(`${origin(siteA.host)}/toss`); const tossedA = JSON.parse(await visit(`${origin(siteA.host)}/echo`)) as { cookie: string | null }; check( "a Domain= cookie is kept as host-only", (tossedA.cookie ?? "").includes("own=1"), String(tossedA.cookie), ); check( "a parent-domain cookie is dropped by the dispatcher", !(tossedA.cookie ?? "").includes("tossed=1"), String(tossedA.cookie), ); const tossedB = JSON.parse(await visit(`${origin(siteB.host)}/echo`)) as { cookie: string | null }; check( "a site can't toss cookies onto a sibling", !(tossedB.cookie ?? "").includes("tossed") && !(tossedB.cookie ?? "").includes("own="), String(tossedB.cookie), ); const gitScratch = await createScratchRepo(); const gitMarker = `claimed-${Date.now()}`; const gitPushed = await gitPush(gitScratch, { "index.html": `

${gitMarker}

` }); check( "a git scratch site deploys before the claim", gitPushed.code === 0 && (await waitForSite(new URL(gitScratch.url).hostname, gitMarker)).body.includes(gitMarker), ); const user = `e2e${Date.now().toString(36)}`; await visit(new URL("/signup", FORGE).toString()); await Bun.sleep(2500); await evaluate(`(() => { const set = (name, value) => { const input = document.querySelector('input[name="' + name + '"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }; set("username", "${user}"); set("email", "${user}@example.com"); set("password", "correct horse battery"); document.querySelector('input[name="username"]').form.requestSubmit(); })()`); await Bun.sleep(3000); const afterSignup = await evaluate("location.pathname + ' ' + document.body.innerText.slice(0, 200)"); check("signup succeeds", !afterSignup.startsWith("/signup"), afterSignup); await visit(siteA.claimUrl); const claimForm = await evaluate(`document.querySelector("form") ? "form" : document.body.innerText`); check("the claim page is shown to a signed-in user", claimForm === "form", claimForm); const claimed = new Promise((resolve) => loads.push(resolve)); await evaluate(`document.querySelector("form")?.submit()`); await Promise.race([claimed, Bun.sleep(5000)]); const link = await evaluate(`document.querySelector("a")?.href ?? ""`); check( "a signed-in user can claim a scratch site", link.includes(`--${user}.`), await evaluate("document.body.innerText"), ); if (link) { const blocked = await visit(`${link}fetch?u=${encodeURIComponent(new URL("/", FORGE).toString())}`); check("claimed sites can't reach the forge", blocked.includes("fetched 403"), blocked); const old = await visit(`${origin(siteA.host)}/`); check("the scratch host stops serving after a claim", old.includes("Site not found"), old); } await visit(gitScratch.claimUrl); const gitClaimed = new Promise((resolve) => loads.push(resolve)); await evaluate(`document.querySelector("form")?.submit()`); await Promise.race([gitClaimed, Bun.sleep(5000)]); const gitLink = await evaluate(`document.querySelector("a")?.href ?? ""`); const gitId = new URL(gitScratch.url).hostname.split("--")[0]; const moved = await d1<{ owner: string; visibility: string }>( "SELECT a.name AS owner, r.visibility FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.name = ?", [gitId], ); check( "claiming moves the scratch repository to the user", moved[0]?.owner === user && moved[0]?.visibility === "public", JSON.stringify(moved), ); check("the claimed git site lives under the user's handle", gitLink.includes(`${gitId}--${user}.`), gitLink); if (gitLink) { const served = await visit(gitLink); check("the claimed git site keeps serving", served.includes(gitMarker), served); } } finally { socket.close(); chrome.kill(); rmSync(profile, { recursive: true, force: true }); } log(failures ? `\n${failures} check(s) failed` : "\nall checks passed"); process.exit(failures ? 1 : 0);