//! 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 } } 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()).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.delegates.contains(&app.hub_key.key)); 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); let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; assert!(r.body.contains("Landed."), "{}", r.body); 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.body.contains("Landed."), "#{n}: {}", r.body); } let conflicts = send(app, "GET", "/andy/demo/conflicts", Some(&andy), None).await.body; assert!(conflicts.contains("resolve in the browser"), "{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); // 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); 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("Both\ntwo\nthree\n"), enc("Resolve a.txt") )), ) .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/file/main/a.txt?plain=1", None, None).await.body, "Both\ntwo\nthree\n"); // 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:?}"); }