//! Tests for the owner's chat settings: policy, retention, and wipe-now. //! //! Separate from `chat_routes` because these are not routes into the room. They //! are the two writes that decide what the room *is*, and both are owner-only: //! a moderator moderates people, and neither reshaping retention nor destroying //! everyone's messages at once is that. //! //! The three properties worth holding on to here are the ones that are quiet //! when they break. Retention that shortens without restamping leaves the old //! window in force on every message already sent. A bound that is only checked //! by migration 039's CHECK gives the owner a 500 instead of a sentence. And a //! wipe that lands without a `mod_log` row is content destruction with no //! record of who did it. use axum::http::StatusCode; use mt_core::types::ChatPolicy; use uuid::Uuid; use crate::harness::TestHarness; async fn user(h: &TestHarness, username: &str) -> Uuid { let id = Uuid::new_v4(); sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3)") .bind(id) .bind(username) .bind(username) .execute(&h.db) .await .expect("insert user"); id } async fn sign_in(h: &mut TestHarness, id: Uuid, username: &str) { h.client.get("/").await; h.client .post_json( "/_test/login", &serde_json::json!({ "user_id": id.to_string(), "username": username }).to_string(), ) .await; } /// A community with an owner signed in and the settings page loaded, which is /// what mints the CSRF token every write below needs. async fn owned_community(h: &mut TestHarness) -> (Uuid, Uuid) { let id = h.create_community("Test", "test").await; h.create_category(id, "General", "general").await; let owner = user(h, "owner").await; h.add_membership(owner, id, "owner").await; sign_in(h, owner, "owner").await; h.client.get("/p/test/settings").await; (id, owner) } async fn chat_columns(h: &TestHarness, community: Uuid) -> (String, i32, i32) { sqlx::query_as::<_, (String, i32, i32)>( "SELECT chat_policy, chat_retention_hours, chat_max_messages FROM communities WHERE id = $1", ) .bind(community) .fetch_one(&h.db) .await .expect("read chat columns") } fn form(policy: &str, hours: &str, messages: &str) -> String { format!("chat_policy={policy}&retention_hours={hours}&max_messages={messages}") } // Saving the settings #[sqlx::test] async fn an_owner_turns_chat_on_and_the_room_becomes_reachable(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, _owner) = owned_community(&mut h).await; // Off by default, including for a community that predates chat: migration // 037 backfills `off` rather than handing every owner a live room. assert_eq!(chat_columns(&h, id).await.0, "off"); assert_eq!( h.client.get("/p/test/chat").await.status, StatusCode::NOT_FOUND ); let resp = h .client .post_form("/p/test/settings/chat", &form("members", "72", "1000")) .await; assert_eq!(resp.status, StatusCode::SEE_OTHER); assert_eq!(chat_columns(&h, id).await, ("members".into(), 72, 1_000)); assert_eq!(h.client.get("/p/test/chat").await.status, StatusCode::OK); } #[sqlx::test] async fn the_settings_page_offers_every_policy(_pool: sqlx::PgPool) { // The form is generated from `ChatPolicy::ALL`, so a fifth variant appears // without anyone editing the template. This is what pins that. let mut h = TestHarness::new().await; let (_id, _owner) = owned_community(&mut h).await; let page = h.client.get("/p/test/settings").await; assert_eq!(page.status, StatusCode::OK); for policy in ChatPolicy::ALL { assert!( page.text .contains(&format!("value=\"{}\"", policy.as_str())), "{} is not offered", policy.as_str() ); } } #[sqlx::test] async fn shortening_retention_reaches_back_over_messages_already_sent(_pool: sqlx::PgPool) { // The one that is silent when it breaks. Expiry is stamped at insert to // make the sweep one indexed delete, so a shortened window that does not // restamp applies only to future messages and the owner keeps holding the // backlog they just asked to be rid of. let mut h = TestHarness::new().await; let (id, owner) = owned_community(&mut h).await; h.client .post_form("/p/test/settings/chat", &form("members", "720", "5000")) .await; mt_db::mutations::insert_chat_message(&h.db, id, owner, "old", 720) .await .unwrap(); h.client .post_form("/p/test/settings/chat", &form("members", "1", "5000")) .await; let hours: f64 = sqlx::query_scalar( "SELECT (EXTRACT(EPOCH FROM (expires_at - created_at)) / 3600)::FLOAT8 FROM chat_messages WHERE community_id = $1", ) .bind(id) .fetch_one(&h.db) .await .unwrap(); assert!( (hours - 1.0).abs() < 0.01, "the existing message still expires in {hours} hours, not 1" ); } #[sqlx::test] async fn a_bound_past_the_crate_ceiling_is_a_sentence_not_a_500(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, _owner) = owned_community(&mut h).await; for (hours, messages) in [ ("721", "5000"), ("168", "20001"), ("0", "5000"), ("168", "0"), ] { let resp = h .client .post_form("/p/test/settings/chat", &form("members", hours, messages)) .await; assert_eq!( resp.status, StatusCode::UNPROCESSABLE_ENTITY, "{hours}h / {messages} messages should be refused" ); } // Migration 039's CHECKs never came into it, and nothing was written. assert_eq!(chat_columns(&h, id).await, ("off".into(), 168, 5_000)); } #[sqlx::test] async fn a_bound_that_is_not_a_number_is_refused(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (_id, _owner) = owned_community(&mut h).await; let resp = h .client .post_form("/p/test/settings/chat", &form("members", "lots", "5000")) .await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); } #[sqlx::test] async fn an_unknown_policy_is_refused_rather_than_stored(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, _owner) = owned_community(&mut h).await; let resp = h .client .post_form("/p/test/settings/chat", &form("everyone", "168", "5000")) .await; assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(chat_columns(&h, id).await.0, "off"); } #[sqlx::test] async fn a_moderator_cannot_change_the_chat_settings(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let id = h.create_community("Test", "test").await; h.create_category(id, "General", "general").await; let owner = user(&h, "owner").await; h.add_membership(owner, id, "owner").await; let moderator = user(&h, "mod").await; h.add_membership(moderator, id, "moderator").await; sign_in(&mut h, moderator, "mod").await; h.client.get("/p/test").await; let resp = h .client .post_form("/p/test/settings/chat", &form("members", "168", "5000")) .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert_eq!(chat_columns(&h, id).await.0, "off"); } // Wipe now #[sqlx::test] async fn an_owner_empties_the_room_and_it_is_logged(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, owner) = owned_community(&mut h).await; h.client .post_form("/p/test/settings/chat", &form("members", "168", "5000")) .await; let member = user(&h, "member").await; h.add_membership(member, id, "member").await; for author in [owner, member] { mt_db::mutations::insert_chat_message(&h.db, id, author, "hi", 168) .await .unwrap(); } let resp = h .client .post_form("/p/test/settings/chat/wipe", "confirm=test") .await; assert_eq!(resp.status, StatusCode::SEE_OTHER); assert!( mt_db::queries::recent_backlog(&h.db, id, 10) .await .unwrap() .is_empty(), "a wipe takes everyone's messages, not the owner's" ); // Content destruction without an audit row is the combination the // moderation impl writes on one transaction to make impossible. let logged: i64 = sqlx::query_scalar( "SELECT count(*) FROM mod_log WHERE community_id = $1 AND action = 'chat_wipe'", ) .bind(id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(logged, 1); } #[sqlx::test] async fn a_wipe_works_after_chat_is_turned_off(_pool: sqlx::PgPool) { // The likeliest order of events: an owner shuts the room, then wants the // backlog gone. Refusing here would mean turning chat back on to clear it. let mut h = TestHarness::new().await; let (id, owner) = owned_community(&mut h).await; h.client .post_form("/p/test/settings/chat", &form("members", "168", "5000")) .await; mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168) .await .unwrap(); h.client .post_form("/p/test/settings/chat", &form("off", "168", "5000")) .await; let resp = h .client .post_form("/p/test/settings/chat/wipe", "confirm=test") .await; assert_eq!(resp.status, StatusCode::SEE_OTHER); assert!( mt_db::queries::recent_backlog(&h.db, id, 10) .await .unwrap() .is_empty() ); } #[sqlx::test] async fn a_moderator_cannot_wipe_the_room(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let id = h.create_community("Test", "test").await; h.create_category(id, "General", "general").await; let owner = user(&h, "owner").await; h.add_membership(owner, id, "owner").await; let moderator = user(&h, "mod").await; h.add_membership(moderator, id, "moderator").await; sqlx::query("UPDATE communities SET chat_policy = 'members' WHERE id = $1") .bind(id) .execute(&h.db) .await .unwrap(); mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168) .await .unwrap(); sign_in(&mut h, moderator, "mod").await; h.client.get("/p/test/chat").await; let resp = h .client .post_form("/p/test/settings/chat/wipe", "confirm=test") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert_eq!( mt_db::queries::recent_backlog(&h.db, id, 10) .await .unwrap() .len(), 1, "the room is intact" ); } #[sqlx::test] async fn a_wipe_without_the_typed_slug_does_nothing(_pool: sqlx::PgPool) { // The misclick guard. Checked server-side because a confirmation the client // owns is a suggestion, and this destroys everyone's messages at once. let mut h = TestHarness::new().await; let (id, owner) = owned_community(&mut h).await; h.client .post_form("/p/test/settings/chat", &form("members", "168", "5000")) .await; mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168) .await .unwrap(); // Case is significant, so the community's own name in title case is not it. for body in ["confirm=", "confirm=Test", "confirm=wrong"] { let resp = h.client.post_form("/p/test/settings/chat/wipe", body).await; assert_eq!( resp.status, StatusCode::UNPROCESSABLE_ENTITY, "{body:?} should not have been accepted" ); } assert_eq!( mt_db::queries::recent_backlog(&h.db, id, 10) .await .unwrap() .len(), 1, "the room is intact" ); } #[sqlx::test] async fn wiping_without_a_csrf_token_is_refused(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, owner) = owned_community(&mut h).await; sqlx::query("UPDATE communities SET chat_policy = 'members' WHERE id = $1") .bind(id) .execute(&h.db) .await .unwrap(); mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168) .await .unwrap(); let resp = h .client .post_form_no_csrf("/p/test/settings/chat/wipe", "confirm=test") .await; assert_eq!(resp.status, StatusCode::FORBIDDEN); assert_eq!( mt_db::queries::recent_backlog(&h.db, id, 10) .await .unwrap() .len(), 1 ); } // The entry point #[sqlx::test] async fn the_community_page_links_to_chat_only_when_the_room_is_reachable(_pool: sqlx::PgPool) { // `off` must be total: no route, no hub room, and no affordance. The link // is the affordance, and before this it did not exist at all, which made // the room unreachable except by typing the URL. let mut h = TestHarness::new().await; let (_id, _owner) = owned_community(&mut h).await; assert!( !h.client.get("/p/test").await.text.contains("/p/test/chat"), "chat is off, so nothing should point at the room" ); h.client .post_form("/p/test/settings/chat", &form("members", "168", "5000")) .await; assert!( h.client.get("/p/test").await.text.contains("/p/test/chat"), "chat is on and the room is not linked from anywhere" ); } #[sqlx::test] async fn a_logged_out_visitor_is_offered_a_public_room(_pool: sqlx::PgPool) { // Nobody signs in here: `public_read` exists so a stranger can read the // room, and a link they cannot see is the same as no link. let mut h = TestHarness::new().await; let id = h.create_community("Test", "test").await; h.create_category(id, "General", "general").await; sqlx::query("UPDATE communities SET chat_policy = 'public_read' WHERE id = $1") .bind(id) .execute(&h.db) .await .unwrap(); assert!(h.client.get("/p/test").await.text.contains("/p/test/chat")); }