Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

264 lines · 13.1 KBRaw History
1// End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium
2// with a virtual WebAuthn authenticator (Chrome DevTools Protocol).
3//
4// pnpm --filter @diffinery/app e2e
5//
6// Covers: signed-out redirects, joining from an invite link with a new passkey
7// (which also applies the invite's org membership), adding and removing
8// passkeys, signing out and back in, admin-issued one-time sign-in links,
9// single-use invites and links, and refusing cross-site and token-driven calls.
10import { spawn, execFileSync } from "node:child_process";
11import { createHash } from "node:crypto";
12import { mkdtempSync, rmSync } from "node:fs";
13import { tmpdir } from "node:os";
14import { join, dirname, resolve } from "node:path";
15import { fileURLToPath } from "node:url";
16import { createServer } from "node:net";
17import { test, before, after } from "node:test";
18import assert from "node:assert/strict";
19import { chromium } from "playwright";
20
21const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-"));
23let base;
24let dev;
25let devLog = "";
26let browser;
27
28const INVITE = "invite-token-for-pal-0123456789abcdef";
29const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
30const hash = (s) => createHash("sha256").update(s).digest("hex");
31
32const freePort = () =>
33 new Promise((res) => {
34 const s = createServer();
35 s.listen(0, "127.0.0.1", () => {
36 const { port } = s.address();
37 s.close(() => res(port));
38 });
39 });
40
41const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42async function until(what, fn, timeoutMs = 60_000) {
43 const end = Date.now() + timeoutMs;
44 while (Date.now() < end) {
45 const v = await fn();
46 if (v) return v;
47 await sleep(500);
48 }
49 assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
50}
51
52/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */
53async function person() {
54 const context = await browser.newContext();
55 const page = await context.newPage();
56 page.on("dialog", (d) => d.accept());
57 const cdp = await context.newCDPSession(page);
58 await cdp.send("WebAuthn.enable");
59 const addAuthenticator = async () =>
60 (
61 await cdp.send("WebAuthn.addVirtualAuthenticator", {
62 options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
63 })
64 ).authenticatorId;
65 let authenticatorId = await addAuthenticator();
66 return {
67 context,
68 page,
69 /** Swap in a new, empty authenticator (another device). */
70 async newDevice() {
71 await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
72 authenticatorId = await addAuthenticator();
73 },
74 async credentials() {
75 return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials;
76 },
77 };
78}
79
80const text = async (page) => (await page.textContent("main")) ?? "";
81
82before(async () => {
83 const port = await freePort();
84 // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not.
85 base = `http://localhost:${port}`;
86 const persist = join(work, "state");
87 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
88 wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
89 const at = new Date().toISOString();
90 const later = new Date(Date.now() + 86400_000).toISOString();
91 wrangler(
92 "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
93 [
94 `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`,
95 `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`,
96 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`,
97 `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`,
98 `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
99 ].join("; "),
100 );
101 dev = spawn(
102 "pnpm",
103 ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"],
104 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
105 );
106 dev.stdout.on("data", (d) => (devLog += d));
107 dev.stderr.on("data", (d) => (devLog += d));
108 await until(
109 "wrangler dev",
110 async () => {
111 try {
112 return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
113 } catch {
114 return false;
115 }
116 },
117 120_000,
118 );
119 browser = await chromium.launch();
120});
121
122after(async () => {
123 await browser?.close();
124 if (dev?.pid) {
125 try {
126 process.kill(-dev.pid, "SIGTERM");
127 } catch {}
128 }
129 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
130});
131
132test("passkey sign-in, invites and sign-in links", async () => {
133 // ------------------------------------------------------------ signed out
134 const pal = await person();
135 const { page } = pal;
136 await page.goto(`${base}/settings`);
137 assert.equal(new URL(page.url()).pathname, "/login");
138 assert.equal(new URL(page.url()).searchParams.get("next"), "/settings");
139 assert.match(await text(page), /Sign in with a passkey/);
140
141 // A passkey nobody registered is refused.
142 await page.click("text=Sign in with a passkey");
143 await page.waitForSelector("[data-passkey-msg]:not([hidden])");
144 assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i);
145
146 // ------------------------------------------------------------ join from an invite link
147 await page.goto(`${base}/invite/${INVITE}`);
148 assert.match(await text(page), /invited as pal@example\.com/);
149 await page.fill("#login", "pal");
150 await page.fill("#name", "Pal Friend");
151 await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]);
152 assert.match(await text(page), /Welcome to Diffinery, pal/);
153 assert.equal((await pal.credentials()).length, 1);
154 const cookies = await pal.context.cookies(base);
155 const session = cookies.find((c) => c.name === "__Host-dfy_session");
156 assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`);
157 assert.equal(session.httpOnly, true);
158 assert.equal(session.sameSite, "Lax");
159 // The invite's org membership was applied.
160 await page.goto(`${base}/settings/orgs`);
161 assert.match(await text(page), /crew/);
162
163 // Invites work once.
164 const stranger = await person();
165 const reused = await stranger.page.goto(`${base}/invite/${INVITE}`);
166 assert.equal(reused.status(), 404);
167
168 // ------------------------------------------------------------ manage passkeys
169 await page.goto(`${base}/settings/security`);
170 let body = await text(page);
171 assert.match(body, /Passkeys and sessions/);
172 assert.match(body, /this browser/);
173 assert.match(body, /Only passkey/);
174 await pal.newDevice();
175 await page.fill("#pklabel", "Laptop");
176 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]);
177 await page.waitForSelector("text=Passkey \"Laptop\" added.");
178 body = await text(page);
179 assert.match(body, /Laptop/);
180 assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
181 // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in.
182 await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]);
183 await page.waitForSelector("text=Passkey removed.");
184 body = await text(page);
185 assert.match(body, /Only passkey/);
186 assert.match(body, /Laptop/);
187
188 // ------------------------------------------------------------ sign out, then in with the remaining passkey
189 await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]);
190 await page.goto(`${base}/settings`);
191 assert.equal(new URL(page.url()).pathname, "/login");
192 await page.goto(`${base}/login?next=/settings/security`);
193 await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]);
194 assert.match(await text(page), /Laptop/);
195
196 // ------------------------------------------------------------ admin sign-in links
197 const boss = await person();
198 await boss.page.goto(`${base}/signin/${ADMIN_LINK}`);
199 assert.match(await text(boss.page), /add a passkey to boss/);
200 await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]);
201 assert.match(await text(boss.page), /Use it to sign in from now on/);
202 const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`);
203 assert.equal(usedLink.status(), 404);
204
205 // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device.
206 await boss.page.goto(`${base}/settings/admin`);
207 await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]);
208 await boss.page.waitForSelector("text=Sign-in link for pal");
209 const link = (await boss.page.textContent(".secret")).trim();
210 assert.match(link, new RegExp(`^${base}/signin/`));
211 await pal.newDevice();
212 await page.goto(link);
213 await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]);
214 assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
215
216 // ------------------------------------------------------------ refused requests
217 const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" });
218 assert.equal(cross.status, 403);
219 const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" });
220 assert.equal(form.status, 415);
221 const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) });
222 assert.equal(noChallenge.status, 400);
223 const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
224 assert.equal(anonymous.status, 401);
225
226 // ------------------------------------------------------------ Kumo form controls
227 // Every select, checkbox and radio is a Kumo component; the forms still post plain fields.
228 const nativeControls = (p) => p.locator("select:not([aria-hidden]), input[type=checkbox]:not([aria-hidden]), input[type=radio]:not([aria-hidden])").count();
229 await page.goto(`${base}/new`);
230 await page.waitForSelector(".dfy-k[data-mounted]");
231 assert.equal(await nativeControls(page), 0);
232 await page.fill("#name", "kumo");
233 await page.getByRole("radio", { name: /Internal/ }).click();
234 await Promise.all([page.waitForURL(`${base}/pal/kumo`), page.click("button:has-text('Create repository')")]);
235 await page.goto(`${base}/pal/kumo/settings`);
236 await page.waitForSelector(".dfy-k[data-mounted]");
237 assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "internal");
238 assert.equal(await page.getByRole("radio", { name: "internal" }).getAttribute("aria-checked"), "true");
239 await page.goto(`${base}/settings/tokens`);
240 await page.fill("#tname", "kumo");
241 await page.click("#repos");
242 await page.getByRole("option", { name: "pal/kumo" }).click();
243 await page.keyboard.press("Escape");
244 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Create token')")]);
245 await page.waitForSelector("text=pal/kumo");
246 assert.equal(await nativeControls(page), 0);
247 await boss.page.goto(`${base}/settings/admin`);
248 await boss.page.waitForSelector(".dfy-k[data-mounted]");
249 assert.equal(await nativeControls(boss.page), 0);
250 const kumo = await fetch(`${base}${await page.locator("script[src^='/-/kumo/']").getAttribute("src")}`);
251 assert.equal(kumo.status, 200);
252 assert.match(kumo.headers.get("cache-control") ?? "", /immutable/);
253
254 // Screenshots for review (kept with E2E_KEEP=1).
255 if (process.env.E2E_SCREENSHOTS) {
256 const out = process.env.E2E_SCREENSHOTS;
257 await stranger.page.goto(`${base}/login`);
258 await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true });
259 await page.goto(`${base}/settings/security`);
260 await page.screenshot({ path: join(out, "security.png"), fullPage: true });
261 await boss.page.goto(`${base}/settings/admin`);
262 await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true });
263 }
264});