//! Integration tests for comments on tickets and patch proposals.
//!
//! Tests the full stack: HTTP endpoints, database, views.
//! Comments can be created, edited, and deleted on both tickets and patches.
//! Permissions: author or repo owner can edit/delete, anyone logged in can comment.
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.
fn create_repo(client: &mut Client, owner: &str, slug: &str) {
let response = client.post_form(
&format!("/~{owner}/repositories"),
&[
("slug", slug),
("description", "a repository from the comments tests"),
("visibility", "public"),
],
);
assert_eq!(
response.status, 303,
"creating repository should redirect\n{}",
response.body
);
}
/// Opens a ticket and returns its number.
fn open_ticket(client: &mut Client, owner: &str, slug: &str, summary: &str) -> i64 {
let response = client.post_form(
&format!("/~{owner}/{slug}/tickets"),
&[
("summary", summary),
("description", "Test description"),
("state", "open"),
],
);
assert_eq!(
response.status, 303,
"opening ticket should redirect\n{}",
response.body
);
// Extract ticket number from redirect location
let location = response.location.as_ref().expect("should have location");
let parts: Vec<&str> = location.split('/').collect();
parts
.last()
.expect("should have ticket number")
.parse()
.expect("should be a number")
}
/// Extracts the UUID of the last comment from the ticket page HTML.
fn extract_last_comment_uuid(body: &str) -> Option<String> {
// Look for the delete form which contains the UUID in the action
// Pattern: action="/~owner/repo/tickets/1/comments/{uuid}/delete"
let mut uuid_start = body.rfind("/comments/")?;
uuid_start += "/comments/".len();
let uuid_end = body[uuid_start..].find("/delete")?;
Some(body[uuid_start..uuid_start + uuid_end].to_string())
}
#[tokio::test]
async fn can_create_comment_on_ticket() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Create a comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "This is a test comment")],
);
assert_eq!(
response.status, 303,
"comment creation should redirect\n{}",
response.body
);
// Verify comment appears on ticket page
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(
response.status, 200,
"ticket page should load\n{}",
response.body
);
assert!(
response.body.contains("This is a test comment"),
"comment should appear on ticket page, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn comment_content_is_trimmed() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Create a comment with extra whitespace
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", " trimmed content ")],
);
assert_eq!(response.status, 303);
// Verify comment was trimmed
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
response.body.contains("trimmed content"),
"comment should be trimmed, got:\n{}",
response.body
);
assert!(
!response.body.contains(" trimmed content "),
"whitespace should be removed, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn unauthenticated_user_cannot_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Try to comment without being logged in
let mut anon = Client::new(port);
let response = anon.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "This should be rejected")],
);
// Should redirect to login or reject
assert!(
response.status == 401
|| (response.status == 303 && response.location.as_deref() == Some("/login")),
"unauthenticated user should be rejected or redirected to login, got status {} location {:?}\n{}",
response.status,
response.location,
response.body
);
Ok(())
}
#[tokio::test]
async fn can_edit_own_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Create a comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Original comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID from the ticket page
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
let uuid = extract_last_comment_uuid(&response.body).expect("should find comment UUID");
// Edit the comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}"),
&[("content", "Edited comment")],
);
assert_eq!(
response.status, 303,
"editing comment should redirect\n{}",
response.body
);
// Verify comment was updated
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
response.body.contains("Edited comment"),
"comment should be edited, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn cannot_edit_someone_elses_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Alice creates a comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Alice's comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Bob tries to edit it
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}"),
&[("content", "Bob's edit")],
);
assert_eq!(
response.status, 403,
"bob should not be able to edit alice's comment\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn repo_owner_can_delete_any_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Bob creates a comment
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Bob's comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID
let response = bob.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Alice (repo owner) deletes it
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}/delete"),
&[],
);
assert_eq!(
response.status, 303,
"alice should be able to delete bob's comment\n{}",
response.body
);
// Verify comment was deleted
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
!response.body.contains("Bob's comment"),
"comment should be deleted, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn comment_author_can_delete_own_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Alice creates a comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Alice's comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Alice deletes her own comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}/delete"),
&[],
);
assert_eq!(response.status, 303);
// Verify comment was deleted
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
!response.body.contains("Alice's comment"),
"comment should be deleted, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn stranger_cannot_delete_comment() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Bob creates a comment
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Bob's comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID
let response = bob.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Charlie (stranger) tries to delete it
let mut charlie = Client::new(port);
register(&mut charlie, "charlie");
let response = charlie.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}/delete"),
&[],
);
assert_eq!(
response.status, 403,
"charlie should not be able to delete bob's comment\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn comment_markdown_is_sanitized() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Create a comment with a script tag
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Hello <script>alert(1)</script> world")],
);
assert_eq!(response.status, 303);
// Verify the script tag is removed or escaped, not executed
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
!response.body.contains("<script>alert(1)</script>"),
"script tag should not be rendered as HTML, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn edited_comment_shows_edited_marker() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Create a comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "Original comment")],
);
assert_eq!(response.status, 303);
// Get the comment UUID
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Edit the comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments/{uuid}"),
&[("content", "Edited comment")],
);
assert_eq!(response.status, 303);
// Verify the edited marker appears
let response = alice.get(&format!("/~alice/test-repo/tickets/{ticket_num}"));
assert_eq!(response.status, 200);
assert!(
response.body.contains("(last updated at "),
"edited comment should show marker, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn empty_comment_is_refused() -> miette::Result<()> {
let (_server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
let ticket_num = open_ticket(&mut alice, "alice", "test-repo", "Test ticket");
// Try to create an empty comment
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", "")],
);
// Refused comments re-render the detail page with the error shown (200 status)
assert_eq!(
response.status, 200,
"empty comment should be refused with page re-rendered\n{}",
response.body
);
assert!(
response.body.contains("a comment needs content"),
"error message should be shown, got:\n{}",
response.body
);
// Try whitespace only
let response = alice.post_form(
&format!("/~alice/test-repo/tickets/{ticket_num}/comments"),
&[("content", " ")],
);
assert_eq!(
response.status, 200,
"whitespace-only comment should be refused with page re-rendered\n{}",
response.body
);
assert!(
response.body.contains("a comment needs content"),
"error message should be shown, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn can_create_edit_delete_comment_on_patch() -> miette::Result<()> {
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
// Create a patch proposal directly in the database
let db = Database::connect(server.database_url())
.await
.expect("connecting to test database");
// Get the repository ID
let repo = frogue::entities::repositories::Entity::find()
.filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
.one(&db)
.await
.expect("finding repository")
.expect("repository should exist");
// Get alice's user ID
let user = frogue::entities::users::Entity::find()
.filter(frogue::entities::users::Column::Name.eq("alice"))
.one(&db)
.await
.expect("finding user")
.expect("user should exist");
// Create a patch
let now = chrono::Utc::now().fixed_offset();
let pp = frogue::entities::patch_proposals::ActiveModel {
uuid: Set(Uuid::new_v4()),
repository_id: Set(repo.id),
creator_id: Set(Some(user.id)),
number: Set(1),
summary: Set("Test patch".to_string()),
description: Set(Some("Test description".to_string())),
source_ref: Set("patches/1".to_string()),
target_branch: Set("main".to_string()),
status: Set(frogue::entities::patch_proposals::Status::Open),
merge_policy: Set(frogue::entities::patch_proposals::MergePolicy::PreferFastForward),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
let _pp = pp.insert(&db).await.expect("inserting patch");
// Create a comment on the patch
let response = alice.post_form(
"/~alice/test-repo/patches/1/comments",
&[("content", "Comment on patch")],
);
assert_eq!(response.status, 303);
// Verify comment appears on patch page
let response = alice.get("/~alice/test-repo/patches/1");
assert_eq!(response.status, 200);
assert!(
response.body.contains("Comment on patch"),
"comment should appear on patch page, got:\n{}",
response.body
);
// Get the comment UUID
let uuid = extract_last_comment_uuid(&response.body).expect("should find UUID");
// Edit the comment
let response = alice.post_form(
&format!("/~alice/test-repo/patches/1/comments/{uuid}"),
&[("content", "Edited patch comment")],
);
assert_eq!(response.status, 303);
// Verify it was edited
let response = alice.get("/~alice/test-repo/patches/1");
assert!(
response.body.contains("Edited patch comment"),
"comment should be edited, got:\n{}",
response.body
);
// Delete the comment
let response = alice.post_form(
&format!("/~alice/test-repo/patches/1/comments/{uuid}/delete"),
&[],
);
assert_eq!(response.status, 303);
// Verify it was deleted
let response = alice.get("/~alice/test-repo/patches/1");
assert!(
!response.body.contains("Edited patch comment"),
"comment should be deleted, got:\n{}",
response.body
);
Ok(())
}
#[tokio::test]
async fn stranger_cannot_comment_on_draft_patch() -> miette::Result<()> {
use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
let (server, port) = spawn();
let mut alice = Client::new(port);
register(&mut alice, "alice");
create_repo(&mut alice, "alice", "test-repo");
// Create a draft patch proposal
let db = Database::connect(server.database_url())
.await
.expect("connecting to test database");
let repo = frogue::entities::repositories::Entity::find()
.filter(frogue::entities::repositories::Column::Slug.eq("test-repo"))
.one(&db)
.await
.expect("finding repository")
.expect("repository should exist");
let user = frogue::entities::users::Entity::find()
.filter(frogue::entities::users::Column::Name.eq("alice"))
.one(&db)
.await
.expect("finding user")
.expect("user should exist");
let now = chrono::Utc::now().fixed_offset();
let pp = frogue::entities::patch_proposals::ActiveModel {
uuid: Set(Uuid::new_v4()),
repository_id: Set(repo.id),
creator_id: Set(Some(user.id)),
number: Set(1),
summary: Set("Draft patch".to_string()),
description: Set(Some("Draft description".to_string())),
source_ref: Set("patches/1".to_string()),
target_branch: Set("main".to_string()),
status: Set(frogue::entities::patch_proposals::Status::Draft),
merge_policy: Set(frogue::entities::patch_proposals::MergePolicy::PreferFastForward),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
};
pp.insert(&db).await.expect("inserting draft patch");
// Bob (stranger) tries to comment on the draft
let mut bob = Client::new(port);
register(&mut bob, "bob");
let response = bob.post_form(
"/~alice/test-repo/patches/1/comments",
&[("content", "Should not work")],
);
// Should get 404 (same as viewing the draft)
assert_eq!(
response.status, 404,
"stranger should get 404 on draft patch comment, got:\n{}",
response.body
);
Ok(())
}