//! Web edits (hub spec §6.9, §6.10) under a trust policy: refused until //! the user delegates to the hub key, then edits, new files, proposals //! landed by the hub as the user's delegate, browser conflict resolution, //! and closing keywords. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::{IdentityScope, Repo}; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::repos::RepoName; use oxygenhub::Shared; use std::path::Path; use tower::ServiceExt; fn hermetic() { static ONCE: std::sync::Once = std::sync::Once::new(); static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); ONCE.call_once(|| { let d = tempfile::tempdir().unwrap(); std::env::set_var("OXY_HOME", d.path()); DIR.set(d).ok(); }); } fn write(root: &Path, rel: &str, content: &[u8]) { let p = root.join(rel); std::fs::create_dir_all(p.parent().unwrap()).unwrap(); std::fs::write(p, content).unwrap(); } struct Res { status: StatusCode, body: String, cookie: Option, location: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { let mut req = Request::builder().method(method).uri(path); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } let body = match form { Some(f) => { req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); Body::from(f.to_string()) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .and_then(|v| v.split(';').next()) .and_then(|kv| kv.split_once('=')) .map(|(_, v)| v.to_string()); let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); let bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } } /// Read server-sent events from `body` until one of `kind` arrives. async fn next_event(body: &mut Body, kind: &str) -> String { let mut buf = String::new(); loop { let frame = tokio::time::timeout(std::time::Duration::from_secs(5), body.frame()) .await .unwrap_or_else(|_| panic!("no {kind} event; got {buf}")) .expect("the stream ended") .unwrap(); if let Ok(data) = frame.into_data() { buf.push_str(&String::from_utf8_lossy(&data)); } while let Some(end) = buf.find("\n\n") { let event: String = buf.drain(..end + 2).collect(); if event.contains(&format!("event: {kind}\n")) { return event; } } } } fn csrf_of(page: &str) -> String { let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); page[i..].split('"').next().unwrap().to_string() } fn enc(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } #[tokio::test(flavor = "multi_thread")] async fn web_edits_under_a_policy() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = vec!["site".into()]; config.registration.mode = "open".into(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); let base = format!("http://127.0.0.1:{port}"); let app = &app; let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; let andy = r.cookie.unwrap(); let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); // Andy's client: an identity, a policy naming it, a first push. let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", IdentityScope::Repo).unwrap(); client.config.set("core.sign", "change").unwrap(); client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); client.policy_init().unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); client.snapshot("t").unwrap(); client.commit("first", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); let key = client.config.author_key.unwrap(); let id = client.identity(&key).unwrap(); db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap(); // Before delegating: the page explains, and a save is refused. let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("not delegated"), "{}", page.body); assert!(page.body.contains("textarea class=\"code\"")); let csrf = csrf_of(&page.body); let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content=x&message=m")), ) .await; assert!(r.body.contains("not delegated"), "refused: {}", r.body); // Delegate to the hub key (the policy follows the rotation), push it, link the new identity. client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "policy update pushed"); let key2 = client.config.author_key.unwrap(); let id2 = client.identity(&key2).unwrap(); assert!(id2.delegate(&app.hub_key.key).is_some()); // The successor was pushed with a change it authored? Not yet: link it // by hand here, as before; the auto-link is checked further down. db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap(); // An edit lands on main, authored by Andy, signed by the hub as his delegate. let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!( "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=commit", enc("one\ntwo\nthree\n"), enc("Add three") )), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/a.txt")); let raw = send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await; assert_eq!(raw.body, "one\ntwo\nthree\n"); let hist = send(app, "GET", "/andy/demo/changes/main", Some(&andy), None).await.body; assert!(hist.contains("Add three")); assert!(send(app, "GET", "/andy/demo/pushes", None, None).await.body.contains("web edit")); let trust = send(app, "GET", "/andy/demo/trust", None, None).await.body; assert!(!trust.contains("INVALID"), "every change verifies: {trust}"); // A new file. let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!("_csrf={csrf}&path=docs/guide.md&content={}&message=Guide&mode=commit", enc("# Guide\n"))), ) .await; assert_eq!(r.location.as_deref(), Some("/andy/demo/file/main/docs/guide.md"), "{}", r.body); // An issue closed by a web edit's message. let csrf_i = csrf_of(&send(app, "GET", "/andy/demo/issues/new", Some(&andy), None).await.body); send(app, "POST", "/andy/demo/issues/new", Some(&andy), Some(&format!("_csrf={csrf_i}&title=Typo&body="))).await; let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!( "_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&content={}&message={}&mode=commit", enc("# The guide\n"), enc("Fix typo, closes #1") )), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let row = db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap(); assert_eq!(db::issues::get(&app.db, row.id, 1).await.unwrap().unwrap().state, "closed"); // Propose: a new bookmark and a proposal, which the hub lands under the policy as Andy's delegate. let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!( "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={}&mode=propose&new_bookmark=tweak", enc("ONE\ntwo\nthree\n"), enc("Shout one") )), ) .await; assert_eq!(r.location.as_deref(), Some("/andy/demo/proposal/2"), "{}", r.body); let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await; assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body); // Nobody approved, and the repository requires no approval: the page says so and Land asks first. assert!(page.body.contains("settings require none."), "{}", page.body); assert!(page.body.contains("without any approval?"), "{}", page.body); let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; // The action redirects back (so a reload never repeats it), saying so. let back = r.location.clone().unwrap_or_default(); assert!(back.starts_with("/andy/demo/proposal/2?") && back.contains("notice=Landed."), "{back}"); assert!(send(app, "GET", &back, Some(&andy), None).await.body.contains("Landed.")); assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "ONE\ntwo\nthree\n"); // A conflict: two proposals edit the same line from the same base; landing both conflicts main. db::proposals::set_requirements( &app.db, row.id, db::proposals::Requirements { required_approvals: 0, require_owners: false, allow_conflicts: true }, ) .await .unwrap(); for (b, text) in [("left", "Left\ntwo\nthree\n"), ("right", "Right\ntwo\nthree\n")] { let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!("_csrf={csrf}&path=a.txt&original=a.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))), ) .await; assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body); } for n in [3, 4] { let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; let r = send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?}", r.location); } let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; assert!(conflicts.contains("resolve region by region") && conflicts.contains("/andy/demo/resolve/main/a.txt"), "{conflicts}"); let page = send(app, "GET", "/andy/demo/edit/main/a.txt", Some(&andy), None).await; assert!(page.body.contains("Resolve a.txt") && page.body.contains("<<<<<<< oxy conflict"), "{}", page.body); // Blame of a conflicted file: its markers are lines no change wrote, // so the page has more lines than blame rows, and still renders. let blamed = send(app, "GET", "/andy/demo/file/main/a.txt?blame=1", Some(&andy), None).await; assert_eq!(blamed.status, 200, "{}", blamed.body); assert!(blamed.body.contains("oxy conflict") && blamed.body.contains("class=\"blame\">"), "{}", blamed.body); // Leaving markers in is refused; removing them resolves. let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!( "_csrf={csrf}&path=a.txt&original=a.txt&content={}&message=half&mode=commit", enc("<<<<<<< oxy conflict 1 of 1, side 1 of 2: change x \"y\"\nLeft\n") )), ) .await; assert_ne!(r.status, StatusCode::SEE_OTHER); // The resolver: region by region, against the state it showed. let page = send(app, "GET", "/andy/demo/resolve/main/a.txt", Some(&andy), None).await; assert!( page.body.contains("id=\"region-1\"") && page.body.contains("keep side 2") && page.body.contains("keep every side"), "{}", page.body ); assert!( page.body.contains("vscode://oxygenhub.oxygen-vscode/resolve?url=") && page.body.contains("&bookmark=main&path=a.txt"), "{}", page.body ); let state = { let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len(); page.body[i..i + 64].to_string() }; // Live: one tab follows the room, another shares a choice (P1b). let live_req = Request::builder() .uri("/andy/demo/resolve-live/main/a.txt?client=tab-a") .header(header::COOKIE, format!("oxyhub={andy}")) .body(Body::empty()) .unwrap(); let res = oxygenhub::router(app.clone()).oneshot(live_req).await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let mut stream = res.into_body(); let snapshot = next_event(&mut stream, "snapshot").await; assert!(snapshot.contains("\"present\":[\"andy\"]"), "{snapshot}"); let choose = |region: &str, choice: &str| { let body = serde_json::json!({ "_csrf": csrf, "client": "tab-b", "region": region, "choice": choice, "text": "" }).to_string(); Request::builder() .method("POST") .uri("/andy/demo/resolve-live/main/a.txt") .header(header::COOKIE, format!("oxyhub={andy}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) .unwrap() }; let r = oxygenhub::router(app.clone()).oneshot(choose("1", "side2")).await.unwrap(); assert_eq!(r.status(), StatusCode::NO_CONTENT); let shared = next_event(&mut stream, "choice").await; assert!(shared.contains("\"side2\"") && shared.contains("\"tab-b\""), "{shared}"); assert_ne!(oxygenhub::router(app.clone()).oneshot(choose("1", "rm -rf")).await.unwrap().status(), StatusCode::NO_CONTENT); let stale = send( app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={}&r1=side2&message=x", "0".repeat(64))), ) .await; assert!(stale.body.contains("has moved since this page was loaded"), "{}", stale.body); let unchosen = send(app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={state}&message=x"))).await; assert!(unchosen.body.contains("choose how to resolve conflict 1"), "{}", unchosen.body); let r = send( app, "POST", "/andy/demo/resolve/main/a.txt", Some(&andy), Some(&format!("_csrf={csrf}&state={state}&r1=text&t1={}&message={}", enc("Both"), enc("Resolve a.txt"))), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let change = r.location.unwrap(); assert!(change.contains("/andy/demo/change/"), "{change}"); let landed = next_event(&mut stream, "landed").await; assert!(landed.contains(&change), "the other tab hears the resolution land: {landed}"); drop(stream); assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict")); assert_eq!(send(app, "GET", "/andy/demo/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n"); let shown = send(app, "GET", &change, Some(&andy), None).await.body; assert!(shown.to_lowercase().contains("resolution"), "a resolution change"); // A name conflict: two proposals add different files called new.txt. for (b, text) in [("first-new", "first\n"), ("second-new", "second\n")] { let r = send( app, "POST", "/andy/demo/edit/main", Some(&andy), Some(&format!("_csrf={csrf}&path=new.txt&content={}&message={b}&mode=propose&new_bookmark={b}", enc(text))), ) .await; assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body); } for n in [5, 6] { let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await; let r = send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body); } let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; assert!(conflicts.contains("choose which to keep") && conflicts.contains("/andy/demo/resolve/main/new.txt"), "{conflicts}"); let page = send(app, "GET", "/andy/demo/resolve/main/new.txt", Some(&andy), None).await; assert!(page.body.contains("new.txt.oxy-conflict-1") && page.body.contains("new.txt.oxy-conflict-2"), "{}", page.body); let state = { let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len(); page.body[i..i + 64].to_string() }; // Which suffix holds "second" depends on node order; keep that one. let preview = |text: &str| page.body.find(&format!("
{text}")).expect("a candidate's preview");
    let second = if preview("second") < preview("first") { 0 } else { 1 };
    let r = send(
        app,
        "POST",
        "/andy/demo/resolve/main/new.txt",
        Some(&andy),
        Some(&format!("_csrf={csrf}&state={state}&keep={second}&message={}", enc("Keep the second new.txt"))),
    )
    .await;
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
    // The kept file moved to the name, keeping its identity.
    let hash = r.location.unwrap().rsplit('/').next().unwrap().to_string();
    let detail = send(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{hash}"), Some(&andy), None).await.body;
    assert!(detail.contains("\"kind\":\"renamed\""), "{detail}");
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
    assert_eq!(send(app, "GET", "/andy/demo/file/main/new.txt?plain=1", None, None).await.body, "second\n");
    let tree = send(app, "GET", "/andy/demo/tree/main", None, None).await.body;
    assert!(!tree.contains("oxy-conflict"), "the candidates are gone");

    // A replace conflict: a binary file (a NUL makes it opaque) replaced
    // two ways by proposals that do not know each other.
    let r = send(
        app,
        "POST",
        "/andy/demo/edit/main",
        Some(&andy),
        Some(&format!("_csrf={csrf}&path=img.bin&content=base%00&message=img&mode=commit")),
    )
    .await;
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
    for (b, text) in [("img-a", "alpha%00"), ("img-b", "beta%00")] {
        let r = send(
            app,
            "POST",
            "/andy/demo/edit/main",
            Some(&andy),
            Some(&format!("_csrf={csrf}&path=img.bin&original=img.bin&content={text}&message={b}&mode=propose&new_bookmark={b}")),
        )
        .await;
        assert!(r.location.as_deref().is_some_and(|l| l.contains("/proposal/")), "{}", r.body);
    }
    for n in [7, 8] {
        let page = send(app, "GET", &format!("/andy/demo/proposal/{n}"), Some(&andy), None).await;
        let r =
            send(app, "POST", &format!("/andy/demo/proposal/{n}/land"), Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await;
        assert!(r.location.as_deref().is_some_and(|l| l.contains("notice=Landed.")), "#{n}: {:?} {}", r.location, r.body);
    }
    let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body;
    assert!(conflicts.contains("replace (2 candidates)") && conflicts.contains("/andy/demo/resolve/main/img.bin"), "{conflicts}");
    let page = send(app, "GET", "/andy/demo/resolve/main/img.bin", Some(&andy), None).await;
    assert!(page.body.contains("has 2 versions") && page.body.contains("version 2 from change"), "{}", page.body);
    let state = {
        let i = page.body.find("name=\"state\" value=\"").unwrap() + "name=\"state\" value=\"".len();
        page.body[i..i + 64].to_string()
    };
    let version = |text: &str| page.body.find(&format!("
{text}")).expect("a version's preview");
    let beta = if version("beta") < version("alpha") { 0 } else { 1 };
    let r = send(
        app,
        "POST",
        "/andy/demo/resolve/main/img.bin",
        Some(&andy),
        Some(&format!("_csrf={csrf}&state={state}&keep={beta}&message={}", enc("Keep beta"))),
    )
    .await;
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
    assert!(send(app, "GET", "/andy/demo/conflicts", None, None).await.body.contains("No bookmark carries a conflict"));
    assert_eq!(send(app, "GET", "/andy/demo/raw/main/img.bin", None, None).await.body, "beta\u{0}");
    let change = send(app, "GET", r.location.as_deref().unwrap(), Some(&andy), None).await.body;
    assert!(change.contains(">resolution<"), "a resolution change");

    // Delete a file.
    let r = send(
        app,
        "POST",
        "/andy/demo/edit/main",
        Some(&andy),
        Some(&format!("_csrf={csrf}&path=docs/guide.md&original=docs/guide.md&message=Drop&delete=1")),
    )
    .await;
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
    assert_eq!(send(app, "GET", "/andy/demo/file/main/docs/guide.md", None, None).await.status, StatusCode::NOT_FOUND);

    // Andy's client pulls it all and every change verifies there too.
    client.pull("origin").unwrap();
    let v: Vec<_> =
        client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect();
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
}

/// A rotation reaches the hub with the next push: an author whose identity
/// is a successor of a linked one, with the same key pair, is linked
/// without another challenge. An agent's change shows who signed for the
/// author, on the change page, in history rows and over the API.
#[tokio::test(flavor = "multi_thread")]
async fn a_rotated_identity_links_itself_and_an_agent_s_change_says_via() {
    hermetic();
    let dir = tempfile::tempdir().unwrap();
    let mut config = Config::load(dir.path()).unwrap();
    config.auth.push_tokens = vec!["site".into()];
    config.registration.mode = "open".into();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let port = listener.local_addr().unwrap().port();
    config.server.base_url = format!("http://127.0.0.1:{port}");
    let app = oxygenhub::build(config).await.unwrap();
    let router = oxygenhub::router(app.clone());
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
    let base = format!("http://127.0.0.1:{port}");
    let app = &app;
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
    assert!(r.cookie.is_some());
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
    let repo = RepoName::parse("andy/demo").unwrap();
    app.repos.create(&repo, None).unwrap();
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
    let work = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    let mut client = Repo::init(work.path()).unwrap().with_home(home.path());
    client.create_identity("Andy", "andy@example.com", IdentityScope::Repo).unwrap();
    client.config.set("core.sign", "change").unwrap();
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
    client.config.set("remote.origin.token", "site").unwrap();
    client.save_config().unwrap();
    write(work.path(), "a.txt", b"one\n");
    client.snapshot("t").unwrap();
    client.commit("first", &[]).unwrap();
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
    let key1 = client.config.author_key.unwrap();
    let id1 = client.identity(&key1).unwrap();
    db::accounts::link_identity(&app.db, andy_id, &key1.to_hex(), &id1.name, &id1.email, &id1.encode()).await.unwrap();

    // An agent with its own key; Andy delegates (a rotation) and commits.
    let agent_home = tempfile::tempdir().unwrap();
    let mut agent_repo = Repo::init(&work.path().join("agent-scratch")).unwrap().with_home(agent_home.path());
    let (agent_key, agent_id) = agent_repo.create_identity("Antigravity", "antigravity@agents.local", IdentityScope::User).unwrap();
    client.delegate_add(agent_id, oxy_core::identity::Scope::CHANGES, None).unwrap();
    let key2 = client.config.author_key.unwrap();
    assert_ne!(key1, key2);
    write(work.path(), "a.txt", b"one\ntwo\n");
    client.snapshot("t").unwrap();
    let by_andy = client.commit("second, by Andy", &[]).unwrap();
    // The agent commits in Andy's checkout: Andy as author, its own signature.
    let mut as_agent = Repo::open(work.path()).unwrap().with_home(agent_home.path());
    as_agent.set_user_config("author.signer", &agent_key.to_hex()).unwrap();
    as_agent.set_user_config("core.sign", "change").unwrap();
    write(work.path(), "b.txt", b"by the agent\n");
    as_agent.snapshot("t").unwrap();
    let by_agent = as_agent.commit("third, by the agent", &[]).unwrap();
    // Andy pushes both.
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));

    // The successor is linked to Andy's account by the push hook, which runs
    // after the push answers: wait for it, up to ten seconds (#121).
    let mut linked = None;
    for _ in 0..200 {
        linked = db::accounts::identity_by_key(&app.db, &key2.to_hex()).await.unwrap();
        if linked.is_some() {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    }
    assert_eq!(linked.map(|l| l.account_id), Some(andy_id), "the rotated identity links itself");
    assert!(db::accounts::identity_by_key(&app.db, &agent_key.to_hex()).await.unwrap().is_none(), "an agent's key is nobody's account");

    // The change page: Andy as author, a via row naming the agent.
    let page = send(app, "GET", &format!("/andy/demo/change/{}", by_agent.to_hex()), None, None).await.body;
    assert!(page.contains("
via
"), "{page}"); assert!(page.contains("Antigravity") && page.contains("delegate for changes"), "{page}"); assert!(page.contains("href=\"/andy\""), "the author is Andy's account: {page}"); let andy_page = send(app, "GET", &format!("/andy/demo/change/{}", by_andy.to_hex()), None, None).await.body; assert!(!andy_page.contains("
via
"), "{andy_page}"); // History rows and the API say so too. let rows = send(app, "GET", "/andy/demo/changes/main", None, None).await.body; assert!(rows.contains("via Antigravity"), "{rows}"); let hist = send(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await.body; let v: serde_json::Value = serde_json::from_str(&hist).unwrap(); let agent_change = v["changes"].as_array().unwrap().iter().find(|c| c["hash"] == by_agent.to_hex()).unwrap(); assert_eq!(agent_change["via"]["name"], "Antigravity"); assert_eq!(agent_change["via"]["scope"], "changes"); assert_eq!(agent_change["author"]["account"], "andy"); // Contributors: the agent's change counts for Andy, named under him. let overview = send(app, "GET", "/andy/demo", None, None).await.body; assert!( overview.contains("/avatar/agent/Antigravity") && overview.contains("Antigravity · 1"), "{overview}" ); assert_eq!(overview.matches("class=\"avatar sm\" src=\"/avatar/andy\"").count(), 1, "one row for Andy, whatever his keys"); let mark = send(app, "GET", "/avatar/agent/Claude%20Code", None, None).await; assert!(mark.body.contains("Claude"), "a known agent gets its brand mark"); assert!(send(app, "GET", "/avatar/agent/HAL", None, None).await.body.contains("archived<")); write(work.path(), "a.txt", b"one\ntwo\nthree\n"); client.snapshot("t").unwrap(); client.commit("third", &[]).unwrap(); let pushed = client.push("origin", false); assert!(pushed.is_err() || !pushed.unwrap().iter().all(|r| r.ok), "an archived repository takes no pushes"); let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await; assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body); let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await; assert!(r.body.contains("The repository is back"), "{}", r.body); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); } #[tokio::test(flavor = "multi_thread")] async fn a_wiki_is_edited_on_the_web_and_cloned_and_pushed_as_name_dot_wiki() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = vec!["site".into()]; config.registration.mode = "open".into(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); let base = format!("http://127.0.0.1:{port}"); let app = &app; let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; let andy = r.cookie.unwrap(); let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; let repo = RepoName::parse("andy/demo").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); // Andy delegates to the hub key and links the identity, as for code edits. let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", IdentityScope::Repo).unwrap(); client.config.set("core.sign", "change").unwrap(); client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\n"); client.snapshot("t").unwrap(); client.commit("first", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); let key = client.config.author_key.unwrap(); let id = client.identity(&key).unwrap(); db::accounts::link_identity(&app.db, andy_id, &key.to_hex(), &id.name, &id.email, &id.encode()).await.unwrap(); // No wiki yet: the tab shows for a writer, the page explains. let page = send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await; assert_eq!(page.status, StatusCode::OK, "{}", page.body); assert!(page.body.contains("No wiki yet") && page.body.contains(">Wiki") && page.body.contains("andy/demo.wiki"), "{}", page.body); assert!(!send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki"), "no tab for a reader without a wiki"); // A repository cannot take the wiki's address. let csrf_n = csrf_of(&send(app, "GET", "/new", Some(&andy), None).await.body); let r = send(app, "POST", "/new", Some(&andy), Some(&format!("_csrf={csrf_n}&owner=andy&name=demo.wiki&visibility=public"))).await; assert!(r.body.contains("cannot end in .wiki"), "{}", r.body); // The first page, with a link to a page not yet written. let csrf = csrf_of(&page.body); let r = send( app, "POST", "/andy/demo/wiki/_new/edit", Some(&andy), Some(&format!("_csrf={csrf}&title=Home&content={}", enc("# Welcome\n\nSee [the guide](Guide).\n"))), ) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home")); let home = send(app, "GET", "/andy/demo/wiki", None, None).await.body; assert!( home.contains("Welcome") && home.contains("href=\"/andy/demo/wiki/Guide\"") && home.contains("Last changed by Andy McCall"), "{home}" ); assert!(send(app, "GET", "/andy/demo", None, None).await.body.contains(">Wiki"), "the tab shows to everyone now"); let missing = send(app, "GET", "/andy/demo/wiki/Guide", Some(&andy), None).await.body; assert!(missing.contains("no page called Guide") && missing.contains("_new?title=Guide"), "{missing}"); // An edit, then a rename: history follows, the old address is empty. let r = send( app, "POST", "/andy/demo/wiki/Home/edit", Some(&andy), Some(&format!("_csrf={csrf}&title=Home&content={}&message=Say+more", enc("# Welcome\n\nMore.\n"))), ) .await; assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Home"), "{}", r.body); let hist = send(app, "GET", "/andy/demo/wiki/Home/history", None, None).await.body; assert!(hist.contains("Say more") && hist.contains("Create Home"), "{hist}"); let r = send( app, "POST", "/andy/demo/wiki/Home/edit", Some(&andy), Some(&format!("_csrf={csrf}&title=Start+Here&content={}", enc("# Start\n"))), ) .await; assert_eq!(r.location.as_deref(), Some("/andy/demo/wiki/Start-Here"), "{}", r.body); assert!(send(app, "GET", "/andy/demo/wiki/Start-Here", None, None).await.body.contains("

