//! A repository's landing page, and the read-only git access it offers.
//!
//! The page is served by the real binary, and the content behind it is put
//! there with a real `git`, pushed straight into the bare repository. The
//! HTTP clone at the end drives a real `git` client against the server, so
//! what is checked is that the two sides agree rather than that a particular
//! sequence of bytes was assembled.
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 landing 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 commits, a readme and a tag, by
/// pushing straight into it.
///
/// The bare path is the one the server itself created, so this is the same
/// repository a visitor's page reads - not a copy.
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");
fs::write(work.join("README.md"), "# Notes\n\nWelcome to *notes*.\n")
.expect("writing the readme");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Initial commit"]),
"committing",
);
fs::write(
work.join("README.fr.md"),
"# Notes\n\nBienvenue dans *notes*.\n",
)
.expect("writing the French readme");
assert_ok(&git(&work, &["add", "-A"]), "staging");
assert_ok(
&git(&work, &["commit", "-q", "-m", "Add a French readme"]),
"committing",
);
assert_ok(
&git(&work, &["tag", "-a", "v0.1", "-m", "First release"]),
"tagging",
);
assert_ok(
&git(&work, &["push", "-q", bare.to_str().unwrap(), "main"]),
"pushing the commits",
);
assert_ok(
&git(&work, &["push", "-q", bare.to_str().unwrap(), "--tags"]),
"pushing the tags",
);
}
/// The abbreviated hash of the tip commit of the repository `populate` built.
fn head_hash(scratch: &Scratch) -> String {
let work = scratch.path("work");
let output = git(&work, &["rev-parse", "--short", "HEAD"]);
assert_ok(&output, "reading the head hash");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[tokio::test]
async fn a_landing_page_shows_the_readme_branches_and_tags() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-data");
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");
assert_eq!(response.status, 200, "repository page");
let body = &response.body;
assert!(
body.contains("Welcome to <em>notes</em>."),
"the readme should be rendered from markdown, got:\n{body}"
);
assert!(
body.contains("main"),
"the branch should be listed, got:\n{body}"
);
assert!(
body.contains("Add a French readme"),
"the branch's latest commit subject should be listed, got:\n{body}"
);
assert!(
body.contains("Test"),
"the commit's author should be listed, got:\n{body}"
);
let head = head_hash(&scratch);
assert!(
body.contains(&head),
"the commit hash {head} should be listed, got:\n{body}"
);
assert!(
body.contains("v0.1"),
"the tag should be listed, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn the_readme_follows_the_visitors_language() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-readme");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// A browser asking for French gets the French readme.
let response = alice.get_with("/~alice/test-repo", &[("Accept-Language", "fr")]);
assert_eq!(response.status, 200, "repository page");
assert!(
response.body.contains("Bienvenue dans <em>notes</em>."),
"a French visitor should get the French readme, got:\n{}",
response.body
);
assert!(
!response.body.contains("Welcome to <em>notes</em>."),
"the plain readme should have been passed over, got:\n{}",
response.body
);
// One asking for a language nobody wrote for gets the plain readme.
let response = alice.get_with("/~alice/test-repo", &[("Accept-Language", "de")]);
assert!(
response.body.contains("Welcome to <em>notes</em>."),
"an unwritten language should fall back to the plain readme, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn the_clone_url_suits_the_visitor() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-clone-url");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// Signed out, the read-only HTTP URL.
let anonymous = Client::new(port).get("/~alice/test-repo");
assert_eq!(anonymous.status, 200, "repository page");
assert!(
anonymous
.body
.contains(&format!("http://127.0.0.1:{port}/alice/test-repo.git")),
"a signed-out visitor should be offered the HTTP URL, got:\n{}",
anonymous.body
);
assert!(
!anonymous.body.contains("ssh://"),
"a signed-out visitor should not be offered an SSH URL, got:\n{}",
anonymous.body
);
// Signed in, the read-write SSH URL.
let response = alice.get("/~alice/test-repo");
assert!(
response.body.contains("ssh://git@"),
"a signed-in visitor should be offered the SSH URL, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn an_http_clone_fetches_the_repository() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-clone");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
let destination = scratch.path("clone");
let url = format!("http://127.0.0.1:{port}/alice/test-repo.git");
let output = git(
&scratch.dir,
&["clone", &url, destination.to_str().unwrap()],
);
assert_ok(&output, "cloning over HTTP");
assert_eq!(
fs::read_to_string(destination.join("README.md")).expect("the readme should be there"),
"# Notes\n\nWelcome to *notes*.\n",
"the clone should carry the pushed content"
);
Ok(())
}
#[tokio::test]
async fn http_git_access_is_read_only() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-read-only");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
populate(&server, &scratch, "alice", "test-repo");
// Asking which refs may be written is answered before git is consulted.
let response = Client::new(port).get("/alice/test-repo.git/info/refs?service=git-receive-pack");
assert_eq!(
response.status, 403,
"a push's ref advertisement should be refused\n{}",
response.body
);
// A client that posts a push anyway is refused too.
let mut anonymous = Client::new(port);
let response = anonymous.post_form("/alice/test-repo.git/git-receive-pack", &[]);
assert_eq!(
response.status, 403,
"a push should be refused over HTTP\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_private_repository_is_not_served_over_http() -> miette::Result<()> {
let (server, port) = spawn();
let scratch = Scratch::new("landing-private");
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "secret", "private");
populate(&server, &scratch, "alice", "secret");
// Its page is hidden, as before.
assert_eq!(
Client::new(port).get("/~alice/secret").status,
404,
"a stranger should not reach the page"
);
// And so is git access, which answers as though the repository is not
// there rather than admitting it is private.
let response = Client::new(port).get("/alice/secret.git/info/refs?service=git-upload-pack");
assert_eq!(
response.status, 404,
"a stranger should not be told the private repository exists\n{}",
response.body
);
let destination = scratch.path("clone");
let url = format!("http://127.0.0.1:{port}/alice/secret.git");
let output = git(
&scratch.dir,
&["clone", &url, destination.to_str().unwrap()],
);
assert!(
!output.status.success(),
"a stranger should not clone a private repository\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
Ok(())
}
#[tokio::test]
async fn an_empty_repository_still_has_a_page() -> 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");
assert_eq!(response.status, 200, "empty repository page");
// Nothing has been pushed, so both preview lists would be empty, and a
// section with nothing under its heading is not shown at all.
assert!(
!response.body.contains("<h3>Branches</h3>") && !response.body.contains("<h3>Tags</h3>"),
"an empty repository should show no branch or tag sections, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_repository_that_was_never_pushed_to_has_no_readme() -> 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");
assert_eq!(response.status, 200, "repository page");
assert!(
!response.body.contains("class=\"readme\""),
"nothing has been pushed, so there is no readme to show, got:\n{}",
response.body
);
Ok(())
}