//! Repositories and SSH keys as the webui serves them.
//!
//! The SSH tests prove the whole path works end to end; these pin the web
//! surface on its own - what a page shows, what a stranger is told, and which
//! refusals come back as redirects rather than errors.
mod support;
use frogue::entities::repositories;
use miette::IntoDiagnostic;
use sea_orm::{Database, EntityTrait, PaginatorTrait};
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
/// A real ed25519 public key, so the fingerprint the server computes can be
/// compared with what `ssh-keygen` says. Regenerated never: it is only ever a
/// key.
const PUBLIC_KEY: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAkLWlr2CY+UXEqurEAn4eb5OdKx2psKG8i033Wgpc4v test key";
/// The fingerprint `ssh-keygen -lf` reports for [`PUBLIC_KEY`].
const FINGERPRINT: &str = "SHA256:39WEiHo7Z2Zstu8Pf7Vhcbo6OBZYw+giz14QYIbZ/hE";
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 webui tests"),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating `{owner}/{slug}` should redirect\n{}",
response.body
);
assert_eq!(
response.location.as_deref(),
Some(format!("/~{owner}/{slug}").as_str()),
);
}
#[tokio::test]
async fn creating_a_repository_lists_it_on_the_profile() -> 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");
assert_eq!(response.status, 200, "profile");
assert!(
response.body.contains("/~alice/test-repo"),
"the profile should link to the new repository, got:\n{}",
response.body
);
assert!(
response.body.contains("public"),
"the profile should say the repository is public, got:\n{}",
response.body
);
// And the repository's own page shows how to clone it.
let response = alice.get("/~alice/test-repo");
assert_eq!(response.status, 200, "repository page");
assert!(
response.body.contains("ssh://git@"),
"the repository page should show a clone URL, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_private_repository_is_hidden_from_strangers() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "secret", "private");
// The owner sees it.
assert_eq!(
alice.get("/~alice/secret").status,
200,
"the owner should reach her private repository"
);
// Bob does not get to know it exists.
let mut bob = Client::new(port);
register(&mut bob, "bob");
assert_eq!(
bob.get("/~alice/secret").status,
404,
"a stranger should not be told the private repository exists"
);
assert!(
!bob.get("/~alice").body.contains("secret"),
"the private repository should not be listed to a stranger"
);
// Nor does anyone signed out.
assert_eq!(
Client::new(port).get("/~alice/secret").status,
404,
"an anonymous visitor should not reach a private repository"
);
Ok(())
}
#[tokio::test]
async fn a_public_repository_is_readable_by_anyone() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
assert_eq!(
Client::new(port).get("/~alice/test-repo").status,
200,
"a public repository should be readable while signed out"
);
assert!(
Client::new(port).get("/~alice").body.contains("test-repo"),
"a public repository should be listed to anyone"
);
Ok(())
}
#[tokio::test]
async fn creating_a_repository_is_the_accounts_own_affair() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Nobody signed in has no account to create under.
let mut anonymous = Client::new(port);
let response = anonymous.post_form(
"/~alice/repositories",
&[("slug", "test-repo"), ("visibility", "public")],
);
assert_eq!(
response.status, 401,
"creating a repository needs a session\n{}",
response.body
);
// And a signed-in visitor cannot create one under someone else's name.
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
"/~alice/repositories",
&[("slug", "test-repo"), ("visibility", "public")],
);
assert_eq!(
response.status, 403,
"a visitor should not create repositories for someone else\n{}",
response.body
);
// Nothing was created either way.
assert_eq!(alice.get("/~alice/test-repo").status, 404);
assert_eq!(bob.get("/~alice").status, 200);
assert!(!bob.get("/~alice").body.contains("test-repo"));
Ok(())
}
#[tokio::test]
async fn an_unusable_slug_is_refused_and_not_stored() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
for slug in ["has space", ".hidden", "notes.git", ""] {
let response = alice.post_form(
"/~alice/repositories",
&[("slug", slug), ("visibility", "public")],
);
assert_eq!(
response.status, 200,
"{slug:?} should come back as the form, not a redirect\n{}",
response.body
);
assert!(
response.body.contains("cannot name a repository"),
"{slug:?} should be refused with a reason, got:\n{}",
response.body
);
}
Ok(())
}
#[tokio::test]
async fn a_slug_taken_within_one_account_is_refused() -> 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.post_form(
"/~alice/repositories",
&[("slug", "test-repo"), ("visibility", "public")],
);
assert_eq!(
response.status, 200,
"a duplicate should be refused\n{}",
response.body
);
assert!(
response.body.contains("already have a repository"),
"the refusal should say the name is taken, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn two_accounts_may_have_a_repository_of_the_same_name() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let mut bob = Client::new(port);
register(&mut bob, "bob");
create(&mut bob, "bob", "test-repo", "public");
assert_eq!(alice.get("/~alice/test-repo").status, 200);
assert_eq!(alice.get("/~bob/test-repo").status, 200);
Ok(())
}
#[tokio::test]
async fn adding_and_removing_an_ssh_key() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
let response = alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);
assert_eq!(
response.status, 303,
"adding a key should redirect\n{}",
response.body
);
assert_eq!(response.location.as_deref(), Some("/~alice/ssh-keys"));
// The list shows the key by the fingerprint `ssh-keygen` would report, and
// by the comment its owner gave it.
let response = alice.get("/~alice/ssh-keys");
assert_eq!(response.status, 200, "key list\n{}", response.body);
assert!(
response.body.contains(FINGERPRINT),
"the list should show the key's fingerprint, got:\n{}",
response.body
);
assert!(
response.body.contains("test key"),
"the list should show the key's name, got:\n{}",
response.body
);
// Deleting it takes it off the list. The id is 1: the first key stored.
let response = alice.post_form("/~alice/ssh-keys/1/delete", &[]);
assert_eq!(
response.status, 303,
"deleting a key should redirect\n{}",
response.body
);
let response = alice.get("/~alice/ssh-keys");
assert!(
!response.body.contains(FINGERPRINT),
"the deleted key should be gone, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_key_that_is_not_a_key_is_refused() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
let response = alice.post_form("/~alice/ssh-keys", &[("key", "this is not a key")]);
assert_eq!(
response.status, 200,
"an unusable key should come back as the form\n{}",
response.body
);
assert!(
response.body.contains("not a public key this understands"),
"the refusal should say why, got:\n{}",
response.body
);
let response = alice.get("/~alice/ssh-keys");
assert!(
response.body.contains("No keys yet."),
"nothing should have been stored, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_key_belonging_to_another_account_is_refused() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form("/~bob/ssh-keys", &[("key", PUBLIC_KEY)]);
assert_eq!(
response.status, 200,
"the same key should not be added twice\n{}",
response.body
);
assert!(
response.body.contains("another account"),
"the refusal should say the key is taken, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn ssh_keys_are_the_accounts_own_affair() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
alice.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)]);
let mut bob = Client::new(port);
register(&mut bob, "bob");
// Bob cannot look at, add to, or delete from Alice's keys.
assert_eq!(bob.get("/~alice/ssh-keys").status, 403);
assert_eq!(
bob.post_form("/~alice/ssh-keys", &[("key", PUBLIC_KEY)])
.status,
403
);
assert_eq!(bob.post_form("/~alice/ssh-keys/1/delete", &[]).status, 403);
// Alice's key is still there.
assert!(alice.get("/~alice/ssh-keys").body.contains(FINGERPRINT));
// A visitor signed out is turned away, too.
let mut anonymous = Client::new(port);
assert_eq!(anonymous.get("/~alice/ssh-keys").status, 401);
Ok(())
}
#[tokio::test]
async fn a_name_the_webui_uses_cannot_name_a_repository() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
// Each of these is a page of the webui under `/~alice/`. A repository that
// took one of them would exist on disk but its page would show something
// else, so it is refused up front.
for slug in ["edit", "repositories", "ssh-keys"] {
let response = alice.post_form(
"/~alice/repositories",
&[("slug", slug), ("visibility", "public")],
);
assert_eq!(
response.status, 200,
"{slug:?} should come back as the form, not a redirect\n{}",
response.body
);
assert!(
response.body.contains("reserved"),
"{slug:?} should be refused as reserved, got:\n{}",
response.body
);
}
// And none of them was stored, on disk or in the database.
let db = Database::connect(server.database_url())
.await
.into_diagnostic()?;
let stored = repositories::Entity::find()
.count(&db)
.await
.into_diagnostic()?;
assert_eq!(stored, 0, "no reserved name should have been stored");
for slug in ["edit", "repositories", "ssh-keys"] {
assert!(
!server
.repo_root()
.join("alice")
.join(format!("{slug}.git"))
.exists(),
"{slug:?} should not have been created on disk"
);
}
// The pages those names belong to are still the webui's own.
assert_eq!(
alice.get("/~alice/edit").status,
200,
"the profile edit form"
);
assert_eq!(
alice.get("/~alice/ssh-keys").status,
200,
"the SSH key list"
);
Ok(())
}