Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

401 lines · 23.2 KBRaw History
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).
10import "./fresh-connections.mjs";
11import { spawn, execFileSync } from "node:child_process";
12import { createHash, randomBytes } from "node:crypto";
13import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join, dirname, resolve } from "node:path";
16import { fileURLToPath } from "node:url";
17import { test, before, after } from "node:test";
18import assert from "node:assert/strict";
19import { migratedState } from "./d1-state.mjs";
20import { freePort } from "./ports.mjs";
21
22const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
23const work = mkdtempSync(join(tmpdir(), "diffinery-accounts-"));
24let base;
25let dev;
26let devLog = "";
27
28const hash = (s) => createHash("sha256").update(s).digest("hex");
29const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
30const sess = (name) => `${name}-session-${randomBytes(12).toString("hex")}`;
31const T = { sully: tok("sully"), alice: tok("alice"), alicebot: tok("alicebot"), bob: tok("bob"), carl: tok("carl"), erin: tok("erin"), dan: tok("dan") };
32const 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
34const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
35async 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). */
46async 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}
61async 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}
66async 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}
71const b64 = (s) => Buffer.from(s).toString("base64");
72
73/** A web page as someone signed in with `session`. */
74async 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. */
80async 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. */
91async 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(/&quot;/g, '"').replace(/\s+/g, " "));
94}
95
96/** Files in an uncompressed tar archive (ustar, with pax headers for long names). */
97function 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
119before(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
178after(() => {
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
187test("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
263test("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
297test("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
361test("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});