//! Cloning over HTTP, through the smart protocol endpoints and with a real //! git client against a live server. mod common; use std::{process::Command, time::Duration}; use assert2::check; use common::Fixture; use poem::{ Body, Server, http::StatusCode, listener::{Acceptor, Listener, TcpListener}, test::{TestClient, TestResponse}, }; use tokio::io::AsyncWriteExt; fn fixture() -> Fixture { let fx = Fixture::new(); fx.repo( "widget", true, "Widgets", &[("README.md", "# Widget\n"), ("src.rs", "fn main() {}\n")], ); fx.repo("secret", false, "Secret", &[("README.md", "# Secret\n")]); fx } #[tokio::test] async fn advertises_refs_for_public_repositories_only() { let fx = fixture(); let client = TestClient::new(fx.app.routes()); let resp = client .get("/widget.git/info/refs") .query("service", &"git-upload-pack") .send() .await; resp.assert_status_is_ok(); resp.assert_content_type("application/x-git-upload-pack-advertisement"); let body = resp.0.into_body().into_string().await.unwrap(); check!(body.starts_with("001e# service=git-upload-pack")); check!(body.contains("refs/heads/main")); for path in ["/secret.git/info/refs", "/missing.git/info/refs"] { let resp = client .get(path) .query("service", &"git-upload-pack") .send() .await; check!(resp.0.status() == StatusCode::NOT_FOUND, "{path}"); } } #[tokio::test] async fn protocol_v2_is_negotiated_when_the_client_asks() { let fx = fixture(); let client = TestClient::new(fx.app.routes()); let resp = client .get("/widget.git/info/refs") .query("service", &"git-upload-pack") .header("git-protocol", "version=2") .send() .await; resp.assert_status_is_ok(); let body = resp.0.into_body().into_string().await.unwrap(); check!(body.contains("version 2")); check!(body.contains("ls-refs")); } #[tokio::test] async fn pushing_is_not_offered() { let fx = fixture(); let client = TestClient::new(fx.app.routes()); let resp = client .get("/widget.git/info/refs") .query("service", &"git-receive-pack") .send() .await; check!(resp.0.status() != StatusCode::OK); let resp = client.post("/widget.git/git-receive-pack").send().await; check!(resp.0.status() == StatusCode::NOT_FOUND); } #[tokio::test(flavor = "multi_thread")] async fn git_can_clone_a_public_repository_but_not_a_private_one() { let fx = fixture(); let acceptor = TcpListener::bind("127.0.0.1:0") .into_acceptor() .await .expect("bind"); let addr = *acceptor.local_addr()[0] .as_socket_addr() .expect("socket addr"); let routes = fx.app.routes(); let server = mugit::serve::configure(Server::new_with_acceptor(acceptor), &fx.app.config); let server = tokio::spawn(async move { server.run(routes).await }); let dest = tempfile::tempdir().expect("temp dir"); let (public, private) = tokio::task::spawn_blocking(move || { let public = Command::new("git") .args(["clone", "--quiet", &format!("http://{addr}/widget.git")]) .arg(dest.path().join("widget")) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("run git clone"); let private = Command::new("git") .args(["clone", "--quiet", &format!("http://{addr}/secret.git")]) .arg(dest.path().join("secret")) .env("GIT_TERMINAL_PROMPT", "0") .output() .expect("run git clone"); let cloned_readme = std::fs::read_to_string(dest.path().join("widget/README.md")).ok(); ( (public.status.success(), cloned_readme), private.status.success(), ) }) .await .expect("clone task"); check!(public.0, "public clone should succeed"); check!(public.1.as_deref() == Some("# Widget\n")); check!(!private, "private clone should fail"); server.abort(); } // A fixture that serves one git process at a time and gives up on a // stalled one after a second. fn tightly_bounded_fixture() -> Fixture { let fx = Fixture::with_args(&["--git-processes", "1", "--git-idle-timeout", "1"]); fx.repo("widget", true, "Widgets", &[("README.md", "# Widget\n")]); fx } // Starts a fetch whose request body is whatever gets written to the // returned end of a pipe, so the backend can be kept busy at will. async fn fetch_with_open_body( client: &TestClient, ) -> (TestResponse, tokio::io::DuplexStream) { let (writer, reader) = tokio::io::duplex(1024); let resp = client .post("/widget.git/git-upload-pack") .content_type("application/x-git-upload-pack-request") .body(Body::from_async_read(reader)) .send() .await; (resp, writer) } async fn refs_status(client: &TestClient) -> StatusCode { client .get("/widget.git/info/refs") .query("service", &"git-upload-pack") .send() .await .0 .status() } #[tokio::test] async fn a_request_beyond_the_process_bound_is_refused() { let fx = tightly_bounded_fixture(); let client = TestClient::new(fx.app.routes()); let (busy, mut writer) = fetch_with_open_body(&client).await; busy.assert_status_is_ok(); // The client keeps its request trickling in, so the backend is at // work and stays in its slot. let trickle = tokio::spawn(async move { while writer.write_all(b"0000").await.is_ok() { tokio::time::sleep(Duration::from_millis(100)).await; } }); let refused = client .get("/widget.git/info/refs") .query("service", &"git-upload-pack") .send() .await; check!(refused.0.status() == StatusCode::SERVICE_UNAVAILABLE); check!(refused.0.headers().contains_key("retry-after")); // Once the fetch is over, its slot is free again. trickle.abort(); let _ = tokio::time::timeout(Duration::from_secs(10), busy.0.into_body().into_vec()) .await .expect("the fetch should end once its request does"); check!(refs_status(&client).await == StatusCode::OK); } #[tokio::test] async fn a_stalled_client_is_dropped_and_its_slot_freed() { let fx = tightly_bounded_fixture(); let client = TestClient::new(fx.app.routes()); // A request whose body never arrives. let (stalled, writer) = fetch_with_open_body(&client).await; stalled.assert_status_is_ok(); let _ = tokio::time::timeout(Duration::from_secs(10), stalled.0.into_body().into_vec()) .await .expect("the stalled fetch should be dropped"); check!(refs_status(&client).await == StatusCode::OK); drop(writer); }