#!/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. */ import { execFileSync } from "node:child_process"; 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 HTTP = { ok: 200, created: 201, noContent: 204, redirect: 300, badRequest: 400, unauthorized: 401, forbidden: 403, tooLarge: 413, conflict: 409, serverError: 500, } as const; 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 === HTTP.created, `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], [".gitattributes", "*.psd filter=lfs diff=lfs merge=lfs -text\n"], ]) { 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 < HTTP.redirect, `seeded ${path} (${put.status})`); } const minted = await call(`/api/v1${repo}/editor-sessions`, PAT, { method: "POST", body: { ref: "main" } }); check( minted.status === HTTP.created && 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 === HTTP.noContent && preflight.headers.get("access-control-allow-origin") === "*", "CORS preflight", ); const anonymous = await fetch(`${BASE}/api/editor/session`); check(anonymous.status === HTTP.unauthorized, "API rejects requests without an editor token"); const withPat = await call("/api/editor/session", PAT); check(withPat.status === HTTP.unauthorized, "API rejects personal access tokens (editor tokens only)"); const session = await call("/api/editor/session", token); check( session.status === HTTP.ok && 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 === HTTP.forbidden, "commit refuses to overwrite an LFS pointer"); const lfsTracked = await call("/api/editor/commit", token, { method: "POST", body: { message: "psd", expectedHeadSha: session.body.sha, changes: [{ path: "art/new.psd", content: "x" }] }, }); check(lfsTracked.status === HTTP.forbidden, "commit refuses new files that .gitattributes routes through LFS"); const duplicate = await call("/api/editor/commit", token, { method: "POST", body: { message: "dupe", expectedHeadSha: session.body.sha, changes: [ { path: "README.md", content: "a" }, { path: "README.md", content: "b" }, ], }, }); check(duplicate.status === HTTP.badRequest, "duplicate paths are rejected"); const huge = await call("/api/editor/commit", token, { method: "POST", body: { message: "huge", expectedHeadSha: session.body.sha, changes: [{ path: "big.txt", content: "x".repeat(11 * 1024 * 1024) }], }, }); check(huge.status === HTTP.tooLarge, "oversized commit bodies are rejected"); 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 >= HTTP.badRequest && badPath.status < HTTP.serverError, `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 === HTTP.created && 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 === HTTP.conflict, "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 === HTTP.created && 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 < HTTP.redirect, "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 === HTTP.created && guarded.body.branch !== "main" && guarded.body.pullUrl, "protected branch → PR", ); } else { check(guarded.status === HTTP.created, "admin bypasses protection as configured"); } const WRITER = process.env.GITFOR_WRITER_TOKEN ?? ""; const LOCAL_DB = process.env.GITFOR_E2E_D1 ?? ""; const SQLITE = process.env.SQLITE ?? "sqlite3"; if (WRITER && LOCAL_DB) { const repoId = String(created.body.id).replace(/[^a-z0-9]/gi, ""); execFileSync(SQLITE, [ LOCAL_DB, `UPDATE branch_protections SET require_pull_request = 1 WHERE repo_id = '${repoId}'`, ]); 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 < HTTP.redirect, `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 === HTTP.created && 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 < HTTP.redirect, "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");