#!/usr/bin/env bun /** * End-to-end check of the workspace editor protocol against a running dev server. * Usage: BASE=http://localhost:3000 GITFOR_TOKEN=gitfor_pat_... bun run scripts/e2e-editor.ts * Optional: GITFOR_WRITER_TOKEN (a second user) and GITFOR_E2E_D1 (local D1 sqlite) cover protected branches. */ export {}; const BASE = process.env.BASE ?? "http://localhost:3000"; const PAT = process.env.GITFOR_TOKEN ?? ""; if (!PAT) throw new Error("Set GITFOR_TOKEN to a personal access token"); const LFS_POINTER = `version https://git-lfs.github.com/spec/v1\noid sha256:${"a".repeat(64)}\nsize 12\n`; function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(`FAIL: ${message}`); console.log(`ok - ${message}`); } async function call(path: string, token: string, init: { method?: string; body?: unknown } = {}) { const response = await fetch(`${BASE}${path}`, { method: init.method ?? "GET", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", origin: "null" }, body: init.body === undefined ? undefined : JSON.stringify(init.body), signal: AbortSignal.timeout(60_000), }); const text = await response.text(); return { status: response.status, headers: response.headers, body: text ? JSON.parse(text) : null }; } const me = await call("/api/v1/user", PAT); const name = `editor-e2e-${Date.now()}`; const created = await call("/api/v1/repos", PAT, { method: "POST", body: { name, private: true } }); check(created.status === 201, `created ${me.body.login}/${name}`); const repo = `/repos/${me.body.login}/${name}`; for (const [path, content] of [ ["README.md", "# hello\n"], ["src/app.ts", "export const answer = 41;\n"], ["src/old.txt", "remove me\n"], ["assets/big.bin", LFS_POINTER], ]) { const put = await call(`/api/v1${repo}/contents/${path}`, PAT, { method: "PUT", body: { branch: "main", message: `add ${path}`, content: Buffer.from(content).toString("base64") }, }); check(put.status < 300, `seeded ${path} (${put.status})`); } const minted = await call(`/api/v1${repo}/editor-sessions`, PAT, { method: "POST", body: { ref: "main" } }); check(minted.status === 201 && minted.body.token.startsWith("gitfor_edt_"), "minted editor session via /api/v1"); const token: string = minted.body.token; const frame = await fetch(`${BASE}/editor-frame`); const csp = frame.headers.get("content-security-policy") ?? ""; check(frame.ok && csp.includes("default-src 'none'") && csp.includes("frame-ancestors 'self'"), "frame has strict CSP"); check(!csp.includes("unsafe-inline"), "frame CSP has no unsafe-inline"); const preflight = await fetch(`${BASE}/api/editor/session`, { method: "OPTIONS", headers: { origin: "null", "access-control-request-headers": "authorization" }, }); check(preflight.status === 204 && preflight.headers.get("access-control-allow-origin") === "*", "CORS preflight"); const anonymous = await fetch(`${BASE}/api/editor/session`); check(anonymous.status === 401, "API rejects requests without an editor token"); const withPat = await call("/api/editor/session", PAT); check(withPat.status === 401, "API rejects personal access tokens (editor tokens only)"); const session = await call("/api/editor/session", token); check(session.status === 200 && session.body.writable === true && session.body.ref === "main", "session is writable"); const tree = await call("/api/editor/tree", token); check( tree.body.entries.some((entry: { path: string }) => entry.path === "src/app.ts"), "tree lists src/app.ts", ); const blob = await call("/api/editor/blob?path=src/app.ts", token); check(blob.body.kind === "text" && blob.body.content.includes("41"), "blob returns text"); const lfs = await call("/api/editor/blob?path=assets/big.bin", token); check(lfs.body.kind === "lfs" && lfs.body.content === null, "LFS pointer is not returned as editable text"); const lfsWrite = await call("/api/editor/commit", token, { method: "POST", body: { message: "clobber", expectedHeadSha: session.body.sha, changes: [{ path: "assets/big.bin", content: "x" }] }, }); check(lfsWrite.status === 403, "commit refuses to overwrite an LFS pointer"); const badPath = await call("/api/editor/commit", token, { method: "POST", body: { message: "escape", expectedHeadSha: session.body.sha, changes: [{ path: "../etc/passwd", content: "x" }] }, }); check(badPath.status >= 400 && badPath.status < 500, `path validation applies (${badPath.status})`); const commit = await call("/api/editor/commit", token, { method: "POST", body: { message: "Edit from the workspace editor", expectedHeadSha: session.body.sha, changes: [ { path: "src/app.ts", content: "export const answer = 42;\n" }, { path: "src/new.ts", content: "export {};\n" }, { path: "src/old.txt", content: null }, ], }, }); check(commit.status === 201 && commit.body.branch === "main", `committed ${commit.body.sha}`); const stale = await call("/api/editor/commit", token, { method: "POST", body: { message: "stale", expectedHeadSha: session.body.sha, changes: [{ path: "README.md", content: "x" }] }, }); check(stale.status === 409, "stale head is rejected"); const after = await call("/api/editor/session", token); check(after.body.sha === commit.body.sha, "session follows the new branch head"); const edited = await call("/api/editor/blob?path=src/app.ts", token); check(edited.body.content.includes("42"), "edited content is committed"); const afterTree = await call("/api/editor/tree", token); const paths = afterTree.body.entries.map((entry: { path: string }) => entry.path); check(paths.includes("src/new.ts") && !paths.includes("src/old.txt"), "add and delete are committed"); const pr = await call("/api/editor/commit", token, { method: "POST", body: { message: "Propose a change\n\nOpened from the editor", expectedHeadSha: commit.body.sha, newBranch: "editor-proposal", changes: [{ path: "README.md", content: "# hello, editor\n" }], }, }); check( pr.status === 201 && pr.body.branch === "editor-proposal" && pr.body.pullUrl, `opened ${JSON.stringify(pr.body)}`, ); const protection = await call(`/api/v1${repo}/protections`, PAT, { method: "POST", body: { pattern: "main", kind: "branch" }, }); check(protection.status < 300, "protected main"); const protectedSession = await call("/api/editor/session", token); console.log(` main protected for owner: ${protectedSession.body.protected}`); const guarded = await call("/api/editor/commit", token, { method: "POST", body: { message: "Change a protected branch", expectedHeadSha: commit.body.sha, changes: [{ path: "README.md", content: "# protected\n" }], }, }); if (protectedSession.body.protected) { check(guarded.status === 201 && guarded.body.branch !== "main" && guarded.body.pullUrl, "protected branch → PR"); } else { check(guarded.status === 201, "admin bypasses protection as configured"); } const WRITER = process.env.GITFOR_WRITER_TOKEN ?? ""; const LOCAL_DB = process.env.GITFOR_E2E_D1 ?? ""; if (WRITER && LOCAL_DB) { const { Database } = await import("bun:sqlite"); const db = new Database(LOCAL_DB); db.run("UPDATE branch_protections SET require_pull_request = 1 WHERE repo_id = ?", [created.body.id]); db.close(); const writer = await call("/api/v1/user", WRITER); const added = await call(`/api/v1${repo}/collaborators/${writer.body.login}`, PAT, { method: "PUT", body: { permission: "write" }, }); check(added.status < 300, `added ${writer.body.login} as a write collaborator`); const writerSession = await call(`/api/v1${repo}/editor-sessions`, WRITER, { method: "POST", body: { ref: "main" } }); const writerToken: string = writerSession.body.token; const info = await call("/api/editor/session", writerToken); check(info.body.writable && info.body.protected, "protected main is flagged for a write collaborator"); const proposed = await call("/api/editor/commit", writerToken, { method: "POST", body: { message: "Fix from a collaborator", expectedHeadSha: info.body.sha, changes: [{ path: "src/app.ts", content: "export const answer = 43;\n" }], }, }); check( proposed.status === 201 && proposed.body.branch.startsWith(`${writer.body.login}-patch-`) && proposed.body.pullUrl, `protected branch commit went to ${proposed.body.branch} with ${proposed.body.pullUrl}`, ); const mainAfter = await call("/api/editor/session", writerToken); check(mainAfter.body.sha === info.body.sha, "protected main itself is unchanged"); const demoted = await call(`/api/v1${repo}/collaborators/${writer.body.login}`, PAT, { method: "PUT", body: { permission: "read" }, }); check(demoted.status < 300, "demoted collaborator to read"); const revoked = await call("/api/editor/session", writerToken); check(revoked.body.writable === false, "existing editor token loses write access immediately"); } await call(`/api/v1${repo}`, PAT, { method: "DELETE" }); console.log("all editor e2e checks passed");