Gunnar

nordisk/gunnar-ui

uitest/src/members.rs

//! **Members never buy** (owner, 2026-10-07: a customer is an ACCOUNT that
//! pays; MEMBERS — the users an account's admin enrols — use the account's
//! space, never reach the shop, and ask the account's admin for more).
//!
//! The members walkthrough of 2026-10-07, turned into rows. Three people:
//!
//! * `acme` — the account. Its admin logs in AS `acme`: on Gunnar the account
//!   IS its admin's principal.
//! * `ben` — a member `acme` enrols from `/acme` → Add users, who signs in
//!   through his invite with his own soft-authenticator passkey.
//! * `kim` — the matrix's second person: an individual with an account of her
//!   own and no members.
//!
//! What the walkthrough measured, and what each row now holds: ben saw the
//! same You ▾ pages as an account admin — "More room", "Grow your account",
//! "Buy a private Gunnar" — and a Storage page about a namespace `ben` that
//! does not exist; he could open a real checkout for tenant `ben` and pay for
//! room that lands nowhere; acme's admin had no Settings tab and no way to give
//! ben Write; acme's audit log named the rig's box operator, "alice
//! (operator)", for the users acme bought.
//!
//! Pictures go to `$MEMBERS_OUT` (default `<work>/members`); secrets (invite
//! links, checkout ids) never reach a row's text.

use std::path::PathBuf;
use std::time::Duration;

use crate::cdp::Cdp;
use crate::http;
use crate::json::esc;
use crate::matrix::{go, want, Row};
use crate::rig::Rig;

const ACCOUNT: &str = "acme";
const EMPLOYEE: &str = "ben";
const REPO: &str = "handbook";
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
const HEADINGS: &str = "[...document.querySelectorAll('main h1, main h2, main h3, main h4')].map(h => h.textContent.trim()).filter(Boolean).join(' | ')";
/// The line a member reads where the shop would be.
const LINE: &str = "Your space comes from acme. Ask acme for more room.";

/// Invite secrets and checkout ids never reach a row.
fn scrub(s: &str) -> String {
    let mut out = s.to_owned();
    for marker in ["invite=", "cs_test_", "cs_live_", "secret\":\""] {
        let mut at = 0;
        while let Some(i) = out[at..].find(marker) {
            let start = at + i + marker.len();
            let end = out[start..]
                .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'))
                .map(|e| start + e)
                .unwrap_or(out.len());
            out.replace_range(start..end, "…");
            at = start + "…".len();
        }
    }
    out.replace('\n', " ⏎ ")
}

fn type_into(c: &mut Cdp, sess: &str, id: &str, value: &str) -> Result<(), String> {
    let done = c.eval(
        sess,
        &format!(
            "(() => {{ const i = document.getElementById({}); if (!i) return false;
                const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
                set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
            esc(id),
            esc(value)
        ),
    )?;
    want(done.as_bool() == Some(true), || format!("no #{id} on the page"))
}

/// One request made by the page's own session: status and the body's start.
fn probe(c: &mut Cdp, sess: &str, method: &str, path: &str, body: Option<&str>) -> Result<(u64, String), String> {
    let init = match body {
        None => format!("{{method: {}}}", esc(method)),
        Some(b) => format!(
            "{{method: {}, headers: {{'Content-Type': 'application/json'}}, body: {}}}",
            esc(method),
            esc(b)
        ),
    };
    let j = c.eval(
        sess,
        &format!(
            "fetch({}, {init}).then(async r => ({{status: r.status, body: (await r.text()).slice(0, 900)}}))",
            esc(path)
        ),
    )?;
    let status = j.path("status").and_then(|v| v.as_f64()).unwrap_or(0.0) as u64;
    Ok((status, j.text("body")))
}

fn settle(c: &mut Cdp, sess: &str) {
    let _ = c.wait_for(
        sess,
        "!!document.querySelector('main, body') && !document.querySelector('[aria-busy=\"true\"]')",
        15_000,
    );
    std::thread::sleep(Duration::from_millis(1500));
}

fn visit(c: &mut Cdp, sess: &str, url: &str) -> Result<(), String> {
    c.navigate(sess, url)?;
    settle(c, sess);
    Ok(())
}

