| 1 | // End-to-end account lifecycle against a real `wrangler dev` Worker with seeded accounts, tokens and sessions. |
| 2 | // |
| 3 | // pnpm --filter @diffinery/app e2e |
| 4 | // |
| 5 | // Covers: deleting your own account (refused while you are an organization's only owner, which it names; |
| 6 | // then your repositories, tokens, sessions and agents go, your comments elsewhere show as ghost, and your |
| 7 | // username waits 90 days), a site administrator deleting someone's account, exporting an account (a tar |
| 8 | // archive with a clonable Git bundle and the issues you wrote), and granting and revoking site |
| 9 | // administration (signed in recently, audited, never the last site administrator). |
| 10 | import "./fresh-connections.mjs"; |
| 11 | import { spawn, execFileSync } from "node:child_process"; |
| 12 | import { createHash, randomBytes } from "node:crypto"; |
| 13 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 14 | import { tmpdir } from "node:os"; |
| 15 | import { join, dirname, resolve } from "node:path"; |
| 16 | import { fileURLToPath } from "node:url"; |
| 17 | import { test, before, after } from "node:test"; |
| 18 | import assert from "node:assert/strict"; |
| 19 | import { migratedState } from "./d1-state.mjs"; |
| 20 | import { freePort } from "./ports.mjs"; |
| 21 | |
| 22 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 23 | const work = mkdtempSync(join(tmpdir(), "diffinery-accounts-")); |
| 24 | let base; |
| 25 | let dev; |
| 26 | let devLog = ""; |
| 27 | |
| 28 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 29 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 30 | const sess = (name) => `${name}-session-${randomBytes(12).toString("hex")}`; |
| 31 | const T = { sully: tok("sully"), alice: tok("alice"), alicebot: tok("alicebot"), bob: tok("bob"), carl: tok("carl"), erin: tok("erin"), dan: tok("dan") }; |
| 32 | const S = { sully: sess("sully"), sullyOld: sess("sullyold"), alice: sess("alice"), aliceOther: sess("aliceother"), bob: sess("bob"), carl: sess("carl"), erin: sess("erin"), dan: sess("dan") }; |
| 33 | |
| 34 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 35 | async function until(what, fn, timeoutMs = 120_000) { |
| 36 | const end = Date.now() + timeoutMs; |
| 37 | while (Date.now() < end) { |
| 38 | const v = await fn(); |
| 39 | if (v) return v; |
| 40 | await sleep(500); |
| 41 | } |
| 42 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 43 | } |
| 44 | |
| 45 | /** Call the REST API as a token, a session cookie (`{ cookie }`) or anonymously (null). */ |
| 46 | async function api(method, path, body, as) { |
| 47 | const headers = { "content-type": "application/json" }; |
| 48 | if (typeof as === "string") headers.authorization = `Bearer ${as}`; |
| 49 | else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; |
| 50 | const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 51 | const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e))); |
| 52 | const text = await res.text(); |
| 53 | let json = null; |
| 54 | try { |
| 55 | json = text ? JSON.parse(text) : null; |
| 56 | } catch { |
| 57 | json = text; |
| 58 | } |
| 59 | return { status: res.status, json }; |
| 60 | } |
| 61 | async function ok(method, path, body, as) { |
| 62 | const r = await api(method, path, body, as); |
| 63 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 64 | return r.json; |
| 65 | } |
| 66 | async function status(expected, method, path, body, as) { |
| 67 | const r = await api(method, path, body, as); |
| 68 | assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 69 | return r.json; |
| 70 | } |
| 71 | const b64 = (s) => Buffer.from(s).toString("base64"); |
| 72 | |
| 73 | /** A web page as someone signed in with `session`. */ |
| 74 | async function page(session, path) { |
| 75 | const res = await fetch(`${base}${path}`, { headers: { cookie: `__Host-dfy_session=${session}` }, redirect: "manual" }); |
| 76 | return { status: res.status, location: res.headers.get("location"), text: await res.text(), headers: res.headers }; |
| 77 | } |
| 78 | |
| 79 | /** A web form post signed in with `session`, with the CSRF pair taken from `from`. The flash message comes back decoded. */ |
| 80 | async function submit(session, from, action, fields) { |
| 81 | const cookie = `__Host-dfy_session=${session}`; |
| 82 | const res = await fetch(`${base}${from}`, { headers: { cookie } }); |
| 83 | const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; |
| 84 | const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; |
| 85 | assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`); |
| 86 | const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); |
| 87 | return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; |
| 88 | } |
| 89 | |
| 90 | /** The audit log's rows (site administrators' page), as text. */ |
| 91 | async function auditRows() { |
| 92 | const html = (await page(S.sully, "/settings/audit")).text; |
| 93 | return [...html.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((m) => m[1].replace(/<[^>]+>/g, " ").replace(/"/g, '"').replace(/\s+/g, " ")); |
| 94 | } |
| 95 | |
| 96 | /** Files in an uncompressed tar archive (ustar, with pax headers for long names). */ |
| 97 | function untar(buf) { |
| 98 | const files = new Map(); |
| 99 | let i = 0; |
| 100 | let longName = null; |
| 101 | while (i + 512 <= buf.length) { |
| 102 | const h = buf.subarray(i, i + 512); |
| 103 | if (h.every((b) => b === 0)) break; |
| 104 | const field = (from, len) => h.subarray(from, from + len).toString("utf8").replace(/\0[\s\S]*$/, ""); |
| 105 | const size = parseInt(field(124, 12).trim(), 8); |
| 106 | const type = String.fromCharCode(h[156]); |
| 107 | const body = buf.subarray(i + 512, i + 512 + size); |
| 108 | i += 512 + Math.ceil(size / 512) * 512; |
| 109 | if (type === "x") { |
| 110 | longName = /\d+ path=([^\n]*)\n/.exec(body.toString("utf8"))?.[1] ?? null; |
| 111 | continue; |
| 112 | } |
| 113 | files.set(longName ?? field(0, 100), Buffer.from(body)); |
| 114 | longName = null; |
| 115 | } |
| 116 | return files; |
| 117 | } |
| 118 | |
| 119 | before(async () => { |
| 120 | const port = await freePort(); |
| 121 | base = `http://127.0.0.1:${port}`; |
| 122 | const persist = join(work, "state"); |
| 123 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 124 | migratedState(APP, persist); |
| 125 | const at = new Date().toISOString(); |
| 126 | const hoursAgo = (h) => new Date(Date.now() - h * 3600_000).toISOString(); |
| 127 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 128 | const account = (id, login, kind, owner = null) => `INSERT INTO accounts (id, login, kind, name, email, status, owner_id, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', ${kind === "user" ? `'${login}@example.com'` : "NULL"}, 'active', ${owner ? `'${owner}'` : "NULL"}, '${at}')`; |
| 129 | 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}')`; |
| 130 | const session = (account, cookie, signedIn = at) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${signedIn}', '${at}', '${later}')`; |
| 131 | wrangler( |
| 132 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 133 | [ |
| 134 | account("u-sully", "sully", "user"), |
| 135 | "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", |
| 136 | account("u-alice", "alice", "user"), |
| 137 | account("a-alicebot", "alice-bot", "agent", "u-alice"), |
| 138 | account("u-bob", "bob", "user"), |
| 139 | account("u-carl", "carl", "user"), |
| 140 | account("u-erin", "erin", "user"), |
| 141 | account("u-dan", "dan", "user"), |
| 142 | account("o-solo", "solo", "org"), |
| 143 | account("o-team", "team", "org"), |
| 144 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-solo', 'u-alice', 'owner', '${at}'), ('o-team', 'u-alice', 'owner', '${at}'), ('o-team', 'u-bob', 'owner', '${at}')`, |
| 145 | `INSERT INTO passkeys (id, account_id, public_key, device_type, name, created_at) VALUES ('pk-alice', 'u-alice', 'AAAA', 'singleDevice', 'Laptop', '${at}')`, |
| 146 | ...Object.entries(T).map(([who, t]) => token(`t-${who}`, who === "alicebot" ? "a-alicebot" : `u-${who}`, t)), |
| 147 | session("u-sully", S.sully), |
| 148 | // sully signed in on another browser 3 hours ago: too long ago for sensitive actions. |
| 149 | session("u-sully", S.sullyOld, hoursAgo(3)), |
| 150 | session("u-alice", S.alice), |
| 151 | session("u-alice", S.aliceOther), |
| 152 | session("u-bob", S.bob), |
| 153 | session("u-carl", S.carl), |
| 154 | session("u-erin", S.erin), |
| 155 | session("u-dan", S.dan), |
| 156 | ].join("; "), |
| 157 | ); |
| 158 | dev = spawn( |
| 159 | "pnpm", |
| 160 | ["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:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], |
| 161 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 162 | ); |
| 163 | dev.stdout.on("data", (d) => (devLog += d)); |
| 164 | dev.stderr.on("data", (d) => (devLog += d)); |
| 165 | await until( |
| 166 | "wrangler dev", |
| 167 | async () => { |
| 168 | try { |
| 169 | return (await fetch(`${base}/healthz`)).ok; |
| 170 | } catch { |
| 171 | return false; |
| 172 | } |
| 173 | }, |
| 174 | 120_000, |
| 175 | ); |
| 176 | }); |
| 177 | |
| 178 | after(() => { |
| 179 | if (dev?.pid) { |
| 180 | try { |
| 181 | process.kill(-dev.pid, "SIGTERM"); |
| 182 | } catch {} |
| 183 | } |
| 184 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 185 | }); |
| 186 | |
| 187 | test("deleting your account: refused while you are an organization's only owner; then everything goes and your comments show as ghost", async () => { |
| 188 | // ------------------------------------------------------------ alice's own repository, and her work in bob's |
| 189 | await ok("POST", "/user/repos", { name: "notes" }, T.alice); |
| 190 | await ok("PUT", "/repos/alice/notes/contents/README.md", { message: "init", content: b64("# notes\n") }, T.alice); |
| 191 | await ok("POST", "/user/repos", { name: "app", visibility: "public" }, T.bob); |
| 192 | await ok("PUT", "/repos/bob/app/contents/README.md", { message: "init", content: b64("# app\n") }, T.bob); |
| 193 | const issue = await ok("POST", "/repos/bob/app/issues", { title: "Crash on start", body: "It crashes." }, T.alice); |
| 194 | assert.equal(issue.user.login, "alice"); |
| 195 | await ok("POST", `/repos/bob/app/issues/${issue.number}/comments`, { body: "Here is the stack trace." }, T.alice); |
| 196 | await ok("POST", `/repos/bob/app/issues/${issue.number}/comments`, { body: "Thanks, looking." }, T.bob); |
| 197 | await ok("GET", "/user", undefined, T.alicebot); |
| 198 | assert.equal((await api("GET", "/user", undefined, { cookie: S.aliceOther })).status, 200); |
| 199 | |
| 200 | // ------------------------------------------------------------ refused while she is the only owner of solo (team has bob too) |
| 201 | const settings = await page(S.alice, "/settings/account"); |
| 202 | assert.equal(settings.status, 200); |
| 203 | assert.match(settings.text, /Delete your account/); |
| 204 | assert.match(settings.text, /You are the only owner of the organization solo/); |
| 205 | assert.doesNotMatch(settings.text, /\bteam\b/); |
| 206 | let r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alice" }); |
| 207 | assert.equal(r.status, 303); |
| 208 | assert.equal(r.location, "/settings/account"); |
| 209 | assert.match(r.flash, /You are the only owner of the organization solo: make someone else an owner of it, or delete it, first/); |
| 210 | await ok("GET", "/user", undefined, T.alice); |
| 211 | |
| 212 | // She deletes solo; a wrong confirmation still deletes nothing. |
| 213 | await status(202, "DELETE", "/orgs/solo", undefined, T.alice); |
| 214 | r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alicе" }); |
| 215 | assert.match(r.flash, /type your username, alice, to confirm/); |
| 216 | await ok("GET", "/repos/alice/notes", undefined, T.alice); |
| 217 | |
| 218 | // ------------------------------------------------------------ deleted |
| 219 | r = await submit(S.alice, "/settings/account", "/settings/account/delete", { confirm: "alice" }); |
| 220 | assert.equal(r.status, 303); |
| 221 | assert.equal(r.location, "/login"); |
| 222 | assert.match(r.flash, /Your account alice was deleted/); |
| 223 | |
| 224 | // Tokens (hers and her agent's) and every browser session are dead. |
| 225 | await status(401, "GET", "/user", undefined, T.alice); |
| 226 | await status(401, "GET", "/user", undefined, T.alicebot); |
| 227 | await status(401, "GET", "/user", undefined, { cookie: S.aliceOther }); |
| 228 | await status(401, "GET", "/user", undefined, { cookie: S.alice }); |
| 229 | // Her repository is gone, for everyone and by every name. |
| 230 | await status(404, "GET", "/repos/alice/notes", undefined, T.sully); |
| 231 | await status(404, "GET", "/repos/deleted-u-alice/notes", undefined, T.sully); |
| 232 | assert.equal((await fetch(`${base}/alice/notes.git/info/refs?service=git-upload-pack`, { headers: { authorization: `Bearer ${T.sully}` } })).status, 404); |
| 233 | // Her profile and memberships are gone. |
| 234 | await status(404, "GET", "/users/alice", undefined, T.bob); |
| 235 | assert.equal((await page(S.bob, "/alice")).status, 404); |
| 236 | assert.deepEqual((await ok("GET", "/orgs/team/members", undefined, T.bob)).map((m) => m.login), ["bob"]); |
| 237 | |
| 238 | // What she wrote in bob's repository stays, as ghost's; bob's own comment is untouched. |
| 239 | const ghosted = await until("alice's issue attributed to ghost", async () => { |
| 240 | const i = await ok("GET", `/repos/bob/app/issues/${issue.number}`, undefined, T.bob); |
| 241 | return i.user.login === "ghost" ? i : null; |
| 242 | }); |
| 243 | assert.equal(ghosted.title, "Crash on start"); |
| 244 | const comments = await ok("GET", `/repos/bob/app/issues/${issue.number}/comments`, undefined, T.bob); |
| 245 | assert.deepEqual(comments.map((c) => [c.user.login, c.body]), [["ghost", "Here is the stack trace."], ["bob", "Thanks, looking."]]); |
| 246 | const ghostPage = await page(S.bob, "/ghost"); |
| 247 | assert.equal(ghostPage.status, 200); |
| 248 | assert.match(ghostPage.text, /placeholder for people who deleted their accounts/); |
| 249 | |
| 250 | // Her username waits 90 days before anyone can take it. |
| 251 | const taken = await status(422, "POST", "/user/orgs", { login: "alice" }, T.bob); |
| 252 | assert.match(taken.message, /belonged to an account that was deleted recently; it can be used again from \d{4}-\d{2}-\d{2}/); |
| 253 | r = await submit(S.bob, "/settings", "/settings/profile", { login: "Alice", name: "Bob" }); |
| 254 | assert.match(r.flash, /belonged to an account that was deleted recently/); |
| 255 | assert.equal((await ok("GET", "/user", undefined, T.bob)).login, "bob"); |
| 256 | |
| 257 | // Audited, with what went. |
| 258 | const row = (await auditRows()).find((t) => / account\.delete /.test(t) && / alice /.test(t)); |
| 259 | assert.ok(row, "account.delete is in the audit log"); |
| 260 | assert.match(row, /"via":"settings","repositories":1,"packages":0,"agents":\["alice-bot"\]/); |
| 261 | }); |
| 262 | |
| 263 | test("a site administrator deletes someone's account, signed in recently; their own is refused while they are the only one", async () => { |
| 264 | // Not for other people. |
| 265 | assert.equal((await page(S.bob, "/settings/admin/users/u-carl/delete")).status, 403); |
| 266 | const confirm = await page(S.sully, "/settings/admin/users/u-carl/delete"); |
| 267 | assert.equal(confirm.status, 200); |
| 268 | assert.match(confirm.text, /Delete the account carl/); |
| 269 | assert.match((await page(S.sully, "/settings/admin")).text, /href="\/settings\/admin\/users\/u-carl\/delete"/); |
| 270 | |
| 271 | // A sign-in from 3 hours ago must confirm it's them first; nothing is deleted. |
| 272 | let r = await submit(S.sullyOld, "/settings/admin/users/u-carl/delete", "/settings/admin/users/u-carl/delete", { confirm: "carl" }); |
| 273 | assert.equal(r.status, 303); |
| 274 | assert.match(r.location, /^\/login\?confirm=1&next=/); |
| 275 | await ok("GET", "/user", undefined, T.carl); |
| 276 | |
| 277 | r = await submit(S.sully, "/settings/admin/users/u-carl/delete", "/settings/admin/users/u-carl/delete", { confirm: "carl" }); |
| 278 | assert.equal(r.status, 303); |
| 279 | assert.equal(r.location, "/settings/admin"); |
| 280 | assert.match(r.flash, /Deleted the account carl/); |
| 281 | await status(401, "GET", "/user", undefined, T.carl); |
| 282 | await status(401, "GET", "/user", undefined, { cookie: S.carl }); |
| 283 | assert.doesNotMatch((await page(S.sully, "/settings/admin")).text, /carl@example\.com/); |
| 284 | const row = (await auditRows()).find((t) => / account\.delete /.test(t) && / carl /.test(t)); |
| 285 | assert.ok(row, "the admin deletion is audited"); |
| 286 | assert.match(row, /sully/); |
| 287 | assert.match(row, /"via":"admin"/); |
| 288 | |
| 289 | // sully is the only site administrator: deleting that account is refused, and says why. |
| 290 | const own = await page(S.sully, "/settings/admin/users/u-sully/delete"); |
| 291 | assert.match(own.text, /You are the only site administrator: make someone else a site administrator first/); |
| 292 | r = await submit(S.sully, "/settings/account", "/settings/account/delete", { confirm: "sully" }); |
| 293 | assert.match(r.flash, /only site administrator/); |
| 294 | await ok("GET", "/user", undefined, T.sully); |
| 295 | }); |
| 296 | |
| 297 | test("exporting your account: a tar archive with a clonable Git bundle and the issues you wrote, for you only", async () => { |
| 298 | await ok("POST", "/user/repos", { name: "diary" }, T.erin); |
| 299 | await ok("PUT", "/repos/erin/diary/contents/README.md", { message: "first entry", content: b64("# Dear diary\n") }, T.erin); |
| 300 | const mine = await ok("POST", "/repos/erin/diary/issues", { title: "Export me", body: "An issue in my own repository." }, T.erin); |
| 301 | const theirs = await ok("POST", "/repos/bob/app/issues", { title: "Erin was here", body: "An issue in someone else's repository." }, T.erin); |
| 302 | |
| 303 | const settings = await page(S.erin, "/settings/account"); |
| 304 | assert.match(settings.text, /Export your data/); |
| 305 | let r = await submit(S.erin, "/settings/account", "/settings/account/export", {}); |
| 306 | assert.equal(r.status, 303); |
| 307 | assert.match(r.flash, /We're preparing your export|Your export is ready/); |
| 308 | |
| 309 | const href = await until("the export", async () => { |
| 310 | const p = (await page(S.erin, "/settings/account")).text; |
| 311 | assert.doesNotMatch(p, /data-export="failed"/, "the export failed"); |
| 312 | return /href="(\/settings\/account\/exports\/[^"]+\/download)"/.exec(p)?.[1] ?? null; |
| 313 | }); |
| 314 | // Nobody else can download it. |
| 315 | assert.equal((await page(S.bob, href)).status, 404); |
| 316 | // Not with a token, even erin's own: the archive is the whole account. |
| 317 | const viaToken = await fetch(`${base}${href}`, { headers: { authorization: `Bearer ${T.erin}` } }); |
| 318 | assert.equal(viaToken.status, 403); |
| 319 | await viaToken.arrayBuffer(); |
| 320 | const res = await fetch(`${base}${href}`, { headers: { cookie: `__Host-dfy_session=${S.erin}` } }); |
| 321 | assert.equal(res.status, 200); |
| 322 | assert.equal(res.headers.get("content-type"), "application/x-tar"); |
| 323 | assert.match(res.headers.get("content-disposition") ?? "", /attachment; filename="diffinery-export-erin-\d{4}-\d{2}-\d{2}\.tar"/); |
| 324 | const archive = Buffer.from(await res.arrayBuffer()); |
| 325 | const file = join(work, "export.tar"); |
| 326 | writeFileSync(file, archive); |
| 327 | // A real tar archive. |
| 328 | const listed = execFileSync("tar", ["-tf", file], { encoding: "utf8" }).trim().split("\n"); |
| 329 | const top = "diffinery-export-erin"; |
| 330 | for (const p of ["manifest.json", "account.json", "repositories/diary.json", "repositories/diary.bundle", "issues/erin/diary.json", "issues/bob/app.json"]) assert.ok(listed.includes(`${top}/${p}`), `${p} in ${listed.join(", ")}`); |
| 331 | const files = untar(archive); |
| 332 | const manifest = JSON.parse(files.get(`${top}/manifest.json`)); |
| 333 | assert.equal(manifest.format, 1); |
| 334 | assert.equal(manifest.account, "erin"); |
| 335 | const acct = JSON.parse(files.get(`${top}/account.json`)); |
| 336 | assert.equal(acct.login, "erin"); |
| 337 | assert.equal(acct.email, "erin@example.com"); |
| 338 | assert.deepEqual(acct.tokens.map((t) => t.name), ["e2e"]); |
| 339 | assert.doesNotMatch(files.get(`${top}/account.json`).toString(), new RegExp(T.erin)); |
| 340 | const meta = JSON.parse(files.get(`${top}/repositories/diary.json`)); |
| 341 | assert.equal(meta.bundle, "diary.bundle"); |
| 342 | assert.equal(meta.clone_url, `${base}/erin/diary.git`); |
| 343 | // The bundle clones. |
| 344 | writeFileSync(join(work, "diary.bundle"), files.get(`${top}/repositories/diary.bundle`)); |
| 345 | execFileSync("git", ["bundle", "verify", join(work, "diary.bundle")], { stdio: "pipe" }); |
| 346 | execFileSync("git", ["clone", "-q", join(work, "diary.bundle"), join(work, "diary")], { stdio: "pipe" }); |
| 347 | assert.equal(readFileSync(join(work, "diary", "README.md"), "utf8"), "# Dear diary\n"); |
| 348 | // The issues erin opened, in her repository and in bob's. |
| 349 | const own = JSON.parse(files.get(`${top}/issues/erin/diary.json`)); |
| 350 | assert.deepEqual(own.issues.map((i) => [i.number, i.title]), [[mine.number, "Export me"]]); |
| 351 | const elsewhere = JSON.parse(files.get(`${top}/issues/bob/app.json`)); |
| 352 | assert.deepEqual(elsewhere.issues.map((i) => [i.number, i.title]), [[theirs.number, "Erin was here"]]); |
| 353 | assert.ok(manifest.files.some((f) => f.path === "repositories/diary.bundle" && f.size === files.get(`${top}/repositories/diary.bundle`).length)); |
| 354 | |
| 355 | // Asking again replaces it; the request and the download are audited. |
| 356 | const rows = await auditRows(); |
| 357 | assert.ok(rows.some((t) => / account\.export /.test(t) && / erin /.test(t))); |
| 358 | assert.ok(rows.some((t) => / account\.export_download /.test(t) && / erin /.test(t))); |
| 359 | }); |
| 360 | |
| 361 | test("site administration: granting and revoking needs a recent sign-in, is audited, and never removes the last site administrator", async () => { |
| 362 | const user = async (t) => (await ok("GET", "/user", undefined, t)).site_admin; |
| 363 | // sully is the only site administrator: removing that is refused. |
| 364 | let r = await submit(S.sully, "/settings/admin", "/settings/admin/users/u-sully/site-admin", { grant: "0" }); |
| 365 | assert.equal(r.status, 303); |
| 366 | assert.match(r.flash, /Diffinery needs at least one site administrator: make someone else a site administrator first/); |
| 367 | assert.equal(await user(T.sully), true); |
| 368 | |
| 369 | // People who aren't site administrators can't grant it. |
| 370 | r = await submit(S.dan, "/settings/account", "/settings/admin/users/u-dan/site-admin", { grant: "1" }); |
| 371 | assert.notEqual(r.status, 303); |
| 372 | assert.equal(await user(T.dan), false); |
| 373 | |
| 374 | // A sign-in from 3 hours ago must confirm it's them first. |
| 375 | r = await submit(S.sullyOld, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "1" }); |
| 376 | assert.match(r.location, /^\/login\?confirm=1&next=%2Fsettings%2Fadmin$/); |
| 377 | assert.equal(await user(T.dan), false); |
| 378 | |
| 379 | // Granted from the users table. |
| 380 | assert.match((await page(S.sully, "/settings/admin")).text, /action="\/settings\/admin\/users\/u-dan\/site-admin"/); |
| 381 | r = await submit(S.sully, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "1" }); |
| 382 | assert.equal(r.location, "/settings/admin"); |
| 383 | assert.match(r.flash, /dan is now a site administrator/); |
| 384 | assert.equal(await user(T.dan), true); |
| 385 | assert.equal((await page(S.dan, "/settings/admin")).status, 200); |
| 386 | |
| 387 | // dan removes sully; then dan is the last one and can't step down. |
| 388 | r = await submit(S.dan, "/settings/admin", "/settings/admin/users/u-sully/site-admin", { grant: "0" }); |
| 389 | assert.match(r.flash, /sully is no longer a site administrator/); |
| 390 | assert.equal(await user(T.sully), false); |
| 391 | assert.equal((await page(S.sully, "/settings/admin")).status, 403); |
| 392 | r = await submit(S.dan, "/settings/admin", "/settings/admin/users/u-dan/site-admin", { grant: "0" }); |
| 393 | assert.match(r.flash, /needs at least one site administrator/); |
| 394 | assert.equal(await user(T.dan), true); |
| 395 | |
| 396 | // Both changes are in the audit log, with who did them. |
| 397 | const html = (await page(S.dan, "/settings/audit")).text; |
| 398 | const rows = [...html.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((m) => m[1].replace(/<[^>]+>/g, " ").replace(/\s+/g, " ")); |
| 399 | assert.ok(rows.some((t) => / sully /.test(t) && / site_admin\.grant /.test(t) && / dan /.test(t)), "the grant is audited"); |
| 400 | assert.ok(rows.some((t) => / dan /.test(t) && / site_admin\.revoke /.test(t) && / sully /.test(t)), "the revocation is audited"); |
| 401 | }); |