use crate::harness::TestHarness; #[tokio::test] async fn create_thread_happy_path() { let mut h = TestHarness::new().await; let user_id = h.login_as("threadcreator").await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; // GET new thread form to get CSRF h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=My+Thread&body=Hello+world") .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn create_thread_requires_login() { let mut h = TestHarness::new().await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; // GET page to get CSRF token (unauthenticated) h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=Nope&body=Should+fail") .await; assert!( resp.status.is_redirection(), "Expected redirect to login, got {}", resp.status ); } #[tokio::test] async fn create_reply_happy_path() { let mut h = TestHarness::new().await; let user_id = h.login_as("replier").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Test Thread", "OP content") .await; // GET thread page for CSRF let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h.client.post_form(&reply_url, "body=Great+thread!").await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn reply_to_locked_thread_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("lockedout").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Locked Thread", "OP") .await; mt_db::mutations::set_thread_locked(&h.db, thread_id, true) .await .unwrap(); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h.client.post_form(&reply_url, "body=Nope").await; assert_eq!(resp.status.as_u16(), 403); } // --- immutable posts: the old edit/delete routes 404 #[tokio::test] async fn user_cannot_edit_post() { let mut h = TestHarness::new().await; let user_id = h.login_as("editor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Edit Test", "Original body") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // GET edit form should 404 (route removed) let edit_url = format!("/p/test/general/{thread_id}/posts/{post_id}/edit"); let resp = h.client.get(&edit_url).await; assert_eq!(resp.status.as_u16(), 404, "Edit GET should be 404"); let resp = h.client.post_form(&edit_url, "body=Updated+body").await; assert_eq!(resp.status.as_u16(), 404, "Edit POST should be 404"); } #[tokio::test] async fn user_cannot_delete_post() { let mut h = TestHarness::new().await; let user_id = h.login_as("deleter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Delete Test", "Will not be deleted") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let delete_url = format!("/p/test/general/{thread_id}/posts/{post_id}/delete"); let resp = h.client.post_form(&delete_url, "").await; assert_eq!(resp.status.as_u16(), 404, "Delete POST should be 404"); } // --- mod removal #[tokio::test] async fn mod_can_remove_post() { let mut h = TestHarness::new().await; let author_id = h.login_as("author").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Mod Test", "Problematic content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let mod_id = h.login_as("moduser").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let remove_url = format!("/p/test/general/{thread_id}/posts/{post_id}/remove"); let resp = h.client.post_form(&remove_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); // Direct loader use is fine in a test assertion (no community-scope // invariant to uphold here). #[allow(clippy::disallowed_methods)] let post_data = mt_db::queries::get_post_for_edit(&h.db, post_id) .await .unwrap() .unwrap() .into_inner_unchecked(); assert_eq!( post_data.body_markdown, "Problematic content", "Content should be preserved" ); let removed_at: Option> = sqlx::query_scalar("SELECT removed_at FROM posts WHERE id = $1") .bind(post_id) .fetch_one(&h.db) .await .unwrap(); assert!(removed_at.is_some(), "removed_at should be set"); } // --- thread edit/delete restricted to mod and owner #[tokio::test] async fn mod_can_edit_thread_title() { let mut h = TestHarness::new().await; let author_id = h.login_as("threadauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Old Title", "Body") .await; let mod_id = h.login_as("modeditor").await; h.add_membership(mod_id, comm_id, "moderator").await; let edit_url = format!("/p/test/general/{thread_id}/edit"); h.client.get(&edit_url).await; let resp = h.client.post_form(&edit_url, "title=New+Title").await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn mod_can_delete_thread() { let mut h = TestHarness::new().await; let author_id = h.login_as("threadauthor2").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "To Delete", "Body") .await; let mod_id = h.login_as("moddeleter").await; h.add_membership(mod_id, comm_id, "moderator").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let delete_url = format!("/p/test/general/{thread_id}/delete"); let resp = h.client.post_form(&delete_url, "").await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn user_cannot_edit_thread_title() { let mut h = TestHarness::new().await; let user_id = h.login_as("regularuser").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "My Thread", "Body") .await; let edit_url = format!("/p/test/general/{thread_id}/edit"); h.client.get(&edit_url).await; let resp = h.client.post_form(&edit_url, "title=Hacked+Title").await; assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403"); } #[tokio::test] async fn user_cannot_delete_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("regularuser2").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "My Thread", "Body") .await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let delete_url = format!("/p/test/general/{thread_id}/delete"); let resp = h.client.post_form(&delete_url, "").await; assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403"); } // --- footnotes #[tokio::test] async fn add_footnote_by_author() { let mut h = TestHarness::new().await; let user_id = h.login_as("fnauthor").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Footnote Test", "Original post") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote"); let resp = h .client .post_form(&footnote_url, "body=Correction:+I+meant+something+else") .await; assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!(footnotes.len(), 1); assert_eq!(footnotes[0].author_id, user_id); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("Correction:"), "Footnote should be visible on thread page" ); } #[tokio::test] async fn add_footnote_non_author_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("fnoriginal").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "No Footnote For You", "Original") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let other_id = h.login_as("fnother").await; h.add_membership(other_id, comm_id, "member").await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote"); let resp = h .client .post_form(&footnote_url, "body=Uninvited+footnote") .await; assert_eq!(resp.status.as_u16(), 403, "Non-author should get 403"); } #[tokio::test] async fn multiple_footnotes_ordered() { let mut h = TestHarness::new().await; let user_id = h.login_as("fnmulti").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Multi Footnote", "Post body") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; mt_db::mutations::insert_footnote( &h.db, post_id, user_id, "First correction", "

