site.rs raw

//! The HTML site: listing, repository pages, and what stays hidden.

mod common;

use assert2::check;
use common::{Fixture, PUBLIC_URL};
use poem::{http::StatusCode, test::TestClient};

const README: &str = "# Widget\n\nA *widget* for testing.\n\n<script>alert(1)</script>\n";

fn fixture() -> Fixture {
    let fx = Fixture::new();
    fx.repo(
        "widget",
        true,
        "Widgets for everyone",
        &[("README.md", README)],
    );
    fx.repo(
        "secret",
        false,
        "Nobody's business",
        &[("README.md", "# Secret\n")],
    );
    fx.repo("plain", true, "", &[("README", "Just text <here>\n")]);
    fx.repo("bare", true, "Nothing committed", &[]);
    fx
}

#[tokio::test]
async fn index_lists_only_public_repositories() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/").send().await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("href=\"/widget\""));
    check!(body.contains("Widgets for everyone"));
    check!(body.contains("href=\"/plain\""));
    check!(body.contains("href=\"/bare\""));
    check!(!body.contains("secret"));
    check!(body.contains("Test Site"));
}

#[tokio::test]
async fn index_survives_a_repository_that_cannot_be_opened() {
    let fx = fixture();
    let broken = fx.dir.path().join("broken.git");
    std::fs::create_dir(&broken).unwrap();
    common::set_public(&broken, true);
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/").send().await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("href=\"/widget\""));
    check!(!body.contains("broken"));
}

#[tokio::test]
async fn a_directory_named_dot_git_dot_git_is_not_a_second_listing() {
    let fx = Fixture::new();
    fx.repo("tool", true, "The tool", &[]);
    fx.repo("tool.git", true, "Named with a suffix", &[]);
    let client = TestClient::new(fx.app.routes());
    let body = client
        .get("/")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    check!(body.matches("href=\"/tool\"").count() == 1);
    check!(!body.contains("Named with a suffix"));
    check!(client.get("/tool.git.git").send().await.0.status() == StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn index_is_sorted_case_insensitively() {
    let fx = Fixture::new();
    for name in ["zeta", "Alpha", "beta", "Gamma"] {
        fx.repo(name, true, "", &[]);
    }
    let client = TestClient::new(fx.app.routes());
    let body = client
        .get("/")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    let position = |name: &str| body.find(&format!("href=\"/{name}\"")).expect(name);
    check!(position("Alpha") < position("beta"));
    check!(position("beta") < position("Gamma"));
    check!(position("Gamma") < position("zeta"));
}

#[tokio::test]
async fn index_paginates_when_there_are_more_repositories_than_fit() {
    let fx = Fixture::with_page_size(2);
    for name in ["a", "b", "c", "d", "e"] {
        fx.repo(name, true, "", &[]);
    }
    let client = TestClient::new(fx.app.routes());

    let first = client
        .get("/")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    check!(first.contains("href=\"/a\"") && first.contains("href=\"/b\""));
    check!(!first.contains("href=\"/c\""));
    check!(first.contains("Page 1 of 3"));
    check!(first.contains("href=\"/?page=2\""));

    let last = client
        .get("/?page=3")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    check!(last.contains("href=\"/e\"") && !last.contains("href=\"/d\""));
    check!(last.contains("href=\"/?page=2\"") && !last.contains("href=\"/?page=4\""));

    let beyond = client.get("/?page=4").send().await;
    check!(beyond.0.status() == StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn index_has_no_pager_when_everything_fits() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let body = client
        .get("/")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    check!(!body.contains("class=\"pager\""));
}

#[tokio::test]
async fn repository_page_shows_readme_and_clone_url() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/widget").send().await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("<h1>Widget</h1>"));
    check!(body.contains("<em>widget</em>"));
    check!(!body.contains("<script"));
    check!(body.contains(&format!("git clone {PUBLIC_URL}/widget.git")));
    check!(body.contains("Widgets for everyone"));
    check!(body.contains("main"));
}

#[tokio::test]
async fn repository_page_accepts_dot_git_suffix_and_trailing_slash() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    for path in ["/widget.git", "/widget/", "/widget/file/"] {
        client.get(path).send().await.assert_status_is_ok();
    }
}

#[tokio::test]
async fn clone_url_follows_the_request_host_when_nothing_is_configured() {
    let fx = Fixture::new();
    fx.repo("widget", true, "", &[]);
    let mut config = fx.app.config.clone();
    config.public_url = None;
    let app = mugit::App::new(config).unwrap();
    let client = TestClient::new(app.routes());
    let body = client
        .get("/widget")
        .header("host", "git.example.org")
        .send()
        .await
        .0
        .into_body()
        .into_string()
        .await
        .unwrap();
    check!(body.contains("git clone http://git.example.org/widget.git"));
}

#[tokio::test]
async fn every_failure_is_a_page_of_the_site() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/?page=abc").send().await;
    check!(resp.0.status() == StatusCode::BAD_REQUEST);
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("Bad Request"));
    check!(body.contains("Test Site"));
}

#[tokio::test]
async fn plain_readme_is_shown_verbatim_and_escaped() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/plain").send().await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("Just text &lt;here&gt;"));
}

#[tokio::test]
async fn empty_repository_page_still_renders() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/bare").send().await;
    resp.assert_status_is_ok();
    let body = resp.0.into_body().into_string().await.unwrap();
    check!(body.contains("no README"));
}

#[tokio::test]
async fn private_and_missing_repositories_are_indistinguishable() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    for path in [
        "/secret",
        "/secret.git",
        "/nonexistent",
        "/.hidden",
        "/a..b",
    ] {
        let resp = client.get(path).send().await;
        check!(resp.0.status() == StatusCode::NOT_FOUND, "{path}");
        let body = resp.0.into_body().into_string().await.unwrap();
        check!(body.contains("Not Found"), "{path}");
        check!(body.contains("Test Site"), "{path}");
    }
}

#[tokio::test]
async fn unpublishing_hides_a_repository_immediately() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    client.get("/widget").send().await.assert_status_is_ok();
    common::set_public(&fx.dir.path().join("widget.git"), false);
    let resp = client.get("/widget").send().await;
    check!(resp.0.status() == StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn every_page_carries_the_browser_policy() {
    let fx = fixture();
    let client = TestClient::new(fx.app.routes());
    for path in [
        "/",
        "/widget",
        "/widget/file",
        "/widget/file/README.md",
        "/nonexistent",
    ] {
        let resp = client.get(path).send().await;
        let headers = resp.0.headers();
        let policy = headers
            .get("content-security-policy")
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default();
        check!(policy.contains("default-src 'none'"), "{path}");
        check!(policy.contains("frame-ancestors 'none'"), "{path}");
        check!(headers.get("referrer-policy").is_some(), "{path}");
        check!(
            headers
                .get("x-content-type-options")
                .is_some_and(|v| v == "nosniff"),
            "{path}"
        );
        check!(headers.get("strict-transport-security").is_none(), "{path}");
    }
}

#[tokio::test]
async fn strict_transport_is_demanded_once_there_is_a_tls_domain() {
    let fx = Fixture::with_args(&["--tls-domain", "git.test"]);
    let client = TestClient::new(fx.app.routes());
    let resp = client.get("/").send().await;
    resp.assert_status_is_ok();
    resp.assert_header("strict-transport-security", "max-age=31536000");
}