//! Community moderation state machine, enforcement and state-change tests. //! //! Predicate semantics (which actions are allowed in each state) live in //! `mt-core::types::CommunityState` unit tests. These tests exercise the //! wire-up: that write handlers consult the state, mods/superadmin bypass, //! and the state-change route is authorized correctly. use crate::harness::TestHarness; use axum::http::StatusCode; use uuid::Uuid; // --- setup /// Build a community in the requested state with a category, returning /// (community_id, category_id). async fn setup_community(h: &mut TestHarness, state: &str) -> (Uuid, Uuid) { let comm_id = h.create_community("Test", "test").await; let cat_id = h.create_category(comm_id, "General", "general").await; sqlx::query("UPDATE communities SET state = $2 WHERE id = $1") .bind(comm_id) .bind(state) .execute(&h.db) .await .expect("set state"); (comm_id, cat_id) } // --- restricted: block new threads, allow replies #[tokio::test] async fn restricted_blocks_member_new_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmem").await; let (comm_id, _cat) = setup_community(&mut h, "restricted").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=Blocked&body=No") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("restricted"), "body: {}", resp.text); } #[tokio::test] async fn restricted_allows_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmem2").await; let (comm_id, cat_id) = setup_community(&mut h, "restricted").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Existing", "OP") .await; let url = format!("/p/test/general/{thread_id}"); h.client.get(&url).await; let resp = h .client .post_form(&format!("{url}/reply"), "body=Hello+still") .await; assert!( resp.status.is_redirection() || resp.status.is_success(), "status: {}", resp.status ); } #[tokio::test] async fn restricted_mod_can_create_thread() { let mut h = TestHarness::new().await; let user_id = h.login_as("rmod").await; let (comm_id, _cat) = setup_community(&mut h, "restricted").await; h.add_membership(user_id, comm_id, "moderator").await; h.client.get("/p/test/general/new").await; let resp = h .client .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed") .await; assert!( resp.status.is_redirection(), "mod should bypass, got {}", resp.status ); } // --- frozen: block all member writes #[tokio::test] async fn frozen_blocks_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("fmem").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, user_id, "Thread", "OP") .await; // Freeze after thread exists sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{thread_id}"); h.client.get(&url).await; let resp = h .client .post_form(&format!("{url}/reply"), "body=Reply") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("frozen")); } #[tokio::test] async fn frozen_blocks_member_endorsement() { let mut h = TestHarness::new().await; let author_id = h.login_as("fauth").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "T", "OP") .await; let post_id: Uuid = sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1") .bind(thread_id) .fetch_one(&h.db) .await .unwrap(); // Switch to a different user and freeze the community h.client.post_form("/auth/logout", "").await; let other_id = h.login_as("fother").await; h.add_membership(other_id, comm_id, "member").await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse"); h.client.get(&format!("/p/test/general/{thread_id}")).await; let resp = h.client.post_form(&url, "").await; assert_eq!(resp.status, StatusCode::FORBIDDEN); } #[tokio::test] async fn frozen_mod_can_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("fmod").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "moderator").await; let thread_id = h.create_thread_with_post(cat_id, user_id, "T", "OP").await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{thread_id}"); h.client.get(&url).await; let resp = h .client .post_form(&format!("{url}/reply"), "body=Mod+reply") .await; assert!( resp.status.is_redirection(), "mod reply blocked: {}", resp.status ); } // --- superadmin override #[tokio::test] async fn superadmin_can_reply_in_frozen_without_role() { // Superadmin is a platform-level user with no community role here. let admin_id = Uuid::new_v4(); let mut h = TestHarness::new_with_admin(admin_id).await; // Seed a community + author with role, then a thread. let author_id = h.login_as("sauth").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(author_id, comm_id, "member").await; let thread_id = h .create_thread_with_post(cat_id, author_id, "T", "OP") .await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); // Become the superadmin, explicitly no community role. h.client.post_form("/auth/logout", "").await; sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) \ VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(admin_id) .execute(&h.db) .await .unwrap(); h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) .to_string(), ) .await; let url = format!("/p/test/general/{thread_id}"); h.client.get(&url).await; let resp = h .client .post_form(&format!("{url}/reply"), "body=Super+reply") .await; assert!( resp.status.is_redirection(), "superadmin reply blocked: status={} body={}", resp.status, resp.text ); } // --- archived: same as frozen + hidden from default listing #[tokio::test] async fn archived_blocks_member_reply() { let mut h = TestHarness::new().await; let user_id = h.login_as("amem").await; let (comm_id, cat_id) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; let thread_id = h.create_thread_with_post(cat_id, user_id, "T", "OP").await; sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") .bind(comm_id) .execute(&h.db) .await .unwrap(); let url = format!("/p/test/general/{thread_id}"); h.client.get(&url).await; let resp = h.client.post_form(&format!("{url}/reply"), "body=No").await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert!(resp.text.contains("archived")); } #[tokio::test] async fn archived_excluded_from_default_listing() { let mut h = TestHarness::new().await; h.create_community("Active", "active-comm").await; let arch_id = h.create_community("Archived", "arch-comm").await; sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") .bind(arch_id) .execute(&h.db) .await .unwrap(); let resp = h.client.get("/").await; assert!(resp.text.contains("Active")); assert!( !resp.text.contains("/p/arch-comm"), "archived community should not appear in default listing" ); let resp = h.client.get("/?filter=archived").await; assert!( resp.text.contains("/p/arch-comm"), "archived view should show it" ); assert!( !resp.text.contains("/p/active-comm"), "archived view should not include active communities" ); } #[tokio::test] async fn archived_directory_pagination_preserves_filter() { // With >1 page of archived communities, the next/prev links must keep // ?filter=archived, else page 2 reverts to the default listing (audit U2). let mut h = TestHarness::new().await; // 26 archived communities → 2 pages at 25/page. for i in 0..26 { let id = h .create_community(&format!("Arch {i}"), &format!("arch-{i}")) .await; sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1") .bind(id) .execute(&h.db) .await .unwrap(); } let resp = h.client.get("/?filter=archived").await; assert_eq!(resp.status.as_u16(), 200); // The Next link must carry the filter forward. Askama HTML-escapes the `&` // to the numeric entity `&`, which the browser decodes back to `&`. assert!( resp.text.contains("?page=2&filter=archived"), "archived pagination Next link must preserve filter; got body:\n{}", resp.text ); // Page 2 of the archived view still shows archived communities (filter held). let resp2 = h.client.get("/?page=2&filter=archived").await; assert_eq!(resp2.status.as_u16(), 200); assert!( resp2.text.contains("?page=1&filter=archived"), "Previous link on page 2 must preserve filter" ); } #[tokio::test] async fn missing_community_page_renders_branded_404() { // An in-handler 404 (missing community) must render the branded error page // with site chrome, not bare "Not found" plaintext (audit U1). let mut h = TestHarness::new().await; let resp = h.client.get("/p/no-such-community").await; assert_eq!(resp.status.as_u16(), 404); assert!( resp.text .contains("The page you're looking for doesn't exist."), "should render the branded 404 body" ); assert!( resp.text.contains("Back to Forums"), "branded 404 includes the styled call-to-action" ); assert_ne!(resp.text.trim(), "Not found", "must not be bare plaintext"); } // --- state-change route #[tokio::test] async fn owner_can_change_state() { let mut h = TestHarness::new().await; let user_id = h.login_as("owner").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "owner").await; // Prime CSRF + cookie h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=frozen") .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "frozen"); } #[tokio::test] async fn superadmin_can_change_state_without_role() { let admin_id = Uuid::new_v4(); let mut h = TestHarness::new_with_admin(admin_id).await; let (comm_id, _cat) = setup_community(&mut h, "active").await; sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) \ VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(admin_id) .execute(&h.db) .await .unwrap(); h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" }) .to_string(), ) .await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=archived") .await; assert!(resp.status.is_redirection(), "status: {}", resp.status); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "archived"); } #[tokio::test] async fn member_cannot_change_state() { let mut h = TestHarness::new().await; let user_id = h.login_as("nope").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "member").await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=frozen") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(state, "active", "state should be unchanged"); } #[tokio::test] async fn state_change_rejects_unknown_value() { let mut h = TestHarness::new().await; let user_id = h.login_as("ownerbad").await; let (comm_id, _cat) = setup_community(&mut h, "active").await; h.add_membership(user_id, comm_id, "owner").await; h.client.get("/p/test/settings").await; let resp = h .client .post_form("/p/test/settings/state", "state=bogus") .await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1") .bind(comm_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( state, "active", "state should be unchanged on validation error" ); }