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.

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