//! Milestone 0.3 end to end: a proposal from a bookmark and from a fork, //! an atom-anchored comment, a seal-backed approval, requirements and a //! landing from the hub. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::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 Hub { app: Shared, base: String, _dir: tempfile::TempDir, } async fn hub() -> Hub { 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() }); Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } } struct Res { status: StatusCode, body: String, cookie: Option, } async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>, bearer: 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}")); } if let Some(b) = bearer { req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); } 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 bytes = res.into_body().collect().await.unwrap().to_bytes(); Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } } async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { send(app, "GET", path, cookie, None, None).await } async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { send(app, "POST", path, cookie, Some(form), None).await } /// POST a proposal action and follow its redirect back to the page, as a /// browser does (actions redirect so a reload never repeats them). async fn act(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { let mut req = Request::builder().method("POST").uri(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); if let Some(c) = cookie { req = req.header(header::COOKIE, format!("oxyhub={c}")); } let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(form.to_string())).unwrap()).await.unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER, "{path} redirects back"); let to = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); get(app, &to, cookie).await } async fn register(app: &Shared, user: &str) -> String { let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); r.cookie.unwrap() } 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() } #[tokio::test(flavor = "multi_thread")] async fn propose_review_seal_and_land() { let h = hub().await; let app = &h.app; let andy = register(app, "andy").await; let bob = register(app, "bob").await; // A repository with main and a feature bookmark, pushed by Andy's client. 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 mut client = Repo::init(work.path()).unwrap(); client.create_identity("Andy McCall", "andy@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); client.config.set("remote.origin.token", "site").unwrap(); client.config.set("core.sign", "change").unwrap(); client.save_config().unwrap(); write(work.path(), "a.txt", b"one\ntwo\n"); write(work.path(), ".oxyowners", b"* @andy\n"); client.snapshot("t").unwrap(); let base = client.commit("base", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); client.bookmark_create("feature", Some(&[base])).unwrap(); client.bookmark_switch("feature", false).unwrap(); write(work.path(), "a.txt", b"one\ntwo\nthree\n"); client.snapshot("t").unwrap(); let feat = client.commit("add three", &[]).unwrap(); assert!(client.push_bookmark("origin", "feature", false).unwrap().iter().all(|r| r.ok)); // The compare page offers the proposal to writers only (hub spec §6.3). let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; assert!(cmp.body.contains("href=\"/andy/demo/proposals/new?source=feature&target=main\">Propose this"), "{}", cmp.body); assert!(!get(app, "/andy/demo/compare/main...feature", Some(&bob)).await.body.contains("Propose this")); assert!( !get(app, "/andy/demo/compare/feature...main", Some(&andy)).await.body.contains("Propose this"), "nothing to propose the other way" ); // Bob previews and opens a proposal feature → main. let preview = get(app, "/andy/demo/proposals/new?source=feature&target=main", Some(&bob)).await; assert_eq!(preview.status, StatusCode::OK); assert!(preview.body.contains("1 change") && preview.body.contains("add three"), "{}", preview.body); let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=Add+three&body=Please&target=main&source=feature")) .await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let page = get(app, "/andy/demo/proposal/1", Some(&bob)).await; assert_eq!(page.status, StatusCode::OK); assert!(page.body.contains("Add three") && page.body.contains("1 change"), "{}", page.body); assert!(page.body.contains("Please")); assert!(get(app, "/andy/demo/proposals", None).await.body.contains("#1")); let cmp = get(app, "/andy/demo/compare/main...feature", Some(&andy)).await; assert!(cmp.body.contains("proposal #1 is open") && !cmp.body.contains("Propose this"), "the open proposal instead"); // A comment anchored to line 3 of a.txt lands in the thread on the files tab. let files = get(app, "/andy/demo/proposal/1?tab=files", Some(&bob)).await; assert!(files.body.contains("+three") || files.body.contains(">three<"), "diff shown: {}", files.body); let csrf_b = csrf_of(&files.body); let r = act(app, "/andy/demo/proposal/1/comment", Some(&bob), &format!("_csrf={csrf_b}&body=nice+line&path=a.txt&line=3")).await; assert_eq!(r.status, StatusCode::OK, "{}", r.body); assert!(r.body.contains("class=\"thread\"") && r.body.contains("nice line"), "anchored comment placed: {}", r.body); let c = db::proposals::comments(&app.db, 1).await.unwrap(); assert_eq!(c[0].anchor_change, feat.to_hex(), "anchored to the atom the feature change introduced"); // Requirements: one sealed approval and code owners. let settings = get(app, "/andy/demo/settings", Some(&andy)).await; let csrf_a = csrf_of(&settings.body); assert_eq!( post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1")) .await .status, StatusCode::OK ); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body); assert!(page.body.contains("code owners still to approve: andy"), "{}", page.body); // Bob cannot approve his own; Andy approves, which is pending until sealed. let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/1", Some(&bob)).await.body); let r = act(app, "/andy/demo/proposal/1/review", Some(&bob), &format!("_csrf={csrf_b}&verdict=approve")).await; assert!(r.body.contains("cannot approve your own"), "{}", r.body); let r = act(app, "/andy/demo/proposal/1/review", Some(&andy), &format!("_csrf={csrf_a}&verdict=approve&body=LGTM")).await; assert!(r.body.contains("seal pending"), "{}", r.body); assert!(r.body.contains("oxy seal --proposal")); // Andy links his key, seals the proposal's change and pushes the seal: the approval counts. let key = client.config.author_key.unwrap(); let identity = client.identity(&key).unwrap(); db::accounts::link_identity( &app.db, db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id, &key.to_hex(), &identity.name, &identity.email, &identity.encode(), ) .await .unwrap(); let seal = client.seal(&[feat]).unwrap(); client.push_seals("origin", &[seal]).unwrap(); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("approved and sealed"), "{}", page.body); assert!(page.body.contains("ready to land"), "{}", page.body); // The API agrees, and names the projected heads for `oxy proposal land`. let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; assert_eq!(api.status, StatusCode::OK); let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); assert_eq!(v["landable"], true, "{}", api.body); assert_eq!(v["approvals"][0]["sealed"], true); assert_eq!(v["projected_heads"][0], feat.to_hex()); assert_eq!(v["changes"][0]["hash"], feat.to_hex()); // Land from the hub (no policy): main moves to the projected heads. let csrf_a = csrf_of(&page.body); let r = act(app, "/andy/demo/proposal/1/land", Some(&andy), &format!("_csrf={csrf_a}")).await; assert!(r.body.contains("Landed."), "{}", r.body); let heads = app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().find(|b| b.name == "main").unwrap().heads)).await.unwrap(); assert_eq!(heads, vec![feat]); assert!(get(app, "/andy/demo/proposals?state=landed", None).await.body.contains("#1")); assert!(get(app, "/andy/demo/pushes", None).await.body.contains("landed #1 (web)")); // Landed, it still shows what it landed, by whom and how, and nothing holds it back. let api = send(app, "GET", "/api/v1/repos/andy/demo/proposals/1", None, None, Some("site")).await; let v: serde_json::Value = serde_json::from_str(&api.body).unwrap(); assert_eq!(v["state"], "landed", "{}", api.body); assert_eq!(v["changes"][0]["hash"], feat.to_hex(), "{}", api.body); assert_eq!(v["blockers"], serde_json::json!([]), "{}", api.body); assert_eq!(v["landed"]["by"], "andy"); assert_eq!(v["landed"]["via"], "web"); let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; assert!(page.body.contains("by andy, web"), "{}", page.body); // Lists by state through the API. let list = |q: &'static str| async move { let r = send(app, "GET", &format!("/api/v1/repos/andy/demo/proposals{q}"), None, None, Some("site")).await; serde_json::from_str::(&r.body).unwrap().as_array().unwrap().len() }; assert_eq!(list("").await, 0, "open ones only by default"); assert_eq!(list("?state=landed").await, 1); assert_eq!(list("?state=all").await, 1); // A proposal from a fork: a copy of the repository with the same id. let fork = RepoName::parse("bob/demo").unwrap(); app.repos.create(&fork, Some(client.id().unwrap())).unwrap(); db::repos::create(&app.db, "bob", "demo", "fork", hub_api::Visibility::Public).await.unwrap(); db::accounts::set_collaborator( &app.db, db::repos::get(&app.db, "bob", "demo").await.unwrap().unwrap().id, Some(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id), None, "admin", ) .await .unwrap(); client.config.set("remote.fork.url", &format!("{}/bob/demo", h.base)).unwrap(); client.config.set("remote.fork.token", "site").unwrap(); client.bookmark_switch("main", false).unwrap(); client.fetch("origin").unwrap(); client.bookmark_set("main", &[feat]).unwrap(); client.bookmark_create("fix", Some(&[feat])).unwrap(); client.bookmark_switch("fix", false).unwrap(); write(work.path(), "b.txt", b"b\n"); client.snapshot("t").unwrap(); let fix = client.commit("add b", &[]).unwrap(); assert!(client.push_bookmark("fork", "fix", false).unwrap().iter().all(|r| r.ok)); let preview = get(app, "/andy/demo/proposals/new?source=bob/demo:fix&target=main", Some(&bob)).await; assert!(preview.body.contains("add b"), "fork preview: {}", preview.body); let csrf = csrf_of(&preview.body); let r = post(app, "/andy/demo/proposals/new", Some(&bob), &format!("_csrf={csrf}&title=From+fork&target=main&source=bob/demo:fix")).await; assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); let page = get(app, "/andy/demo/proposal/2?tab=changes", Some(&andy)).await; assert!(page.body.contains("add b"), "{}", page.body); assert!( app.repos.read(&repo, move |r, _| Ok(r.store().has(&fix))).await.unwrap(), "the fork's change was copied into the target's store" ); // Closing and reopening. let csrf_b = csrf_of(&get(app, "/andy/demo/proposal/2", Some(&bob)).await.body); let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=close")).await; assert!(r.body.contains(">closed<"), "{}", r.body); let r = act(app, "/andy/demo/proposal/2/state", Some(&bob), &format!("_csrf={csrf_b}&action=reopen")).await; assert!(r.body.contains(">open<"), "{}", r.body); }