| | @@ -1,0 +1,414 @@ |
| 1 | +// End-to-end early access (lib/early-access.ts, web/early-access.tsx) on a real `wrangler dev` Worker: |
| 2 | +// the home page, sign-in page and closed sign-up lead to /early-access; a request is confirmed with |
| 3 | +// the link from its email (double opt-in); the form answers the same for known addresses and accounts; |
| 4 | +// bots, bursts, oversized and hostile input get nowhere; only site administrators see requests, on the |
| 5 | +// web and through the API; inviting sends the instance invitation; declining, putting back, deleting and |
| 6 | +// removing your own request work and are audited without the request's content. |
| 7 | +// |
| 8 | +// The Worker keeps env.dev's DEV_AUTH so it keeps whole copies of the emails it sends (for reading the |
| 9 | +// links). Visitors who aren't signed in are requests with another Host header: local development's fixed |
| 10 | +// identity only applies to requests addressed to this machine. |
| 11 | +// |
| 12 | +// pnpm --filter @diffinery/app exec node --test test/e2e/early-access.test.mjs |
| 13 | +import { spawn, execFileSync } from "node:child_process"; |
| 14 | +import { createHash, randomBytes } from "node:crypto"; |
| 15 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 16 | +import { tmpdir } from "node:os"; |
| 17 | +import { join, dirname, resolve } from "node:path"; |
| 18 | +import { fileURLToPath } from "node:url"; |
| 19 | +import { createServer } from "node:net"; |
| 20 | +import { request as httpRequest } from "node:http"; |
| 21 | +import { test, after } from "node:test"; |
| 22 | +import assert from "node:assert/strict"; |
| 23 | + |
| 24 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 25 | +const work = mkdtempSync(join(tmpdir(), "diffinery-early-access-e2e-")); |
| 26 | +const running = []; |
| 27 | + |
| 28 | +const freePort = () => |
| 29 | + new Promise((res) => { |
| 30 | + const s = createServer(); |
| 31 | + s.listen(0, "127.0.0.1", () => { |
| 32 | + const { port } = s.address(); |
| 33 | + s.close(() => res(port)); |
| 34 | + }); |
| 35 | + }); |
| 36 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 37 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 38 | + |
| 39 | +/** |
| 40 | + * A fetch-like request over node:http, which (unlike fetch) sends the Host header it is given: the |
| 41 | + * Worker sees a visitor's requests addressed to VISITOR. Redirects are never followed. |
| 42 | + */ |
| 43 | +function send(url, { method = "GET", headers = {}, body } = {}) { |
| 44 | + const data = body === undefined ? undefined : Buffer.from(typeof body === "string" ? body : body.toString()); |
| 45 | + return new Promise((res, rej) => { |
| 46 | + const r = httpRequest(url, { method, headers: { ...headers, ...(data ? { "content-length": String(data.length) } : {}) } }, (resp) => { |
| 47 | + const chunks = []; |
| 48 | + resp.on("data", (c) => chunks.push(c)); |
| 49 | + resp.on("end", () => { |
| 50 | + const text = Buffer.concat(chunks).toString("utf8"); |
| 51 | + const h = resp.headers; |
| 52 | + res({ |
| 53 | + status: resp.statusCode, |
| 54 | + headers: { get: (k) => (Array.isArray(h[k.toLowerCase()]) ? h[k.toLowerCase()].join(", ") : (h[k.toLowerCase()] ?? null)) }, |
| 55 | + text: async () => text, |
| 56 | + json: async () => JSON.parse(text), |
| 57 | + }); |
| 58 | + }); |
| 59 | + }); |
| 60 | + r.on("error", rej); |
| 61 | + if (data) r.write(data); |
| 62 | + r.end(); |
| 63 | + }); |
| 64 | +} |
| 65 | + |
| 66 | +function stopWorker(dev) { |
| 67 | + try { |
| 68 | + process.kill(-dev.pid, "SIGTERM"); |
| 69 | + } catch {} |
| 70 | +} |
| 71 | + |
| 72 | +after(() => { |
| 73 | + for (const dev of running) stopWorker(dev); |
| 74 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 75 | +}); |
| 76 | + |
| 77 | +const T = { root: `dfy_root_${randomBytes(12).toString("hex")}`, ann: `dfy_ann_${randomBytes(12).toString("hex")}` }; |
| 78 | +const S = { root: `root-session-${randomBytes(12).toString("hex")}`, ann: `ann-session-${randomBytes(12).toString("hex")}` }; |
| 79 | +/** The Host a visitor who isn't signed in uses (see the header comment). */ |
| 80 | +const VISITOR = "visitor.example"; |
| 81 | + |
| 82 | +let w; |
| 83 | +async function worker() { |
| 84 | + if (w) return w; |
| 85 | + const port = await freePort(); |
| 86 | + const base = `http://127.0.0.1:${port}`; |
| 87 | + const persist = join(work, "state"); |
| 88 | + const at = new Date().toISOString(); |
| 89 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 90 | + const person = (id, login, email, admin) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${email}', 'active', ${admin}, '${at}')`; |
| 91 | + const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; |
| 92 | + const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; |
| 93 | + const seed = [person("u-root", "root", "root@example.com", 1), person("u-ann", "ann", "ann@example.com", 0), token("t-root", "u-root", T.root), token("t-ann", "u-ann", T.ann), session("u-root", S.root), session("u-ann", S.ann)]; |
| 94 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 95 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); |
| 96 | + wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; ")); |
| 97 | + const args = ["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}`, "--show-interactive-dev-session=false"]; |
| 98 | + const dev = spawn("pnpm", args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }); |
| 99 | + let log = ""; |
| 100 | + dev.stdout.on("data", (d) => (log += d)); |
| 101 | + dev.stderr.on("data", (d) => (log += d)); |
| 102 | + running.push(dev); |
| 103 | + const end = Date.now() + 120_000; |
| 104 | + while (Date.now() < end) { |
| 105 | + try { |
| 106 | + if ((await fetch(`${base}/healthz`)).ok) break; |
| 107 | + } catch {} |
| 108 | + await sleep(500); |
| 109 | + } |
| 110 | + assert.ok((await fetch(`${base}/healthz`).catch(() => ({ ok: false }))).ok, `wrangler dev did not start\n${log.slice(-4000)}`); |
| 111 | + |
| 112 | + /** Headers for a visitor (no `who`), or a signed-in person's browser. */ |
| 113 | + const headers = (who, extra = {}) => ({ ...(who ? { cookie: `__Host-dfy_session=${S[who]}` } : { host: VISITOR }), ...extra }); |
| 114 | + const page = (path, who) => send(`${base}${path}`, { headers: headers(who) }); |
| 115 | + const api = async (method, path, body, tok) => { |
| 116 | + const r = await send(`${base}/api/v3${path}`, { method, headers: { "content-type": "application/json", ...(tok ? { authorization: `Bearer ${tok}` } : { host: VISITOR }) }, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 117 | + const text = await r.text(); |
| 118 | + let json = null; |
| 119 | + try { |
| 120 | + json = text ? JSON.parse(text) : null; |
| 121 | + } catch { |
| 122 | + json = text; |
| 123 | + } |
| 124 | + return { status: r.status, json }; |
| 125 | + }; |
| 126 | + /** A form's CSRF pair and timing stamp, read from `path` the way a browser does. */ |
| 127 | + const formOf = async (path, who) => { |
| 128 | + const res = await page(path, who); |
| 129 | + assert.equal(res.status, 200, `GET ${path} -> ${res.status}`); |
| 130 | + const html = await res.text(); |
| 131 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1]; |
| 132 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; |
| 133 | + assert.ok(csrf && pair, `no CSRF pair on ${path}`); |
| 134 | + return { csrf, pair, stamp: /name="stamp" value="([^"]*)"/.exec(html)?.[1] ?? "", html }; |
| 135 | + }; |
| 136 | + /** POST a form; `form` is from formOf. `ip` sets the address the Worker sees. */ |
| 137 | + const post = async (action, form, fields, who, ip) => { |
| 138 | + const body = new URLSearchParams({ _csrf: form.csrf, ...(form.stamp ? { stamp: form.stamp } : {}), ...fields }); |
| 139 | + const cookie = [who ? `__Host-dfy_session=${S[who]}` : "", `dfy_csrf=${form.pair}`].filter(Boolean).join("; "); |
| 140 | + const r = await send(`${base}${action}`, { method: "POST", body, headers: { ...headers(who), cookie, "content-type": "application/x-www-form-urlencoded", "sec-fetch-site": "same-origin", ...(ip ? { "cf-connecting-ip": ip } : {}) } }); |
| 141 | + // Apostrophes come back as '; nothing else is unescaped, so the escaping checks still hold. |
| 142 | + return { status: r.status, location: r.headers.get("location"), html: (await r.text()).replace(/'/g, "'") }; |
| 143 | + }; |
| 144 | + /** Every email the Worker sent, newest first, with the whole message (DEV_AUTH keeps it). */ |
| 145 | + const emails = async () => { |
| 146 | + const r = await api("GET", "/diffinery/emails?all=1&per_page=200", undefined, T.root); |
| 147 | + assert.equal(r.status, 200, JSON.stringify(r.json)); |
| 148 | + return r.json; |
| 149 | + }; |
| 150 | + const email = async (what, pred) => { |
| 151 | + const end = Date.now() + 60_000; |
| 152 | + while (Date.now() < end) { |
| 153 | + const m = (await emails()).find(pred); |
| 154 | + if (m) return m; |
| 155 | + await sleep(500); |
| 156 | + } |
| 157 | + assert.fail(`timed out waiting for the email: ${what}\n${log.slice(-3000)}`); |
| 158 | + }; |
| 159 | + const linkIn = (m, path) => { |
| 160 | + const text = m.dev_copy?.text ?? ""; |
| 161 | + const found = new RegExp(`${base.replace(/[.]/g, "\\.")}${path}#([A-Za-z0-9_-]{20,100})`).exec(text); |
| 162 | + assert.ok(found, `no ${path} link in:\n${text}`); |
| 163 | + return found[1]; |
| 164 | + }; |
| 165 | + /** A form whose stamp is old enough to count as a person's (3 seconds). */ |
| 166 | + const ready = async (path = "/early-access", who) => { |
| 167 | + const f = await formOf(path, who); |
| 168 | + await sleep(3200); |
| 169 | + return f; |
| 170 | + }; |
| 171 | + w = { base, page, api, formOf, post, emails, email, linkIn, ready, log: () => log }; |
| 172 | + return w; |
| 173 | +} |
| 174 | + |
| 175 | +/** A page with the address typed and the per-page nonce taken out, to compare two answers. */ |
| 176 | +const same = (html, address) => html.replace(address, "ADDRESS").replace(/nonce="[^"]*"/g, ""); |
| 177 | +const request = (email, over = {}) => ({ email, name: "", company: "", use_case: "", hosting: "", website: "", ...over }); |
| 178 | +const byEmail = async (api, address) => (await api("GET", `/diffinery/admin/early-access?q=${encodeURIComponent(address)}`, undefined, T.root)).json.find((r) => r.email === address); |
| 179 | +let nextIp = 1; |
| 180 | +/** A fresh address for the Worker to see each request from, so the daily limit per network doesn't mix tests. */ |
| 181 | +const ip = () => `198.51.100.${nextIp++}`; |
| 182 | + |
| 183 | +test("early access: the home page, the sign-in page and closed sign-up lead to the form", async () => { |
| 184 | + const { page } = await worker(); |
| 185 | + const home = await (await page("/")).text(); |
| 186 | + assert.match(home, /href="\/early-access"[^>]*>\s*Request early access/); |
| 187 | + assert.match(await (await page("/login")).text(), /href="\/early-access">request early access</); |
| 188 | + const signup = await page("/signup"); |
| 189 | + assert.equal(signup.status, 302); |
| 190 | + assert.equal(signup.headers.get("location"), "/early-access"); |
| 191 | + const form = await page("/early-access"); |
| 192 | + assert.equal(form.status, 200); |
| 193 | + const html = await form.text(); |
| 194 | + assert.match(html, /Request early access to Diffinery/); |
| 195 | + for (const name of ["email", "name", "company", "use_case", "hosting", "website", "stamp"]) assert.match(html, new RegExp(`name="${name}"`), name); |
| 196 | + assert.doesNotMatch(html, /<select|type="checkbox"/, "Kumo controls only"); |
| 197 | + assert.match(html, /href="\/early-access\/remove"/); |
| 198 | + // Signed in, the form isn't needed. |
| 199 | + const annForm = await page("/early-access", "ann"); |
| 200 | + assert.equal(annForm.status, 302); |
| 201 | + assert.equal(annForm.headers.get("location"), "/"); |
| 202 | + // Search engines find the form, not the confirmation pages. |
| 203 | + const robots = await (await page("/robots.txt")).text(); |
| 204 | + assert.match(robots, /Disallow: \/early-access\/confirm/); |
| 205 | + assert.match(robots, /Disallow: \/early-access\/remove/); |
| 206 | + assert.match(await (await page("/sitemap.xml")).text(), /\/early-access<\/loc>/); |
| 207 | +}); |
| 208 | + |
| 209 | +test("early access: a request is confirmed with the link from its email, then waits for site administrators", async () => { |
| 210 | + const { page, api, post, email, linkIn, ready } = await worker(); |
| 211 | + const f = await ready(); |
| 212 | + const hostile = { name: `Ada <script>alert(1)</script>`, company: "Acme & Co", use_case: "Move 40 repos.\n<img src=x onerror=alert(1)>", hosting: "github" }; |
| 213 | + const sent = await post("/early-access", f, request("Ada@Example.net", hostile), undefined, ip()); |
| 214 | + assert.equal(sent.status, 200, sent.html.slice(0, 600)); |
| 215 | + assert.match(sent.html, /Check your inbox/); |
| 216 | + assert.match(sent.html, /ada@example\.net/); |
| 217 | + const m = await email("the confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "ada@example.net"); |
| 218 | + assert.equal(m.subject, "[Diffinery] Confirm your early access request"); |
| 219 | + assert.doesNotMatch(JSON.stringify(m.dev_copy), /Ada|Acme|onerror|Move 40/, "nothing typed in the form goes into the email"); |
| 220 | + const token = linkIn(m, "/early-access/confirm"); |
| 221 | + |
| 222 | + // Unconfirmed: not waiting yet. |
| 223 | + const before = await byEmail(api, "ada@example.net"); |
| 224 | + assert.equal(before.state, "unconfirmed"); |
| 225 | + assert.doesNotMatch(await (await page("/settings/admin/early-access", "root")).text(), /ada@example\.net/); |
| 226 | + |
| 227 | + const confirmPage = await page("/early-access/confirm"); |
| 228 | + assert.equal(confirmPage.status, 200); |
| 229 | + const cf = { ...(await w.formOf("/early-access/confirm")), stamp: "" }; |
| 230 | + const wrong = await post("/early-access/confirm", cf, { token: "x".repeat(40) }); |
| 231 | + assert.equal(wrong.status, 410); |
| 232 | + assert.match(wrong.html, /This link doesn't work any more/); |
| 233 | + const ok = await post("/early-access/confirm", cf, { token }); |
| 234 | + assert.equal(ok.status, 200, ok.html.slice(0, 600)); |
| 235 | + assert.match(ok.html, /You're on the list/); |
| 236 | + assert.ok(ok.html.includes("Ada <script>alert(1)</script>"), "the name is escaped"); |
| 237 | + assert.equal((await post("/early-access/confirm", cf, { token })).status, 410, "a link works once"); |
| 238 | + |
| 239 | + const after = await byEmail(api, "ada@example.net"); |
| 240 | + assert.equal(after.state, "confirmed"); |
| 241 | + assert.deepEqual({ name: after.name, company: after.company, use_case: after.use_case, hosting: after.hosting }, { name: hostile.name, company: hostile.company, use_case: hostile.use_case, hosting: "github" }); |
| 242 | + assert.ok(!("confirm_hash" in after)); |
| 243 | + const admin = await (await page("/settings/admin/early-access", "root")).text(); |
| 244 | + assert.match(admin, /ada@example\.net/); |
| 245 | + assert.ok(!admin.includes("<script>alert(1)") && !admin.includes("<img src=x"), "everything is escaped"); |
| 246 | + assert.match(admin, /Early access\s*<span class="badge blue"[^>]*>\s*1\s*</, "the sidebar counts what waits"); |
| 247 | + const detail = await (await page(`/settings/admin/early-access/${after.id}`, "root")).text(); |
| 248 | + assert.ok(detail.includes("Move 40 repos.\n<img src=x onerror=alert(1)>"), "the use is plain text"); |
| 249 | + assert.match(await (await page("/notifications", "root")).text(), /New early access requests are waiting for you/); |
| 250 | + assert.match(await (await page("/settings/admin", "root")).text(), /1 confirmed early access request is waiting/); |
| 251 | +}); |
| 252 | + |
| 253 | +test("early access: the same answer for an address that already asked or has an account; only its inbox learns which", async () => { |
| 254 | + const { api, post, email, emails, ready } = await worker(); |
| 255 | + const f = await ready(); |
| 256 | + const again = await post("/early-access", f, request("ada@example.net", { name: "Mallory" }), undefined, ip()); |
| 257 | + assert.equal(again.status, 200); |
| 258 | + assert.match(again.html, /Check your inbox/); |
| 259 | + const known = await email("already asked", (e) => e.kind === "early_access_known" && e.to_addr === "ada@example.net"); |
| 260 | + assert.equal(known.subject, "[Diffinery] We already have your early access request"); |
| 261 | + assert.equal((await byEmail(api, "ada@example.net")).name, "Ada <script>alert(1)</script>", "a confirmed request isn't changed"); |
| 262 | + const account = await post("/early-access", f, request("ANN@example.com"), undefined, ip()); |
| 263 | + assert.equal(account.status, 200); |
| 264 | + assert.equal(same(account.html, /ann@example\.com/gi), same(again.html, /ada@example\.net/g), "the page is the same either way"); |
| 265 | + const note = await email("already an account", (e) => e.kind === "early_access_known" && e.to_addr === "ann@example.com"); |
| 266 | + assert.equal(note.subject, "[Diffinery] You already have a Diffinery account"); |
| 267 | + assert.equal(await byEmail(api, "ann@example.com"), undefined); |
| 268 | + assert.ok((await emails()).every((e) => e.kind !== "early_access_confirm" || e.to_addr !== "ann@example.com")); |
| 269 | +}); |
| 270 | + |
| 271 | +test("early access: bots, bad input and big bodies get nowhere", async () => { |
| 272 | + const { api, post, emails, formOf, ready } = await worker(); |
| 273 | + const fresh = await formOf("/early-access"); |
| 274 | + // Too fast after the page was shown, a filled honeypot, or a forged stamp: the usual page, nothing kept or sent. |
| 275 | + const fast = await post("/early-access", fresh, request("bot1@example.net"), undefined, ip()); |
| 276 | + assert.equal(fast.status, 200); |
| 277 | + assert.match(fast.html, /Check your inbox/); |
| 278 | + const f = await ready(); |
| 279 | + const honey = await post("/early-access", f, request("bot2@example.net", { website: "https://spam.example" }), undefined, ip()); |
| 280 | + assert.match(honey.html, /Check your inbox/); |
| 281 | + const forged = await post("/early-access", { ...f, stamp: `${Date.now() - 10_000}.${"A".repeat(43)}` }, request("bot3@example.net"), undefined, ip()); |
| 282 | + assert.match(forged.html, /Check your inbox/); |
| 283 | + const missing = await post("/early-access", { ...f, stamp: "" }, request("bot4@example.net"), undefined, ip()); |
| 284 | + assert.match(missing.html, /Check your inbox/); |
| 285 | + for (const a of ["bot1@example.net", "bot2@example.net", "bot3@example.net", "bot4@example.net"]) assert.equal(await byEmail(api, a), undefined, a); |
| 286 | + |
| 287 | + // Bad input is refused with the reason, keeping what was typed. |
| 288 | + const long = await post("/early-access", f, request("long@example.net", { use_case: "x".repeat(1001), name: "Keep me" }), undefined, ip()); |
| 289 | + assert.equal(long.status, 422); |
| 290 | + assert.match(long.html, /Keep what you'd use Diffinery for to 1,000 characters/); |
| 291 | + assert.match(long.html, /value="Keep me"/); |
| 292 | + const bad = await post("/early-access", f, request("not-an-address"), undefined, ip()); |
| 293 | + assert.equal(bad.status, 422); |
| 294 | + assert.match(bad.html, /Enter a valid email address/); |
| 295 | + const hosting = await post("/early-access", f, request("h@example.net", { hosting: "<script>" }), undefined, ip()); |
| 296 | + assert.equal(hosting.status, 422); |
| 297 | + assert.ok(!hosting.html.includes('value="<script>"')); |
| 298 | + const huge = await post("/early-access", f, request("huge@example.net", { use_case: "y".repeat(40_000) }), undefined, ip()); |
| 299 | + assert.equal(huge.status, 413); |
| 300 | + await sleep(1500); |
| 301 | + const tried = new Set(["bot1", "bot2", "bot3", "bot4", "long", "h", "huge"].map((n) => `${n}@example.net`)); |
| 302 | + assert.deepEqual((await emails()).filter((e) => tried.has(e.to_addr)), [], "nothing was emailed"); |
| 303 | +}); |
| 304 | + |
| 305 | +test("early access: only site administrators see requests (404 for anyone else)", async () => { |
| 306 | + const { page, api, post, formOf } = await worker(); |
| 307 | + const ada = await byEmail(api, "ada@example.net"); |
| 308 | + assert.ok(ada, "the request from the tests above"); |
| 309 | + for (const path of ["/settings/admin/early-access", `/settings/admin/early-access/${ada.id}`]) { |
| 310 | + assert.equal((await page(path, "ann")).status, 404, `${path} for a person who isn't a site administrator`); |
| 311 | + const anon = await page(path); |
| 312 | + assert.equal(anon.status, 302, `${path} sends a visitor to sign in`); |
| 313 | + assert.match(anon.headers.get("location") ?? "", /^\/login\?next=/); |
| 314 | + } |
| 315 | + const annForm = await formOf("/settings", "ann"); |
| 316 | + for (const action of ["invite", "decline", "delete"]) assert.equal((await post(`/settings/admin/early-access/${ada.id}/${action}`, annForm, {}, "ann")).status, 404, action); |
| 317 | + assert.equal((await api("GET", "/diffinery/admin/early-access", undefined, T.ann)).status, 404); |
| 318 | + assert.equal((await api("GET", `/diffinery/admin/early-access/${ada.id}`, undefined, T.ann)).status, 404); |
| 319 | + assert.equal((await api("POST", `/diffinery/admin/early-access/${ada.id}/invite`, undefined, T.ann)).status, 404); |
| 320 | + assert.equal((await api("PATCH", `/diffinery/admin/early-access/${ada.id}`, { state: "declined" }, T.ann)).status, 404); |
| 321 | + assert.equal((await api("DELETE", `/diffinery/admin/early-access/${ada.id}`, undefined, T.ann)).status, 404); |
| 322 | + assert.equal((await api("GET", "/diffinery/admin/early-access")).status, 401); |
| 323 | + assert.doesNotMatch(await (await page("/settings", "ann")).text(), /href="\/settings\/admin\/early-access"/); |
| 324 | + assert.equal((await byEmail(api, "ada@example.net")).state, "confirmed", "nothing changed"); |
| 325 | +}); |
| 326 | + |
| 327 | +test("early access: invite, decline, put back and delete, on the web and through the API, audited", async () => { |
| 328 | + const { page, api, post, formOf, email, emails, linkIn, ready } = await worker(); |
| 329 | + const ada = await byEmail(api, "ada@example.net"); |
| 330 | + // Invite from the web: the instance invitation goes to the address. |
| 331 | + const adminForm = await formOf("/settings/admin/early-access", "root"); |
| 332 | + const invited = await post(`/settings/admin/early-access/${ada.id}/invite`, adminForm, { back: "/settings/admin/early-access" }, "root"); |
| 333 | + assert.equal(invited.status, 303); |
| 334 | + assert.equal(invited.location, "/settings/admin/early-access"); |
| 335 | + const now = await byEmail(api, "ada@example.net"); |
| 336 | + assert.equal(now.state, "invited"); |
| 337 | + assert.equal(now.decided_by, "root"); |
| 338 | + assert.ok(now.invite_id); |
| 339 | + const inv = await email("the invitation", (e) => e.kind === "invite" && e.to_addr === "ada@example.net"); |
| 340 | + assert.match(inv.subject, /invited you to Diffinery/); |
| 341 | + assert.equal((await api("POST", `/diffinery/admin/early-access/${ada.id}/invite`, undefined, T.root)).status, 422, "not twice"); |
| 342 | + assert.match(await (await page("/settings/admin/early-access?state=invited", "root")).text(), /invited by root/); |
| 343 | + // Asking again now says so. |
| 344 | + const f = await ready(); |
| 345 | + await post("/early-access", f, request("ada@example.net"), undefined, ip()); |
| 346 | + const already = await email("already invited", (e) => e.kind === "early_access_known" && e.to_addr === "ada@example.net" && /already invited/.test(e.subject)); |
| 347 | + assert.equal(already.subject, "[Diffinery] You're already invited"); |
| 348 | + |
| 349 | + // A second request: decline and put back through the API (no email), then delete on the web. |
| 350 | + await post("/early-access", f, request("bob@example.net", { use_case: "secret plans" }), undefined, ip()); |
| 351 | + const token = linkIn(await email("bob's confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "bob@example.net"), "/early-access/confirm"); |
| 352 | + await post("/early-access/confirm", { ...(await formOf("/early-access/confirm")), stamp: "" }, { token }); |
| 353 | + const bob = await byEmail(api, "bob@example.net"); |
| 354 | + assert.equal(bob.state, "confirmed"); |
| 355 | + const sent = (await emails()).length; |
| 356 | + const declined = await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "declined" }, T.root); |
| 357 | + assert.equal(declined.status, 200, JSON.stringify(declined.json)); |
| 358 | + assert.equal(declined.json.state, "declined"); |
| 359 | + assert.equal((await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "invited" }, T.root)).status, 422); |
| 360 | + assert.equal((await api("PATCH", `/diffinery/admin/early-access/${bob.id}`, { state: "confirmed" }, T.root)).json.state, "confirmed"); |
| 361 | + await sleep(1500); |
| 362 | + assert.equal((await emails()).length, sent, "declining emails nobody"); |
| 363 | + const del = await post(`/settings/admin/early-access/${bob.id}/delete`, await formOf(`/settings/admin/early-access/${bob.id}`, "root"), {}, "root"); |
| 364 | + assert.equal(del.status, 303); |
| 365 | + assert.equal(del.location, "/settings/admin/early-access"); |
| 366 | + assert.equal((await api("GET", `/diffinery/admin/early-access/${bob.id}`, undefined, T.root)).status, 404); |
| 367 | + |
| 368 | + const audit = await (await page("/settings/audit", "root")).text(); |
| 369 | + for (const action of ["early_access.confirm", "early_access.invite", "early_access.decline", "early_access.restore", "early_access.delete", "invite.create"]) assert.ok(audit.includes(action), `${action} is audited`); |
| 370 | + assert.ok(!audit.includes("secret plans") && !audit.includes("onerror"), "the audit log holds no request content"); |
| 371 | +}); |
| 372 | + |
| 373 | +test("early access: people remove their own request with an emailed link", async () => { |
| 374 | + const { api, post, formOf, email, emails, linkIn, ready } = await worker(); |
| 375 | + const f = await ready(); |
| 376 | + await post("/early-access", f, request("carol@example.net"), undefined, ip()); |
| 377 | + await email("carol's confirmation", (e) => e.kind === "early_access_confirm" && e.to_addr === "carol@example.net"); |
| 378 | + assert.ok(await byEmail(api, "carol@example.net")); |
| 379 | + const rf = await ready("/early-access/remove"); |
| 380 | + const none = await post("/early-access/remove", rf, { email: "nobody@example.net", website: "" }, undefined, ip()); |
| 381 | + assert.equal(none.status, 200); |
| 382 | + assert.match(none.html, /If there's an early access request for <b>nobody@example\.net<\/b>/); |
| 383 | + const asked = await post("/early-access/remove", rf, { email: "Carol@Example.net", website: "" }, undefined, ip()); |
| 384 | + assert.equal(same(asked.html, /carol@example\.net/gi), same(none.html, /nobody@example\.net/g), "the same page either way"); |
| 385 | + const m = await email("the removal link", (e) => e.kind === "early_access_remove" && e.to_addr === "carol@example.net"); |
| 386 | + assert.equal(m.subject, "[Diffinery] Remove your early access request"); |
| 387 | + assert.ok((await emails()).every((e) => e.to_addr !== "nobody@example.net"), "no request, no email"); |
| 388 | + const token = linkIn(m, "/early-access/remove/confirm"); |
| 389 | + const cf = { ...(await formOf("/early-access/remove/confirm")), stamp: "" }; |
| 390 | + const gone = await post("/early-access/remove/confirm", cf, { token }); |
| 391 | + assert.equal(gone.status, 200); |
| 392 | + assert.match(gone.html, /Your request is removed/); |
| 393 | + assert.equal(await byEmail(api, "carol@example.net"), undefined); |
| 394 | + assert.equal((await post("/early-access/remove/confirm", cf, { token })).status, 410); |
| 395 | +}); |
| 396 | + |
| 397 | +test("early access: daily limits per network and per address", async () => { |
| 398 | + const { api, post, emails, ready } = await worker(); |
| 399 | + const f = await ready(); |
| 400 | + // One address: three emails a day, then the page stays the same and nothing more is sent. |
| 401 | + const flood = "flood@example.net"; |
| 402 | + for (let i = 0; i < 4; i++) assert.equal((await post("/early-access", f, request(flood), undefined, ip())).status, 200); |
| 403 | + await sleep(1500); |
| 404 | + assert.equal((await emails()).filter((e) => e.to_addr === flood).length, 3); |
| 405 | + // One network: ten submissions a day, then a visible refusal. |
| 406 | + const net = "203.0.113.77"; |
| 407 | + for (let i = 0; i < 10; i++) assert.equal((await post("/early-access", f, request(`n${i}@example.net`), undefined, net)).status, 200, `submission ${i + 1}`); |
| 408 | + const over = await post("/early-access", f, request("n10@example.net"), undefined, net); |
| 409 | + assert.equal(over.status, 429); |
| 410 | + assert.match(over.html, /Too many requests from your network today/); |
| 411 | + assert.equal(await byEmail(api, "n10@example.net"), undefined); |
| 412 | + // Another network still gets through. |
| 413 | + assert.equal((await post("/early-access", f, request("other@example.net"), undefined, ip())).status, 200); |
| 414 | +}); |