Start Here

")); assert!(send(app, "GET", "/andy/demo/wiki", None, None).await.body.contains("no page called Home")); let pages = send(app, "GET", "/andy/demo/wiki/_pages?q=start", None, None).await.body; assert!(pages.contains("href=\"/andy/demo/wiki/Start-Here\"") && pages.contains("mentions"), "{pages}"); // By client: clone the wiki, add a page, push it. let wdir = tempfile::tempdir().unwrap(); let mut w = Repo::clone(&format!("{base}/andy/demo.wiki"), wdir.path()).unwrap(); assert_eq!(std::fs::read_to_string(wdir.path().join("Start-Here.md")).unwrap(), "# Start\n"); w.create_identity("Andy McCall", "andy@example.com", IdentityScope::Repo).unwrap(); w.config.set("core.sign", "change").unwrap(); w.config.set("remote.origin.token", "site").unwrap(); w.save_config().unwrap(); write(wdir.path(), "Guide.md", b"# Guide\n\nPushed from a clone.\n"); w.snapshot("t").unwrap(); w.commit("Add the guide", &[]).unwrap(); assert!(w.push("origin", false).unwrap().iter().all(|r| r.ok), "pushed to the wiki"); let guide = send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body; assert!(guide.contains("Pushed from a clone"), "{guide}"); assert!( db::flow::runs(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, 10, None) .await .map(|r| r.is_empty()) .unwrap_or(true), "a wiki push starts no run" ); // Delete a page, then the whole wiki from Advanced settings. let r = send(app, "POST", "/andy/demo/wiki/Guide/delete", Some(&andy), Some(&format!("_csrf={csrf}"))).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); assert!(send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body.contains("no page called")); let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None).await.body; assert!(settings.contains("DELETE WIKI DATA"), "{settings}"); let r = send( app, "POST", "/andy/demo/settings/wiki/delete", Some(&andy), Some(&format!("_csrf={}&confirm=andy%2Fdemo", csrf_of(&settings))), ) .await; assert!(r.body.contains("The wiki is in the trash"), "{}", r.body); assert!(send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await.body.contains("No wiki yet")); } /// A wiki can start with a push: someone who may write to the repository /// reaching `OWNER/NAME.wiki` before it exists gets an empty wiki, which /// the push fills; a reader still finds nothing there. #[tokio::test(flavor = "multi_thread")] async fn a_wiki_can_start_with_a_push() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = vec!["site".into()]; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); config.server.base_url = format!("http://127.0.0.1:{port}"); let app = oxygenhub::build(config).await.unwrap(); let router = oxygenhub::router(app.clone()); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); let base = format!("http://127.0.0.1:{port}"); let repo = RepoName::parse("andy/docs").unwrap(); app.repos.create(&repo, None).unwrap(); db::repos::create(&app.db, "andy", "docs", "", hub_api::Visibility::Public).await.unwrap(); let url = format!("{base}/andy/docs.wiki"); // A reader: nothing there, and nothing made. let anon = tempfile::tempdir().unwrap(); let u = url.clone(); let p = anon.path().join("w"); assert!(tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().is_err()); assert!(!app.wikis.exists(&repo)); // A writer pushes pages it wrote locally. let work = tempfile::tempdir().unwrap(); let mut w = Repo::init(work.path()).unwrap(); w.config.set("remote.origin.url", &url).unwrap(); w.config.set("remote.origin.token", "site").unwrap(); w.save_config().unwrap(); write(work.path(), "Home.md", b"# Docs\n\nSee [the overview](Overview).\n"); write(work.path(), "Overview.md", b"# Overview\n\n```mermaid\nflowchart LR\n A --> B\n```\n"); w.snapshot("t").unwrap(); w.commit("First pages", &[]).unwrap(); let pushed = tokio::task::spawn_blocking(move || w.push("origin", false).unwrap()).await.unwrap(); assert!(pushed.iter().all(|r| r.ok), "{pushed:?}"); assert!(app.wikis.exists(&repo)); let home = send(&app, "GET", "/andy/docs/wiki", None, None).await.body; assert!(home.contains("

Docs

") && home.contains("href=\"/andy/docs/wiki/Overview\""), "{home}"); let overview = send(&app, "GET", "/andy/docs/wiki/Overview", None, None).await.body; assert!(overview.contains("class=\"language-mermaid\""), "a diagram block for the page's script to draw: {overview}"); }