🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! The tree browser: a repository's files at a given ref, walked directory
//! by directory.
//!
//! As with the landing and refs tests, the content is put there with a real
//! `git` pushed straight into the bare repository the server created.

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 tree 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.
fn git(dir: &Path, args: &[&str]) -> Output {
    Command::new("git")
        .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")
        .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 small directory tree on
/// `main`, a branch whose name contains a slash, and an annotated tag, then
/// returns the abbreviated hash of `main`'s tip.
fn populate(server: &Server, scratch: &Scratch, owner: &str, slug: &str) -> String {
    let bare = server.repo_root().join(owner).join(format!("{slug}.git"));

    let work = scratch.path("work");
    fs::create_dir_all(work.join("src/deep")).expect("the directories should be creatable");
    fs::create_dir_all(work.join("docs")).expect("the directories should be creatable");

    assert_ok(&git(&work, &["init", "-q", "-b", "main"]), "initialising");

    fs::write(work.join("README.md"), "# Notes\n").expect("writing a file");
    fs::write(work.join("notes.txt"), "plain notes\n").expect("writing a file");
    fs::write(work.join("docs/guide.md"), "# Guide\n").expect("writing a file");
    // A markdown file with relative links to verify they get rewritten to tree
    // URLs
    fs::write(
        work.join("docs/index.md"),
        "# Index\n\nSee [the guide](./guide.md) and [the readme](../README.md).\n",
    )
    .expect("writing a file");
    fs::write(work.join("src/lib.rs"), "pub fn lib() {}\n").expect("writing a file");
    fs::write(work.join("src/main.rs"), "fn main() {}\n").expect("writing a file");
    fs::write(work.join("src/deep/nested.txt"), "deep\n").expect("writing a file");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Root commit"]),
        "committing",
    );

    // A branch whose name holds a slash, so the address has to keep it one
    // ref rather than read it as a directory path.
    assert_ok(
        &git(&work, &["checkout", "-q", "-b", "feature/x"]),
        "branching",
    );
    fs::create_dir_all(work.join("pkg")).expect("the directory should be creatable");
    fs::write(work.join("pkg/tool.py"), "print('tool')\n").expect("writing a file");
    assert_ok(&git(&work, &["add", "-A"]), "staging");
    assert_ok(
        &git(&work, &["commit", "-q", "-m", "Add the tool"]),
        "committing",
    );

    assert_ok(
        &git(&work, &["checkout", "-q", "main"]),
        "returning to main",
    );
    assert_ok(
        &git(&work, &["tag", "-a", "v1", "-m", "First release"]),
        "tagging",
    );

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

    let output = git(&work, &["rev-parse", "--short", "HEAD"]);
    assert_ok(&output, "reading the head hash");
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

/// The repository tab bar out of a page's body.
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_root_listing_shows_directories_then_files() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("tree-root");
    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/tree/main");
    assert_eq!(response.status, 200, "tree page");
    let body = &response.body;

    // Directories are suffixed with a slash; every entry leads somewhere -
    // a directory into itself, a file onto its own page.
    for directory in ["docs", "src"] {
        assert!(
            body.contains(&format!(
                "href=\"/~alice/test-repo/tree/main/{directory}\">{directory}/</a>"
            )),
            "the directory `{directory}` should be linked, got:\n{body}"
        );
    }
    for file in ["README.md", "notes.txt"] {
        assert!(
            body.contains(&format!(
                "href=\"/~alice/test-repo/tree/main/{file}\">{file}</a>"
            )),
            "the file `{file}` should lead to its own page, got:\n{body}"
        );
    }

    // Directories first, which alphabetical order would not give: `docs`
    // sorts after `README.md` byte for byte.
    let docs = body.find(">docs/</a>").expect("docs should be listed");
    let readme = body
        .find(">README.md</a>")
        .expect("README.md should be listed");
    assert!(
        docs < readme,
        "directories should come before files, got:\n{body}"
    );

    // The breadcrumb at the root names the ref, and the tab bar marks the
    // page as the repository's browse page.
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a></p>"
        ),
        "the breadcrumb at the root, got:\n{body}"
    );
    let bar = tab_bar(body);
    assert!(
        bar.contains(r#"<a href="/~alice/test-repo/tree/main" aria-current="page">Browse</a>"#),
        "the browse tab should be current, got:\n{bar}"
    );

    Ok(())
}

