//! **"Add users"** (owner, 2026-10-04) — the account admin's dialog, driven
//! as a person drives it, and held against the SERVER's answer.
//!
//! The dialog starts at ONE user and 10 GB of room, and asks a name only for
//! one user. A user that fits in the account's unused room is added at once,
//! with an invite to sign in. Where the console sells room (the bench
//! monetize, the FAKE Stripe), an account short of room is sent to ONE
//! hosted checkout; when the vendor says paid, the seats exist on gunnar, the
//! order page reads "queued" and then "done" from the server — the
//! enlargement is queued, never done inline — and an expired checkout adds
//! nobody.
//!
//! `person` is kim's session: a viewer of this console, and the admin of
//! kim's own account (`/kim`).
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 MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
/// Set an input as a person typing would (the value, then the `input` event
/// the page listens to).
fn type_into(c: &mut Cdp, sess: &str, id: &str, value: &str) -> Result<(), String> {
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)
),
)?;
Ok(())
}
/// The account the session is the admin of, as the server names it.
fn own_account(c: &mut Cdp, sess: &str) -> Result<String, String> {
let j = c.eval(sess, "fetch('/api/account/room').then(r => r.json())")?;
let ns = j.text("namespace");
want(!ns.is_empty(), || format!("the server names no account for this session: {}", http::cut(&j.dump(), 200)))?;
Ok(ns)
}
/// The account's employees and seats, as gunnar answers them.
fn employees(c: &mut Cdp, sess: &str, account: &str) -> Result<crate::json::Json, String> {
c.eval(sess, &format!("fetch('/api/account/employees?account=' + encodeURIComponent({})).then(r => r.json())", esc(account)))
}
fn number(j: &crate::json::Json, p: &str) -> u64 {
j.path(p).and_then(|v| v.as_f64()).unwrap_or(-1.0) as u64
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, person: &str) {
let mut account = String::new();
go(rows, "the-add-users-dialog-starts-at-one-user-and-10-gb-and-asks-a-name-only-for-one", || {
account = own_account(c, person)?;
c.navigate(person, &format!("{}/{}", r.origin(), account))?;
c.wait_for(person, "!!document.querySelector('#add-users #users-add')", 30_000)?;
let count = c.eval_str(person, "document.getElementById('users-count').value")?;
let room = c.eval_str(person, "document.getElementById('users-room').value")?;
let named = c.eval(person, "!!document.getElementById('users-name')")?;
want(count == "1" && room == "10", || format!("the dialog starts at {count} user(s) and {room} GB, not 1 and 10"))?;
want(named.as_bool() == Some(true), || "no name is asked for ONE user".to_owned())?;
type_into(c, person, "users-count", "3")?;
c.wait_for(person, "!document.getElementById('users-name')", 10_000)
.map_err(|_| "a name is still asked for three users".to_owned())?;
type_into(c, person, "users-count", "1")?;
c.wait_for(person, "!!document.getElementById('users-name')", 10_000)
.map_err(|_| "back at one user, no name is asked".to_owned())?;
Ok(format!("/{account}: 1 user, 10 GB; a name for one user only"))
});
go(rows, "one-named-user-is-added-at-once-holding-10-gb-with-an-invite", || {
want(!account.is_empty(), || "no account (the row before failed)".to_owned())?;
type_into(c, person, "users-count", "1")?;
type_into(c, person, "users-room", "10")?;
type_into(c, person, "users-name", "dora")?;
c.eval(person, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
c.wait_for(person, "!!document.getElementById('users-added') || !!document.querySelector('#add-users .fail, .fail-box')", 30_000)?;
let said = c.eval_str(person, "(document.getElementById('users-added') || {textContent: ''}).textContent")?;
let invite = c.eval_str(person, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
want(said.contains("dora is added, with 10 GB"), || format!("the page says {:?}; {}", said, http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
want(invite.contains("invite="), || format!("no invite for dora: {invite:?}"))?;
let listed = employees(c, person, &account)?;
let names: Vec<String> = listed.path("employees").map(|e| e.arr().iter().map(|p| p.text("id")).collect()).unwrap_or_default();
want(names.iter().any(|n| n == "dora"), || format!("gunnar does not list dora: {}", http::cut(&listed.dump(), 200)))?;
Ok(format!("{said} gunnar lists {names:?}"))
});
if r.bench_control.is_none() {
for name in [
"a-short-account-pays-once-at-the-fake-stripe-and-the-seats-come-with-the-room-queued-then-done",
"an-expired-checkout-adds-nobody",
"a-bought-seat-is-filled-from-the-console-with-a-new-user-and-their-invite",
"a-user-whose-name-is-taken-during-the-checkout-is-refunded",
] {
go(rows, name, || Err("NOT RUN: no --bench-monetize with a control door (the fake Stripe is what this row pays)".into()));
}
return;
}
go(rows, "a-short-account-pays-once-at-the-fake-stripe-and-the-seats-come-with-the-room-queued-then-done", || {
want(!account.is_empty(), || "no account (the rows before failed)".to_owned())?;
// The account holds 10 GiB (dora holds all of it): three more users of
// 10 GB are 30 GiB short.
let (code, said) = r.bench_post(&format!("/grant/{account}/10"))?;
want(code == 200, || format!("the bench could not grant {account} 10 GiB: {code} {said}"))?;
let before = employees(c, person, &account)?;
c.navigate(person, &format!("{}/{}", r.origin(), account))?;
c.wait_for(person, "!!document.querySelector('#add-users #users-add')", 30_000)?;
type_into(c, person, "users-count", "3")?;
type_into(c, person, "users-room", "10")?;
c.call(Some(person), "Fetch.enable", r#"{"patterns":[{"urlPattern":"https://checkout.fake-stripe.test/*","requestStage":"Request"}]}"#)?;
c.events.clear();
c.eval(person, "(() => { document.getElementById('users-add').click(); return 1; })()")?;
let Some((id, url)) = c.paused_request(person, 30_000) else {
let _ = c.call(Some(person), "Fetch.disable", "{}");
return Err(format!("Add users did not go to the vendor within 30 s; the page says {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)));
};
let _ = c.call(Some(person), "Fetch.failRequest", &format!(r#"{{"requestId":{},"errorReason":"Aborted"}}"#, esc(&id)));
let _ = c.call(Some(person), "Fetch.disable", "{}");
let sessions: Vec<_> = c.events.iter().filter(|e| e.text("method") == "Fetch.requestPaused").collect();
want(sessions.len() == 1, || format!("{} checkouts for one request", sessions.len()))?;
let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
want(cs.starts_with("cs_test_"), || format!("not a checkout session: {url}"))?;
let none_yet = employees(c, person, &account)?;
want(number(&none_yet, "seats") == number(&before, "seats"), || format!("seats before the money: {}", http::cut(&none_yet.dump(), 200)))?;
// The customer pays; the vendor's notice reaches monetize.
let (code, paid) = r.bench_post(&format!("/pay/{cs}"))?;
want(code == 200 && paid.contains("200"), || format!("paying {cs}: {code} {paid}"))?;
let reference = paid.lines().next().unwrap_or_default().trim_start_matches("reference=").to_owned();
want(!reference.is_empty(), || format!("the bench named no order for {cs}: {paid}"))?;
// Where the vendor's return lands: the order, followed from the server.
let path = format!("{}/paid/{}", r.origin(), reference);
c.navigate(person, &path)?;
c.wait_for(person, "!!document.getElementById('room-progress')", 30_000)
.map_err(|_| format!("no room progress on {path}: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)))?;
let first = c.eval_str(person, "document.getElementById('room-progress').dataset.state")?;
let first_words = c.eval_str(person, "document.getElementById('room-progress').textContent")?;
want(first == "queued" || first == "enlarging", || format!("the paid order's room reads {first:?} ({first_words:?}), not queued"))?;
// monetize's words since 2026-10-05: "Paid · queued for today's batch — your room is reserved …".
want(first_words.starts_with("Paid") && first_words.contains("your room is reserved"), || format!("the buyer reads {first_words:?}"))?;
c.wait_for(person, "document.getElementById('room-progress') && document.getElementById('room-progress').dataset.state === 'done'", 60_000)
.map_err(|_| format!("the room never read done: {}", c.eval_str(person, "document.getElementById('room-progress').textContent").unwrap_or_default()))?;
let done_words = c.eval_str(person, "document.getElementById('room-progress').textContent")?;
let users = c.eval_str(person, "(document.getElementById('order-users') || {textContent: ''}).textContent")?;
want(users.contains("3 seat(s) added"), || format!("the order page says {users:?}"))?;
let after = employees(c, person, &account)?;
want(number(&after, "seats") == number(&before, "seats") + 3, || format!("gunnar holds {} seats, wanted {} + 3: {}", number(&after, "seats"), number(&before, "seats"), http::cut(&after.dump(), 200)))?;
Ok(format!("ONE checkout {cs}; paid → {first} ({first_words}) → done ({done_words}); {users}"))
});
go(rows, "an-expired-checkout-adds-nobody", || {
let before = employees(c, person, &account)?;
let started = c.eval(
person,
&format!(
"fetch('/api/account/employees', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}, count: 2, room_gib: 50}})}}).then(r => r.json())",
esc(&account)
),
)?;
let url = started.text("checkout_url");
want(url.contains("cs_test_"), || format!("no checkout for 100 GiB more: {}", http::cut(&started.dump(), 300)))?;
let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
let (code, said) = r.bench_post(&format!("/expire/{cs}"))?;
want(code == 200, || format!("expiring {cs}: {code} {said}"))?;
std::thread::sleep(Duration::from_millis(500));
let after = employees(c, person, &account)?;
want(number(&after, "seats") == number(&before, "seats"), || format!("an expired checkout added seats: {}", http::cut(&after.dump(), 200)))?;
Ok(format!("{cs} expired; seats stay {}", number(&after, "seats")))
});
go(rows, "a-bought-seat-is-filled-from-the-console-with-a-new-user-and-their-invite", || {
let before = employees(c, person, &account)?;
want(number(&before, "seats_open") > 0, || format!("no open seat to fill: {}", http::cut(&before.dump(), 200)))?;
c.navigate(person, &format!("{}/{}", r.origin(), account))?;
c.wait_for(person, "!!document.querySelector('#fill-seat #seat-fill')", 30_000)
.map_err(|_| format!("no Fill-a-seat on /{account}: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
type_into(c, person, "seat-name", "sam")?;
c.eval(person, "(() => { document.getElementById('seat-fill').click(); return 1; })()")?;
c.wait_for(person, "!!document.getElementById('users-added') && document.getElementById('users-added').textContent.includes('sam')", 30_000)
.map_err(|_| format!("the seat was not filled: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
let invite = c.eval_str(person, "(document.getElementById('employee-invite') || {textContent: ''}).textContent")?;
want(invite.contains("invite=") && invite.contains("user=sam"), || format!("no invite for sam: {invite:?}"))?;
let after = employees(c, person, &account)?;
let names: Vec<String> = after.path("employees").map(|e| e.arr().iter().map(|p| p.text("id")).collect()).unwrap_or_default();
want(names.iter().any(|n| n == "sam"), || format!("gunnar does not list sam: {}", http::cut(&after.dump(), 200)))?;
want(number(&after, "seats_open") + 1 == number(&before, "seats_open"), || format!("no seat was taken: {} → {}", number(&before, "seats_open"), number(&after, "seats_open")))?;
Ok(format!("sam took a seat ({} open now) with an invite", number(&after, "seats_open")))
});
go(rows, "a-user-whose-name-is-taken-during-the-checkout-is-refunded", || {
// A named user on a short account: one checkout. While the customer is
// at the vendor, somebody else takes the name.
let started = c.eval(
person,
&format!(
"fetch('/api/account/employees', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{account: {}, count: 1, room_gib: 50, name: 'quill'}})}}).then(r => r.json())",
esc(&account)
),
)?;
let url = started.text("checkout_url");
want(url.contains("cs_test_"), || format!("no checkout for quill: {}", http::cut(&started.dump(), 300)))?;
let cs = url.rsplit('/').next().unwrap_or_default().split('?').next().unwrap_or_default().to_owned();
let key = r.work.join("quill-key");
let _ = std::fs::remove_file(&key);
let made = std::process::Command::new("ssh-keygen")
.current_dir(&r.work)
.args(["-q", "-t", "ed25519", "-N", "", "-C", "quill", "-f"])
.arg(&key)
.output()
.map_err(|e| format!("ssh-keygen: {e}"))?;
want(made.status.success(), || "ssh-keygen failed".to_owned())?;
r.control(&["user", "add", "quill", "--key", &format!("@{}", key.with_extension("pub").display())])?;
let (code, paid) = r.bench_post(&format!("/pay/{cs}"))?;
want(code == 200, || format!("paying {cs}: {code} {paid}"))?;
let reference = paid.lines().next().unwrap_or_default().trim_start_matches("reference=").to_owned();
c.navigate(person, &format!("{}/paid/{}", r.origin(), reference))?;
c.wait_for(person, "!!document.getElementById('order-users') && document.getElementById('order-users').dataset.refunded === 'true'", 60_000)
.map_err(|_| format!("the order was not refunded: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 400)))?;
let said = c.eval_str(person, "document.getElementById('order-users').textContent")?;
want(said.contains("refunded"), || format!("the buyer reads {said:?}"))?;
Ok(format!("{cs}: {said}"))
});
}