nordisk/gunnar-ui
uitest/src/enterprise.rs
//! **The owner's enterprise list in the console** (2026-10-05), driven as a
//! person drives it and held against gunnar's answers: the account admin's
//! audit log (and its export), the operator's Accounts page that shows totals
//! and no account's names, the look the account admin approves with a touch
//! of their own key, and a name the filter refuses with gunnar's sentence.
//!
//! `admin` is the box administrator's session (the operator); `person` is
//! kim's — a viewer of this console and the admin of kim's own account
//! (`/kim`, holding the private `kim/tool` imported earlier in the matrix).
use std::time::{Duration, Instant};
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.
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 HTTP status the session gets for `path`.
fn status_of(c: &mut Cdp, sess: &str, path: &str) -> Result<u64, String> {
let s = c.eval(sess, &format!("fetch({}).then(r => r.status)", esc(path)))?;
Ok(s.as_f64().unwrap_or(0.0) as u64)
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str, person: &str) {
let mut kim = String::new();
go(rows, "the-account-admin-reads-the-audit-log-and-exports-it", || {
kim = own_account(c, person)?;
c.navigate(person, &format!("{}/account", r.origin()))?;
c.wait_for(person, "!!document.getElementById('account-audit-log')", 30_000)?;
c.eval(person, "(() => { document.getElementById('account-audit-log').click(); return 1; })()")?;
c.wait_for(person, "!!document.getElementById('audit-chain') || !!document.getElementById('audit-refused')", 30_000)?;
let holds = c.eval_str(person, "(document.getElementById('audit-chain') || {dataset: {}}).dataset.holds || ''")?;
let line = c.eval_str(person, "(document.getElementById('audit-chain') || {textContent: ''}).textContent")?;
want(holds == "true", || format!("the chain line is {line:?}; {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
let kinds = c.eval_str(person, "[...document.querySelectorAll('#audit-entries tbody tr')].map(t => t.dataset.kind).join(',')")?;
want(kinds.split(',').any(|k| k == "login"), || format!("kim's own login is not in kim's log: {kinds:?}"))?;
let csv = c.eval_str(
person,
&format!("fetch('/api/account/audit/export?format=csv&account=' + encodeURIComponent({})).then(r => r.text())", esc(&kim)),
)?;
want(csv.starts_with("seq,at_utc,kind,actor,actor_role,target,detail,outcome,hash"), || format!("the export is {}", http::cut(&csv, 200)))?;
// The export is itself in the log.
c.eval(person, "(() => { document.getElementById('audit-apply').click(); return 1; })()")?;
let deadline = Instant::now() + Duration::from_secs(20);
let mut after = String::new();
while Instant::now() < deadline {
after = c.eval_str(person, "[...document.querySelectorAll('#audit-entries tbody tr')].map(t => t.dataset.kind).join(',')")?;
if after.split(',').any(|k| k == "audit.exported") {
break;
}
std::thread::sleep(Duration::from_millis(300));
}
want(after.split(',').any(|k| k == "audit.exported"), || format!("the export is not in the log: {after:?}"))?;
Ok(format!("{line}; {} rows; CSV {} lines", after.split(',').count(), csv.lines().count()))
});
go(rows, "the-operator-is-refused-an-account's-audit-log", || {
want(!kim.is_empty(), || "no account (the row before failed)".to_owned())?;
c.navigate(admin, &format!("{}/admin/audit?account={kim}", r.origin()))?;
c.wait_for(admin, "!!document.getElementById('audit-refused') || !!document.getElementById('audit-chain')", 30_000)?;
let refused = c.eval_str(admin, "(document.getElementById('audit-refused') || {innerText: ''}).innerText")?;
want(!refused.is_empty(), || format!("the operator READ kim's log: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
want(refused.contains("account admin"), || format!("the refusal is not gunnar's sentence: {refused:?}"))?;
Ok(format!("refused: {}", http::cut(&refused, 140)))
});
go(rows, "the-operator's-accounts-page-shows-totals-and-no-account's-names", || {
want(!kim.is_empty(), || "no account (the rows before failed)".to_owned())?;
c.navigate(admin, &format!("{}/admin/accounts", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('#totals dd[data-total=Accounts]') || !!document.querySelector('#accounts-totals .fail-box, #accounts-totals .fail')", 30_000)?;
let accounts = c.eval_str(admin, "(document.querySelector('#totals dd[data-total=Accounts]') || {textContent: ''}).textContent")?;
let n: u64 = accounts.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
want(n >= 2, || format!("Accounts reads {accounts:?}; {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
let page = c.eval_str(admin, MAIN_TEXT)?;
want(!page.contains(&format!("{kim}/tool")), || "the Accounts page names a repository inside kim's account".to_owned())?;
// gunnar's own answer, not the page's: the operator's listing skips
// what is inside kim's account, and a read of it by name is refused.
let listed = c.eval_str(admin, "fetch('/api/repos?per_page=500').then(r => r.text())")?;
want(!listed.contains(&format!("\"{kim}\",\"name\":\"tool\"")) && !listed.contains(&format!("{kim}/tool")), || {
format!("the operator's repository listing shows kim/tool: {}", http::cut(&listed, 300))
})?;
let status = status_of(c, admin, &format!("/api/repos/{kim}/tool"))?;
want(status == 403 || status == 404, || format!("the operator read kim/tool by name: HTTP {status}"))?;
Ok(format!("Accounts {accounts}; kim/tool is not the operator's (HTTP {status})"))
});
// ★ Round 5.3 (the owner on test2 2026-10-08: "Repositories 0 (0 public) ·
// Storage used 0 B" over seven repositories in the operator's `nordisk`):
// the totals are the WHOLE estate's. Two repositories made in a namespace
// of the operator's own — one public — move them by two, and by one public.
go(rows, "the-operator's-totals-count-the-whole-estate-its-own-namespaces-too", || {
let read = |c: &mut Cdp| -> Result<(u64, u64), String> {
c.navigate(admin, &format!("{}/admin/accounts", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('#totals dd[data-total=Repositories]') || !!document.querySelector('#accounts-totals .fail-box, #accounts-totals .fail')", 30_000)?;
let line = c.eval_str(admin, "(document.querySelector('#totals dd[data-total=Repositories]') || {textContent: ''}).textContent")?;
let numbers: Vec<u64> = line.split(|ch: char| !ch.is_ascii_digit()).filter(|w| !w.is_empty()).filter_map(|w| w.parse().ok()).collect();
match numbers.as_slice() {
[repos, public, ..] => Ok((*repos, *public)),
_ => Err(format!("Repositories reads {line:?}")),
}
};
let before = read(c)?;
let forge = format!("forge{}", std::process::id() % 100_000);
let made = c.eval_str(
admin,
&format!(
"fetch('/api/namespaces', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{name: {}, description: ''}})}}).then(r => String(r.status))",
esc(&forge)
),
)?;
want(made == "200", || format!("the operator could not make namespace {forge}: HTTP {made}"))?;
for (name, visibility) in [("open", "public"), ("closed", "private")] {
let status = c.eval_str(
admin,
&format!(
"fetch('/api/repos', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: JSON.stringify({{namespace: {}, name: {}, hash_kind: 'sha256', visibility: {}, description: 'uitest totals'}})}}).then(r => String(r.status))",
esc(&forge),
esc(name),
esc(visibility)
),
)?;
want(status == "200", || format!("the operator could not make {forge}/{name}: HTTP {status}"))?;
}
let after = read(c)?;
want(after.0 == before.0 + 2 && after.1 == before.1 + 1, || {
format!("the totals do not count the operator's own namespace: Repositories {before:?} -> {after:?} after {forge}/open (public) and {forge}/closed")
})?;
Ok(format!("Repositories {} ({} public) -> {} ({} public) with {forge}/open and {forge}/closed", before.0, before.1, after.0, after.1))
});
go(rows, "the-account-admin-approves-the-operator's-look-with-a-touch-and-ends-it", || {
want(!kim.is_empty(), || "no account (the rows before failed)".to_owned())?;
c.navigate(admin, &format!("{}/admin/accounts", r.origin()))?;
c.wait_for(admin, "!!document.getElementById('look-ask')", 30_000)?;
type_into(c, admin, "look-account", &kim)?;
type_into(c, admin, "look-reason", "uitest: a support case")?;
type_into(c, admin, "look-minutes", "30")?;
c.eval(admin, "(() => { document.getElementById('look-ask').click(); return 1; })()")?;
c.wait_for(admin, "!!document.getElementById('look-asked') || !!document.querySelector('#accounts-totals .fail-box, #accounts-totals .fail')", 30_000)?;
let code = c.eval_str(admin, "(document.getElementById('look-asked') || {dataset: {}}).dataset.code || ''")?;
want(code.len() == 7, || format!("no code for kim: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
let before = status_of(c, admin, &format!("/api/repos/{kim}/tool"))?;
want(before == 403 || before == 404, || format!("asking was having: HTTP {before}"))?;
c.navigate(person, &format!("{}/admin/audit", r.origin()))?;
c.wait_for(person, "!!document.querySelector('#looks li[data-state=pending] .look-approve')", 30_000)?;
let shown = c.eval_str(person, "document.querySelector('#looks li[data-state=pending]').innerText")?;
want(shown.contains(&code), || format!("kim is not shown the code {code}: {shown:?}"))?;
c.eval(person, "(() => { document.querySelector('#looks li[data-state=pending] .look-approve').click(); return 1; })()")?;
c.wait_for(person, "!!document.querySelector('#looks li[data-state=approved]') || !!document.querySelector('#audit .fail-box, #audit .fail')", 30_000)?;
let approved = c.eval(person, "!!document.querySelector('#looks li[data-state=approved]')")?;
want(approved.as_bool() == Some(true), || format!("not approved: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
let during = status_of(c, admin, &format!("/api/repos/{kim}/tool"))?;
want(during == 200, || format!("the approved look does not read kim/tool: HTTP {during}"))?;
c.eval(person, "(() => { document.querySelector('#looks li[data-state=approved] .look-end').click(); return 1; })()")?;
c.wait_for(person, "!document.querySelector('#looks li[data-state=approved]')", 30_000)?;
let after = status_of(c, admin, &format!("/api/repos/{kim}/tool"))?;
want(after == 403 || after == 404, || format!("the ended look still reads: HTTP {after}"))?;
// Every step is in kim's log.
let log = c.eval(person, &format!("fetch('/api/account/audit?kinds=operator_access&account=' + encodeURIComponent({})).then(r => r.json())", esc(&kim)))?;
let kinds: Vec<String> = log.path("entries").map(|e| e.arr().iter().map(|x| x.text("kind")).collect()).unwrap_or_default();
want(
kinds == ["operator_access.revoked", "operator_access.approved", "operator_access.requested"],
|| format!("kim's log of the look: {kinds:?}"),
)?;
Ok(format!("code {code}: HTTP {before} → {during} → {after}; logged {kinds:?}"))
});
go(rows, "a-name-the-filter-refuses-is-refused-with-gunnar's-sentence-and-the-admin-is-told", || {
let own = own_account(c, admin)?;
c.navigate(admin, &format!("{}/repos", r.origin()))?;
c.wait_for(admin, "!!document.getElementById('new-repository')", 30_000)?;
c.eval(admin, "(() => { document.getElementById('new-repository').click(); return 1; })()")?;
c.wait_for(admin, "!!document.getElementById('name')", 10_000)?;
type_into(c, admin, "ns", &own)?;
type_into(c, admin, "name", "fuck-this")?;
c.eval(admin, "(() => { [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Create').click(); return 1; })()")?;
let deadline = Instant::now() + Duration::from_secs(20);
let mut page = String::new();
while Instant::now() < deadline {
page = c.eval_str(admin, MAIN_TEXT)?;
if page.contains("is refused") {
break;
}
std::thread::sleep(Duration::from_millis(200));
}
want(page.contains("is refused") && page.to_lowercase().contains("choose another name"), || {
format!("gunnar's sentence is not shown: {}", http::cut(&page, 300))
})?;
let status = status_of(c, admin, &format!("/api/repos/{own}/fuck-this"))?;
want(status == 404 || status == 403, || format!("the refused name exists: HTTP {status}"))?;
// The account admin is told — the operator is the admin of its own
// account here, and its Audit log's Names panel lists the refusal.
c.navigate(admin, &format!("{}/admin/audit", r.origin()))?;
c.wait_for(admin, "!!document.querySelector('#names li[data-name=\"fuck-this\"]') || !!document.getElementById('names-none')", 30_000)?;
let noted = c.eval_str(admin, "(document.querySelector('#names li[data-name=\"fuck-this\"]') || {dataset: {}}).dataset.state || ''")?;
want(noted == "refused", || format!("the refusal is not in the account's queue: {noted:?}"))?;
Ok(format!("{own}/fuck-this refused with gunnar's sentence; noted for the account admin"))
});
}