//! The repository settings page: who may reach it, what may be changed, and
//! what stays put.
//!
//! Every assertion goes through the forms the forge serves and the HTML a
//! browser would be handed.
mod support;
use support::{Client, Server, free_port};
/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";
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 settings tests"),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating `{owner}/{slug}` should redirect\n{}",
response.body
);
}
#[tokio::test]
async fn the_settings_page_needs_an_owner() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// Anonymous users are told to sign in rather than being shown the form.
let mut anonymous = Client::new(port);
assert_eq!(
anonymous.get("/~alice/test-repo/settings").status,
401,
"a stranger should not reach the settings page"
);
assert_eq!(
anonymous
.post_form("/~alice/test-repo/settings", &[("visibility", "private")])
.status,
401,
"a stranger should not be able to change visibility"
);
// Another signed-in user is refused with 403, the same as for ticket
// writes: ownership is the one rule here.
let mut bob = Client::new(port);
register(&mut bob, "bob");
assert_eq!(
bob.get("/~alice/test-repo/settings").status,
403,
"a non-owner should not reach the settings page"
);
assert_eq!(
bob.post_form("/~alice/test-repo/settings", &[("visibility", "private")])
.status,
403,
"a non-owner should not be able to change visibility"
);
// A private repository the stranger cannot see is answered as though it
// did not exist, just like every other repository-scoped page does.
create(&mut alice, "alice", "secret", "private");
assert_eq!(
bob.get("/~alice/secret/settings").status,
404,
"another user should not reach a private repository's settings"
);
// The owner, of course, gets the page.
let response = alice.get("/~alice/test-repo/settings");
assert_eq!(
response.status, 200,
"the owner should reach the settings page"
);
// The settings page has the repository tab bar, with Settings marked as current.
let body = &response.body;
assert!(
body.contains(r#"<a href="/~alice/test-repo">Summary</a>"#),
"the settings page should show the Summary tab, got:\n{body}"
);
assert!(
body.contains(r#"<a href="/~alice/test-repo/tickets">Tickets</a>"#),
"the settings page should show the Tickets tab, got:\n{body}"
);
assert!(
body.contains(r#"<a href="/~alice/test-repo/refs">Refs</a>"#),
"the settings page should show the Refs tab, got:\n{body}"
);
assert!(
body.contains(r#"<a href="/~alice/test-repo/tree/main">Browse</a>"#),
"the settings page should show the Browse tab, got:\n{body}"
);
assert!(
body.contains(r#"<a href="/~alice/test-repo/settings" aria-current="page">Settings</a>"#),
"the settings page should show the Settings tab as current, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn the_owner_may_flip_visibility() -> 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");
// A public repository is visible to bob: the page loads.
assert_eq!(
bob.get("/~alice/test-repo").status,
200,
"a public repository should be reachable"
);
// Alice flips it to private.
let saved = alice.post_form("/~alice/test-repo/settings", &[("visibility", "private")]);
assert_eq!(
saved.status, 303,
"saving visibility should redirect\n{}",
saved.body
);
assert_eq!(
saved.location.as_deref(),
Some("/~alice/test-repo/settings"),
"saving should lead back to the settings page"
);
// Bob is now answered as though the repository were not there.
assert_eq!(
bob.get("/~alice/test-repo").status,
404,
"a private repository should be invisible to a stranger"
);
assert_eq!(
bob.get("/~alice/test-repo/tickets").status,
404,
"a private repository's tickets should be invisible too"
);
// Flip it back.
let reopened = alice.post_form("/~alice/test-repo/settings", &[("visibility", "public")]);
assert_eq!(
reopened.status, 303,
"reopening should redirect\n{}",
reopened.body
);
assert_eq!(
bob.get("/~alice/test-repo").status,
200,
"a public repository should be visible again"
);
Ok(())
}
#[tokio::test]
async fn the_settings_page_shows_the_current_visibility() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let page = alice.get("/~alice/test-repo/settings");
assert_eq!(page.status, 200, "the settings page");
let body = &page.body;
// The current visibility is the one that comes back checked.
assert!(
body.contains(r#"value="public" checked"#),
"a public repository should show `public` checked, got:\n{body}"
);
assert!(
!body.contains(r#"value="private" checked"#),
"a public repository should not show `private` checked, got:\n{body}"
);
// Flip to private and look again.
alice.post_form("/~alice/test-repo/settings", &[("visibility", "private")]);
let private = alice.get("/~alice/test-repo/settings");
assert!(
private.body.contains(r#"value="private" checked"#),
"a private repository should show `private` checked, got:\n{}",
private.body
);
Ok(())
}
#[tokio::test]
async fn the_settings_page_lists_the_labels_and_lets_the_owner_add_one() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let page = alice.get("/~alice/test-repo/settings");
assert_eq!(page.status, 200, "the settings page");
let body = &page.body;
// The defaults a repository starts with are all listed.
for label in ["bug", "improvement"] {
assert!(
body.contains(&format!(">{label}<")),
"`{label}` should be listed, got:\n{body}"
);
}
// A new label is made and then shown on the page.
let created = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
assert_eq!(
created.status, 303,
"creating a label should redirect\n{}",
created.body
);
let refreshed = alice.get("/~alice/test-repo/settings");
assert!(
refreshed.body.contains(">urgent<"),
"a new label should appear on the settings page, got:\n{}",
refreshed.body
);
// And on the tickets list as a filter.
let tickets = alice.get("/~alice/test-repo/tickets");
assert!(
tickets.body.contains("?state=open&labels=urgent\""),
"a new label should be offered as a filter, got:\n{}",
tickets.body
);
Ok(())
}
#[tokio::test]
async fn a_duplicate_label_name_is_refused_with_an_inline_error() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let again = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "bug")]);
assert_eq!(
again.status, 200,
"a duplicate label should be refused, not a redirect\n{}",
again.body
);
assert!(
again.body.contains("`bug`"),
"the refusal should name the label, got:\n{}",
again.body
);
// A label the repository has, made again: same story.
alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
let twice = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
assert_eq!(
twice.status, 200,
"a label of the same name again should be refused\n{}",
twice.body
);
assert!(
twice.body.contains("`urgent`"),
"the refusal should name the label, got:\n{}",
twice.body
);
Ok(())
}
#[tokio::test]
async fn a_label_can_be_deleted_and_tickets_lose_it() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// Create a label to delete
alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
// Create a ticket with the label (state is now separate from labels)
let mut fields = vec![
("summary", "a ticket"),
("description", ""),
("state", "open"),
];
fields.push(("labels", "urgent"));
let ticket = alice.post_form("/~alice/test-repo/tickets", &fields);
assert_eq!(ticket.status, 303, "opening a ticket\n{}", ticket.body);
// Grab the label id off the settings page: the delete form posts to it.
let page = alice.get("/~alice/test-repo/settings");
let delete_marker = "/~alice/test-repo/settings/labels/";
// Find the "urgent" label specifically by searching for its name
let urgent_name_pos = page
.body
.find(">urgent<")
.expect("the urgent label should be listed");
// Look forward from the label name to find the delete form URL
let after_urgent = &page.body[urgent_name_pos..];
let delete_marker_pos = after_urgent
.find(delete_marker)
.expect("delete form should be after label name");
let urgent_start = urgent_name_pos + delete_marker_pos + delete_marker.len();
let urgent_end = page.body[urgent_start..]
.find("/delete")
.expect("the id should end at /delete")
+ urgent_start;
let label_id = &page.body[urgent_start..urgent_end];
let delete_url = format!("/~alice/test-repo/settings/labels/{label_id}/delete");
let deleted = alice.post_form(&delete_url, &[]);
assert_eq!(
deleted.status, 303,
"deleting a label should redirect\n{}",
deleted.body
);
// Gone from the settings page.
let refreshed = alice.get("/~alice/test-repo/settings");
assert!(
!refreshed.body.contains(">urgent<"),
"a deleted label should leave the settings page, got:\n{}",
refreshed.body
);
// Gone from the tickets-list filters.
let tickets = alice.get("/~alice/test-repo/tickets");
assert!(
!tickets.body.contains("?labels=urgent\""),
"a deleted label should no longer be offered as a filter, got:\n{}",
tickets.body
);
// The ticket is still there, but without the label.
let detail = alice.get("/~alice/test-repo/tickets/1");
assert_eq!(detail.status, 200, "the ticket");
assert!(
!detail.body.contains("ticket-label\">urgent"),
"the ticket should no longer show the label, got:\n{}",
detail.body
);
Ok(())
}
#[tokio::test]
async fn a_deleted_default_label_stays_deleted() -> miette::Result<()> {
// The lazy seeding used to bring defaults back on the next visit to the
// tickets list. This test makes sure that does not happen.
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// Find the "bug" label and its delete form
let page = alice.get("/~alice/test-repo/settings");
let bug_label_pos = page.body.find(">bug<").expect("bug should be listed");
let delete_marker = "/~alice/test-repo/settings/labels/";
// Find the delete form after the label
let after_bug = &page.body[bug_label_pos..];
let delete_form_pos = after_bug
.find(delete_marker)
.expect("delete form should be after label");
let bug_start = bug_label_pos + delete_form_pos + delete_marker.len();
let bug_end = page.body[bug_start..]
.find("/delete")
.expect("the id should end at /delete")
+ bug_start;
let bug_id = &page.body[bug_start..bug_end];
let delete_url = format!("/~alice/test-repo/settings/labels/{bug_id}/delete");
let deleted = alice.post_form(&delete_url, &[]);
assert_eq!(
deleted.status, 303,
"deleting `bug` should redirect\n{}",
deleted.body
);
// Visit the tickets list: the label is not there.
let list = alice.get("/~alice/test-repo/tickets");
assert!(
!list.body.contains("?labels=bug\""),
"a deleted default should not come back as a filter, got:\n{}",
list.body
);
// And the settings page agrees: still gone.
let settings = alice.get("/~alice/test-repo/settings");
assert!(
!settings.body.contains(">bug<"),
"a deleted default should not come back on the settings page, got:\n{}",
settings.body
);
Ok(())
}
#[tokio::test]
async fn a_new_repository_gets_the_default_labels() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// The settings page lists them all without any prior visit to the tickets
// list, because seeding happens at creation time now.
let page = alice.get("/~alice/test-repo/settings");
assert_eq!(page.status, 200, "the settings page");
Ok(())
}