//! Tests for Multithreaded's four `livechat` trait impls. //! //! The authz matrix is the bulk of it. Every `chat_policy` mode has to be //! covered for both reads and writes, and the cases that matter are the //! crossings: a logged-out visitor against each mode, a non-member against //! each mode, a banned user, a muted one, and Fan+ with and without the perk. use livechat::{ ChatAuthz, ChatIdentity, ChatModeration, ChatRooms, DenyReason, MessageId, Room, RoomState, UserId, WriteAccess, }; use mt_core::types::ChatPolicy; use multithreaded::chat::{ IdentityCache, authz::MtChatAuthz, identity::MtChatIdentity, moderation::MtChatModeration, rooms::MtChatRooms, }; use uuid::Uuid; use crate::harness::TestHarness; /// A `UserPerks` with the Fan+ perk on or off. /// /// `fan_plus` rather than `is_creator`, because `effective_plus()` is true for /// either and the subscriber case is the one the policy is named for. fn perks(plus: bool) -> multithreaded::auth::UserPerks { multithreaded::auth::UserPerks { fan_plus: plus, ..Default::default() } } 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"); } fn gate(h: &TestHarness, policy: ChatPolicy, viewer: Option, plus: bool) -> MtChatAuthz { MtChatAuthz::new(h.db.clone(), policy, viewer, perks(plus)) } async fn resolve(h: &TestHarness, slug: &str) -> Option { MtChatRooms::new(h.db.clone()) .resolve(slug) .await .expect("resolve") } // Room resolution #[sqlx::test] async fn an_unknown_slug_resolves_to_nothing(_pool: sqlx::PgPool) { let h = TestHarness::new().await; assert!(resolve(&h, "no-such-community").await.is_none()); } #[sqlx::test] async fn chat_is_off_by_default_so_a_new_community_has_a_closed_room(_pool: sqlx::PgPool) { // Migration 037 defaults every community, existing ones included, to off. // Chat is a live moderation burden and an owner opts into carrying it. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let room = resolve(&h, "test").await.expect("community exists"); assert_eq!(room.state, RoomState::Closed); assert_eq!(room.id.0, id, "the room id is the community id"); } #[sqlx::test] async fn enabling_chat_opens_the_room_with_the_default_retention(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); assert_eq!(room.state, RoomState::Open); assert_eq!(room.retention, livechat::Retention::forum_default()); } // Reads, per policy #[sqlx::test] async fn only_public_read_shows_the_room_to_a_logged_out_visitor(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; for (policy, expected) in [ (ChatPolicy::Off, false), (ChatPolicy::Members, false), (ChatPolicy::PublicRead, true), (ChatPolicy::FanPlus, false), ] { set_policy(&h, id, policy).await; let room = resolve(&h, "test").await.unwrap(); let can = gate(&h, policy, None, false) .can_read(None, &room) .await .unwrap(); assert_eq!(can, expected, "logged-out read under {policy}"); } } #[sqlx::test] async fn a_signed_in_stranger_reads_every_enabled_mode(_pool: sqlx::PgPool) { // "Anyone who can view the forum" is the read rule for members and // fan_plus alike; the modes differ on writing, not on reading. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let stranger = h.login_as("stranger").await; for policy in [ ChatPolicy::Members, ChatPolicy::PublicRead, ChatPolicy::FanPlus, ] { set_policy(&h, id, policy).await; let room = resolve(&h, "test").await.unwrap(); assert!( gate(&h, policy, Some(stranger), false) .can_read(Some(UserId(stranger)), &room) .await .unwrap(), "signed-in read under {policy}" ); } } #[sqlx::test] async fn off_refuses_a_read_from_anybody(_pool: sqlx::PgPool) { // `off` must be total. The room is Closed too, so this is the second of // two independent refusals. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let room = resolve(&h, "test").await.unwrap(); let g = gate(&h, ChatPolicy::Off, Some(owner), true); assert!(!g.can_read(Some(UserId(owner)), &room).await.unwrap()); assert!(!g.can_read(None, &room).await.unwrap()); } #[sqlx::test] async fn a_banned_user_loses_the_room_not_just_the_keyboard(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::PublicRead).await; let room = resolve(&h, "test").await.unwrap(); h.ban_user(id, member, owner, "ban").await; assert!( !gate(&h, ChatPolicy::PublicRead, Some(member), false) .can_read(Some(UserId(member)), &room) .await .unwrap(), "a ban beats public_read for the banned user specifically" ); } // Writes, per policy #[sqlx::test] async fn a_member_in_good_standing_writes_in_every_non_fan_mode(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; for policy in [ChatPolicy::Members, ChatPolicy::PublicRead] { set_policy(&h, id, policy).await; let room = resolve(&h, "test").await.unwrap(); assert_eq!( gate(&h, policy, Some(member), false) .can_write(UserId(member), &room) .await .unwrap(), WriteAccess::Allow, "member write under {policy}" ); } } #[sqlx::test] async fn a_non_member_reads_but_cannot_send(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let stranger = h.login_as("stranger").await; set_policy(&h, id, ChatPolicy::PublicRead).await; let room = resolve(&h, "test").await.unwrap(); let g = gate(&h, ChatPolicy::PublicRead, Some(stranger), true); assert!(g.can_read(Some(UserId(stranger)), &room).await.unwrap()); assert_eq!( g.can_write(UserId(stranger), &room).await.unwrap(), WriteAccess::Deny(DenyReason::NotAMember), "every write mode is members in good standing, Fan+ perk or not" ); } #[sqlx::test] async fn fan_plus_gates_writing_on_the_perk_and_nothing_else(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::FanPlus).await; let room = resolve(&h, "test").await.unwrap(); assert_eq!( gate(&h, ChatPolicy::FanPlus, Some(member), false) .can_write(UserId(member), &room) .await .unwrap(), WriteAccess::Deny(DenyReason::TierRequired) ); assert_eq!( gate(&h, ChatPolicy::FanPlus, Some(member), true) .can_write(UserId(member), &room) .await .unwrap(), WriteAccess::Allow ); } #[sqlx::test] async fn the_perk_does_not_rescue_a_muted_member(_pool: sqlx::PgPool) { // Good standing is checked before the tier, so paying does not buy past a // moderator's decision. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::FanPlus).await; h.ban_user(id, member, owner, "mute").await; let room = resolve(&h, "test").await.unwrap(); assert_eq!( gate(&h, ChatPolicy::FanPlus, Some(member), true) .can_write(UserId(member), &room) .await .unwrap(), WriteAccess::Deny(DenyReason::Muted) ); } #[sqlx::test] async fn a_ban_and_a_mute_are_reported_as_different_refusals(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let banned = h.login_as("banned").await; h.add_membership(banned, id, "member").await; h.ban_user(id, banned, owner, "ban").await; let muted = h.login_as("muted").await; h.add_membership(muted, id, "member").await; h.ban_user(id, muted, owner, "mute").await; assert_eq!( gate(&h, ChatPolicy::Members, Some(banned), false) .can_write(UserId(banned), &room) .await .unwrap(), WriteAccess::Deny(DenyReason::Banned) ); assert_eq!( gate(&h, ChatPolicy::Members, Some(muted), false) .can_write(UserId(muted), &room) .await .unwrap(), WriteAccess::Deny(DenyReason::Muted) ); } #[sqlx::test] async fn a_gate_refuses_to_answer_about_a_different_user(_pool: sqlx::PgPool) { // The seal on the per-request gate: it holds one viewer's session perks, // and answering about anybody else would consult the wrong ones. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let other = h.login_as("other").await; h.add_membership(other, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); assert!( gate(&h, ChatPolicy::Members, Some(owner), false) .can_write(UserId(other), &room) .await .is_err() ); } #[sqlx::test] async fn moderators_are_recognised_and_members_are_not(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let moderator = h.login_as("mod").await; h.add_membership(moderator, id, "moderator").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; let stranger = h.login_as("stranger").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let g = gate(&h, ChatPolicy::Members, Some(owner), false); for (user, expected) in [ (owner, true), (moderator, true), (member, false), (stranger, false), ] { assert_eq!( g.is_moderator(UserId(user), &room).await.unwrap(), expected, "is_moderator for {user}" ); } } // Identity #[sqlx::test] async fn identity_resolves_names_and_falls_back_to_the_username(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let alice = h.login_as("alice").await; let directory = MtChatIdentity::new(h.db.clone(), IdentityCache::new()); let resolved = directory.identify(&[UserId(alice)]).await.unwrap(); let identity = resolved.get(&UserId(alice)).expect("alice resolves"); assert!( !identity.display_name.is_empty(), "a blank name in a room is worse than an ugly one" ); } #[sqlx::test] async fn an_unknown_user_is_omitted_rather_than_erroring(_pool: sqlx::PgPool) { // A deleted account must not take down the room. let h = TestHarness::new().await; let ghost = UserId(Uuid::new_v4()); let resolved = MtChatIdentity::new(h.db.clone(), IdentityCache::new()) .identify(&[ghost]) .await .expect("a missing user is not an error"); assert!(resolved.is_empty()); } #[sqlx::test] async fn attach_fills_in_authors_across_a_backlog(_pool: sqlx::PgPool) { // The crate's provided batching, over the real directory. let mut h = TestHarness::new().await; let alice = h.login_as("alice").await; let id = h.create_community("Test", "test").await; h.add_membership(alice, id, "owner").await; let mut messages: Vec<_> = (1..=3) .map(|i| livechat::Message { id: MessageId(i), room_id: livechat::RoomId(id), author_id: UserId(alice), body_html: "hi".into(), created_at: 0, nonce: None, author: None, }) .collect(); MtChatIdentity::new(h.db.clone(), IdentityCache::new()) .attach(&mut messages) .await .unwrap(); assert!(messages.iter().all(|m| m.author.is_some())); } // Moderation #[sqlx::test] async fn an_author_deletes_their_own_message_and_not_another(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let mine = mt_db::mutations::insert_chat_message(&h.db, id, member, "mine", 168) .await .unwrap() .id; let theirs = mt_db::mutations::insert_chat_message(&h.db, id, owner, "theirs", 168) .await .unwrap() .id; let as_member = MtChatModeration::new(h.db.clone(), false); as_member .delete_message(UserId(member), &room, MessageId(mine)) .await .expect("own message"); assert!( as_member .delete_message(UserId(member), &room, MessageId(theirs)) .await .is_err(), "a member cannot delete someone else's message" ); let left = mt_db::queries::recent_backlog(&h.db, id, 100) .await .unwrap(); assert_eq!(left.len(), 1); assert_eq!(left[0].body_html, "theirs"); } #[sqlx::test] async fn a_moderator_removes_anyones_message_and_it_is_logged(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let theirs = mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168) .await .unwrap() .id; MtChatModeration::new(h.db.clone(), true) .delete_message(UserId(owner), &room, MessageId(theirs)) .await .unwrap(); let logged: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM mod_log WHERE community_id = $1 AND action = 'chat_delete_message'", ) .bind(id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(logged, 1); } #[sqlx::test] async fn deleting_your_own_message_is_not_a_mod_log_entry(_pool: sqlx::PgPool) { // The mod log is a record of moderation, not of everything anyone thought // better of. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let mine = mt_db::mutations::insert_chat_message(&h.db, id, owner, "oops", 168) .await .unwrap() .id; // Owner is a moderator, and still deleting their own message. MtChatModeration::new(h.db.clone(), true) .delete_message(UserId(owner), &room, MessageId(mine)) .await .unwrap(); let logged: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM mod_log WHERE community_id = $1") .bind(id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(logged, 0); } #[sqlx::test] async fn deleting_a_message_that_is_already_gone_is_not_an_error(_pool: sqlx::PgPool) { // Two moderators clicking the same message is normal, and the desired end // state has been reached either way. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); MtChatModeration::new(h.db.clone(), true) .delete_message(UserId(owner), &room, MessageId(999_999)) .await .expect("idempotent"); } #[sqlx::test] async fn a_member_cannot_ban_timeout_or_purge(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); let as_member = MtChatModeration::new(h.db.clone(), false); assert!( as_member .ban_user(UserId(member), &room, UserId(owner), None) .await .is_err() ); assert!( as_member .purge_user(UserId(member), &room, UserId(owner)) .await .is_err() ); assert!( as_member .timeout_user( UserId(member), &room, UserId(owner), std::time::Duration::from_mins(5) ) .await .is_err() ); } #[sqlx::test] async fn a_chat_ban_is_a_community_ban(_pool: sqlx::PgPool) { // Not a chat-scoped punishment: the same row a forum ban writes, so a user // banned from chat is banned. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); MtChatModeration::new(h.db.clone(), true) .ban_user(UserId(owner), &room, UserId(member), Some("spam")) .await .unwrap(); assert!( mt_db::queries::is_user_banned(&h.db, id, member) .await .unwrap(), "the forum's own ban check must see it" ); } #[sqlx::test] async fn a_timeout_is_a_mute_that_expires(_pool: sqlx::PgPool) { let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); MtChatModeration::new(h.db.clone(), true) .timeout_user( UserId(owner), &room, UserId(member), std::time::Duration::from_mins(10), ) .await .unwrap(); assert!( mt_db::queries::is_user_muted(&h.db, id, member) .await .unwrap() ); let expires: Option> = sqlx::query_scalar( "SELECT expires_at FROM community_bans WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute'", ) .bind(id) .bind(member) .fetch_one(&h.db) .await .unwrap(); assert!(expires.is_some(), "a timeout must expire, unlike a mute"); } #[sqlx::test] async fn a_purge_records_what_it_destroyed(_pool: sqlx::PgPool) { // Content destruction is the case that most needs an audit row, and it is // written on the same transaction as the deletion. let mut h = TestHarness::new().await; let owner = h.login_as("owner").await; let id = h.create_community("Test", "test").await; h.add_membership(owner, id, "owner").await; let member = h.login_as("member").await; h.add_membership(member, id, "member").await; set_policy(&h, id, ChatPolicy::Members).await; let room = resolve(&h, "test").await.unwrap(); for _ in 0..3 { mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168) .await .unwrap(); } let removed = MtChatModeration::new(h.db.clone(), true) .purge_user(UserId(owner), &room, UserId(member)) .await .unwrap(); assert_eq!(removed, 3); let reason: String = sqlx::query_scalar( "SELECT reason FROM mod_log WHERE community_id = $1 AND action = 'chat_purge'", ) .bind(id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(reason, "3 chat messages"); }