/// The whole page, grown to its height (at most 2600 px), 1440 wide.
fn shot(c: &mut Cdp, sess: &str, dir: &std::path::Path, name: &str) -> Result<(), String> {
    let _ = std::fs::create_dir_all(dir);
    let h = c
        .eval(sess, "Math.min(2600, Math.max(900, document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0))")?
        .as_f64()
        .unwrap_or(900.0) as u32;
    c.viewport(sess, 1440, h)?;
    std::thread::sleep(Duration::from_millis(500));
    c.screenshot(sess, &dir.join(format!("{name}.png")))?;
    c.viewport(sess, 1440, 900)?;
    Ok(())
}

/// `/auth/status` as `sess` sees it.
fn status(c: &mut Cdp, sess: &str) -> Result<crate::json::Json, String> {
    c.eval(sess, "fetch('/auth/status').then(r => r.json())")
}

/// A passkey session for `user`, from an invite link `url`.
fn sign_in_through(c: &mut Cdp, o: &str, url: &str, label: &str, user: &str) -> Result<String, String> {
    let sess = c.isolated_page()?;
    let auth = c.add_authenticator(&sess)?;
    // A link's registration signs in at once where the press is still live (owner
    // 2026-10-21); otherwise "Log in" is the way on.
    let signed_in = crate::matrix::register_through_link(c, &sess, url, label).map_err(|e| format!("{user}'s registration: {e}"))?;
    want(c.credentials(&sess, &auth)?.len() == 1, || format!("{user}'s authenticator holds no key"))?;
    if !signed_in {
        c.navigate(&sess, &format!("{o}/login"))?;
        c.wait_for(&sess, "!!document.getElementById('login')", 30_000)?;
        c.eval(&sess, "document.getElementById('login').click()")?;
        c.wait_for(&sess, "fetch('/auth/status').then(r => r.json()).then(j => j.authenticated === true)", 30_000)?;
    }
    let st = status(c, &sess)?;
    want(st.text("user") == user, || format!("logged in as {:?}, not {user}", st.text("user")))?;
    Ok(sess)
}

/// A commit of `bytes` random bytes in `wt`, named `what`.
fn commit_noise(r: &Rig, wt: &std::path::Path, what: &str, bytes: usize) -> Result<(), String> {
    let mut noise = vec![0u8; bytes];
    {
        use std::io::Read as _;
        std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut noise)).map_err(|e| e.to_string())?;
    }
    std::fs::write(wt.join(format!("{what}.bin")), &noise).map_err(|e| e.to_string())?;
    r.git(wt, &["add", &format!("{what}.bin")])?;
    r.git(wt, &["commit", "-qm", what])?;
    Ok(())
}

pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, boxadmin: &str, kim: &str) {
    let out: PathBuf = std::env::var("MEMBERS_OUT").map(PathBuf::from).unwrap_or_else(|_| r.work.join("members"));
    let o = r.origin();
    let mut admin = String::new();
    let mut ben = String::new();
    let ben_key = r.work.join("ben-key");
    let wt = r.work.join("members-wt");

    // ── the account: its admin's invite, her passkey, her session ──
    go(rows, "members-the-account-admin-signs-in-as-acme", || {
        let j = c.eval(
            boxadmin,
            &format!(
                "fetch('/auth/passkey/invites', {{method:'POST', headers:{{'Content-Type':'application/json'}},
                     body: JSON.stringify({{user:{}, role:'viewer'}})}}).then(r => r.json())",
                esc(ACCOUNT)
            ),
        )?;
        let url = j.text("url");
        want(url.contains("/login?invite="), || format!("no invite for acme: {}", scrub(&http::cut(&j.dump(), 200))))?;
        admin = sign_in_through(c, &o, &url, "anna-laptop", ACCOUNT)?;
        Ok("acme signed in".to_owned())
    });

    // ── a private repository of the account, with something in it ──
    go(rows, "members-acme-makes-a-private-repository-and-it-is-pushed", || {
        want(!admin.is_empty(), || "no acme session".to_owned())?;
        let made = c.eval(
            &admin,
            &format!(
                "fetch('/api/repos', {{method: 'POST', headers: {{'Content-Type': 'application/json'}},
                    body: JSON.stringify({{namespace: {}, name: {}, hash_kind: 'sha256', visibility: 'private', description: 'acme handbook'}})}}).then(r => r.json())",
                esc(ACCOUNT),
                esc(REPO)
            ),
        )?;
        want(made.text("path") == format!("{ACCOUNT}/{REPO}"), || format!("acme could not make {REPO}: {}", http::cut(&made.dump(), 300)))?;
        let _ = std::fs::remove_dir_all(&wt);
        std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
        r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
        r.git(&wt, &["config", "user.name", "uitest"])?;
        r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
        std::fs::write(wt.join("README.md"), "# acme handbook\n\nHow acme works.\n").map_err(|e| e.to_string())?;
        r.git(&wt, &["add", "README.md"])?;
        r.git(&wt, &["commit", "-qm", "first"])?;
        r.git(&wt, &["push", "-q", &r.remote(&format!("{ACCOUNT}/{REPO}")), "main"])?;
        Ok(format!("{ACCOUNT}/{REPO} made by acme, private, pushed"))
    });

    // ── the member: Add users on /acme, his invite, his own passkey ──
    go(rows, "members-acme-enrols-ben-from-add-users", || {
        want(!admin.is_empty(), || "no acme session".to_owned())?;
        visit(c, &admin, &format!("{o}/{ACCOUNT}"))?;
        c.wait_for(&admin, "!!document.querySelector('#add-users #users-add')", 30_000)
            .map_err(|_| format!("no Add users on /acme for acme: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        type_into(c, &admin, "users-count", "1")?;
        type_into(c, &admin, "users-room", "10")?;
        type_into(c, &admin, "users-name", EMPLOYEE)?;
        c.eval(&admin, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
        c.wait_for(&admin, "!!document.getElementById('users-added') || !!document.querySelector('#add-users .fail, .fail-box, .failure')", 30_000)?;
        let said = c.eval_str(&admin, "(document.getElementById('users-added') || {textContent: ''}).textContent")?;
        want(said.contains(&format!("{EMPLOYEE} is added")), || format!("the page says {said:?}: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        Ok(said)
    });

    go(rows, "members-ben-signs-in-through-his-invite-with-his-own-passkey", || {
        let invite = c.eval_str(&admin, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
        want(invite.contains("invite="), || "no invite link for ben on acme's page".to_owned())?;
        ben = sign_in_through(c, &o, &invite, "ben-laptop", EMPLOYEE)?;
        Ok("ben signed in".to_owned())
    });

    // ── (b) Gunnar says whose space it is; the shop's doors are not his ──
    go(rows, "members-the-employee-is-told-whose-space-it-is", || {
        let st = status(c, &ben)?;
        let said = scrub(&http::cut(&st.dump(), 400));
        want(st.text("account") == ACCOUNT && st.text("account_role") == "employee", || format!("/auth/status does not say ben is acme's member: {said}"))?;
        want(st.text("account_line") == LINE, || format!("no line naming whom to ask: {said}"))?;
        want(st.text("buy_url").is_empty() && st.text("private_url").is_empty(), || format!("a member is given the shop's doors: {said}"))?;
        Ok(said)
    });

    // ── (c) /account/storage: no shop — a member is sent on to Account,
    // where the ACCOUNT's room is, read only (owner 2026-10-17) ──
    go(rows, "members-the-employee-storage-is-the-accounts-room-and-no-shop", || {
        visit(c, &ben, &format!("{o}/account/storage"))?;
        c.wait_for(&ben, "location.pathname === '/account'", 15_000).map_err(|e| format!("a member's /account/storage did not go on to Account: {e}"))?;
        let _ = c.wait_for(&ben, "!!document.querySelector('main .metrics, main .failure, main .fail-box')", 15_000);
        let text = c.eval_str(&ben, MAIN_TEXT)?;
        let heads = c.eval_str(&ben, HEADINGS)?;
        let doors = c.eval_str(&ben, "String(document.querySelectorAll('#account-buy-grow').length)")?;
        let line = c.eval_str(&ben, "(document.getElementById('member-line') || {textContent: ''}).textContent")?;
        shot(c, &ben, &out, "employee-storage")?;
        want(!heads.contains("More room") && doors == "0", || format!("the shop is drawn for a member: headings {heads:?}, {doors} door(s)"))?;
        want(line == LINE, || format!("no member line, the page says {:?}", http::cut(&text, 300)))?;
        want(!text.contains("not in the catalog") && heads.contains("acme's room"), || format!("not acme's room: headings {heads:?}: {}", http::cut(&text, 300)))?;
        Ok(format!("{heads} · {line}"))
    });

    go(rows, "members-the-employee-account-page-is-the-accounts-space", || {
        visit(c, &ben, &format!("{o}/account"))?;
        let _ = c.wait_for(&ben, "!!document.getElementById('member-line')", 10_000);
        let heads = c.eval_str(&ben, HEADINGS)?;
        let line = c.eval_str(&ben, "(document.getElementById('member-line') || {textContent: ''}).textContent")?;
        let audit = c.eval_str(&ben, "String(!!document.getElementById('account-audit-log'))")?;
        shot(c, &ben, &out, "employee-account")?;
        want(!heads.contains("Plan") && !heads.contains("Subscription"), || format!("a member is shown a plan of his own: {heads:?}"))?;
        want(line == LINE, || format!("no member line on Account: {heads:?}"))?;
        want(heads.contains("acme's room"), || format!("the account's room is not shown: {heads:?}"))?;
        want(audit == "false", || "the account admin's audit log is offered to a member".to_owned())?;
        Ok(heads)
    });

    // ── (a)+(c) pressing the shop anyway: refused, nothing to pay ──
    go(rows, "members-the-employee-cannot-buy", || {
        want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
        let (s, b) = probe(c, &ben, "POST", "/api/account/subscription/buy-bundle", Some("{\"bundle\":\"10gb\"}"))?;
        want(s == 403 && b.contains("Ask acme"), || format!("a member's order was not refused by name: {s} {}", scrub(&http::cut(&b, 300))))?;
        want(!b.contains("checkout"), || format!("a checkout for a member: {}", scrub(&http::cut(&b, 300))))?;
        let (ps, pb) = probe(
            c,
            &ben,
            "POST",
            "/auth/private/start",
            Some("{\"company\":\"benco\",\"email\":\"ben@example.com\",\"front_plan\":\"\",\"main_plan\":\"\",\"main_data_gib\":0}"),
        )?;
        want(ps == 403 && pb.contains("Members don"), || format!("a member's private Gunnar was not refused: {ps} {}", scrub(&http::cut(&pb, 300))))?;
        Ok(format!("buy-bundle {s} · private {ps}: {}", scrub(&http::cut(&b, 160))))
    });

    // ── (a) a member owns no namespace by his name ──
    go(rows, "members-the-employee-is-offered-no-namespace-of-his-own", || {
        let (s, b) = probe(c, &ben, "GET", "/api/repos/creatable", None)?;
        want(s == 200 && !b.contains(&format!("\"name\":\"{EMPLOYEE}\"")), || format!("ben is offered a namespace `ben`: {s} {b}"))?;
        Ok(b)
    });

    // ── round 5.3: no Clans for a member, and no import into `ben/…` ──
    //
    // The walkthrough's two leftovers: ben's You ▾ offered Clans (a "Make a
    // clan" form gunnar refuses an enterprise account's people), and his
    // Import read "Lands at ben/." — a namespace that does not exist. gunnar
    // names where he may make a repository (`/api/repos/creatable`: nowhere,
    // with Read on acme), so the console offers no Import and the import page
    // says, in words, whom to ask; gunnar's word that he is a member hides Clans.
    go(rows, "members-the-employee-has-no-clans-and-no-import-into-a-namespace-of-his-own", || {
        visit(c, &ben, &format!("{o}/repos"))?;
        let _ = c.wait_for(&ben, "document.querySelectorAll('header.topbar details.menu .menu-link').length > 0", 15_000);
        let you = c.eval_str(&ben, "[...document.querySelectorAll('header.topbar details.menu:not(.admin-menu) .menu-link')].map(a => a.textContent.trim()).join(' | ')")?;
        want(you.contains("Account") && !you.contains("Groups"), || format!("a member's You menu: {you}"))?;
        // New repository and Import wait on the same answer; acme's admin's
        // page draws both from it, so its absence here is the answer's.
        let _ = c.wait_for(&ben, "!!document.getElementById('filter')", 10_000);
        std::thread::sleep(Duration::from_millis(800));
        let import = c.eval_str(&ben, "String([...document.querySelectorAll('a')].some(a => a.textContent.includes('Import repository')))")?;
        want(import == "false", || "an Import button for a member gunnar names no namespace for".to_owned())?;
        visit(c, &ben, &format!("{o}/mirrors/import"))?;
        c.wait_for(&ben, "!!document.getElementById('import-nowhere')", 15_000)
            .map_err(|_| format!("no words where ben may import nowhere: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
        let line = c.eval_str(&ben, "document.getElementById('import-nowhere').innerText")?;
        let form = c.eval_str(&ben, "String(!(document.getElementById('import-forms') || {hidden: true}).hidden)")?;
        shot(c, &ben, &out, "employee-import")?;
        want(form == "false", || "an import form gunnar would refuse is drawn".to_owned())?;
        want(line.contains("acme") && !line.contains("ben/"), || format!("the words do not name the account: {line:?}"))?;
        // The admin keeps both, and her import lands in acme.
        visit(c, &admin, &format!("{o}/mirrors/import"))?;
        c.wait_for(&admin, "(document.getElementById('import-namespace') || {}).value === 'acme'", 15_000)
            .map_err(|_| "acme's admin's import does not land in acme".to_owned())?;
        let admin_you = c.eval_str(&admin, "[...document.querySelectorAll('header.topbar details.menu:not(.admin-menu) .menu-link')].map(a => a.textContent.trim()).join(' | ')")?;
        want(admin_you.contains("Groups"), || format!("acme's admin lost Groups: {admin_you}"))?;
        Ok(format!("ben: You {you}; import: {line}"))
    });

    // ── (f) the sign-up page says members don't buy, and names the admin ──
    go(rows, "members-the-signup-page-tells-the-employee-he-buys-nothing", || {
        want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
        visit(c, &ben, &format!("{o}/signup?plan=10gb"))?;
        let heading = c.eval_str(&ben, "document.getElementById('heading').textContent")?;
        let msg = c.eval_str(&ben, "document.getElementById('msg').textContent")?;
        let go_hidden = c.eval_str(&ben, "String(document.getElementById('go').hidden)")?;
        shot(c, &ben, &out, "employee-signup")?;
        want(heading == "You are a member of acme", || format!("heading {heading:?}"))?;
        want(msg.contains("Members don’t buy") && msg.contains("Ask acme"), || format!("message {msg:?}"))?;
        want(go_hidden == "true", || "a buy button is drawn for a member".to_owned())?;
        Ok(msg)
    });

    // ── the account's admin and an individual keep the shop ──
    go(rows, "members-the-admin-and-the-individual-keep-the-shop", || {
        want(r.bench.is_some(), || "NOT RUN: no --bench-monetize (a community console sells nothing: the sign-up page, the Plan panel and the shop are a metered console's)".into())?;
        let mut said = Vec::new();
        for (who, sess, role) in [(ACCOUNT, admin.as_str(), "admin"), ("kim", kim, "individual")] {
            let st = status(c, sess)?;
            want(st.text("account_role") == role && !st.text("buy_url").is_empty(), || format!("{who}: {}", http::cut(&st.dump(), 300)))?;
            said.push(format!("{who} {role} buy_url {}", st.text("buy_url")));
        }
        // The account's admin buys: Account's ONE button, and /account/storage
        // goes on to the same sign-up page (owner 2026-10-17).
        visit(c, &admin, &format!("{o}/account"))?;
        c.wait_for(&admin, "!!document.getElementById('account-buy-grow')", 15_000).map_err(|e| format!("acme's admin has no button on Account: {e}"))?;
        shot(c, &admin, &out, "admin-account")?;
        visit(c, &admin, &format!("{o}/account/storage"))?;
        c.wait_for(&admin, "location.pathname === '/signup' && ['Buy a plan', 'Grow your account'].includes(document.getElementById('heading')?.textContent)", 15_000)
            .map_err(|e| format!("acme's admin's /account/storage did not go on to the sign-up page: {e}"))?;
        Ok(said.join("; "))
    });

    // ── (e) the account's admin has the Settings tab of the account's repos ──
    go(rows, "members-the-account-admin-sees-the-repo-settings-tab", || {
        visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
        let _ = c.wait_for(&admin, "[...document.querySelectorAll('nav.repo-tabs a')].some(a => a.textContent === 'Settings')", 10_000);
        let tabs = c.eval_str(&admin, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
        want(tabs.ends_with("Settings"), || format!("no Settings tab for acme's admin: {tabs}"))?;
        visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
        let ben_tabs = c.eval_str(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
        want(!ben_tabs.contains("Settings"), || format!("a member is given Settings: {ben_tabs}"))?;
        Ok(tabs)
    });

    // ── round 5.3.1: a member's publish, posted past the page, is refused ──
    go(rows, "members-a-member-cannot-publish-and-the-repository-stays-private", || {
        want(!ben.is_empty() && !admin.is_empty(), || "no ben or acme session".to_owned())?;
        let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"public\"}"))?;
        want(s == 403, || format!("a member's publish was not refused: {s} {}", scrub(&http::cut(&b, 300))))?;
        let (s2, after) = probe(c, &admin, "GET", &format!("/api/repos/{ACCOUNT}/{REPO}"), None)?;
        want(s2 == 200 && after.contains("\"visibility\":\"private\""), || format!("{ACCOUNT}/{REPO} is not private after a member's publish: {s2} {}", http::cut(&after, 300)))?;
        Ok(format!("403: {}", scrub(&http::cut(&b, 140))))
    });

    // ── (d) the admin gives ben Write; ben pushes; the admin takes it away ──
    go(rows, "members-the-admin-grants-write-and-the-employee-can-push", || {
        let _ = std::fs::remove_file(&ben_key);
        let _ = std::fs::remove_file(ben_key.with_extension("pub"));
        let made = std::process::Command::new("ssh-keygen")
            .current_dir(&r.work)
            .args(["-q", "-t", "ed25519", "-N", "", "-C", "ben", "-f"])
            .arg(&ben_key)
            .output()
            .map_err(|e| format!("ssh-keygen: {e}"))?;
        want(made.status.success(), || "ssh-keygen failed".to_owned())?;
        let public = std::fs::read_to_string(ben_key.with_extension("pub")).map_err(|e| e.to_string())?;
        let (s, b) = probe(c, &ben, "POST", "/api/account/keys", Some(&format!("{{\"openssh_public_key\":{}}}", esc(public.trim()))))?;
        want(s == 200, || format!("ben's own key: {s} {}", http::cut(&b, 200)))?;
        let remote = r.remote_as(EMPLOYEE, &format!("{ACCOUNT}/{REPO}"));
        commit_noise(r, &wt, "ben-1", 1 << 20)?;
        want(r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).is_err(), || "ben pushed with Read only".to_owned())?;

        // The members panel, on the repository's Settings, as acme's admin.
        visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
        c.wait_for(&admin, "!!document.getElementById('member-ben-write')", 15_000)
            .map_err(|_| format!("no Members panel with ben for acme's admin: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
        let before = c.eval_str(&admin, "document.querySelector('#member-ben .member-holds').textContent")?;
        want(before == "Read on all of acme", || format!("ben holds {before:?} before"))?;
        c.eval(&admin, "(() => { document.getElementById('member-ben-write').click(); return 1; })()")?;
        c.wait_for(&admin, "(document.querySelector('#member-ben .member-holds') || {}).textContent === 'Write on this repository'", 15_000)
            .map_err(|_| format!("the grant did not land: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
        shot(c, &admin, &out, "admin-members-panel")?;
        r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).map_err(|e| format!("ben's push with Write: {}", http::cut(&e, 300)))?;

        // Taken away: back to the account's Read, and the next push is refused.
        c.eval(&admin, "(() => { document.getElementById('member-ben-take').click(); return 1; })()")?;
        c.wait_for(&admin, "(document.querySelector('#member-ben .member-holds') || {}).textContent === 'Read on all of acme'", 15_000)
            .map_err(|_| "taking Write away did not land".to_owned())?;
        commit_noise(r, &wt, "ben-2", 1 << 10)?;
        want(r.git_as(&ben_key, &wt, &["push", "-q", &remote, "main"]).is_err(), || "ben still pushes after Write was taken away".to_owned())?;
        Ok("Read: refused · Write: pushed · taken away: refused".to_owned())
    });

    // ── (e) the audit log names the account's admin for the users it bought ──
    go(rows, "members-the-audit-log-names-the-account-admin", || {
        let (s, b) = probe(c, &admin, "GET", "/api/account/audit?kinds=member", None)?;
        want(s == 200, || format!("acme's log: {s} {}", http::cut(&b, 200)))?;
        visit(c, &admin, &format!("{o}/admin/audit"))?;
        let rows_text = c.eval_str(&admin, "[...document.querySelectorAll('main tr')].map(t => t.innerText.replace(/\\t/g, ' · ')).join(' ⏎ ')")?;
        shot(c, &admin, &out, "admin-audit-log")?;
        let added = rows_text.split(" ⏎ ").find(|row| row.contains("member added")).unwrap_or_default().to_owned();
        want(added.contains("acme (account admin)") && !added.contains("(operator)"), || format!("the member's row does not name acme's admin: {added:?}"))?;
        Ok(added)
    });

    // ── round 5.3.1: the account's admin turns encryption on for an EXISTING repository ──
    //
    // README §5f: the account admin turns it on at the console. Settings → "Turn encryption
    // on…", the confirmation, and gunnar refuses a plaintext push from then on; a member is
    // offered no control; turned off again, so the rows after it push plaintext as before.
    go(rows, "repo-settings-the-account-admin-turns-encryption-on", || {
        want(!admin.is_empty() && !ben.is_empty(), || "no acme or ben session".to_owned())?;
        visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
        std::thread::sleep(Duration::from_millis(1500));
        let offered = c.eval_str(&ben, "String(!!document.getElementById('enc-change'))")?;
        want(offered == "false", || "a member is offered the encryption control".to_owned())?;
        visit(c, &admin, &format!("{o}/repo/{ACCOUNT}/{REPO}/settings"))?;
        c.wait_for(&admin, "!!document.getElementById('enc-change')", 15_000)
            .map_err(|_| format!("no encryption control for acme's admin: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
        c.eval(&admin, "(() => { document.getElementById('enc-change').click(); return 1; })()")?;
        c.wait_for(&admin, "!!document.getElementById('enc-confirm')", 10_000).map_err(|_| "no confirmation before turning encryption on".to_owned())?;
        let consequence = c.eval_str(&admin, "document.getElementById('enc-consequence').textContent")?;
        want(consequence.contains("not encrypted") || consequence.contains("plaintext"), || format!("the confirmation does not say what changes: {consequence:?}"))?;
        c.eval(&admin, "(() => { document.getElementById('enc-confirm').click(); return 1; })()")?;
        c.wait_for(&admin, "!!document.getElementById('repo-encrypted')", 15_000)
            .map_err(|_| format!("no encrypted badge after turning it on: {}", http::cut(&c.eval_str(&admin, MAIN_TEXT).unwrap_or_default(), 400)))?;
        shot(c, &admin, &out, "admin-encryption-on")?;
        let (s, b) = probe(c, &admin, "GET", &format!("/api/repos/{ACCOUNT}/{REPO}"), None)?;
        want(s == 200 && b.contains("\"encrypted\":true"), || format!("gunnar does not say acme/handbook is encrypted: {s} {}", http::cut(&b, 300)))?;
        commit_noise(r, &wt, "plain-after-encryption", 1 << 10)?;
        let pushed = r.git(&wt, &["push", "-q", &r.remote(&format!("{ACCOUNT}/{REPO}")), "main"]);
        want(pushed.is_err(), || "gunnar took a plaintext push into an encrypted repository".to_owned())?;
        let refusal = pushed.err().unwrap_or_default();
        r.git(&wt, &["reset", "-q", "--hard", "HEAD~1"])?;
        // Back off: the rows after this one push plaintext.
        c.eval(&admin, "(() => { document.getElementById('enc-change').click(); return 1; })()")?;
        c.wait_for(&admin, "!!document.getElementById('enc-confirm')", 10_000)?;
        c.eval(&admin, "(() => { document.getElementById('enc-confirm').click(); return 1; })()")?;
        c.wait_for(&admin, "!document.getElementById('repo-encrypted') && !!document.getElementById('enc-change')", 15_000)
            .map_err(|_| "turning encryption off did not land".to_owned())?;
        Ok(format!("on: badge, plaintext push refused ({}); off again", http::cut(&refusal, 120)))
    });

    // ── round 5.3.1 (owner 2026-10-08): an APPOINTED account admin ──
    //
    // The account's admin gives ben Admin over the account (gunnar's namespace grant): gunnar
    // answers WhoAmI `admin-appointed`. He decides for the account's repositories (Settings,
    // publish) and reads its audit log — and is still a member who never buys: no door on
    // /auth/status, the members line, every purchase refused. Last in the list: it changes
    // ben, and the rows above read him as a plain member.
    go(rows, "members-an-appointed-admin-publishes-sees-the-audit-log-and-never-buys", || {
        want(!ben.is_empty() && !admin.is_empty(), || "no ben or acme session".to_owned())?;
        r.control(&["grant", EMPLOYEE, "admin", "--namespace", ACCOUNT]).map_err(|e| format!("acme's Admin for ben: {}", http::cut(&e, 300)))?;
        // gunnar's answer about a role is believed for a minute (ROLE_TTL): ask until it says so.
        let mut st = status(c, &ben)?;
        for _ in 0..70 {
            if st.text("account_role") == "admin-appointed" {
                break;
            }
            std::thread::sleep(Duration::from_secs(1));
            st = status(c, &ben)?;
        }
        let said = scrub(&http::cut(&st.dump(), 400));
        want(st.text("account") == ACCOUNT && st.text("account_role") == "admin-appointed", || format!("gunnar does not name ben acme's appointed admin: {said}"))?;
        want(st.text("account_line") == LINE, || format!("no members line for the appointed admin: {said}"))?;
        for door in ["buy_url", "private_url", "signup_url"] {
            want(st.text(door).is_empty(), || format!("{door} for an appointed admin: {said}"))?;
        }
        // Never buys: `may_buy` false where this console sells, and absent where it sells nothing.
        want(st.path("may_buy").and_then(|v| v.as_bool()) != Some(true), || format!("may_buy for an appointed admin: {said}"))?;

        // The account's repository decisions: Settings, and publishing.
        visit(c, &ben, &format!("{o}/repo/{ACCOUNT}/{REPO}"))?;
        let _ = c.wait_for(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].some(a => a.textContent === 'Settings')", 15_000);
        let tabs = c.eval_str(&ben, "[...document.querySelectorAll('nav.repo-tabs a')].map(a => a.textContent).join(' | ')")?;
        want(tabs.contains("Settings"), || format!("no Settings tab for the appointed admin: {tabs}"))?;
        let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"public\"}"))?;
        want(s == 200, || format!("the appointed admin could not publish: {s} {}", scrub(&http::cut(&b, 300))))?;
        let (s, b) = probe(c, &ben, "PATCH", &format!("/api/repos/{ACCOUNT}/{REPO}"), Some("{\"visibility\":\"private\"}"))?;
        want(s == 200, || format!("the appointed admin could not take it back: {s} {}", scrub(&http::cut(&b, 300))))?;

        // The account's audit log: the door on Account, and gunnar's answer.
        visit(c, &ben, &format!("{o}/account"))?;
        c.wait_for(&ben, "!!document.getElementById('account-audit-log')", 15_000)
            .map_err(|_| format!("no Audit log on Account for the appointed admin: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
        // The page asks for the account it shows (`?account=`, from /api/account/room's
        // namespace); a bare /api/account/audit is the person's OWN account, which an
        // appointed admin is not the admin of (r5.3 batch matrix 2026-10-08).
        let (s, b) = probe(c, &ben, "GET", &format!("/api/account/audit?account={ACCOUNT}"), None)?;
        want(s == 200, || format!("gunnar refused the appointed admin acme's audit log: {s} {}", scrub(&http::cut(&b, 300))))?;

        // Never a buyer. A community console (no --bench-monetize) sells nothing at all:
        // there the order door and the sign-up page are a metered console's, not here.
        if r.bench.is_none() {
            return Ok(format!("{tabs}; publish 200; audit 200; a community console sells nothing"));
        }
        let (s, b) = probe(c, &ben, "POST", "/api/account/subscription/buy-bundle", Some("{\"bundle\":\"10gb\"}"))?;
        want(s == 403 && b.contains("Ask acme"), || format!("the appointed admin's order was not refused by name: {s} {}", scrub(&http::cut(&b, 300))))?;
        visit(c, &ben, &format!("{o}/signup?plan=10gb"))?;
        c.wait_for(&ben, "!!document.getElementById('heading') && document.getElementById('heading').textContent.includes('member of acme')", 15_000)
            .map_err(|_| format!("the sign-up page sells to the appointed admin: {}", http::cut(&c.eval_str(&ben, MAIN_TEXT).unwrap_or_default(), 300)))?;
        shot(c, &ben, &out, "appointed-admin-signup")?;
        Ok(format!("{tabs}; publish 200; audit 200; buy-bundle 403; sign-up: a member of acme"))
    });
}