🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The refs browser: every branch and tag a repository has, and the
//! repository navigation that leads to it.
//!
//! Like the landing 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 refs 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 and tags 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 four dated branches and four
/// dated tags, by pushing straight into it.
///
/// The dates are the test's to decide rather than the clock's, and they run
/// against the alphabetical order of the names on purpose: a listing sorted
/// by date reads `dingo, cougar, bison, aardvark`, one sorted by name reads
/// `aardvark, bison, cougar, dingo`, so what the page did is the answer to
/// assert rather than a coincidence of the fixture.
///
/// `main` never gets a commit, so the branches are exactly these four.
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",
    );

    for (name, date, subject) in [
        ("aardvark", "2021-01-01T00:00:00+00:00", "first"),
        ("bison", "2021-01-02T00:00:00+00:00", "second"),
        ("cougar", "2021-01-03T00:00:00+00:00", "third"),
        ("dingo", "2021-01-04T00:00:00+00:00", "fourth"),
    ] {
        let dates = [("GIT_AUTHOR_DATE", date), ("GIT_COMMITTER_DATE", date)];
        assert_ok(
            &git(&work, &dates, &["checkout", "-q", "-B", name]),
            "branching",
        );
        assert_ok(
            &git(
                &work,
                &dates,
                &["commit", "-q", "--allow-empty", "-m", subject],
            ),
            "committing",
        );
    }

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

    // All four tags on the same commit, dated by the tag itself: the tag's
    // own date is what a listing of tags is ordered by.
    for (date, tag) in [
        ("2021-02-01T00:00:00+00:00", "v1"),
        ("2021-02-02T00:00:00+00:00", "v2"),
        ("2021-02-03T00:00:00+00:00", "v3"),
        ("2021-02-04T00:00:00+00:00", "v4"),
    ] {
        let dates = [("GIT_COMMITTER_DATE", date)];
        assert_ok(
            &git(
                &work,
                &dates,
                &["tag", "-a", tag, "-m", &format!("release {tag}")],
            ),
            "tagging",
        );
    }

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

/// 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]
}

/// Asserts that every `name` appears in `body` in the order given, each as a
/// listed ref whose name leads to its commit history rather than as prose that
/// happens to mention it.
fn assert_listed_in_order(body: &str, names: &[&str], what: &str) {
    let mut previous: Option<usize> = None;
    for name in names {
        let needle = format!("/commits/{name}\"");
        let Some(at) = body.find(&needle) else {
            panic!("{what}: `{name}` should be listed, got:\n{body}");
        };
        if let Some(previous) = previous {
            assert!(
                at > previous,
                "{what}: `{name}` should come after the one before it, got:\n{body}"
            );
        }
        previous = Some(at);
    }
}

/// Asserts that `name` is not listed as a ref in `body`.
fn assert_not_listed(body: &str, name: &str, what: &str) {
    assert!(
        !body.contains(&format!("/commits/{name}\"")),
        "{what}: `{name}` should not be listed, got:\n{body}"
    );
}

#[tokio::test]
async fn the_refs_page_lists_every_branch_and_tag_newest_first() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("refs-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/refs");
    assert_eq!(response.status, 200, "refs page");
    let body = &response.body;

    // All four branches - the landing page only ever previews three - and
    // newest first, which for these names is the opposite of alphabetical.
    assert_listed_in_order(body, &["dingo", "cougar", "bison", "aardvark"], "branches");
    assert!(
        body.contains("Branches (4)"),
        "the branch count, got:\n{body}"
    );

    assert_listed_in_order(body, &["v4", "v3", "v2", "v1"], "tags");
    assert!(body.contains("Tags (4)"), "the tag count, got:\n{body}");

    // The commit each ref reaches is shown, as on the landing page.
    assert!(body.contains("Test"), "the author, got:\n{body}");
    assert!(
        body.contains("fourth"),
        "the tip commit's subject, got:\n{body}"
    );

    // The landing page is still a preview: it does not grow to the full
    // listing just because the full listing exists elsewhere.
    let landing = alice.get("/~alice/test-repo");
    assert_eq!(landing.status, 200, "landing page");
    assert_listed_in_order(
        &landing.body,
        &["dingo", "cougar", "bison"],
        "the landing preview",
    );
    assert_not_listed(&landing.body, "aardvark", "the landing preview");
    assert_not_listed(&landing.body, "v1", "the landing preview");

    // And the landing page points at the full listing.
    assert!(
        landing.body.contains("View all branches and tags"),
        "the landing page should offer the full listing, got:\n{}",
        landing.body
    );

    // A repository nobody is signed in for is still readable: the refs of a
    // public repository are public.
    let anonymous = Client::new(port).get("/~alice/test-repo/refs");
    assert_eq!(
        anonymous.status, 200,
        "a stranger should reach a public repository's refs"
    );

    Ok(())
}

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

    let landing = alice.get("/~alice/test-repo");
    assert_eq!(landing.status, 200, "landing page");
    let bar = tab_bar(&landing.body);
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo" aria-current="page">Summary</a>"#),
        "the landing page is the code page, got:\n{bar}"
    );
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo/refs">Refs</a>"#),
        "the tab bar should lead to the refs, got:\n{bar}"
    );

    let refs = alice.get("/~alice/test-repo/refs");
    assert_eq!(refs.status, 200, "refs page");
    let bar = tab_bar(&refs.body);
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo">Summary</a>"#),
        "the tab bar should lead back to the repository, got:\n{bar}"
    );
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo/refs" aria-current="page">Refs</a>"#),
        "the refs page is the refs page, got:\n{bar}"
    );

    Ok(())
}

