//! The rest of milestone 0.6: releases with generated notes, assets and //! source archives; the read-only Git mirror cloned with real git; code //! search; anchored proposal comments through the API. 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::io::Read; 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: Vec, cookie: Option, location: Option, } impl Res { fn text(&self) -> String { String::from_utf8_lossy(&self.body).into_owned() } } async fn send( app: &Shared, method: &str, path: &str, cookie: Option<&str>, bearer: Option<&str>, ctype: Option<&str>, body: Vec, ) -> 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}")); } if let Some(t) = ctype { req = req.header(header::CONTENT_TYPE, t); } let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(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 body = res.into_body().collect().await.unwrap().to_bytes().to_vec(); Res { status, body, cookie, location } } async fn form(app: &Shared, path: &str, cookie: &str, f: &str) -> Res { send(app, "POST", path, Some(cookie), None, Some("application/x-www-form-urlencoded"), f.as_bytes().to_vec()).await } 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 releases_mirror_search_and_anchors() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); // The first account's setup code (oxygenhub#245). config.registration.setup_code = "test-setup".into(); 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 andy = send( app, "POST", "/register", None, None, Some("application/x-www-form-urlencoded"), b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(), ) .await .cookie .unwrap(); 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(); // Two signed changes, a signed tag on the first, and a second tag later. let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); 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(), "src/main.rs", b"fn main() {\n println!(\"hello flux\");\n}\n"); write(work.path(), "run.sh", b"#!/bin/sh\necho run\n"); std::fs::set_permissions(work.path().join("run.sh"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); client.snapshot("t").unwrap(); let c1 = client.commit("First light", &[]).unwrap(); client.tag_create("v0.1", &[c1], "First release").unwrap(); write(work.path(), "README.md", b"# Demo\n"); client.snapshot("t").unwrap(); let c2 = client.commit("Add a README", &[]).unwrap(); client.tag_create("v0.2", &[c2], "Second").unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); // Releases: generated notes list the changes since the previous release. let page = send(app, "GET", "/andy/demo/releases/new?tag=v0.1", Some(&andy), None, None, vec![]).await.text(); assert!(page.contains("First light"), "{page}"); let csrf = csrf_of(&page); let r = form(app, "/andy/demo/releases/new", &andy, &format!("_csrf={csrf}&tag=v0.1&title=Zero+one¬es=First")).await; assert_eq!(r.location.as_deref(), Some("/andy/demo/releases/tag/v0.1"), "{}", r.text()); let (st, v) = { let r = send( app, "POST", "/api/v1/repos/andy/demo/releases", None, Some("site"), Some("application/json"), br#"{"tag":"v0.2","title":"Zero two"}"#.to_vec(), ) .await; (r.status, serde_json::from_slice::(&r.body).unwrap()) }; assert_eq!(st, StatusCode::CREATED, "{v}"); assert!( v["notes"].as_str().unwrap().contains("Add a README") && !v["notes"].as_str().unwrap().contains("First light"), "notes since v0.1: {v}" ); assert_eq!(v["latest"], true); assert_eq!(v["signer"], "Andy"); let r = send( app, "POST", "/api/v1/repos/andy/demo/releases", None, Some("site"), Some("application/json"), br#"{"tag":"nope","title":"x"}"#.to_vec(), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); // oxygenhub#72: a release's notes and flags change through the API; // what is left out stays. let r = send( app, "PATCH", "/api/v1/repos/andy/demo/releases/v0.2", None, Some("site"), Some("application/json"), br#"{"notes":"Corrected notes.","prerelease":true}"#.to_vec(), ) .await; assert_eq!(r.status, StatusCode::OK, "{}", r.text()); let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); assert_eq!( (v["notes"].as_str(), v["prerelease"].as_bool(), v["title"].as_str()), (Some("Corrected notes."), Some(true), Some("Zero two")) ); let r = send(app, "PATCH", "/api/v1/repos/andy/demo/releases/none", None, Some("site"), Some("application/json"), b"{}".to_vec()).await; assert_eq!(r.status, StatusCode::NOT_FOUND); // An asset through the API; download it back; the page lists it. let r = send( app, "POST", "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-linux.bin", None, Some("site"), Some("application/octet-stream"), b"\x7fELF binary".to_vec(), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); let url = v["assets"][0]["url"].as_str().unwrap().replace(&base, ""); assert_eq!(v["assets"][0]["hash"], hex::encode(blake3::hash(b"\x7fELF binary").as_bytes())); let got = send(app, "GET", &url, None, None, None, vec![]).await; assert_eq!(got.body, b"\x7fELF binary"); // A real build is megabytes: the API takes assets up to the configured // limit, not the web framework's 2 MiB default. let big = vec![7u8; 5 << 20]; let r = send( app, "POST", "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-big.tar.gz", None, Some("site"), Some("application/gzip"), big.clone(), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let list = send(app, "GET", "/andy/demo/releases", None, None, None, vec![]).await.text(); assert!(list.contains("Zero two") && list.contains("demo-linux.bin") && list.contains("latest"), "{list}"); // Source archives: tar.zst with the exec bit, and zip. let tz = send(app, "GET", "/andy/demo/archive?ref=v0.1&format=tar.zst", None, None, None, vec![]).await; assert_eq!(tz.status, StatusCode::OK); let mut tar = tar::Archive::new(zstd::Decoder::new(tz.body.as_slice()).unwrap()); let mut seen = vec![]; for e in tar.entries().unwrap() { let mut e = e.unwrap(); let path = e.path().unwrap().to_string_lossy().into_owned(); let mode = e.header().mode().unwrap(); let mut s = String::new(); e.read_to_string(&mut s).unwrap(); seen.push((path, mode, s)); } assert!(seen.iter().any(|(p, _, s)| p == "demo-v0.1/src/main.rs" && s.contains("hello flux")), "{seen:?}"); assert!(seen.iter().any(|(p, m, _)| p == "demo-v0.1/run.sh" && m & 0o111 != 0), "exec bit kept: {seen:?}"); assert!(!seen.iter().any(|(p, _, _)| p.ends_with("README.md")), "v0.1 predates the README"); let zp = send(app, "GET", "/andy/demo/archive?ref=main&format=zip", None, None, None, vec![]).await; let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zp.body)).unwrap(); let mut readme = String::new(); zip.by_name("demo-main/README.md").unwrap().read_to_string(&mut readme).unwrap(); assert_eq!(readme, "# Demo\n"); // Code search: substring and regex, with path matches. let s = send(app, "GET", "/andy/demo/search?q=flux", None, None, None, vec![]).await.text(); assert!(s.contains("src/main.rs:2"), "{s}"); let s = send(app, "GET", "/andy/demo/search?q=print%5Cw%2B&re=1", None, None, None, vec![]).await.text(); assert!(s.contains("src/main.rs:2"), "{s}"); let s = send(app, "GET", "/andy/demo/search?q=%28&re=1", None, None, None, vec![]).await.text(); assert!(s.contains("not a valid regular expression"), "{s}"); assert!(send(app, "GET", "/andy/demo/search?q=README", None, None, None, vec![]).await.text().contains("path matches")); // The Git mirror: off by default, on from settings, cloned with git, push refused. assert_eq!( send(app, "GET", "/andy/demo.git/info/refs?service=git-upload-pack", None, None, None, vec![]).await.status, StatusCode::NOT_FOUND ); let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text(); assert!(overview.contains("oxy clone ") && !overview.contains("git clone "), "no Git in the Code panel without the mirror"); let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text(); let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await; let back = r.location.clone().unwrap_or_default(); assert!(back.starts_with("/andy/demo/settings/git-mirror?"), "{back}"); let r = send(app, "GET", &back, Some(&andy), None, None, vec![]).await; assert!(r.text().contains("mirror is on"), "{}", r.text()); // The Code panel then offers git clone too: oxy first, then git, then the downloads (#215). let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text(); let at = |s: &str| overview.find(s).unwrap_or_else(|| panic!("{s} in the Code panel")); assert!(at("oxy clone ") < at(&format!("git clone {base}/andy/demo.git")), "oxy first"); assert!(at(&format!("data-copy=\"git clone {base}/andy/demo.git\"")) < at("Download ZIP"), "then git, then the downloads"); let clone_dir = tempfile::tempdir().unwrap(); let url = format!("{base}/andy/demo.git"); let target = clone_dir.path().join("demo"); let out = tokio::task::spawn_blocking(move || { std::process::Command::new("git") .args(["clone", "-q", &url, target.to_str().unwrap()]) .env("GIT_TERMINAL_PROMPT", "0") .output() .unwrap() }) .await .unwrap(); assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr)); assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n"); let log = std::process::Command::new("git") .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"]) .output() .unwrap(); assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README")); // A new push refreshes the mirror. write(work.path(), "README.md", b"# Demo\n\nMore.\n"); client.snapshot("t").unwrap(); client.commit("More README", &[]).unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); tokio::time::sleep(std::time::Duration::from_millis(500)).await; let d = clone_dir.path().join("demo"); let pull = tokio::task::spawn_blocking(move || { std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap() }) .await .unwrap(); assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr)); assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More.")); let d = clone_dir.path().join("demo"); let push = tokio::task::spawn_blocking(move || { std::fs::write(d.join("x.txt"), "x").unwrap(); std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap(); std::process::Command::new("git") .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"]) .output() .unwrap(); std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap() }) .await .unwrap(); assert!(!push.status.success(), "pushing to the mirror is refused"); // Anchored comments through the API, at any line of the projected state. client.bookmark_create("feat", None).unwrap(); client.bookmark_switch("feat", false).unwrap(); write(work.path(), "src/main.rs", b"fn main() {\n println!(\"hello flux\");\n println!(\"again\");\n}\n"); client.snapshot("t").unwrap(); client.commit("Again", &[]).unwrap(); assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok)); let r = send( app, "POST", "/api/v1/repos/andy/demo/proposals", None, Some("site"), Some("application/json"), br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let number = serde_json::from_slice::(&r.body).unwrap()["number"].as_i64().unwrap(); let r = send( app, "POST", &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"), None, Some("site"), Some("application/json"), br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(), ) .await; assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); assert_eq!(v["path"], "src/main.rs"); assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change"); let r = send( app, "POST", &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"), None, Some("site"), Some("application/json"), br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(), ) .await; assert_eq!(r.status, StatusCode::BAD_REQUEST); } /// #295, #301: a tag is made on the hub (signed by its key as the user's /// delegate, or its own key where no policy asks for the user), and /// withdrawn there or by a push, never while a release uses it; a clone /// drops it on its next fetch, and the withdrawal is audited and logged. #[tokio::test(flavor = "multi_thread")] async fn tags_are_made_and_withdrawn_on_the_hub() { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.registration.setup_code = "test-setup".into(); 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 andy = send( app, "POST", "/register", None, None, Some("application/x-www-form-urlencoded"), b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(), ) .await .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(); let work = tempfile::tempdir().unwrap(); let mut client = Repo::init(work.path()).unwrap(); 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(); let c1 = client.commit("First", &[]).unwrap(); client.tag_create("v0.1", &[c1], "").unwrap(); client.tag_create("v0.2", &[c1], "").unwrap(); assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); let host = || app.repos.open(&repo).unwrap(); let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text(); assert!(page.contains("Create a tag") && page.contains("WITHDRAW"), "{page}"); let csrf = csrf_of(&page); // Not delegated, no policy: the hub's own key signs it. let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.3&target=main&message=Third")).await; assert!(r.text().contains("Tagged v0.3"), "{}", r.text()); assert_eq!(host().tag_heads("v0.3").unwrap(), Some(vec![c1])); // Delegated: as Andy's delegate. 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(); let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4&message=Fourth")).await; assert!(r.text().contains("Tagged v0.4"), "{}", r.text()); let t = host().tags().unwrap().into_iter().find(|t| t.name == "v0.4").unwrap(); assert!(t.signer.is_some(), "signed by the hub key for Andy"); let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.4")).await; assert!(r.text().contains("immutable"), "a tag never moves: {}", r.text()); // The clone takes the hub's tags. assert_eq!(client.fetch("origin").unwrap().new_tags, 2); assert!(client.tag_heads("v0.3").unwrap().is_some()); // A release on v0.1: neither the page nor a push withdraws it. db::releases::create(&app.db, db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().id, "v0.1", "Zero one", "", false, None) .await .unwrap(); let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.1")).await; assert!(r.text().contains("delete the release first"), "{}", r.text()); let r = client.push_delete("origin", "tags/v0.1").unwrap(); assert!(!r[0].ok && r[0].reason.contains("delete the release first"), "{r:?}"); assert!(host().tag_heads("v0.1").unwrap().is_some()); // On the page: gone, listed as withdrawn with why, and audited. let r = form(app, "/andy/demo/tags/withdraw", &andy, &format!("_csrf={csrf}&tag=v0.3&reason=tagged+by+mistake")).await; assert!(r.text().contains("Withdrew v0.3"), "{}", r.text()); assert_eq!(host().tag_heads("v0.3").unwrap(), None); let page = send(app, "GET", "/andy/demo/tags", Some(&andy), None, None, vec![]).await.text(); assert!(page.contains("Withdrawn") && page.contains("tagged by mistake"), "{page}"); let (w, _) = host().tag_withdrawal("v0.3").unwrap().unwrap(); assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged"); let audited: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit WHERE action = 'tag.withdrawn'").fetch_one(&app.db).await.unwrap(); assert_eq!(audited, 1); // A clone drops it on its next fetch. let rep = client.fetch("origin").unwrap(); assert_eq!(rep.withdrawn_tags, vec!["v0.3".to_string()]); assert_eq!(client.tag_heads("v0.3").unwrap(), None); // By a push: `oxy push --delete tags/NAME`. let r = client.push_delete("origin", "tags/v0.2").unwrap(); assert!(r[0].ok, "{r:?}"); assert_eq!(host().tag_heads("v0.2").unwrap(), None); let (w, _) = host().tag_withdrawal("v0.2").unwrap().unwrap(); assert!(db::tlog::entry_of(&app.db, &w.to_hex(), "untag").await.unwrap().is_some(), "logged"); // The name is never used again. let r = form(app, "/andy/demo/tags/create", &andy, &format!("_csrf={csrf}&name=v0.2")).await; assert!(r.text().contains("withdrawn"), "{}", r.text()); }