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