//! The tickets of a repository: the list and its label filters, the search
//! over it, and what may be done to a ticket and by whom.
//!
//! Everything goes through the forms the forge itself serves, and every page
//! is read back out of the HTML a browser would be handed.
mod support;
use support::{Client, Response, 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 tickets tests"),
("visibility", visibility),
],
);
assert_eq!(
response.status, 303,
"creating `{owner}/{slug}` should redirect\n{}",
response.body
);
}
/// Opens a ticket with the state and labels given, and returns the answer to read the
/// address it landed on out of.
fn open(
client: &mut Client,
owner: &str,
slug: &str,
summary: &str,
description: &str,
state: &str,
labels: &[&str],
) -> Response {
let mut fields: Vec<(&str, &str)> = vec![
("summary", summary),
("description", description),
("state", state),
];
for label in labels {
fields.push(("labels", label));
}
client.post_form(&format!("/~{owner}/{slug}/tickets"), &fields)
}
/// How one ticket is addressed on a page, quote and all, so that a match is
/// the ticket's own link rather than a number the page happens to contain.
fn address(owner: &str, slug: &str, number: i64) -> String {
format!("/~{owner}/{slug}/tickets/{number}\"")
}
#[tokio::test]
async fn a_repositorys_tickets_page_has_the_navigation_the_default_labels_and_a_search()
-> 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/tickets");
assert_eq!(response.status, 200, "the tickets page");
let body = &response.body;
// The tab is one of the repository's own pages, and says so.
assert!(
body.contains(r#"<a href="/~alice/test-repo/tickets" aria-current="page">Tickets</a>"#),
"the tickets page should be marked in the tab bar, got:\n{body}"
);
// And the repository's page leads to it, without marking it as where the
// reader already is.
let landing = alice.get("/~alice/test-repo");
assert_eq!(landing.status, 200, "the repository page");
assert!(
landing
.body
.contains(r#"<a href="/~alice/test-repo/tickets">Tickets</a>"#),
"the repository page should lead to its tickets, got:\n{}",
landing.body
);
// The state tabs: Open and Closed
for state in ["open", "closed"] {
assert!(
body.contains(&format!("?state={state}")),
"`{state}` should be offered as a state filter, got:\n{body}"
);
}
// The labels a repository starts with, each one a way to filter by it.
for label in ["bug", "improvement"] {
assert!(
body.contains(&format!("?state=open&labels={label}")),
"`{label}` should be offered as a filter, got:\n{body}"
);
}
// ... and the way to drop the filter again, which the default one needs.
assert!(
body.contains(r#"All labels"#),
"the list should offer a way to show every label, got:\n{body}"
);
assert!(
body.contains(r#"form class="ticket-search""#),
"the list should offer a search, got:\n{body}"
);
assert!(
body.contains("Nothing here yet."),
"a repository without tickets should say so, got:\n{body}"
);
Ok(())
}
#[tokio::test]
async fn a_ticket_is_numbered_within_its_repository_and_shown_as_markdown() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
create(&mut alice, "alice", "other", "public");
let first = open(
&mut alice,
"alice",
"test-repo",
"Cranberry sauce",
"A **bold** sauce\n\n<script>alert(1)</script>",
"open",
&["bug"],
);
assert_eq!(first.status, 303, "opening a ticket\n{}", first.body);
assert_eq!(
first.location.as_deref(),
Some("/~alice/test-repo/tickets/1"),
"the first ticket should be number one"
);
let second = open(
&mut alice,
"alice",
"test-repo",
"Binary blobs",
"second",
"open",
&[],
);
assert_eq!(second.status, 303, "opening a ticket\n{}", second.body);
assert_eq!(
second.location.as_deref(),
Some("/~alice/test-repo/tickets/2"),
"the next ticket should be number two"
);
// Another repository counts from one again: the number names a ticket of
// one repository, not of the forge.
let elsewhere = open(&mut alice, "alice", "other", "Elsewhere", "", "open", &[]);
assert_eq!(
elsewhere.location.as_deref(),
Some("/~alice/other/tickets/1"),
"another repository's first ticket should be number one\n{}",
elsewhere.body
);
let detail = alice.get("/~alice/test-repo/tickets/1");
assert_eq!(detail.status, 200, "the ticket");
let body = &detail.body;
assert!(
body.contains("<strong>bold</strong>"),
"a ticket's description is markdown, got:\n{body}"
);
assert!(
!body.contains("<script"),
"a ticket's description is not to be trusted as HTML, got:\n{body}"
);
assert!(
body.contains("opened by"),
"the ticket should say who opened it, got:\n{body}"
);
assert!(
body.contains(r#"ticket-label">bug</span>"#),
"the ticket should show its labels, got:\n{body}"
);
// Newest first, which for these numbers is the opposite of counting up.
let list = alice.get("/~alice/test-repo/tickets");
assert_eq!(list.status, 200, "the list");
let two = list
.body
.find(&address("alice", "test-repo", 2))
.expect("the second ticket should be listed");
let one = list
.body
.find(&address("alice", "test-repo", 1))
.expect("the first ticket should be listed");
assert!(two < one, "tickets should be listed newest first");
Ok(())
}
#[tokio::test]
async fn the_list_opens_on_the_open_tickets_and_filters_by_label() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
for (summary, state, labels) in [
("first", "open", vec!["bug"]),
("second", "open", vec![]),
("third", "closed", vec!["bug"]),
] {
let answer = open(
&mut alice,
"alice",
"test-repo",
summary,
"",
state,
&labels,
);
assert_eq!(
answer.status, 303,
"opening `{summary}` should redirect\n{}",
answer.body
);
}
// The list as it opens: the `open` tickets, which is where every ticket
// starts.
let defaulting = alice.get("/~alice/test-repo/tickets");
assert_eq!(defaulting.status, 200, "the list");
assert!(
defaulting.body.contains(&address("alice", "test-repo", 1)),
"an open ticket should be listed by default, got:\n{}",
defaulting.body
);
assert!(
defaulting.body.contains(&address("alice", "test-repo", 2)),
"an open ticket should be listed by default, got:\n{}",
defaulting.body
);
assert!(
!defaulting.body.contains(&address("alice", "test-repo", 3)),
"a closed ticket should not be listed by default, got:\n{}",
defaulting.body
);
// The filter dropped: every ticket, closed ones included.
let every = alice.get("/~alice/test-repo/tickets?state=&labels=");
assert_eq!(every.status, 200, "the unfiltered list");
for number in [1, 2, 3] {
assert!(
every.body.contains(&address("alice", "test-repo", number)),
"ticket {number} should be listed, got:\n{}",
every.body
);
}
// State and label combined: a ticket has to match both.
let bug = alice.get("/~alice/test-repo/tickets?state=open&labels=bug");
assert_eq!(bug.status, 200, "the filtered list");
assert!(
bug.body.contains(&address("alice", "test-repo", 1)),
"a ticket matching both state and label should be listed, got:\n{}",
bug.body
);
for number in [2, 3] {
assert!(
!bug.body.contains(&address("alice", "test-repo", number)),
"ticket {number} should not be listed, got:\n{}",
bug.body
);
}
let closed = alice.get("/~alice/test-repo/tickets?state=closed&labels=bug");
assert_eq!(closed.status, 200, "the filtered list");
assert!(
closed.body.contains(&address("alice", "test-repo", 3)),
"a ticket matching both state and label should be listed, got:\n{}",
closed.body
);
for number in [1, 2] {
assert!(
!closed.body.contains(&address("alice", "test-repo", number)),
"ticket {number} should not be listed, got:\n{}",
closed.body
);
}
// A label nobody has applied matches nothing, rather than falling back to
// the default view.
let none = alice.get("/~alice/test-repo/tickets?labels=improvement");
assert_eq!(none.status, 200, "the filtered list");
assert!(
none.body.contains("Nothing here yet."),
"a label no ticket carries should list nothing, got:\n{}",
none.body
);
Ok(())
}
#[tokio::test]
async fn tickets_can_be_searched() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
open(
&mut alice,
"alice",
"test-repo",
"Cranberry sauce",
"fruity and **rich**",
"open",
&[],
);
open(
&mut alice,
"alice",
"test-repo",
"Binary blobs",
"all about buffers",
"open",
&[],
);
// The summary and the description are both searched.
for query in ["sauce", "fruity"] {
let found = alice.get(&format!("/~alice/test-repo/tickets?q={query}"));
assert_eq!(found.status, 200, "the search");
assert!(
found.body.contains(&address("alice", "test-repo", 1)),
"`{query}` should find the first ticket, got:\n{}",
found.body
);
assert!(
!found.body.contains(&address("alice", "test-repo", 2)),
"`{query}` should not find the second ticket, got:\n{}",
found.body
);
}
let nothing = alice.get("/~alice/test-repo/tickets?q=nosuchterm");
assert_eq!(nothing.status, 200, "the search");
assert!(
nothing.body.contains("Nothing here yet."),
"a search for nothing should list nothing, got:\n{}",
nothing.body
);
// The search and the label filter are one view, not two: a ticket the
// filter leaves out is not found by a search either.
let filtered = alice.get("/~alice/test-repo/tickets?q=sauce&labels=closed");
assert_eq!(filtered.status, 200, "the search");
assert!(
!filtered.body.contains(&address("alice", "test-repo", 1)),
"a ticket the filter leaves out should stay out, got:\n{}",
filtered.body
);
// Words a search engine has opinions of its own about, and a stray quote:
// all of them are a search for those characters, and none of them a fault.
for query in ["%22", "AND%20OR", "NEAR%2F(", "*"] {
let answer = alice.get(&format!("/~alice/test-repo/tickets?q={query}"));
assert_eq!(
answer.status, 200,
"`{query}` should be answered rather than refused"
);
}
Ok(())
}
#[tokio::test]
async fn a_ticket_can_be_edited() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
open(
&mut alice,
"alice",
"test-repo",
"Before",
"the old words",
"open",
&["bug"],
);
// The form comes back filled in with the ticket as it stands, labels
// included - what is checked is what a save without touching them keeps.
let form = alice.get("/~alice/test-repo/tickets/1/edit");
assert_eq!(form.status, 200, "the edit form");
assert!(
form.body.contains(r#"value="Before""#),
"the form should carry the summary, got:\n{}",
form.body
);
assert!(
form.body.contains("the old words"),
"the form should carry the description, got:\n{}",
form.body
);
assert!(
form.body.contains(r#"value="open" checked"#),
"the labels a ticket carries should come back checked, got:\n{}",
form.body
);
assert!(
!form.body.contains(r#"value="closed" checked"#),
"the labels a ticket does not carry should come back unchecked, got:\n{}",
form.body
);
let saved = alice.post_form(
"/~alice/test-repo/tickets/1",
&[
("summary", "After"),
("description", "the new words"),
("state", "closed"),
("labels", "bug"),
],
);
assert_eq!(
saved.status, 303,
"saving an edit should redirect\n{}",
saved.body
);
assert_eq!(
saved.location.as_deref(),
Some("/~alice/test-repo/tickets/1"),
"saving should lead back to the ticket"
);
let detail = alice.get("/~alice/test-repo/tickets/1");
assert_eq!(detail.status, 200, "the ticket");
assert!(
detail.body.contains("After") && detail.body.contains("the new words"),
"the ticket should show what it was edited to, got:\n{}",
detail.body
);
assert!(
detail.body.contains(r#"State: closed"#)
&& detail.body.contains(r#"ticket-label">bug</span>"#),
"the ticket should show its new state and labels, got:\n{}",
detail.body
);
// And the edit moved it: out of the list as it opens, into the closed one.
let defaulting = alice.get("/~alice/test-repo/tickets");
assert!(
!defaulting.body.contains(&address("alice", "test-repo", 1)),
"an edited-away ticket should leave the default list, got:\n{}",
defaulting.body
);
let closed = alice.get("/~alice/test-repo/tickets?state=closed");
assert!(
closed.body.contains(&address("alice", "test-repo", 1)),
"a closed ticket should be listed under `closed`, got:\n{}",
closed.body
);
Ok(())
}
#[tokio::test]
async fn a_ticket_can_be_deleted_and_its_number_is_not_given_again() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
open(&mut alice, "alice", "test-repo", "first", "", "open", &[]);
open(&mut alice, "alice", "test-repo", "second", "", "open", &[]);
let deleted = alice.post_form("/~alice/test-repo/tickets/1/delete", &[]);
assert_eq!(
deleted.status, 303,
"deleting should redirect\n{}",
deleted.body
);
assert_eq!(
deleted.location.as_deref(),
Some("/~alice/test-repo/tickets"),
"deleting should lead back to the list"
);
assert_eq!(
alice.get("/~alice/test-repo/tickets/1").status,
404,
"a deleted ticket should not be served"
);
let list = alice.get("/~alice/test-repo/tickets");
assert!(
!list.body.contains(&address("alice", "test-repo", 1)),
"a deleted ticket should leave the list, got:\n{}",
list.body
);
assert!(
list.body.contains(&address("alice", "test-repo", 2)),
"the ticket that was not deleted should stay, got:\n{}",
list.body
);
// Numbering moves on rather than back: a ticket that is gone is still
// talked about by its number, so the number cannot come to mean another
// ticket.
let third = open(&mut alice, "alice", "test-repo", "third", "", "open", &[]);
assert_eq!(
third.location.as_deref(),
Some("/~alice/test-repo/tickets/3"),
"the next ticket should not be given the deleted number\n{}",
third.body
);
Ok(())
}
#[tokio::test]
async fn a_ticket_is_edited_and_deleted_by_its_creator_or_the_owner_only() -> 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");
let opened = open(&mut bob, "alice", "test-repo", "from bob", "", "open", &[]);
assert_eq!(
opened.status, 303,
"anyone signed in should be able to open a ticket\n{}",
opened.body
);
let mut carol = Client::new(port);
register(&mut carol, "carol");
// The owner and the creator both may edit; somebody who is neither may
// not even be handed the form.
assert_eq!(
alice.get("/~alice/test-repo/tickets/1/edit").status,
200,
"the owner should reach the edit form"
);
assert_eq!(
bob.get("/~alice/test-repo/tickets/1/edit").status,
200,
"the creator should reach the edit form"
);
assert_eq!(
carol.get("/~alice/test-repo/tickets/1/edit").status,
403,
"somebody else should not reach the edit form"
);
assert_eq!(
carol
.post_form(
"/~alice/test-repo/tickets/1",
&[("summary", "not yours"), ("description", "")]
)
.status,
403,
"somebody else should not be able to edit"
);
assert_eq!(
carol
.post_form("/~alice/test-repo/tickets/1/delete", &[])
.status,
403,
"somebody else should not be able to delete"
);
// Labels are the repository's, so only the owner makes them.
assert_eq!(
alice
.post_form(
"/~alice/test-repo/settings/labels",
&[("name", "help wanted")]
)
.status,
303,
"the owner should be able to make a label"
);
assert_eq!(
carol
.post_form("/~alice/test-repo/settings/labels", &[("name", "nope")])
.status,
403,
"somebody else should not be able to make a label"
);
let labelled = open(
&mut alice,
"alice",
"test-repo",
"needs hands",
"",
"open",
&["help wanted"],
);
assert_eq!(
labelled.status, 303,
"a ticket should be able to carry a new label\n{}",
labelled.body
);
let filtered = alice.get("/~alice/test-repo/tickets?labels=help%20wanted");
assert_eq!(filtered.status, 200, "the list");
assert!(
filtered.body.contains(&address("alice", "test-repo", 2)),
"a ticket carrying the label should be listed under it, got:\n{}",
filtered.body
);
// The creator may take their own ticket down again.
assert_eq!(
bob.post_form("/~alice/test-repo/tickets/1/delete", &[])
.status,
303,
"the creator should be able to delete their own ticket"
);
Ok(())
}
#[tokio::test]
async fn tickets_need_a_signed_in_visitor_and_a_repository_that_can_be_read() -> miette::Result<()>
{
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
create(&mut alice, "alice", "secret", "private");
open(&mut alice, "alice", "secret", "hidden", "", "open", &[]);
// Reading a public repository's tickets is what reading it means, so it
// needs nobody signed in - but there is nothing for a stranger to write
// with, and the page does not offer them what they cannot use.
let mut anonymous = Client::new(port);
let seen = anonymous.get("/~alice/test-repo/tickets");
assert_eq!(
seen.status, 200,
"a stranger should be able to read a public repository's tickets"
);
assert!(
!seen.body.contains("/tickets/new"),
"a stranger should not be offered the way to open one, got:\n{}",
seen.body
);
let mut stranger = Client::new(port);
assert_eq!(
stranger.get("/~alice/test-repo/tickets/new").status,
401,
"a stranger should not reach the form"
);
assert_eq!(
stranger
.post_form(
"/~alice/test-repo/tickets",
&[("summary", "mine"), ("description", "")]
)
.status,
401,
"a stranger should not be able to open a ticket"
);
assert_eq!(
stranger
.post_form("/~alice/test-repo/tickets/1/delete", &[])
.status,
401,
"a stranger should not be able to delete"
);
assert_eq!(
stranger
.post_form("/~alice/test-repo/settings/labels", &[("name", "mine")])
.status,
401,
"a stranger should not be able to make a label"
);
// A private repository is answered as though it were not there, its
// tickets no more reachable than the rest of it.
register(&mut stranger, "bob");
assert_eq!(
stranger.get("/~alice/secret/tickets").status,
404,
"another user should not reach a private repository's tickets"
);
assert_eq!(
stranger.get("/~alice/secret/tickets/1").status,
404,
"another user should not reach a private ticket"
);
assert_eq!(
alice.get("/~alice/secret/tickets").status,
200,
"the owner should reach a private repository's tickets"
);
// The same answers a repository's own page gives for what does not exist.
assert_eq!(
alice.get("/~alice/nothing/tickets").status,
404,
"a repository that does not exist"
);
assert_eq!(
alice.get("/~alice/test-repo/tickets/9").status,
404,
"a ticket that does not exist"
);
Ok(())
}
#[tokio::test]
async fn a_ticket_needs_a_summary_and_labels_this_repository_has() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
// The summary is the one thing a ticket cannot do without, and it is asked
// for in the words the column holds rather than in bytes of SQLite.
let blank = open(&mut alice, "alice", "test-repo", "", "", "open", &[]);
assert_eq!(
blank.status, 200,
"a ticket without a summary should be refused\n{}",
blank.body
);
assert!(
blank.body.contains("needs a summary"),
"the refusal should say what is missing, got:\n{}",
blank.body
);
let padded = open(&mut alice, "alice", "test-repo", " ", "", "open", &[]);
assert_eq!(
padded.status, 200,
"a summary of nothing but space should be refused\n{}",
padded.body
);
let long = open(
&mut alice,
"alice",
"test-repo",
&"x".repeat(256),
"",
"open",
&[],
);
assert_eq!(
long.status, 200,
"a summary too long for the column should be refused\n{}",
long.body
);
assert!(
long.body.contains("255"),
"the refusal should say how long a summary may be, got:\n{}",
long.body
);
// A label of another moulding is not applied: the ticket would then be
// filed under a filter that cannot be reached from the page.
let unknown = open(
&mut alice,
"alice",
"test-repo",
"fine",
"",
"open",
&["nope"],
);
assert_eq!(
unknown.status, 200,
"a label this repository does not have should be refused\n{}",
unknown.body
);
assert!(
unknown.body.contains("`nope` is not a label"),
"the refusal should name the label, got:\n{}",
unknown.body
);
// And none of that opened anything: the first ticket is still the first.
let good = open(&mut alice, "alice", "test-repo", "fine", "", "open", &[]);
assert_eq!(
good.location.as_deref(),
Some("/~alice/test-repo/tickets/1"),
"the refused attempts should have created nothing\n{}",
good.body
);
Ok(())
}
#[tokio::test]
async fn a_label_needs_a_name_a_repository_can_carry() -> miette::Result<()> {
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create(&mut alice, "alice", "test-repo", "public");
let blank = alice.post_form("/~alice/test-repo/settings/labels", &[("name", " ")]);
assert_eq!(
blank.status, 200,
"a label without a name should be refused\n{}",
blank.body
);
assert!(
blank.body.contains("needs a name"),
"the refusal should say what is missing, got:\n{}",
blank.body
);
// A comma is the one thing the filter cannot carry, since that is what
// the labels of one filter are joined by.
let comma = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "one,two")]);
assert_eq!(
comma.status, 200,
"a label holding a comma should be refused\n{}",
comma.body
);
assert!(
comma.body.contains("comma"),
"the refusal should say why, got:\n{}",
comma.body
);
let made = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
assert_eq!(
made.status, 303,
"the owner should be able to make a label\n{}",
made.body
);
assert_eq!(
made.location.as_deref(),
Some("/~alice/test-repo/settings"),
"making a label should lead back to the settings page"
);
let list = alice.get("/~alice/test-repo/tickets");
assert!(
list.body.contains("?state=open&labels=urgent\""),
"a new label should be offered as a filter, got:\n{}",
list.body
);
// A label of the same name again is not a second label: the filter would
// not be able to tell them apart.
let again = alice.post_form("/~alice/test-repo/settings/labels", &[("name", "urgent")]);
assert_eq!(
again.status, 200,
"the same label twice should be refused\n{}",
again.body
);
assert!(
again.body.contains("`urgent`"),
"the refusal should name the label, got:\n{}",
again.body
);
Ok(())
}
#[tokio::test]
async fn an_attachment_can_be_uploaded_and_downloaded() -> 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 = open(
&mut alice,
"alice",
"test-repo",
"something broke",
"the sauce is gone",
"open",
&[],
);
assert_eq!(
response.status, 303,
"opening a ticket should redirect\n{}",
response.body
);
// Upload an attachment
let filename = "sauce.txt";
let content_type = "text/plain";
let data = b"The sauce was here";
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
filename,
content_type,
data,
);
assert_eq!(
response.status, 303,
"uploading an attachment should redirect\n{}",
response.body
);
// View the ticket to see the attachment listed
let response = alice.get("/~alice/test-repo/tickets/1");
assert_eq!(response.status, 200, "viewing the ticket should succeed");
assert!(
response.body.contains(filename),
"the ticket page should show the attachment, got:\n{}",
response.body
);
// Find the attachment UUID from the page
let uuid_start = response
.body
.find("/~alice/test-repo/tickets/1/attachments/")
.unwrap();
let uuid_end = response.body[uuid_start + 40..].find('"').unwrap() + uuid_start + 40;
let uuid = &response.body[uuid_start + 40..uuid_end];
// Download the attachment
let response = alice.get(&format!("/~alice/test-repo/tickets/1/attachments/{uuid}"));
assert_eq!(
response.status, 200,
"downloading the attachment should succeed\n{}",
response.body
);
assert_eq!(
response.body.as_bytes(),
data,
"the downloaded content should match"
);
Ok(())
}
#[tokio::test]
async fn an_attachment_larger_than_the_limit_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 = open(
&mut alice,
"alice",
"test-repo",
"something broke",
"the sauce is gone",
"open",
&[],
);
assert_eq!(response.status, 303);
// Try to upload a file larger than 2MB
let large_data = vec![0u8; 2 * 1024 * 1024 + 1];
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"large.bin",
"application/octet-stream",
&large_data,
);
assert_eq!(
response.status, 400,
"a file larger than 2MB should be refused\n{}",
response.body
);
assert!(
response.body.contains("too large"),
"the refusal should mention the size, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn a_ticket_cannot_have_more_than_the_limit_of_attachments() -> 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 = open(
&mut alice,
"alice",
"test-repo",
"something broke",
"the sauce is gone",
"open",
&[],
);
assert_eq!(response.status, 303);
// Upload 4 attachments (the default limit)
for i in 1..=4 {
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
&format!("file{i}.txt"),
"text/plain",
format!("content {i}").as_bytes(),
);
assert_eq!(
response.status, 303,
"uploading attachment {i} should succeed\n{}",
response.body
);
}
// Try to upload a 5th attachment
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"file5.txt",
"text/plain",
b"content 5",
);
assert_eq!(
response.status, 400,
"a 5th attachment should be refused\n{}",
response.body
);
assert!(
response.body.contains("maximum"),
"the refusal should mention the limit, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn an_attachment_can_be_deleted() -> 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 = open(
&mut alice,
"alice",
"test-repo",
"something broke",
"the sauce is gone",
"open",
&[],
);
assert_eq!(response.status, 303);
// Upload an attachment
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"sauce.txt",
"text/plain",
b"The sauce was here",
);
assert_eq!(response.status, 303);
// View the ticket to get the UUID
let response = alice.get("/~alice/test-repo/tickets/1");
let uuid_start = response
.body
.find("/~alice/test-repo/tickets/1/attachments/")
.unwrap();
let uuid_end = response.body[uuid_start + 40..].find('"').unwrap() + uuid_start + 40;
let uuid = &response.body[uuid_start + 40..uuid_end];
// Delete the attachment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/1/attachments/{uuid}/delete"),
&[],
);
assert_eq!(
response.status, 303,
"deleting an attachment should redirect\n{}",
response.body
);
// Verify it's gone
let response = alice.get(&format!("/~alice/test-repo/tickets/1/attachments/{uuid}"));
assert_eq!(
response.status, 404,
"the deleted attachment should not be found\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn attachments_require_permission() -> 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 = open(
&mut alice,
"alice",
"test-repo",
"something broke",
"the sauce is gone",
"open",
&[],
);
assert_eq!(response.status, 303);
// Bob tries to upload an attachment
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"bob.txt",
"text/plain",
b"Bob's file",
);
assert_eq!(
response.status, 403,
"a non-owner should not be able to upload attachments\n{}",
response.body
);
// Anonymous user tries to upload
let mut anonymous = Client::new(port);
let response = anonymous.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"anon.txt",
"text/plain",
b"Anonymous file",
);
assert_eq!(
response.status, 401,
"an anonymous user should not be able to upload attachments"
);
// Alice (the ticket creator) can upload
let response = alice.post_multipart_file(
"/~alice/test-repo/tickets/1/attachments",
"attachment",
"alice.txt",
"text/plain",
b"Alice's file",
);
assert_eq!(
response.status, 303,
"the ticket creator should be able to upload attachments\n{}",
response.body
);
Ok(())
}