#[tokio::test]
async fn a_repository_may_itself_be_called_refs() -> miette::Result<()> {
    // The browser's address has `refs` after the slug, not in the slug's own
    // position, so taking it costs a repository nothing: `/~alice/refs` is
    // the repository, and its refs are one level further down.
    let (server, port) = spawn();
    let mut alice = Client::new(port);
    register(&mut alice, "alice");
    create(&mut alice, "alice", "refs", "public");

    let landing = alice.get("/~alice/refs");
    assert_eq!(landing.status, 200, "repository page");
    let bar = tab_bar(&landing.body);
    assert!(
        bar.contains(r#"<a href="/~alice/refs" aria-current="page">Summary</a>"#),
        "the repository's own page should be the code page, got:\n{bar}"
    );

    let refs = alice.get("/~alice/refs/refs");
    assert_eq!(refs.status, 200, "its refs page");
    let bar = tab_bar(&refs.body);
    assert!(
        bar.contains(r#"<a href="/~alice/refs/refs" aria-current="page">Refs</a>"#),
        "the repository's own refs should be the refs page, got:\n{bar}"
    );

    Ok(())
}

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

    // The name leads to the branch as it stands; the hash beneath it leads
    // to the commit's diff, addressed by the full hash rather than the
    // short one the listing shows.
    let work = scratch.path("work");
    let full = git(&work, &[], &["rev-parse", "dingo"]);
    assert_ok(&full, "reading the full hash");
    let full = String::from_utf8_lossy(&full.stdout).trim().to_string();
    let short = git(&work, &[], &["rev-parse", "--short", "dingo"]);
    assert_ok(&short, "reading the short hash");
    let short = String::from_utf8_lossy(&short.stdout).trim().to_string();

    let response = alice.get("/~alice/test-repo/refs");
    assert_eq!(response.status, 200, "refs page");
    assert!(
        response.body.contains(&format!(
            "<a href=\"/~alice/test-repo/commit/{full}\">{short}</a>"
        )),
        "the hash should lead to the commit's diff, got:\n{}",
        response.body
    );

    // The address works: `dingo` never got a file, so its commit's tree is
    // empty - a page that says so rather than a missing one.
    let tree = alice.get(&format!("/~alice/test-repo/tree/{full}"));
    assert_eq!(tree.status, 200, "commit tree");
    assert!(
        tree.body.contains("Nothing here yet."),
        "an empty commit's tree should be an empty page, got:\n{}",
        tree.body
    );

    Ok(())
}

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

    // As on the landing page, the refs answer as though the repository is
    // not there rather than admitting it is private.
    assert_eq!(
        Client::new(port).get("/~alice/secret/refs").status,
        404,
        "a stranger should not reach the refs"
    );

    let mut bob = Client::new(port);
    register(&mut bob, "bob");
    assert_eq!(
        bob.get("/~alice/secret/refs").status,
        404,
        "another user should not reach the refs"
    );

    // The owner still can.
    assert_eq!(
        alice.get("/~alice/secret/refs").status,
        200,
        "the owner should reach the refs"
    );

    Ok(())
}

#[tokio::test]
async fn an_unknown_repositorys_refs_page_is_not_found() -> miette::Result<()> {
    let (server, port) = spawn();

    let response = Client::new(port).get("/~alice/nothing/refs");
    assert_eq!(response.status, 404, "a repository that does not exist");

    Ok(())
}

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

    let response = alice.get("/~alice/test-repo/refs");
    assert_eq!(response.status, 200, "empty repository refs page");
    assert!(
        response.body.contains("Nothing here yet."),
        "an empty repository should say it has no refs, got:\n{}",
        response.body
    );

    Ok(())
}