#[tokio::test]
async fn directories_lead_to_their_contents_and_the_breadcrumb_back() -> miette::Result<()> {
    let (server, port) = spawn();
    let scratch = Scratch::new("tree-walk");
    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/tree/main/src");
    assert_eq!(response.status, 200, "subdirectory");
    let body = &response.body;
    assert!(
        body.contains("href=\"/~alice/test-repo/tree/main/src/deep\">deep/</a>"),
        "a subdirectory of the subdirectory, got:\n{body}"
    );
    assert!(
        body.contains("href=\"/~alice/test-repo/tree/main/src/lib.rs\">lib.rs</a>"),
        "a file, got:\n{body}"
    );
    assert!(
        !body.contains(">README.md</a>"),
        "the parent's files are not in this directory, got:\n{body}"
    );
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a> / \
             <span class=\"current\">src</span></p>"
        ),
        "the breadcrumb should lead back to the root, got:\n{body}"
    );

    let response = alice.get("/~alice/test-repo/tree/main/src/deep");
    assert_eq!(response.status, 200, "nested directory");
    let body = &response.body;
    assert!(
        body.contains("href=\"/~alice/test-repo/tree/main/src/deep/nested.txt\">nested.txt</a>"),
        "the nested file, now leading to its own page, got:\n{body}"
    );
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/main\">main</a> / \
             <a href=\"/~alice/test-repo/tree/main/src\">src</a> / \
             <span class=\"current\">deep</span></p>"
        ),
        "every step of the path should lead back, got:\n{body}"
    );

    Ok(())
}

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

    // The branch as a whole, not `feature` with `x` beneath it.
    let response = alice.get("/~alice/test-repo/tree/feature/x");
    assert_eq!(response.status, 200, "slashy ref");
    let body = &response.body;
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/feature/x\">feature/x</a></p>"
        ),
        "the ref should be shown whole, got:\n{body}"
    );
    assert!(
        body.contains("href=\"/~alice/test-repo/tree/feature/x/pkg\">pkg/</a>"),
        "the branch's directory, got:\n{body}"
    );

    // And a directory within it: the ref stays whole, the rest is path.
    let response = alice.get("/~alice/test-repo/tree/feature/x/pkg");
    assert_eq!(response.status, 200, "slashy ref subdirectory");
    let body = &response.body;
    assert!(
        body.contains("href=\"/~alice/test-repo/tree/feature/x/pkg/tool.py\">tool.py</a>"),
        "the file in the branch's directory, got:\n{body}"
    );
    assert!(
        body.contains(
            "<p class=\"breadcrumb\"><a href=\"/~alice/test-repo/tree/feature/x\">feature/x</a> / \
             <span class=\"current\">pkg</span></p>"
        ),
        "the breadcrumb should keep the ref whole, got:\n{body}"
    );

    Ok(())
}

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

    // An annotated tag is peeled to the commit it names.
    let response = alice.get("/~alice/test-repo/tree/v1");
    assert_eq!(response.status, 200, "tag tree");
    assert!(
        response.body.contains(">README.md</a>"),
        "the tag should show the tree of its commit, got:\n{}",
        response.body
    );

    // A raw commit hash is not a ref at all, but git knows it, and the
    // address says what to show: that commit's tree.
    let response = alice.get(&format!("/~alice/test-repo/tree/{head}"));
    assert_eq!(response.status, 200, "hash tree");
    assert!(
        response.body.contains(">README.md</a>"),
        "the hash should show that commit's tree, got:\n{}",
        response.body
    );

    Ok(())
}

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

    // The tab on any of the repository's pages leads to the default branch.
    let landing = alice.get("/~alice/test-repo");
    assert_eq!(landing.status, 200, "landing page");
    assert!(
        landing
            .body
            .contains("<a href=\"/~alice/test-repo/tree/main\">Browse</a>"),
        "the browse tab should address the default branch, got:\n{}",
        landing.body
    );

    // The address without a ref settles on the default branch rather than
    // refusing to say what a repository opens on.
    let response = alice.get("/~alice/test-repo/tree/");
    assert_eq!(response.status, 303, "empty tail");
    assert_eq!(
        response.location.as_deref(),
        Some("/~alice/test-repo/tree/main"),
        "the empty tail should redirect to the default branch"
    );

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

    Ok(())
}

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

    // The same page with and without the slash, at a revision and at a path
    // beneath it: one trailing slash is trimmed rather than naming anything
    // of its own.
    let response = alice.get("/~alice/test-repo/tree/main/");
    assert_eq!(response.status, 200, "trailing slash on the revision");
    let response = alice.get("/~alice/test-repo/tree/main/src/");
    assert_eq!(response.status, 200, "trailing slash on the path");
    assert!(
        response
            .body
            .contains("href=\"/~alice/test-repo/tree/main/src/deep\">deep/</a>"),
        "the listing under a slashed path should be the same one, got:\n{}",
        response.body
    );

    Ok(())
}

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

    assert_eq!(
        alice.get("/~alice/test-repo/tree/nothing").status,
        404,
        "a ref that does not exist"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/tree/main/nothing").status,
        404,
        "a path that is not in the tree"
    );
    assert_eq!(
        alice.get("/~alice/test-repo/tree/nothing/deeper").status,
        404,
        "neither a ref nor a revision"
    );

    Ok(())
}

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

    assert_eq!(
        Client::new(port).get("/~alice/secret/tree/main").status,
        404,
        "a stranger should not reach the tree"
    );

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

    assert_eq!(
        alice.get("/~alice/secret/tree/main").status,
        200,
        "the owner should reach the tree"
    );

    Ok(())
}

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

    // When viewing a markdown file, relative links should be rewritten to tree
    // URLs
    let response = alice.get("/~alice/test-repo/tree/main/docs/index.md");
    assert_eq!(response.status, 200, "markdown file");
    let body = &response.body;

    // The relative link ./guide.md should be rewritten to the tree URL
    assert!(
        body.contains(r#"href="/~alice/test-repo/tree/main/docs/guide.md""#),
        "relative link to guide.md should be rewritten to tree URL, got:\n{body}"
    );

    // The relative link ../README.md should be rewritten to the tree URL
    assert!(
        body.contains(r#"href="/~alice/test-repo/tree/main/README.md""#),
        "relative link to README.md should be rewritten to tree URL, got:\n{body}"
    );

    Ok(())
}

#[tokio::test]
async fn an_empty_repositorys_tree_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");

    // Nothing has ever been pushed, so the default branch does not resolve
    // to a commit: that is an empty repository, not a missing page.
    let response = alice.get("/~alice/test-repo/tree/main");
    assert_eq!(response.status, 200, "empty repository tree");
    assert!(
        response.body.contains("Nothing here yet."),
        "an empty repository should say it has nothing to list, got:\n{}",
        response.body
    );

    Ok(())
}