Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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)))
    });
}