🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The commit history viewer: every commit reachable from a revision.
//!
//! Like the refs browser tests, the content is put there with a real `git`
//! pushed straight into the bare repository the server created, so the page
//! is read out of the same repository a clone would fetch.

mod support;

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

/// Names the scratch directories, so several tests can run at once.
static COUNTER: AtomicU64 = AtomicU64::new(0);

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);
    (server, port)
}

/// Registers `name` through the form, which logs them in.
fn register(client: &mut Client, name: &str) {
    let email = format!("{name}@example.org");
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", &email), ("password", PASSWORD)],
    );
    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Creates a repository through the form, and checks it landed where it should.
fn create(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the commits tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating `{owner}/{slug}` should redirect\n{}",
        response.body
    );
}

/// A directory of the test's own, removed when it goes out of scope.
struct Scratch {
    dir: PathBuf,
}

impl Scratch {
    fn new(label: &str) -> Self {
        let count = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
        fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.dir);
    }
}

/// Runs `git` in `dir` with the test's own identity, with `env` set for this
/// call - the dated commits the listing order is asserted against.
fn git(dir: &Path, env: &[(&str, &str)], args: &[&str]) -> Output {
    let mut command = Command::new("git");
    command
        .current_dir(dir)
        .args(["-c", "user.name=Test", "-c", "user.email=test@example.org"])
        .args(args)
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null");
    for (name, value) in env {
        command.env(name, value);
    }

    command.output().expect("git should run")
}

/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
    assert!(
        output.status.success(),
        "{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// Fills `owner/slug`'s bare repository with a linear history of dated commits
/// on `main`, plus a branch with a slash in its name.
///
/// The dates are the test's to decide rather than the clock's, and they run
/// against the alphabetical order of the subjects on purpose: a listing sorted
/// by date reads `fourth, third, second, first`, one sorted by subject reads
/// `first, second, third, fourth`, so what the page did is the answer to
/// assert rather than a coincidence of the fixture.
fn populate(server: &Server, scratch: &Scratch, owner: &str, slug: &str) {
    let bare = server.repo_root().join(owner).join(format!("{slug}.git"));

    let work = scratch.path("work");
    fs::create_dir_all(&work).expect("the working tree should be creatable");
    assert_ok(
        &git(&work, &[], &["init", "-q", "-b", "main"]),
        "initialising",
    );

    // Four dated commits on main.
    for (date, subject) in [
        ("2021-01-01T00:00:00+00:00", "first"),
        ("2021-01-02T00:00:00+00:00", "second"),
        ("2021-01-03T00:00:00+00:00", "third"),
        ("2021-01-04T00:00:00+00:00", "fourth"),
    ] {
        let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
        assert_ok(
            &git(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            ),
            "committing",
        );
    }

    // A branch with a slash in its name, one commit ahead of main.
    assert_ok(
        &git(&work, &[], &["checkout", "-q", "-b", "feature/x"]),
        "branching",
    );
    let dates = [
        ("GIT_AUTHOR_DATE", "2021-01-05T00:00:00+00:00"),
        ("GIT_COMMITTER_DATE", "2021-01-05T00:00:00+00:00"),
    ];
    assert_ok(
        &git(
            &work,
            &dates,
            &["commit", "-q", "--allow-empty", "-m", "on feature/x"],
        ),
        "committing on feature/x",
    );

    assert_ok(
        &git(&work, &[], &["push", "-q", "--all", bare.to_str().unwrap()]),
        "pushing",
    );
}

/// The repository tab bar out of a page's body, which is what says which of
/// the repository's pages a visitor is on.
fn tab_bar(body: &str) -> &str {
    let start = body
        .find("<nav class=\"repository-tabs\">")
        .expect("the page should carry the repository tab bar");
    let rest = &body[start..];
    let end = rest.find("</nav>").expect("the tab bar should be closed") + "</nav>".len();
    &rest[..end]
}

#[tokio::test]
async fn the_commits_page_lists_every_commit_newest_first() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-listing");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "commits page");
    let body = &response.body;

    // All four commits, newest first, which for these subjects is the
    // opposite of alphabetical.
    assert!(
        body.contains("Commits on main"),
        "the heading, got:\n{body}"
    );
    assert!(
        body.find("fourth").unwrap() < body.find("third").unwrap(),
        "fourth before third, got:\n{body}"
    );
    assert!(
        body.find("third").unwrap() < body.find("second").unwrap(),
        "third before second, got:\n{body}"
    );
    assert!(
        body.find("second").unwrap() < body.find("first").unwrap(),
        "second before first, got:\n{body}"
    );

    // The commits tab should be current.
    assert!(
        tab_bar(body).contains(r#"aria-current="page">Commits</a>"#),
        "the Commits tab should be current, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn commit_hashes_link_to_the_diff_at_that_commit() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-hashes");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    // Read the full hash of the "fourth" commit from git.
    let work = scratch.path("work");
    let output = git(&work, &[], &["rev-parse", "main"]);
    assert_ok(&output, "reading the full hash");
    let full = String::from_utf8_lossy(&output.stdout).trim().to_string();

    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "commits page");
    let body = &response.body;

    // The hash should be present and link to the diff at that commit.
    assert!(
        body.contains(&format!("/~alice/test-repo/commit/{full}")),
        "the full hash should link to its diff, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn the_commits_root_redirects_to_the_default_branch() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-redirect");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits");
    assert_eq!(response.status, 303, "/commits should redirect");
    assert_eq!(
        response.header("location"),
        Some("/~alice/test-repo/commits/main"),
        "the redirect should lead to the default branch"
    );

    let response = alice.get("/~alice/test-repo/commits/");
    assert_eq!(response.status, 303, "/commits/ should redirect");
    assert_eq!(
        response.header("location"),
        Some("/~alice/test-repo/commits/main"),
        "the redirect should lead to the default branch"
    );

    Ok(())
}

