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.

606 lines · 35.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// (only from the emailed copy, which proves the address; the invite also applies
8// its org membership), adding and removing
9// passkeys, signing out and back in, admin-issued one-time sign-in links (which sign out the account's
10// other browsers but leave its tokens),
11// single-use invites and links, and refusing cross-site and token-driven calls.
12// Open sign-up (SIGNUP=open) runs on a second Worker: an emailed code, then a
13// passkey, and the sign-up pages answering 404 while it is off.
14import { spawn, execFileSync } from "node:child_process";
15import { createHash, createHmac, randomBytes } from "node:crypto";
16import { mkdtempSync, readFileSync, rmSync } from "node:fs";
17import { tmpdir } from "node:os";
18import { join, dirname, resolve } from "node:path";
19import { fileURLToPath } from "node:url";
20import { createServer } from "node:net";
21import { test, before, after } from "node:test";
22import assert from "node:assert/strict";
23import { chromium } from "playwright";
24import { migratedState } from "./d1-state.mjs";
25
26const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
27const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-"));
28let base;
29let dev;
30let devLog = "";
31let browser;
32
33const INVITE = "invite-token-for-pal-0123456789abcdef";
34const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef";
35/** boss is also signed in on another browser (say, a lost laptop), and has an access token. */
36const BOSS_OTHER = "boss-other-browser-0123456789abcdef";
37const BOSS_TOKEN = "dfy_boss_token_0123456789abcdef";
38/** clio is signed in on one browser, recently enough to approve CLI sign-ins. */
39const CLIO_SESSION = "clio-browser-session-0123456789abcdef";
40const hash = (s) => createHash("sha256").update(s).digest("hex");
41const MASTER_KEY = randomBytes(32).toString("base64");
42/** The proof only the invitation email carries (src/auth/invite-proof.ts). */
43const proofFor = (token) => createHmac("sha256", `diffinery-invite-proof-v1:${MASTER_KEY}`).update(hash(token)).digest("base64url");
44
45const freePort = () =>
46 new Promise((res) => {
47 const s = createServer();
48 s.listen(0, "127.0.0.1", () => {
49 const { port } = s.address();
50 s.close(() => res(port));
51 });
52 });
53
54const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
55async function until(what, fn, timeoutMs = 60_000) {
56 const end = Date.now() + timeoutMs;
57 while (Date.now() < end) {
58 const v = await fn();
59 if (v) return v;
60 await sleep(500);
61 }
62 assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
63}
64
65/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */
66async function person() {
67 const context = await browser.newContext();
68 const page = await context.newPage();
69 page.on("dialog", (d) => d.accept());
70 const cdp = await context.newCDPSession(page);
71 await cdp.send("WebAuthn.enable");
72 const addAuthenticator = async () =>
73 (
74 await cdp.send("WebAuthn.addVirtualAuthenticator", {
75 options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true },
76 })
77 ).authenticatorId;
78 let authenticatorId = await addAuthenticator();
79 return {
80 context,
81 page,
82 /** Swap in a new, empty authenticator (another device). */
83 async newDevice() {
84 await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId });
85 authenticatorId = await addAuthenticator();
86 },
87 async credentials() {
88 return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials;
89 },
90 };
91}
92
93const text = async (page) => (await page.textContent("main")) ?? "";
94
95before(async () => {
96 const port = await freePort();
97 // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not.
98 base = `http://localhost:${port}`;
99 const persist = join(work, "state");
100 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
101 migratedState(APP, persist);
102 const at = new Date().toISOString();
103 const later = new Date(Date.now() + 86400_000).toISOString();
104 wrangler(
105 "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command",
106 [
107 `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}')`,
108 `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`,
109 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`,
110 `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}')`,
111 `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`,
112 `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(BOSS_OTHER)}', 'boss1', 'passkey', '${at}', '${at}', '${later}')`,
113 `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('tboss', 'boss1', 'e2e', '${hash(BOSS_TOKEN)}', '${BOSS_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`,
114 `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('clio1', 'clio', 'user', 'Clio', 'clio@example.com', 'active', '${at}')`,
115 `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(CLIO_SESSION)}', 'clio1', 'passkey', '${at}', '${at}', '${later}')`,
116 ].join("; "),
117 );
118 dev = spawn(
119 "pnpm",
120 ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${MASTER_KEY}`, "--var", "BOOTSTRAP_ADMINS:pal", "--show-interactive-dev-session=false"],
121 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
122 );
123 dev.stdout.on("data", (d) => (devLog += d));
124 dev.stderr.on("data", (d) => (devLog += d));
125 await until(
126 "wrangler dev",
127 async () => {
128 try {
129 return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
130 } catch {
131 return false;
132 }
133 },
134 120_000,
135 );
136 browser = await chromium.launch();
137});
138
139after(async () => {
140 await browser?.close();
141 if (dev?.pid) {
142 try {
143 process.kill(-dev.pid, "SIGTERM");
144 } catch {}
145 }
146 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
147});
148
149test("passkey sign-in, invites and sign-in links", async () => {
150 // ------------------------------------------------------------ signed out
151 const pal = await person();
152 const { page } = pal;
153 await page.goto(`${base}/settings`);
154 assert.equal(new URL(page.url()).pathname, "/login");
155 assert.equal(new URL(page.url()).searchParams.get("next"), "/settings");
156 assert.match(await text(page), /Sign in with a passkey/);
157
158 // A passkey nobody registered is refused.
159 await page.click("text=Sign in with a passkey");
160 await page.waitForSelector("[data-passkey-msg]:not([hidden])");
161 assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i);
162
163 // ------------------------------------------------------------ join from an invite link
164 // The bare link (what the inviter sees) creates nothing: it offers to email the address.
165 await page.goto(`${base}/invite/${INVITE}`);
166 assert.match(await text(page), /Confirm your email/);
167 assert.equal(await page.locator("#login").count(), 0);
168 const options = (body) => fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
169 for (const proof of [undefined, "", "forged", proofFor("another-invite")]) {
170 const r = await options({ invite: INVITE, login: "pal", proof });
171 assert.equal(r.status, 403, `proof ${proof}`);
172 assert.match((await r.json()).message, /invitation email sent to pal@example\.com/);
173 }
174 await Promise.all([page.waitForLoadState("load"), page.click("text=Email me the link")]);
175 assert.match(await text(page), /We emailed the link to pal@example\.com/);
176 // The emailed link carries the proof in its fragment, which the page posts (so it never reaches a request log).
177 await Promise.all([page.waitForURL(`${base}/invite/${INVITE}/proof`), page.goto(`${base}/invite/${INVITE}#proof=${proofFor(INVITE)}`)]);
178 await page.waitForSelector("text=invited as pal@example.com");
179 assert.match(await text(page), /invited as pal@example\.com/);
180 await page.fill("#login", "pal");
181 await page.fill("#name", "Pal Friend");
182 // New people land in the organization they were invited to.
183 await Promise.all([page.waitForURL(`${base}/crew`), page.click("text=Create a passkey and join")]);
184 assert.match(await text(page), /Welcome to Diffinery, pal/);
185 assert.equal((await pal.credentials()).length, 1);
186 const cookies = await pal.context.cookies(base);
187 const session = cookies.find((c) => c.name === "__Host-dfy_session");
188 assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`);
189 assert.equal(session.httpOnly, true);
190 assert.equal(session.sameSite, "Lax");
191 // The invite's org membership was applied.
192 await page.goto(`${base}/settings/orgs`);
193 assert.match(await text(page), /crew/);
194 // Picking a login that is on BOOTSTRAP_ADMINS doesn't make anyone a site administrator: only a verified email does.
195 await page.goto(`${base}/api/v3/user`);
196 assert.equal(JSON.parse(await page.textContent("body")).site_admin, false);
197
198 // Invites work once.
199 const stranger = await person();
200 const reused = await stranger.page.goto(`${base}/invite/${INVITE}`);
201 assert.equal(reused.status(), 404);
202
203 // ------------------------------------------------------------ manage passkeys
204 await page.goto(`${base}/settings/security`);
205 let body = await text(page);
206 assert.match(body, /Passkeys and sessions/);
207 assert.match(body, /this browser/);
208 assert.match(body, /Only passkey/);
209 await pal.newDevice();
210 await page.fill("#pklabel", "Laptop");
211 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]);
212 await page.waitForSelector("text=Passkey \"Laptop\" added.");
213 body = await text(page);
214 assert.match(body, /Laptop/);
215 assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
216 // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in.
217 await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]);
218 await page.waitForSelector("text=Passkey removed.");
219 body = await text(page);
220 assert.match(body, /Only passkey/);
221 assert.match(body, /Laptop/);
222
223 // ------------------------------------------------------------ sign out, then in with the remaining passkey
224 await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]);
225 await page.goto(`${base}/settings`);
226 assert.equal(new URL(page.url()).pathname, "/login");
227 await page.goto(`${base}/login?next=/settings/security`);
228 await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]);
229 assert.match(await text(page), /Laptop/);
230
231 // ------------------------------------------------------------ admin sign-in links
232 const boss = await person();
233 const whoAmI = (headers) => fetch(`${base}/api/v3/user`, { headers });
234 assert.equal((await whoAmI({ cookie: `__Host-dfy_session=${BOSS_OTHER}` })).status, 200);
235 await boss.page.goto(`${base}/signin/${ADMIN_LINK}`);
236 assert.match(await text(boss.page), /add a passkey to boss/);
237 assert.match(await text(boss.page), /signs out every other browser signed in to this account/);
238 await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]);
239 assert.match(await text(boss.page), /Use it to sign in from now on/);
240 // A sign-in link is account recovery: boss's other browser is signed out, the access token keeps working.
241 assert.match(await text(boss.page), /Every other browser signed in to your account was signed out; your access tokens keep working/);
242 assert.equal((await whoAmI({ cookie: `__Host-dfy_session=${BOSS_OTHER}` })).status, 401);
243 assert.equal((await whoAmI({ authorization: `Bearer ${BOSS_TOKEN}` })).status, 200);
244 await boss.page.goto(`${base}/settings/security`);
245 assert.equal(await boss.page.locator("text=this browser").count(), 1);
246 assert.equal(await boss.page.locator("button:has-text('Sign out everywhere else')").count(), 0);
247 const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`);
248 assert.equal(usedLink.status(), 404);
249
250 // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device.
251 await boss.page.goto(`${base}/settings/admin`);
252 await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]);
253 await boss.page.waitForSelector("text=Sign-in link for pal");
254 const link = (await boss.page.textContent(".secret")).trim();
255 // The token is in the fragment: browsers never send it, so no request log holds it.
256 assert.match(link, new RegExp(`^${base}/signin#[A-Za-z0-9_-]{20,}$`));
257 await pal.newDevice();
258 await page.goto(link);
259 await page.waitForSelector("text=add a passkey to pal");
260 assert.equal(page.url(), `${base}/signin`, "the page takes the token out of the address bar");
261 await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]);
262 assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
263
264 // ------------------------------------------------------------ refused requests
265 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: "{}" });
266 assert.equal(cross.status, 403);
267 const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" });
268 assert.equal(form.status, 415);
269 const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) });
270 assert.equal(noChallenge.status, 400);
271 const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
272 assert.equal(anonymous.status, 401);
273
274 // ------------------------------------------------------------ Kumo form controls
275 // Every select, checkbox and radio is a Kumo component; the forms still post plain fields.
276 const nativeControls = (p) => p.locator("select:not([aria-hidden]), input[type=checkbox]:not([aria-hidden]), input[type=radio]:not([aria-hidden])").count();
277 await page.goto(`${base}/new`);
278 await page.waitForSelector(".dfy-k[data-mounted]");
279 assert.equal(await nativeControls(page), 0);
280 await page.fill("#name", "kumo");
281 await page.getByRole("radio", { name: /Public/ }).click();
282 await Promise.all([page.waitForURL(`${base}/pal/kumo`), page.click("button:has-text('Create repository')")]);
283 await page.goto(`${base}/pal/kumo/settings`);
284 await page.waitForSelector(".dfy-k[data-mounted]");
285 assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "public");
286 assert.equal(await page.getByRole("radio", { name: "Public" }).getAttribute("aria-checked"), "true");
287 await page.goto(`${base}/settings/tokens`);
288 await page.fill("#tname", "kumo");
289 await page.click("#repos");
290 await page.getByRole("option", { name: "pal/kumo" }).click();
291 await page.keyboard.press("Escape");
292 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Create token')")]);
293 await page.waitForSelector("text=pal/kumo");
294 assert.equal(await nativeControls(page), 0);
295 await boss.page.goto(`${base}/settings/admin`);
296 await boss.page.waitForSelector(".dfy-k[data-mounted]");
297 assert.equal(await nativeControls(boss.page), 0);
298 const kumo = await fetch(`${base}${await page.locator("script[src^='/-/kumo/']").getAttribute("src")}`);
299 assert.equal(kumo.status, 200);
300 assert.match(kumo.headers.get("cache-control") ?? "", /immutable/);
301
302 // ------------------------------------------------------------ Add people: one form for usernames and emails
303 await boss.page.goto(`${base}/crew?tab=people`);
304 await boss.page.fill("#who", "pal");
305 await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]);
306 assert.match(await text(boss.page), /pal is already a member of crew/);
307 await boss.page.fill("#who", "newbie@example.com");
308 await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]);
309 assert.match(await text(boss.page), /Invitation sent to newbie@example\.com/);
310 assert.match(await boss.page.textContent(".secret"), new RegExp(`^${base}/invite/`));
311 await boss.page.goto(`${base}/crew?tab=people`);
312 assert.match(await text(boss.page), /Pending invitations/);
313 await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("tr:has-text('newbie@example.com') button:has-text('Revoke')")]);
314 assert.match(await text(boss.page), /Invitation revoked/);
315 assert.doesNotMatch(await text(boss.page), /newbie@example\.com/);
316 // An existing account is invited by username, hears about it on Diffinery, and joins once they accept.
317 await page.goto(`${base}/settings/orgs`);
318 await Promise.all([page.waitForLoadState("load"), page.fill("#olog", "palorg")]);
319 await Promise.all([page.waitForURL(/\/palorg/), page.click("button:has-text('Create organization')")]);
320 await page.fill("#who", "boss");
321 await Promise.all([page.waitForLoadState("load"), page.click("form:has(#who) button:has-text('Add')")]);
322 assert.match(await text(page), /Invited boss to palorg as member\. They join once they accept/);
323 assert.match(await text(page), /@boss/);
324 await boss.page.goto(`${base}/notifications`);
325 assert.match(await text(boss.page), /pal invited you to palorg as member/);
326 await boss.page.goto(`${base}/settings/invitations`);
327 assert.match(await text(boss.page), /palorg/);
328 await Promise.all([boss.page.waitForURL(/\/palorg$/), boss.page.click("li:has-text('palorg') button:has-text('Accept')")]);
329 await page.goto(`${base}/palorg?tab=people`);
330 assert.match(await page.textContent("tr:has-text('boss')"), /Member/);
331 // The last owner can't step down: the organization would have nobody to manage it.
332 await page.goto(`${base}/palorg?tab=people`);
333 await Promise.all([
334 page.waitForLoadState("load"),
335 page.evaluate(() => {
336 const csrf = document.querySelector("form input[type=hidden]");
337 const f = document.createElement("form");
338 f.method = "post";
339 f.action = "/palorg/-/org/members";
340 for (const [k, v] of [[csrf.name, csrf.value], ["login", "pal"], ["role", "member"]]) f.append(Object.assign(document.createElement("input"), { type: "hidden", name: k, value: v }));
341 document.body.append(f);
342 f.submit();
343 }),
344 ]);
345 assert.match(await text(page), /needs at least one owner/);
346 await page.goto(`${base}/palorg?tab=people`);
347 assert.match(await page.textContent("tr:has-text('pal')"), /Owner/);
348
349 // ------------------------------------------------------------ Appearance
350 await page.goto(`${base}/settings/appearance`);
351 await page.waitForSelector(".dfy-k[data-mounted]");
352 await page.getByRole("radio", { name: /Dark/ }).click();
353 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]);
354 assert.equal(await page.getAttribute("html", "data-theme"), "dark");
355 assert.equal(await page.getAttribute("html", "data-mode"), "dark");
356 await page.goto(`${base}/`);
357 assert.equal(await page.getAttribute("html", "data-theme"), "dark");
358 await page.goto(`${base}/settings/appearance`);
359 await page.waitForSelector(".dfy-k[data-mounted]");
360 await page.getByRole("radio", { name: /Sync with system/ }).click();
361 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]);
362 assert.equal(await page.getAttribute("html", "data-theme"), null);
363
364 // Screenshots for review (kept with E2E_KEEP=1).
365 if (process.env.E2E_SCREENSHOTS) {
366 const out = process.env.E2E_SCREENSHOTS;
367 await stranger.page.goto(`${base}/login`);
368 await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true });
369 await page.goto(`${base}/settings/security`);
370 await page.screenshot({ path: join(out, "security.png"), fullPage: true });
371 await boss.page.goto(`${base}/settings/admin`);
372 await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true });
373 }
374});
375
376/** Runs the installed CLI; `lines` sees its stderr as it arrives (the sign-in link or code). */
377function cli(bin, args, env, lines = () => {}) {
378 const child = spawn(bin, args, { env: { PATH: process.env.PATH, ...env }, stdio: ["ignore", "pipe", "pipe"] });
379 let out = "";
380 let err = "";
381 child.stdout.on("data", (d) => (out += d));
382 child.stderr.on("data", (d) => {
383 err += d;
384 lines(err);
385 });
386 return new Promise((resolve) => child.on("close", (code) => resolve({ code, out, err })));
387}
388
389test("CLI: one-line install, then sign-in in the browser and with a device code", async () => {
390 // ------------------------------------------------------------ install from this server
391 const home = join(work, "cli-home");
392 execFileSync("mkdir", ["-p", home]);
393 const installed = execFileSync("bash", ["-c", `curl -fsSL ${base}/install | bash`], { env: { PATH: process.env.PATH, HOME: home, SHELL: "/bin/bash" }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
394 assert.equal(installed, "");
395 const bin = join(home, ".diffinery/bin/diffinery");
396 assert.match(execFileSync(bin, ["--version"], { encoding: "utf8" }), /^diffinery \d+\.\d+\.\d+/);
397 assert.match(readFileSync(join(home, ".bashrc"), "utf8"), /\.diffinery\/bin/);
398 const env = { HOME: home, XDG_CONFIG_HOME: join(home, ".config"), BROWSER: "none" };
399
400 const clio = await person();
401 // Signed in already (the seeded session): Chrome treats localhost as secure, so a __Host- cookie works over http here.
402 await clio.page.goto(`${base}/login`);
403 await clio.page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), CLIO_SESSION);
404
405 // ------------------------------------------------------------ browser sign-in: the person clicks Authorize
406 let link;
407 const login = cli(bin, ["login", "--url", base, "--web"], env, (err) => (link ??= /(http:\/\/localhost:\d+\/login\/cli\?\S+)/.exec(err)?.[1]));
408 await until("the sign-in link", async () => link);
409 await clio.page.goto(link);
410 assert.match(await text(clio.page), /Authorize the Diffinery CLI/);
411 assert.match(await text(clio.page), /client wants to sign in as clio/);
412 if (process.env.E2E_SCREENSHOTS) await clio.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "cli-authorize.png"), fullPage: true });
413 await Promise.all([clio.page.waitForURL(`${base}/login/cli/done?result=ok`), clio.page.click("button:has-text('Authorize')")]);
414 assert.match(await text(clio.page), /You're signed in/);
415 const done = await login;
416 assert.equal(done.code, 0, done.err);
417 assert.match(done.out, /Logged in to .* as clio with write access/);
418 assert.doesNotMatch(done.out + done.err, /dfy_/);
419 assert.match((await cli(bin, ["whoami"], env)).out, /^clio/);
420 await clio.page.goto(`${base}/settings/tokens`);
421 assert.match(await text(clio.page), /Diffinery CLI on .*CLI sign-in/);
422
423 // ------------------------------------------------------------ device sign-in: the person types the code
424 const env2 = { ...env, XDG_CONFIG_HOME: join(home, ".config2") };
425 let code;
426 const device = cli(bin, ["login", "--url", base, "--device", "--scope", "read"], env2, (err) => (code ??= /code: ([A-Z]{4}-[A-Z]{4})/.exec(err)?.[1]));
427 await until("the device code", async () => code);
428 await clio.page.goto(`${base}/login/device`);
429 await clio.page.fill("#code", code.toLowerCase());
430 await Promise.all([clio.page.waitForLoadState("load"), clio.page.click("button:has-text('Continue')")]);
431 assert.match(await text(clio.page), new RegExp(`your terminal shows this code: ${code}`));
432 if (process.env.E2E_SCREENSHOTS) await clio.page.screenshot({ path: join(process.env.E2E_SCREENSHOTS, "cli-device.png"), fullPage: true });
433 await Promise.all([clio.page.waitForLoadState("load"), clio.page.click("button:has-text('Authorize')")]);
434 const viaDevice = await device;
435 assert.equal(viaDevice.code, 0, viaDevice.err);
436 assert.match(viaDevice.out, /as clio with read access/);
437
438 // ------------------------------------------------------------ logout revokes the CLI's token
439 const token = JSON.parse(readFileSync(join(home, ".config2/diffinery/config.json"), "utf8")).token;
440 const out = await cli(bin, ["logout"], env2);
441 assert.match(out.out, /revoked the CLI's token/);
442 assert.equal((await fetch(`${base}/api/v3/user`, { headers: { authorization: `Bearer ${token}` } })).status, 401);
443});
444
445test("open sign-up: off by default; on, an emailed code, then a passkey", async () => {
446 // ------------------------------------------------------------ off (this file's Worker has no SIGNUP)
447 // The sign-up page leads to the early access request instead; everything else under /signup is 404.
448 const toEarly = await fetch(`${base}/signup`, { redirect: "manual" });
449 assert.equal(toEarly.status, 302);
450 assert.equal(toEarly.headers.get("location"), "/early-access");
451 assert.equal((await fetch(`${base}/signup/verify`)).status, 404);
452 for (const path of ["/signup", "/signup/verify"]) {
453 const r = await fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "email=a%40example.com&code=123456" });
454 assert.equal(r.status, 404, path);
455 }
456 const closed = await (await fetch(`${base}/login`)).text();
457 assert.match(closed, /Diffinery is invite-only/);
458 assert.doesNotMatch(closed, /href="\/signup"/);
459
460 // ------------------------------------------------------------ on: a second Worker with SIGNUP=open
461 // It keeps env.dev's DEV_AUTH, so emails are kept whole (for reading the code) and its dev account
462 // (dev@diffinery.local, a site admin) can list them. Without Turnstile keys, localhost skips the check.
463 const port = await freePort();
464 const site = `http://localhost:${port}`;
465 const persist = join(work, "signup-state");
466 migratedState(APP, persist);
467 const open = spawn(
468 "pnpm",
469 ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${site}`, "--var", `MASTER_KEY:${MASTER_KEY}`, "--var", "SIGNUP:open", "--show-interactive-dev-session=false"],
470 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
471 );
472 open.stdout.on("data", (d) => (devLog += d));
473 open.stderr.on("data", (d) => (devLog += d));
474 try {
475 await until("wrangler dev with SIGNUP=open", async () => {
476 try {
477 return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok;
478 } catch {
479 return false;
480 }
481 }, 120_000);
482 const minted = await fetch(`${site}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "signup-e2e", scopes: ["admin"] }) });
483 assert.equal(minted.status, 201, await minted.clone().text());
484 const adminToken = (await minted.json()).token;
485 /** Every email the Worker sent, newest first, with the whole message. */
486 const emails = async () => {
487 const r = await fetch(`${site}/api/v3/diffinery/emails?all=1&per_page=200`, { headers: { authorization: `Bearer ${adminToken}` } });
488 assert.equal(r.status, 200);
489 return r.json();
490 };
491 const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred));
492
493 const newbie = await person();
494 const { page } = newbie;
495 await page.goto(`${site}/login`);
496 assert.match(await text(page), /New here\? Create an account/);
497 await Promise.all([page.waitForURL(`${site}/signup`), page.click("text=Create an account")]);
498 assert.match(await text(page), /We'll send you a code/);
499 assert.equal(await page.locator("select, input[type=checkbox]").count(), 0);
500 await page.fill("#email", "Newbie@Example.com");
501 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Email me a code')")]);
502 assert.match(await text(page), /We sent a code to newbie@example\.com/);
503 const sent = await email("sign-up code", (e) => e.kind === "signup_code" && e.to_addr === "newbie@example.com");
504 assert.doesNotMatch(sent.subject, /\d{6}/);
505 const code = /^ {4}(\d{6})$/m.exec(sent.dev_copy.text)?.[1];
506 assert.ok(code, sent.dev_copy.text);
507
508 // A wrong code is refused and counted.
509 const other = String((Number(code) + 1) % 1_000_000).padStart(6, "0");
510 await page.fill("#code", other);
511 const [refused] = await Promise.all([page.waitForResponse((r) => r.url().endsWith("/signup/verify")), page.click("button:has-text('Continue')")]);
512 assert.equal(refused.status(), 400);
513 assert.match(await text(page), /That code is wrong\. You can try 4 more times\./);
514
515 // The right one leads to the passkey step, bound to a single-use ticket.
516 await page.fill("#code", code);
517 await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Continue')")]);
518 assert.match(await text(page), /newbie@example\.com is confirmed/);
519 const ticket = await page.inputValue("input[name=signup]");
520 assert.equal(await page.inputValue("#login"), "newbie");
521 await page.fill("#login", "newbie");
522 await page.fill("#name", "New Person");
523 await Promise.all([page.waitForURL(`${site}/`), page.click("text=Create a passkey and join")]);
524 assert.match(await text(page), /Welcome to Diffinery, newbie/);
525 assert.doesNotMatch(await text(page), /Waiting for an invite/);
526 assert.equal((await newbie.credentials()).length, 1);
527 await page.goto(`${site}/api/v3/user`);
528 const me = JSON.parse(await page.textContent("body"));
529 assert.equal(me.login, "newbie");
530 assert.equal(me.site_admin, false);
531 await page.goto(`${site}/new`);
532 assert.match(await text(page), /Create a new repository|Repository name/);
533 await email("welcome", (e) => e.kind === "welcome" && e.to_addr === "newbie@example.com");
534 // The ticket and the code worked once.
535 const again = await fetch(`${site}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signup: ticket, login: "newbie2" }) });
536 assert.equal(again.status, 404);
537
538 // The audit log records the sign-up (this browser is the dev site admin).
539 const stranger = await person();
540 await stranger.page.goto(`${site}/settings/audit`);
541 assert.ok(await stranger.page.locator("tr:has-text('newbie') .badge:text-is('signup')").count());
542
543 // ------------------------------------------------------------ an address with an account
544 // Same page as for a new address; its owner gets a note instead of a code.
545 await stranger.page.goto(`${site}/signup`);
546 await stranger.page.fill("#email", "dev@diffinery.local");
547 await Promise.all([stranger.page.waitForLoadState("load"), stranger.page.click("button:has-text('Email me a code')")]);
548 assert.match(await text(stranger.page), /We sent a code to dev@diffinery\.local/);
549 const note = await email("someone tried to sign up", (e) => e.kind === "signup_attempt");
550 assert.equal(note.to_addr, "dev@diffinery.local");
551 assert.match(note.dev_copy.text, /already belongs to your account/);
552 assert.doesNotMatch(note.dev_copy.text, /^ {4}\d{6}$/m);
553 assert.equal((await emails()).filter((e) => e.kind === "signup_code" && e.to_addr === "dev@diffinery.local").length, 0);
554 // A guess there answers like any wrong code.
555 await stranger.page.fill("#code", "000000");
556 const [guess] = await Promise.all([stranger.page.waitForResponse((r) => r.url().endsWith("/signup/verify")), stranger.page.click("button:has-text('Continue')")]);
557 assert.equal(guess.status(), 400);
558
559 // ------------------------------------------------------------ at most 5 codes a day for an address
560 await stranger.page.goto(`${site}/signup`);
561 const csrf = await stranger.page.inputValue("input[name=_csrf]");
562 const ask = () => stranger.page.request.post(`${site}/signup`, { form: { _csrf: csrf, email: "limit@example.com" }, maxRedirects: 0 });
563 for (let i = 0; i < 5; i++) {
564 const r = await ask();
565 assert.equal(r.status(), 200, `request ${i + 1}`);
566 assert.match(await r.text(), /We sent a code to <b>limit@example\.com<\/b>/);
567 }
568 const limited = await ask();
569 assert.equal(limited.status(), 429);
570 assert.match(await limited.text(), /We have sent several codes to limit@example\.com today/);
571 } finally {
572 try {
573 process.kill(-open.pid, "SIGTERM");
574 } catch {}
575 }
576});
577
578test("favicons, robots.txt, sitemap and search metadata", async () => {
579 const get = (p) => fetch(`${base}${p}`, { redirect: "manual" });
580 const ico = await get("/favicon.ico");
581 assert.equal(ico.status, 200);
582 assert.equal(ico.headers.get("content-type"), "image/x-icon");
583 assert.deepEqual([...new Uint8Array(await ico.arrayBuffer()).slice(0, 4)], [0, 0, 1, 0]);
584 for (const p of ["/favicon-48.png", "/favicon-96.png", "/apple-touch-icon.png", "/icon-192.png", "/icon-512.png", "/og-image.png"]) {
585 const r = await get(p);
586 assert.equal(r.status, 200, p);
587 assert.equal(r.headers.get("content-type"), "image/png", p);
588 }
589 assert.match(await (await get("/favicon.svg")).text(), /^<svg/);
590 assert.equal((await (await get("/site.webmanifest")).json()).icons.length, 3);
591 const robots = await (await get("/robots.txt")).text();
592 assert.match(robots, /^User-agent: \*\nAllow: \//);
593 assert.match(robots, new RegExp(`Sitemap: ${base}/sitemap.xml`));
594 assert.match(await (await get("/sitemap.xml")).text(), new RegExp(`<loc>${base}/</loc>`));
595
596 const home = await (await get("/")).text();
597 assert.match(home, /<title>Diffinery: Code hosting and CI for the agentic era<\/title>/);
598 assert.match(home, /<link rel="icon" href="\/favicon.ico"/);
599 assert.match(home, /<meta property="og:image" content="[^"]+\/og-image.png"/);
600 assert.match(home, /<link rel="canonical" href="[^"]+\/"/);
601 assert.match(home, /application\/ld\+json/);
602 assert.doesNotMatch(home, /noindex/);
603 const login = await (await get("/login")).text();
604 assert.match(login, /<meta name="robots" content="noindex, nofollow"/);
605 assert.match(login, /<link rel="icon" href="\/favicon.ico"/);
606});