//! End to end: a hub in a temporary data directory, a real `oxy` client //! pushing over the wire protocol, and every milestone 0.1 page fetched. use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use oxy_core::repo::Repo; use oxygenhub::config::Config; use oxygenhub::repos::RepoName; use oxygenhub::{db, Shared}; use std::path::Path; use tower::ServiceExt; /// Point `$OXY_HOME` at a throwaway directory so the client's identity and /// configuration never touch the developer's. 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(push_tokens: Vec) -> Hub { hermetic(); let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.auth.push_tokens = push_tokens; 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 } } async fn get(app: &Shared, path: &str) -> (StatusCode, String) { let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap(); let status = res.status(); let bytes = res.into_body().collect().await.unwrap().to_bytes(); (status, String::from_utf8_lossy(&bytes).into_owned()) } async fn page(app: &Shared, path: &str) -> String { let (status, body) = get(app, path).await; assert_eq!(status, StatusCode::OK, "{path}: {}", body.lines().find(|l| l.contains("lead")).unwrap_or("")); body } /// Push a small history from a client: two changes on main, a second /// bookmark, a tag. fn populate(url: &str, work: &Path) -> (Repo, String, String) { // The hosted repository is unbound until the first push, so it cannot // be cloned yet: start locally and push, as moving an existing // repository to the hub does. assert!(Repo::clone(url, &work.join("early")).err().unwrap().to_string().contains("unbound")); let mut client = Repo::init(work).unwrap(); // A signing identity, so changes and the tag travel signed. client.create_identity("Test Author", "test@example.com", oxy_core::repo::IdentityScope::User).unwrap(); client.config.set("remote.origin.url", url).unwrap(); client.config.set("remote.origin.token", "site-token").unwrap(); client.config.set("core.sign", "change").unwrap(); client.save_config().unwrap(); write(work, "README.md", b"# Demo\n\nSee [docs](docs/guide.md).\n"); write(work, "src/main.rs", b"fn main() {\n println!(\"hi\");\n}\n"); write(work, "docs/guide.md", b"# Guide\n"); client.snapshot("test").unwrap(); let c1 = client.commit("Initial import", &[]).unwrap(); write(work, "src/main.rs", b"fn main() {\n println!(\"hello\");\n}\n\nfn two() {}\n"); client.snapshot("test").unwrap(); let c2 = client.commit("Greet properly\n\nAlso adds two().", &[]).unwrap(); client.tag_create("v0.1", &[c2], "First tag").unwrap(); client.bookmark_create("feature", Some(&[c1])).unwrap(); let results = client.push("origin", false).unwrap(); assert!(results.iter().all(|r| r.ok), "{results:?}"); let results = client.push_bookmark("origin", "feature", false).unwrap(); assert!(results.iter().all(|r| r.ok), "{results:?}"); // Bound now: a clone shares the id. let clone = Repo::clone(url, &work.join("clone")).unwrap(); assert_eq!(clone.id().unwrap(), client.id().unwrap()); (client, c1.to_hex(), c2.to_hex()) } #[tokio::test(flavor = "multi_thread")] async fn create_push_and_browse() { let h = hub(vec!["site-token".into()]).await; let name = RepoName::parse("andy/demo").unwrap(); h.app.repos.create(&name, None).unwrap(); db::repos::create(&h.app.db, "andy", "demo", "A demo", hub_api::Visibility::Public).await.unwrap(); let work = tempfile::tempdir().unwrap(); let (_client, c1, c2) = populate(&format!("{}/andy/demo", h.base), work.path()); // Help, filled in for this hub (hub spec §6.3). let help = page(&h.app, "/help").await; assert!(help.contains(&format!("oxy remote add hub {}/OWNER/REPOSITORY", h.base)), "commands carry this hub's address"); assert!(help.contains(&format!("oxy clone {}/OWNER/REPOSITORY", h.base))); assert!(help.contains(&format!("curl -fsSL {}/.well-known/oxyhub-key -o hub.identity", h.base)), "the delegation recipe"); assert!(!help.contains("oxyhub-ca.pem"), "no certificate step for a hub without a self-signed certificate"); for id in ["start", "identity", "push", "clone", "proposals", "delegate", "flow", "keys", "glossary"] { assert!(help.contains(&format!("id=\"{id}\"")) && help.contains(&format!("href=\"#{id}\"")), "section {id}"); } assert!(help.contains("bookmark") && help.contains("proposal"), "glossary"); assert!(home_has_help(&page(&h.app, "/explore").await), "linked from every page"); // Front page and owner page list it. let home = page(&h.app, "/").await; assert!(home.contains("/andy/demo"), "{home}"); assert!(page(&h.app, "/andy").await.contains("demo")); assert_eq!(get(&h.app, "/nobody").await.0, StatusCode::NOT_FOUND); // Overview: tree, README rendered with rebased links, clone URL, counts. let o = page(&h.app, "/andy/demo").await; assert!(o.contains("src"), "tree entries"); assert!(o.contains("href=\"/andy/demo/file/main/docs/guide.md\""), "README link rebased: {o}"); assert!(o.contains(&format!("oxy clone {}/andy/demo", h.base))); assert!(o.contains("2 changes")); // Overview: clone popover, contributors, languages, visibility badge. assert!(o.contains("class=\"clone-pop\"") && o.contains("value=\"oxy clone "), "clone behind a button"); // The Code button also offers the default bookmark as a zip. assert!(o.contains("<> Code"), "a Code button"); assert!(o.contains("href=\"/andy/demo/archive?ref=main&format=zip\" download>"), "a zip download: {o}"); let (status, zip) = get(&h.app, "/andy/demo/archive?ref=main&format=zip").await; assert_eq!(status, StatusCode::OK); assert!(zip.starts_with("PK"), "a zip archive"); assert!(o.contains("