#[tokio::test]
async fn history_of_a_slashed_branch_works() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-slashed");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits/feature/x");
    assert_eq!(response.status, 200, "commits of feature/x");
    let body = &response.body;

    // The heading should mention feature/x.
    assert!(
        body.contains("Commits on feature/x"),
        "the heading, got:\n{body}"
    );

    // The branch's own commit should be present, plus all of main's.
    assert!(
        body.contains("on feature/x"),
        "the branch's commit, got:\n{body}"
    );
    assert!(
        body.contains("fourth"),
        "main's commits should be included, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn unknown_revision_is_404() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-404");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits/nowhere");
    assert_eq!(response.status, 404, "unknown revision");

    Ok(())
}

#[tokio::test]
async fn tail_starting_with_dash_is_404() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-dash");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    let response = alice.get("/~alice/test-repo/commits/-HEAD");
    assert_eq!(response.status, 404, "tail starting with -");

    Ok(())
}

#[tokio::test]
async fn unborn_default_branch_shows_empty_page() -> miette::Result<()> {
    let (_server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");

    // The repository was just created and has no commits yet.
    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "unborn default branch");
    let body = &response.body;

    assert!(
        body.contains("Nothing here yet."),
        "an unborn branch should be an empty page, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn private_repo_anonymous_gets_404() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-private");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "private");
    populate(&server, &scratch, "alice", "test-repo");

    // An anonymous visitor should get a 404, the same as for a missing repo.
    let mut anonymous = Client::new(port);
    let response = anonymous.get("/~alice/test-repo/commits/main");
    assert_eq!(
        response.status, 404,
        "anonymous should get 404 for a private repo"
    );

    Ok(())
}

#[tokio::test]
async fn commits_root_with_ref_query_redirects_to_that_ref() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-ref-query");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    // Query parameter should redirect to the path-based URL.
    let response = alice.get("/~alice/test-repo/commits/?ref=feature/x");
    assert_eq!(response.status, 303, "/commits/?ref= should redirect");
    assert_eq!(
        response.header("location"),
        Some("/~alice/test-repo/commits/feature/x"),
        "the redirect should lead to the specified ref"
    );

    // Without trailing slash should also work.
    let response = alice.get("/~alice/test-repo/commits?ref=main");
    assert_eq!(response.status, 303, "/commits?ref= should redirect");
    assert_eq!(
        response.header("location"),
        Some("/~alice/test-repo/commits/main"),
        "the redirect should lead to the specified ref"
    );

    // Dash-prefixed ref should be rejected.
    let response = alice.get("/~alice/test-repo/commits/?ref=-HEAD");
    assert_eq!(
        response.status, 404,
        "ref starting with - should be rejected"
    );

    Ok(())
}

#[tokio::test]
async fn commits_page_carries_ref_select_form() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("commits-ref-form");
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "test-repo", "public");
    populate(&server, &scratch, "alice", "test-repo");

    // Create a tag for testing tag selection marking.
    let work = scratch.path("work");
    assert_ok(&git(&work, &[], &["tag", "v1.0"]), "tagging");
    assert_ok(
        &git(
            &work,
            &[],
            &[
                "push",
                "-q",
                "--tags",
                server
                    .repo_root()
                    .join("alice")
                    .join("test-repo.git")
                    .to_str()
                    .unwrap(),
            ],
        ),
        "pushing tags",
    );

    // View commits at main.
    let response = alice.get("/~alice/test-repo/commits/main");
    assert_eq!(response.status, 200, "commits page");
    let body = &response.body;

    // Should have a form with action pointing to /commits/.
    assert!(
        body.contains(r#"action="/~alice/test-repo/commits/""#),
        "should have a form targeting /commits/, got:\n{body}"
    );

    // Should have a select named "ref".
    assert!(
        body.contains(r#"name="ref""#),
        "should have a select named 'ref', got:\n{body}"
    );

    // Should have branches listed.
    assert!(
        body.contains(r#"<optgroup label="Branches">"#),
        "should have a Branches optgroup, got:\n{body}"
    );
    assert!(
        body.contains(r#"value="main""#),
        "should list the main branch, got:\n{body}"
    );

    // Should have tags listed.
    assert!(
        body.contains(r#"<optgroup label="Tags">"#),
        "should have a Tags optgroup, got:\n{body}"
    );
    assert!(
        body.contains(r#"value="v1.0""#),
        "should list the v1.0 tag, got:\n{body}"
    );

    // Should have a submit button.
    assert!(
        body.contains(r#"type="submit""#),
        "should have a submit button, got:\n{body}"
    );

    // The current ref (main) should be marked as selected.
    assert!(
        body.contains(r#"value="main" selected"#),
        "the current ref should be marked as selected, got:\n{body}"
    );

    // Now view at the tag - it should be selected.
    let response = alice.get("/~alice/test-repo/commits/v1.0");
    assert_eq!(response.status, 200, "commits at tag");
    let body = &response.body;
    assert!(
        body.contains(r#"value="v1.0" selected"#),
        "the tag should be marked as selected when viewing at it, got:\n{body}"
    );

    Ok(())
}