uitest/src/repodelete.rs
//! ★ **The repository's Danger zone, in a real browser against the real gunnar** (owner spec
//! 2026-10-28; the GHUNT-SOFTDEL report "deleted in the console, still serves over SSH").
//!
//! The operator `bob` (`sess`) opens Settings of a repository he pushed, presses **Delete this
//! repository** in the red-bordered Danger zone, types the path back (a wrong one keeps the
//! button disabled), and deletes it with a real mouse click. The page goes to the repository
//! list with "Deleted bob/del-probe" and an Undo: the repository is gone from the list and
//! `git ls-remote` over the rig's SSH is refused; Undo brings both back. `kim` (`sess2`, a
//! viewer who is no admin of `bob`) sees the same section with the server's refusal sentence,
//! not a hidden one, and a DELETE sent past the page is gunnar's 403.
use std::time::Duration;
use crate::cdp::Cdp;
use crate::http;
use crate::json::{esc, Json};
use crate::matrix::{go, want, Row};
use crate::rig::Rig;
const NS: &str = "bob";
const PROBE: &str = "del-probe";
const PUBLIC: &str = "del-public";
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";
/// A person's click: the mouse pressed and released at the middle of the element's own box,
/// scrolled into view first.
fn click(c: &mut Cdp, sess: &str, selector: &str) -> Result<(), String> {
let at = c.eval(
sess,
&format!(
"(() => {{ const e = document.querySelector({}); if (!e) return null; e.scrollIntoView({{block: 'center'}}); \
const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }})()",
esc(selector)
),
)?;
let xy: Vec<f64> = at.arr().iter().filter_map(Json::as_f64).collect();
want(xy.len() == 2, || format!("no {selector} to press"))?;
for kind in ["mousePressed", "mouseReleased"] {
c.call(
Some(sess),
"Input.dispatchMouseEvent",
&format!(r#"{{"type":"{kind}","x":{},"y":{},"button":"left","clickCount":1}}"#, xy[0], xy[1]),
)?;
}
Ok(())
}
/// Typed into an input as a person's keystrokes would leave it: the value, then `input`.
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; i.focus();
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"))
}
fn truthy(c: &mut Cdp, sess: &str, expr: &str) -> Result<bool, String> {
Ok(c.eval(sess, &format!("!!({expr})"))?.as_bool().unwrap_or(false))
}
/// One request made by the page's own session: its status.
fn status_of(c: &mut Cdp, sess: &str, method: &str, path: &str, body: Option<&str>) -> Result<u64, 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(r => r.status)", esc(path)))?;
Ok(j.as_f64().unwrap_or(0.0) as u64)
}
/// Is `path` in the console's repository list, as this session's page reads it?
fn listed(c: &mut Cdp, sess: &str, path: &str) -> Result<bool, String> {
let j = c.eval(
sess,
&format!(
"fetch('/api/repos?q=' + encodeURIComponent({})).then(r => r.json()).then(j => (j.items || []).some(x => x.path === {}))",
esc(path),
esc(path)
),
)?;
Ok(j.as_bool() == Some(true))
}
/// Settings of `ns/name`, and the Danger zone's answer drawn.
fn settings(c: &mut Cdp, sess: &str, r: &Rig, name: &str) -> Result<(), String> {
c.navigate(sess, &format!("{}/repo/{NS}/{name}/settings", r.origin()))?;
c.wait_for(
sess,
"!!document.getElementById('repo-delete-open') || !!document.getElementById('repo-delete-refused')",
30_000,
)
.map_err(|e| {
let text = c.eval_str(sess, MAIN_TEXT).unwrap_or_default();
format!("the Danger zone never drew its answer ({e}): {}", http::cut(&text, 400))
})
}
pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, sess: &str, sess2: &str) {
let probe = format!("{NS}/{PROBE}");
go(rows, "a-wrong-name-keeps-the-delete-button-disabled", || {
// A repository of bob's with a commit: what `git ls-remote` is checked against below.
let wt = r.work.join("del-probe");
std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
r.git(&wt, &["init", "-q", "-b", "main", "."])?;
r.git(&wt, &["config", "user.name", "uitest"])?;
r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
std::fs::write(wt.join("README"), "deleted from the console, then undone\n").map_err(|e| e.to_string())?;
r.git(&wt, &["add", "README"])?;
r.git(&wt, &["commit", "-qm", "probe"])?;
r.git(&wt, &["push", "-q", &r.remote(&probe), "main"])?;
settings(c, sess, r, PROBE)?;
want(truthy(c, sess, "document.querySelector('#danger-zone.panel.danger-zone h2')?.textContent.trim() === 'Danger zone'")?, || {
"no section titled Danger zone".to_owned()
})?;
// Red-bordered: the zone's border is not an ordinary panel's.
let borders = c.eval_str(
sess,
"(() => { const z = getComputedStyle(document.getElementById('danger-zone')).borderTopColor; \
const p = [...document.querySelectorAll('section.panel')].find(e => e.id !== 'danger-zone'); \
return z + ' | ' + (p ? getComputedStyle(p).borderTopColor : ''); })()",
)?;
let (zone, plain) = borders.split_once(" | ").unwrap_or((&borders, ""));
want(!zone.is_empty() && zone != plain, || format!("the Danger zone's border is an ordinary panel's: {borders}"))?;
// Last on the tab.
want(truthy(c, sess, "(() => { const all = [...document.querySelectorAll('main section.panel')]; return all[all.length - 1]?.id === 'danger-zone'; })()")?, || {
"the Danger zone is not the last section of Settings".to_owned()
})?;
click(c, sess, "#repo-delete-open")?;
c.wait_for(sess, "!!document.getElementById('repo-delete-confirm')", 10_000)?;
want(truthy(c, sess, "document.getElementById('repo-delete').disabled")?, || "the button is armed before anything is typed".to_owned())?;
type_into(c, sess, "repo-delete-confirm", "bob/del-prob")?;
std::thread::sleep(Duration::from_millis(300));
want(truthy(c, sess, "document.getElementById('repo-delete').disabled")?, || "a wrong name armed the button".to_owned())?;
click(c, sess, "#repo-delete")?;
std::thread::sleep(Duration::from_millis(800));
// GUN-GUEST (2026-10-30): a repository's Settings is `/{ns}/{name}/settings`.
want(truthy(c, sess, &format!("location.pathname === {}", esc(&format!("/{probe}/settings"))))?, || {
"a disabled button left the page".to_owned()
})?;
want(status_of(c, sess, "GET", &format!("/api/repos/{probe}"), None)? == 200, || "a disabled button deleted it".to_owned())?;
r.git(&r.work, &["ls-remote", &r.remote(&probe), "refs/heads/main"])?;
Ok(format!("{probe}: Danger zone last and red-bordered ({zone}); bob/del-prob keeps the button disabled"))
});
go(rows, "an-operator-deletes-a-repository-in-the-danger-zone-and-undoes-it", || {
if !truthy(c, sess, "!!document.getElementById('repo-delete-confirm')")? {
settings(c, sess, r, PROBE)?;
click(c, sess, "#repo-delete-open")?;
c.wait_for(sess, "!!document.getElementById('repo-delete-confirm')", 10_000)?;
}
let said = c.eval_str(sess, "document.getElementById('repo-delete-consequence').innerText")?;
want(said.contains("soft delete") && said.contains("restored for 30 days"), || {
format!("the confirm step does not say soft delete and the server's 30 days: {said}")
})?;
type_into(c, sess, "repo-delete-confirm", &probe)?;
c.wait_for(sess, "!document.getElementById('repo-delete').disabled", 5_000)
.map_err(|e| format!("the exact path does not arm the button: {e}"))?;
click(c, sess, "#repo-delete")?;
c.wait_for(sess, "location.pathname === '/repos' && !!document.getElementById('repo-deleted')", 20_000).map_err(|e| {
let text = c.eval_str(sess, MAIN_TEXT).unwrap_or_default();
format!("the delete did not land on the list with its notice ({e}): {}", http::cut(&text, 300))
})?;
let notice = c.eval_str(sess, "document.getElementById('repo-deleted').innerText.trim()")?;
want(notice == format!("Deleted {probe}"), || format!("the notice says {notice:?}"))?;
want(truthy(c, sess, "document.getElementById('repo-recently-deleted')?.getAttribute('href') === '/admin/storage#deleted'")?, || {
"the administrator is not linked to Recently deleted".to_owned()
})?;
// Gone from the list, the page's and the server's.
c.wait_for(
sess,
&format!("![...document.querySelectorAll('main table a')].some(a => a.textContent.trim() === {})", esc(&probe)),
10_000,
)
.map_err(|e| format!("{probe} is still in the list on the page: {e}"))?;
want(!listed(c, sess, &probe)?, || format!("{probe} is still in /api/repos"))?;
// And git refuses it over SSH.
let refused = r.git(&r.work, &["ls-remote", &r.remote(&probe)]);
want(refused.is_err(), || format!("git ls-remote still answers for a deleted repository: {}", http::cut(&refused.clone().unwrap_or_default(), 200)))?;
// Undo, from the notice.
click(c, sess, "#repo-undo")?;
c.wait_for(sess, "!!document.getElementById('repo-restored') || !!document.getElementById('repo-undo-refused')", 20_000)?;
let back = c.eval_str(sess, "(document.getElementById('repo-restored') || document.getElementById('repo-undo-refused')).innerText.trim()")?;
want(back == format!("Restored {probe}"), || format!("Undo said {back:?}"))?;
c.wait_for(
sess,
&format!("[...document.querySelectorAll('main table a')].some(a => a.textContent.trim() === {})", esc(&probe)),
10_000,
)
.map_err(|e| format!("{probe} is not back in the list on the page: {e}"))?;
want(listed(c, sess, &probe)?, || format!("{probe} is not back in /api/repos"))?;
let refs = r.git(&r.work, &["ls-remote", &r.remote(&probe), "refs/heads/main"])?;
want(refs.contains("refs/heads/main"), || format!("git does not serve it again: {}", http::cut(&refs, 200)))?;
Ok(format!("deleted through the button (\"{notice}\"), gone from the list, ls-remote refused; Undo: \"{back}\", listed and served again"))
});
go(rows, "a-user-below-operator-sees-the-delete-refusal", || {
let made = status_of(
c,
sess,
"POST",
"/api/repos",
Some(&format!(r#"{{"namespace":"{NS}","name":"{PUBLIC}","visibility":"public"}}"#)),
)?;
want((200..300).contains(&made), || format!("bob could not make {NS}/{PUBLIC}: {made}"))?;
settings(c, sess2, r, PUBLIC)?;
want(truthy(c, sess2, "!!document.getElementById('danger-zone')")?, || "the section is hidden from kim".to_owned())?;
want(!truthy(c, sess2, "!!document.getElementById('repo-delete-open')")?, || "kim is offered the delete".to_owned())?;
let why = c.eval_str(sess2, "document.getElementById('repo-delete-refused').innerText.trim()")?;
want(why.contains(&format!("Only an admin of {NS}/{PUBLIC} or of its account may delete it.")), || {
format!("kim is not told whose it is: {why}")
})?;
want(why.len() > "Only an admin of bob/del-public or of its account may delete it.".len(), || {
format!("gunnar's own reason is not in the sentence: {why}")
})?;
// Past the page: gunnar's refusal, and nothing deleted.
let sent = status_of(c, sess2, "DELETE", &format!("/api/repos/{NS}/{PUBLIC}"), None)?;
want(sent == 403, || format!("kim's DELETE answered {sent}"))?;
want(status_of(c, sess, "GET", &format!("/api/repos/{NS}/{PUBLIC}"), None)? == 200, || "kim's DELETE removed it".to_owned())?;
Ok(format!("kim reads: {}", http::cut(&why, 160)))
});
}