Contributors") && o.contains("

Languages

") && o.contains("Rust"), "{o}"); assert!(o.contains("class=\"vis vis-public\"")); // Landmarks, skip link, current tab (hub spec §6.13). assert!(o.contains("") && o.contains("
Code"), "the current tab is announced"); assert!(o.contains("v0.1")); assert!(o.contains("feature")); // Tree at a bookmark, a tag and a change; file with highlighting; raw; blame. let t = page(&h.app, "/andy/demo/tree/main/src").await; assert!(t.contains("main.rs")); assert!(page(&h.app, "/andy/demo/tree/v0.1").await.contains("README.md")); assert!(page(&h.app, &format!("/andy/demo/tree/{}", &c1[..10])).await.contains("README.md")); assert_eq!(get(&h.app, "/andy/demo/tree/main/nope").await.0, StatusCode::NOT_FOUND); let (hits_before, _, _) = oxygenhub::render::highlight::cache_stats(); let f = page(&h.app, "/andy/demo/file/main/src/main.rs").await; let again = page(&h.app, "/andy/demo/file/main/src/main.rs").await; assert!(oxygenhub::render::highlight::cache_stats().0 > hits_before, "the second view is served from the highlight cache"); assert_eq!(f.split("").nth(1).unwrap_or(""); assert!(here.trim_start().starts_with("Demo"), "markdown rendered"); // Last-change JSON for tree rows. let (status, json) = get(&h.app, "/andy/demo/last/main?paths=README.md,src").await; assert_eq!(status, StatusCode::OK); assert!(json.contains("Initial import") && json.contains("Greet properly"), "{json}"); // History with graph rows, filtered by path; change page with diff; compare. let hist = page(&h.app, "/andy/demo/changes/main").await; assert!(hist.contains("Greet properly") && hist.contains("Initial import")); assert!(hist.contains("") && cmp.contains("") && cmp.contains("") ); assert!(cmp.contains("href=\"/andy/demo/compare/main...feature\""), "swap"); assert!(!cmp.contains("Propose this"), "anonymous viewers cannot propose"); let redirect = |path: &'static str| { let app = h.app.clone(); async move { let res = oxygenhub::router(app).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap(); (res.status(), res.headers().get("location").and_then(|v| v.to_str().ok()).unwrap_or("").to_string()) } }; let (st, loc) = redirect("/andy/demo/compare").await; assert!(st.is_redirection() && loc == "/andy/demo/compare/main...feature", "the newest other bookmark against the default: {loc}"); let (_, loc) = redirect("/andy/demo/compare?base=v0.1&head=+feature+").await; assert_eq!(loc, "/andy/demo/compare/v0.1...feature"); // Bookmarks, tags, conflicts (none), trust (no policy), pushes. let bm = page(&h.app, "/andy/demo/bookmarks").await; assert!(bm.contains("feature") && bm.contains("default")); let tags = page(&h.app, "/andy/demo/tags").await; assert!(tags.contains("v0.1") && tags.contains("First tag")); assert!(page(&h.app, "/andy/demo/conflicts").await.contains("No bookmark carries a conflict")); let trust = page(&h.app, "/andy/demo/trust").await; assert!(trust.contains("No trust policy") && trust.contains(&h.app.hub_key.key.to_hex())); assert!(trust.contains("2 signed"), "signed changes verified on the host: {trust}"); assert!(c.contains("signed by Test Author"), "change page shows the signer"); let pushes = page(&h.app, "/andy/demo/pushes").await; assert!(pushes.contains("accepted") && pushes.contains("main"), "{pushes}"); assert!(pushes.contains("2") && pushes.contains(" bool { page.contains("Help") }