//! Tests for the chat routes. //! //! The trait tests cover the authz matrix as a decision; these cover what a //! browser actually gets back, which is a different question: the status code //! for each refusal, that `off` is indistinguishable from absent, that the //! route does not collide with the category catch-all, and that CSRF and the //! rate limiter apply to sending the way they apply to every other write. use axum::http::StatusCode; use mt_core::types::ChatPolicy; use uuid::Uuid; use crate::harness::TestHarness; async fn set_policy(h: &TestHarness, community: Uuid, policy: ChatPolicy) { sqlx::query("UPDATE communities SET chat_policy = $1 WHERE id = $2") .bind(policy.as_str()) .bind(community) .execute(&h.db) .await .expect("set policy"); } /// Insert a user without logging in as them. /// /// `login_as` mints a fresh uuid on every call and replaces the session, so /// there is no way back to an earlier user. Tests therefore create everyone up /// front and log in the actor they care about last. 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 } /// A community with chat on and an owner who is not signed in yet. async fn room(h: &mut TestHarness, policy: ChatPolicy) -> (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; set_policy(h, id, policy).await; (id, owner) } /// Log in as an already-created user. 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; } // Reachability #[sqlx::test] async fn chat_off_is_indistinguishable_from_a_community_without_chat(_pool: sqlx::PgPool) { // `off` must be total: not a 403, which would confirm the room exists and // is merely shut. let mut h = TestHarness::new().await; let (_id, _owner) = room(&mut h, ChatPolicy::Off).await; assert_eq!( h.client.get("/p/test/chat").await.status, StatusCode::NOT_FOUND ); assert_eq!( h.client.get("/p/nope/chat").await.status, StatusCode::NOT_FOUND, "and a community that does not exist reads the same" ); } #[sqlx::test] async fn the_chat_route_is_not_swallowed_by_the_category_catch_all(_pool: sqlx::PgPool) { // `/p/{slug}/{category}` would match `/p/test/chat` if chat were registered // after it. A community with a category actually named "chat" is the case // that would hide the bug. let mut h = TestHarness::new().await; let (id, owner) = room(&mut h, ChatPolicy::Members).await; h.create_category(id, "Chat", "chat").await; sign_in(&mut h, owner, "owner").await; let resp = h.client.get("/p/test/chat").await; assert_eq!(resp.status, StatusCode::OK); assert!( resp.text.contains("chat-room"), "the chat page must win over a category of the same slug" ); } #[sqlx::test] async fn an_enabled_room_renders_its_backlog_server_side(_pool: sqlx::PgPool) { // The page must be readable before any JS runs, and stay readable if the // island fails to load. let mut h = TestHarness::new().await; let (id, owner) = room(&mut h, ChatPolicy::Members).await; mt_db::mutations::insert_chat_message(&h.db, id, owner, "
hello room
", 168) .await .unwrap(); sign_in(&mut h, owner, "owner").await; let resp = h.client.get("/p/test/chat").await; assert_eq!(resp.status, StatusCode::OK); assert!( resp.text.contains("hello room"), "backlog is server-rendered" ); } #[sqlx::test] async fn a_logged_out_visitor_sees_public_read_and_not_members(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, _owner) = room(&mut h, ChatPolicy::PublicRead).await; assert_eq!(h.client.get("/p/test/chat").await.status, StatusCode::OK); set_policy(&h, id, ChatPolicy::Members).await; assert_eq!( h.client.get("/p/test/chat").await.status, StatusCode::NOT_FOUND, "members-only chat is not visible logged out" ); } #[sqlx::test] async fn a_logged_out_visitor_gets_no_composer(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; room(&mut h, ChatPolicy::PublicRead).await; let resp = h.client.get("/p/test/chat").await; assert!(!resp.text.contains("chat-composer")); assert!(resp.text.contains("Sign in")); } #[sqlx::test] async fn a_read_only_community_shows_the_room_without_a_composer(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, owner) = room(&mut h, ChatPolicy::Members).await; sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1") .bind(id) .execute(&h.db) .await .unwrap(); sign_in(&mut h, owner, "owner").await; let resp = h.client.get("/p/test/chat").await; assert_eq!(resp.status, StatusCode::OK); assert!(!resp.text.contains("chat-composer")); assert!(resp.text.contains("read-only")); } // Sending #[sqlx::test] async fn a_member_sends_and_gets_the_id_and_nonce_back(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let (id, owner) = room(&mut h, ChatPolicy::Members).await; sign_in(&mut h, owner, "owner").await; h.client.get("/p/test/chat").await; let resp = h .client .post_form("/p/test/chat/send", "body=hello&nonce=n1") .await; assert_eq!(resp.status, StatusCode::OK); let json: serde_json::Value = serde_json::from_str(&resp.text).expect("json reply"); assert!(json["id"].as_i64().unwrap() > 0); assert_eq!(json["nonce"], "n1", "the sender reconciles by nonce"); let stored = mt_db::queries::recent_backlog(&h.db, id, 10).await.unwrap(); assert_eq!(stored.len(), 1); assert!(stored[0].body_html.contains("hello")); } #[sqlx::test] async fn a_sent_message_is_rendered_and_sanitized(_pool: sqlx::PgPool) { // Sanitization happens once, at insert, through docengine's chat preset. // The store layer never sees markdown and must never see raw HTML. // // Two messages rather than one: a line starting with a raw tag is parsed // as an HTML block, so the rest of that line never reaches the inline // renderer. Asserting both properties on one message would be testing the // block parser, not the two things worth pinning. let mut h = TestHarness::new().await; let (id, owner) = room(&mut h, ChatPolicy::Members).await; sign_in(&mut h, owner, "owner").await; h.client.get("/p/test/chat").await; h.client .post_form("/p/test/chat/send", "body=%2Aemphasis%2A") .await; h.client .post_form( "/p/test/chat/send", "body=%3Cscript%3Ealert(1)%3C%2Fscript%3E", ) .await; let stored = mt_db::queries::recent_backlog(&h.db, id, 10).await.unwrap(); assert_eq!(stored.len(), 2); assert!( stored[0].body_html.contains(""), "markdown renders: {}", stored[0].body_html ); assert!( !stored[1].body_html.contains("