First correction

", ) .await .unwrap(); mt_db::mutations::insert_footnote( &h.db, post_id, user_id, "Second correction", "

Second correction

", ) .await .unwrap(); let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id]) .await .unwrap(); assert_eq!(footnotes.len(), 2); assert!( footnotes[0].created_at <= footnotes[1].created_at, "Footnotes should be chronologically ordered" ); } #[tokio::test] async fn footnote_on_removed_post_rejected() { let mut h = TestHarness::new().await; let author_id = h.login_as("fnremoved").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "Removed Post", "Content") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Mod-remove the post (use author_id since they're the only user in the DB) mt_db::mutations::mod_remove_post(&h.db, post_id, author_id) .await .unwrap(); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let footnote_url = format!("/p/test/general/{thread_id}/posts/{post_id}/footnote"); let resp = h .client .post_form(&footnote_url, "body=After+removal") .await; assert_eq!( resp.status.as_u16(), 403, "Footnote on removed post should be 403" ); } // --- verified quoting fn compute_quote_hash(text: &str) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(text.as_bytes()); let hash = hasher.finalize(); hex::encode(&hash[..4]) } #[tokio::test] async fn valid_quote_accepted() { let mut h = TestHarness::new().await; let user_id = h.login_as("quoter").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post( cat_id, user_id, "Quote Test", "This is the original post content", ) .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let quoted_text = "This is the original post content"; let hash = compute_quote_hash(quoted_text); let body = format!("> {quoted_text}\n[quote:{post_id}:{hash}]\n\nMy reply here"); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h .client .post_form(&reply_url, &format!("body={encoded_body}")) .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "Valid quote should be accepted, got {}", resp.status ); } /// Regression (verify_quotes scope gap): a `[quote:UUID:HASH]` /// marker must only verify against a post *in the same community*. Quoting a post /// that lives in another community, even with the exact text and correct hash, /// must be rejected, closing the cross-community matching-oracle. #[tokio::test] async fn cross_community_quote_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("crosscommquoter").await; // Community Beta holds the target post. let comm_b = h.create_community("Beta", "beta").await; let cat_b = h.create_category(comm_b, "General", "general").await; h.add_membership(user_id, comm_b, "member").await; let secret_text = "Secret content only in community beta"; let thread_b = h .create_thread_with_post(cat_b, user_id, "Beta Thread", secret_text) .await; let post_b = mt_db::queries::list_posts_in_thread(&h.db, thread_b) .await .unwrap()[0] .id; // Community Alpha is where the reply is composed. let comm_a = h.create_community("Alpha", "alpha").await; let cat_a = h.create_category(comm_a, "General", "general").await; h.add_membership(user_id, comm_a, "member").await; let thread_a = h .create_thread_with_post(cat_a, user_id, "Alpha Thread", "alpha op content") .await; // Quote Beta's post (exact text + correct hash) from inside Alpha. let hash = compute_quote_hash(secret_text); let body = format!("> {secret_text}\n[quote:{post_b}:{hash}]\n\nQuoting across communities"); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/alpha/general/{thread_a}"); h.client.get(&thread_url).await; let reply_url = format!("/p/alpha/general/{thread_a}/reply"); let resp = h .client .post_form(&reply_url, &format!("body={encoded_body}")) .await; assert_eq!( resp.status.as_u16(), 422, "quoting a post from another community must be rejected (N3 scope seal), got {}", resp.status ); } #[tokio::test] async fn fabricated_quote_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("fabricator").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post( cat_id, user_id, "Fabrication Test", "Actual original content", ) .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Wrong hash (correct text) let body = format!("> Actual original content\n[quote:{post_id}:deadbeef]\n\nLying reply"); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h .client .post_form(&reply_url, &format!("body={encoded_body}")) .await; assert_eq!( resp.status.as_u16(), 422, "Fabricated quote hash should be rejected" ); } #[tokio::test] async fn altered_quote_rejected() { let mut h = TestHarness::new().await; let user_id = h.login_as("alterer").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Alter Test", "The sky is blue") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; // Correct hash for "The sky is blue" but altered text let original_hash = compute_quote_hash("The sky is blue"); let body = format!("> The sky is green\n[quote:{post_id}:{original_hash}]\n\nMisquoting"); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h .client .post_form(&reply_url, &format!("body={encoded_body}")) .await; assert_eq!( resp.status.as_u16(), 422, "Altered quote should be rejected" ); } #[tokio::test] async fn quote_renders_with_attribution() { let mut h = TestHarness::new().await; let user_id = h.login_as("quoterrender").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Render Test", "Quotable content here") .await; let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id) .await .unwrap(); let post_id = posts[0].id; let quoted_text = "Quotable content here"; let hash = compute_quote_hash(quoted_text); let body = format!("> {quoted_text}\n[quote:{post_id}:{hash}]\n\nGreat point!"); let encoded_body = urlencoding::encode(&body); let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let reply_url = format!("/p/test/general/{thread_id}/reply"); let resp = h .client .post_form(&reply_url, &format!("body={encoded_body}")) .await; assert!(resp.status.is_redirection(), "Quote reply should succeed"); let resp = h.client.get(&thread_url).await; assert!( resp.text.contains("quote-attribution"), "Thread page should contain quote attribution" ); } // --- inline field-error contract: 422 + X-Form-Field so mt.js can render the // message next to the offending input instead of as a transient toast. #[tokio::test] async fn empty_title_returns_inline_field_error() { let mut h = TestHarness::new().await; let user_id = h.login_as("fieldtitle").await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=&body=Some+real+body") .await; assert_eq!(resp.status.as_u16(), 422, "Empty title should be rejected"); assert_eq!( resp.headers .get("X-Form-Field") .and_then(|v| v.to_str().ok()), Some("title"), "Validation error must name the title field for inline display" ); assert!( resp.text.contains("Title"), "Error body should carry the human message, got: {}", resp.text ); } #[tokio::test] async fn empty_reply_body_returns_inline_field_error() { let mut h = TestHarness::new().await; let user_id = h.login_as("fieldbody").await; let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Thread", "OP body") .await; let thread_url = format!("/p/test/general/{thread_id}"); h.client.get(&thread_url).await; let resp = h .client .post_form(&format!("{thread_url}/reply"), "body=") .await; assert_eq!( resp.status.as_u16(), 422, "Empty reply body should be rejected" ); assert_eq!( resp.headers .get("X-Form-Field") .and_then(|v| v.to_str().ok()), Some("body"), "Validation error must name the body field for inline display" ); }