Skip to main content

max / makenotwork

16.6 KB · 512 lines History Blame Raw
1 //! Tests for the chat routes.
2 //!
3 //! The trait tests cover the authz matrix as a decision; these cover what a
4 //! browser actually gets back, which is a different question: the status code
5 //! for each refusal, that `off` is indistinguishable from absent, that the
6 //! route does not collide with the category catch-all, and that CSRF and the
7 //! rate limiter apply to sending the way they apply to every other write.
8
9 use axum::http::StatusCode;
10 use mt_core::types::ChatPolicy;
11 use uuid::Uuid;
12
13 use crate::harness::TestHarness;
14
15 async fn set_policy(h: &TestHarness, community: Uuid, policy: ChatPolicy) {
16 sqlx::query("UPDATE communities SET chat_policy = $1 WHERE id = $2")
17 .bind(policy.as_str())
18 .bind(community)
19 .execute(&h.db)
20 .await
21 .expect("set policy");
22 }
23
24 /// Insert a user without logging in as them.
25 ///
26 /// `login_as` mints a fresh uuid on every call and replaces the session, so
27 /// there is no way back to an earlier user. Tests therefore create everyone up
28 /// front and log in the actor they care about last.
29 async fn user(h: &TestHarness, username: &str) -> Uuid {
30 let id = Uuid::new_v4();
31 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3)")
32 .bind(id)
33 .bind(username)
34 .bind(username)
35 .execute(&h.db)
36 .await
37 .expect("insert user");
38 id
39 }
40
41 /// A community with chat on and an owner who is not signed in yet.
42 async fn room(h: &mut TestHarness, policy: ChatPolicy) -> (Uuid, Uuid) {
43 let id = h.create_community("Test", "test").await;
44 h.create_category(id, "General", "general").await;
45 let owner = user(h, "owner").await;
46 h.add_membership(owner, id, "owner").await;
47 set_policy(h, id, policy).await;
48 (id, owner)
49 }
50
51 /// Log in as an already-created user.
52 async fn sign_in(h: &mut TestHarness, id: Uuid, username: &str) {
53 h.client.get("/").await;
54 h.client
55 .post_json(
56 "/_test/login",
57 &serde_json::json!({ "user_id": id.to_string(), "username": username }).to_string(),
58 )
59 .await;
60 }
61
62 // Reachability
63
64 #[sqlx::test]
65 async fn chat_off_is_indistinguishable_from_a_community_without_chat(_pool: sqlx::PgPool) {
66 // `off` must be total: not a 403, which would confirm the room exists and
67 // is merely shut.
68 let mut h = TestHarness::new().await;
69 let (_id, _owner) = room(&mut h, ChatPolicy::Off).await;
70
71 assert_eq!(
72 h.client.get("/p/test/chat").await.status,
73 StatusCode::NOT_FOUND
74 );
75 assert_eq!(
76 h.client.get("/p/nope/chat").await.status,
77 StatusCode::NOT_FOUND,
78 "and a community that does not exist reads the same"
79 );
80 }
81
82 #[sqlx::test]
83 async fn the_chat_route_is_not_swallowed_by_the_category_catch_all(_pool: sqlx::PgPool) {
84 // `/p/{slug}/{category}` would match `/p/test/chat` if chat were registered
85 // after it. A community with a category actually named "chat" is the case
86 // that would hide the bug.
87 let mut h = TestHarness::new().await;
88 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
89 h.create_category(id, "Chat", "chat").await;
90 sign_in(&mut h, owner, "owner").await;
91
92 let resp = h.client.get("/p/test/chat").await;
93 assert_eq!(resp.status, StatusCode::OK);
94 assert!(
95 resp.text.contains("chat-room"),
96 "the chat page must win over a category of the same slug"
97 );
98 }
99
100 #[sqlx::test]
101 async fn an_enabled_room_renders_its_backlog_server_side(_pool: sqlx::PgPool) {
102 // The page must be readable before any JS runs, and stay readable if the
103 // island fails to load.
104 let mut h = TestHarness::new().await;
105 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
106
107 mt_db::mutations::insert_chat_message(&h.db, id, owner, "<p>hello room</p>", 168)
108 .await
109 .unwrap();
110 sign_in(&mut h, owner, "owner").await;
111
112 let resp = h.client.get("/p/test/chat").await;
113 assert_eq!(resp.status, StatusCode::OK);
114 assert!(
115 resp.text.contains("hello room"),
116 "backlog is server-rendered"
117 );
118 }
119
120 #[sqlx::test]
121 async fn a_logged_out_visitor_sees_public_read_and_not_members(_pool: sqlx::PgPool) {
122 let mut h = TestHarness::new().await;
123 let (id, _owner) = room(&mut h, ChatPolicy::PublicRead).await;
124
125 assert_eq!(h.client.get("/p/test/chat").await.status, StatusCode::OK);
126
127 set_policy(&h, id, ChatPolicy::Members).await;
128 assert_eq!(
129 h.client.get("/p/test/chat").await.status,
130 StatusCode::NOT_FOUND,
131 "members-only chat is not visible logged out"
132 );
133 }
134
135 #[sqlx::test]
136 async fn a_logged_out_visitor_gets_no_composer(_pool: sqlx::PgPool) {
137 let mut h = TestHarness::new().await;
138 room(&mut h, ChatPolicy::PublicRead).await;
139
140 let resp = h.client.get("/p/test/chat").await;
141 assert!(!resp.text.contains("chat-composer"));
142 assert!(resp.text.contains("Sign in"));
143 }
144
145 #[sqlx::test]
146 async fn a_read_only_community_shows_the_room_without_a_composer(_pool: sqlx::PgPool) {
147 let mut h = TestHarness::new().await;
148 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
149
150 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
151 .bind(id)
152 .execute(&h.db)
153 .await
154 .unwrap();
155 sign_in(&mut h, owner, "owner").await;
156
157 let resp = h.client.get("/p/test/chat").await;
158 assert_eq!(resp.status, StatusCode::OK);
159 assert!(!resp.text.contains("chat-composer"));
160 assert!(resp.text.contains("read-only"));
161 }
162
163 // Sending
164
165 #[sqlx::test]
166 async fn a_member_sends_and_gets_the_id_and_nonce_back(_pool: sqlx::PgPool) {
167 let mut h = TestHarness::new().await;
168 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
169 sign_in(&mut h, owner, "owner").await;
170
171 h.client.get("/p/test/chat").await;
172 let resp = h
173 .client
174 .post_form("/p/test/chat/send", "body=hello&nonce=n1")
175 .await;
176
177 assert_eq!(resp.status, StatusCode::OK);
178 let json: serde_json::Value = serde_json::from_str(&resp.text).expect("json reply");
179 assert!(json["id"].as_i64().unwrap() > 0);
180 assert_eq!(json["nonce"], "n1", "the sender reconciles by nonce");
181
182 let stored = mt_db::queries::recent_backlog(&h.db, id, 10).await.unwrap();
183 assert_eq!(stored.len(), 1);
184 assert!(stored[0].body_html.contains("hello"));
185 }
186
187 #[sqlx::test]
188 async fn a_sent_message_is_rendered_and_sanitized(_pool: sqlx::PgPool) {
189 // Sanitization happens once, at insert, through docengine's chat preset.
190 // The store layer never sees markdown and must never see raw HTML.
191 //
192 // Two messages rather than one: a line starting with a raw tag is parsed
193 // as an HTML block, so the rest of that line never reaches the inline
194 // renderer. Asserting both properties on one message would be testing the
195 // block parser, not the two things worth pinning.
196 let mut h = TestHarness::new().await;
197 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
198 sign_in(&mut h, owner, "owner").await;
199 h.client.get("/p/test/chat").await;
200
201 h.client
202 .post_form("/p/test/chat/send", "body=%2Aemphasis%2A")
203 .await;
204 h.client
205 .post_form(
206 "/p/test/chat/send",
207 "body=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
208 )
209 .await;
210
211 let stored = mt_db::queries::recent_backlog(&h.db, id, 10).await.unwrap();
212 assert_eq!(stored.len(), 2);
213 assert!(
214 stored[0].body_html.contains("<em>"),
215 "markdown renders: {}",
216 stored[0].body_html
217 );
218 assert!(
219 !stored[1].body_html.contains("<script"),
220 "raw HTML must not survive: {}",
221 stored[1].body_html
222 );
223 }
224
225 #[sqlx::test]
226 async fn sending_without_a_csrf_token_is_refused(_pool: sqlx::PgPool) {
227 // Chat sends are a POST precisely so they inherit this middleware unchanged.
228 let mut h = TestHarness::new().await;
229 let (_id, owner) = room(&mut h, ChatPolicy::Members).await;
230 sign_in(&mut h, owner, "owner").await;
231
232 let resp = h
233 .client
234 .post_form_no_csrf("/p/test/chat/send", "body=hello")
235 .await;
236 assert_eq!(resp.status, StatusCode::FORBIDDEN);
237 }
238
239 #[sqlx::test]
240 async fn an_empty_message_is_rejected_as_unprocessable(_pool: sqlx::PgPool) {
241 let mut h = TestHarness::new().await;
242 let (_id, owner) = room(&mut h, ChatPolicy::Members).await;
243 sign_in(&mut h, owner, "owner").await;
244
245 h.client.get("/p/test/chat").await;
246 let resp = h.client.post_form("/p/test/chat/send", "body=+++").await;
247 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
248 }
249
250 #[sqlx::test]
251 async fn an_overlong_message_is_rejected(_pool: sqlx::PgPool) {
252 let mut h = TestHarness::new().await;
253 let (_id, owner) = room(&mut h, ChatPolicy::Members).await;
254 sign_in(&mut h, owner, "owner").await;
255
256 h.client.get("/p/test/chat").await;
257 let body = format!("body={}", "x".repeat(livechat::MAX_MESSAGE_LEN + 1));
258 let resp = h.client.post_form("/p/test/chat/send", &body).await;
259 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
260 }
261
262 #[sqlx::test]
263 async fn a_non_member_is_told_to_join_rather_than_that_it_failed(_pool: sqlx::PgPool) {
264 let mut h = TestHarness::new().await;
265 room(&mut h, ChatPolicy::PublicRead).await;
266
267 let stranger = user(&h, "stranger").await;
268 sign_in(&mut h, stranger, "stranger").await;
269 h.client.get("/p/test/chat").await;
270 let resp = h.client.post_form("/p/test/chat/send", "body=hi").await;
271
272 assert_eq!(resp.status, StatusCode::FORBIDDEN);
273 assert!(resp.text.contains("Join this community"));
274 }
275
276 #[sqlx::test]
277 async fn a_muted_member_is_told_they_are_muted(_pool: sqlx::PgPool) {
278 let mut h = TestHarness::new().await;
279 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
280
281 let member = user(&h, "member").await;
282 h.add_membership(member, id, "member").await;
283 h.ban_user(id, member, owner, "mute").await;
284 sign_in(&mut h, member, "member").await;
285
286 h.client.get("/p/test/chat").await;
287 let resp = h.client.post_form("/p/test/chat/send", "body=hi").await;
288
289 assert_eq!(resp.status, StatusCode::FORBIDDEN);
290 assert!(resp.text.contains("muted"));
291 }
292
293 #[sqlx::test]
294 async fn sending_to_a_room_with_chat_off_is_a_404_not_a_403(_pool: sqlx::PgPool) {
295 let mut h = TestHarness::new().await;
296 let (_id, owner) = room(&mut h, ChatPolicy::Off).await;
297 sign_in(&mut h, owner, "owner").await;
298
299 h.client.get("/p/test").await;
300 let resp = h.client.post_form("/p/test/chat/send", "body=hi").await;
301 assert_eq!(resp.status, StatusCode::NOT_FOUND);
302 }
303
304 // Deleting
305
306 #[sqlx::test]
307 async fn an_author_deletes_their_own_message_through_the_route(_pool: sqlx::PgPool) {
308 let mut h = TestHarness::new().await;
309 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
310
311 let member = user(&h, "member").await;
312 h.add_membership(member, id, "member").await;
313 let mine = mt_db::mutations::insert_chat_message(&h.db, id, member, "mine", 168)
314 .await
315 .unwrap()
316 .id;
317 let theirs = mt_db::mutations::insert_chat_message(&h.db, id, owner, "theirs", 168)
318 .await
319 .unwrap()
320 .id;
321
322 sign_in(&mut h, member, "member").await;
323 h.client.get("/p/test/chat").await;
324
325 let ok = h
326 .client
327 .post_form(&format!("/p/test/chat/messages/{mine}/delete"), "")
328 .await;
329 assert_eq!(ok.status, StatusCode::NO_CONTENT);
330
331 let refused = h
332 .client
333 .post_form(&format!("/p/test/chat/messages/{theirs}/delete"), "")
334 .await;
335 assert_eq!(refused.status, StatusCode::FORBIDDEN);
336
337 let left = mt_db::queries::recent_backlog(&h.db, id, 10).await.unwrap();
338 assert_eq!(left.len(), 1);
339 assert_eq!(left[0].body_html, "theirs");
340 }
341
342 #[sqlx::test]
343 async fn a_moderator_deletes_anyones_message_through_the_route(_pool: sqlx::PgPool) {
344 let mut h = TestHarness::new().await;
345 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
346
347 let member = user(&h, "member").await;
348 h.add_membership(member, id, "member").await;
349 let theirs = mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168)
350 .await
351 .unwrap()
352 .id;
353
354 sign_in(&mut h, owner, "owner").await;
355 h.client.get("/p/test/chat").await;
356 let resp = h
357 .client
358 .post_form(&format!("/p/test/chat/messages/{theirs}/delete"), "")
359 .await;
360
361 assert_eq!(resp.status, StatusCode::NO_CONTENT);
362 assert!(
363 mt_db::queries::recent_backlog(&h.db, id, 10)
364 .await
365 .unwrap()
366 .is_empty()
367 );
368 }
369
370 // Moderation routes
371
372 #[sqlx::test]
373 async fn a_moderator_bans_from_chat_and_the_backlog_goes(_pool: sqlx::PgPool) {
374 let mut h = TestHarness::new().await;
375 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
376
377 let member = user(&h, "member").await;
378 h.add_membership(member, id, "member").await;
379 for _ in 0..3 {
380 mt_db::mutations::insert_chat_message(&h.db, id, member, "spam", 168)
381 .await
382 .unwrap();
383 }
384
385 sign_in(&mut h, owner, "owner").await;
386 h.client.get("/p/test/chat").await;
387 let resp = h
388 .client
389 .post_form("/p/test/chat/moderation/ban", "username=member&reason=spam")
390 .await;
391
392 assert_eq!(resp.status, StatusCode::NO_CONTENT);
393 assert!(
394 mt_db::queries::recent_backlog(&h.db, id, 10)
395 .await
396 .unwrap()
397 .is_empty(),
398 "the ban purges the backlog"
399 );
400 assert!(
401 mt_db::queries::is_user_banned(&h.db, id, member)
402 .await
403 .unwrap()
404 );
405 }
406
407 #[sqlx::test]
408 async fn a_member_cannot_reach_the_moderation_routes(_pool: sqlx::PgPool) {
409 let mut h = TestHarness::new().await;
410 let (id, _owner) = room(&mut h, ChatPolicy::Members).await;
411
412 let member = user(&h, "member").await;
413 h.add_membership(member, id, "member").await;
414 sign_in(&mut h, member, "member").await;
415 h.client.get("/p/test/chat").await;
416
417 for (path, body) in [
418 ("/p/test/chat/moderation/ban", "username=owner"),
419 (
420 "/p/test/chat/moderation/timeout",
421 "username=owner&seconds=300",
422 ),
423 ] {
424 assert_eq!(
425 h.client.post_form(path, body).await.status,
426 StatusCode::FORBIDDEN,
427 "{path}"
428 );
429 }
430 }
431
432 #[sqlx::test]
433 async fn chat_moderation_cannot_be_used_to_ban_an_owner(_pool: sqlx::PgPool) {
434 // Chat is a second door onto community_bans, so it must not be a way past
435 // the protections the forum's own ban handler applies.
436 let mut h = TestHarness::new().await;
437 let (id, _owner) = room(&mut h, ChatPolicy::Members).await;
438
439 let moderator = user(&h, "mod").await;
440 h.add_membership(moderator, id, "moderator").await;
441 sign_in(&mut h, moderator, "mod").await;
442 h.client.get("/p/test/chat").await;
443
444 let resp = h
445 .client
446 .post_form("/p/test/chat/moderation/ban", "username=owner")
447 .await;
448 assert_eq!(resp.status, StatusCode::FORBIDDEN);
449 }
450
451 #[sqlx::test]
452 async fn a_chat_timeout_mutes_the_target_with_an_expiry(_pool: sqlx::PgPool) {
453 let mut h = TestHarness::new().await;
454 let (id, owner) = room(&mut h, ChatPolicy::Members).await;
455
456 let member = user(&h, "member").await;
457 h.add_membership(member, id, "member").await;
458
459 sign_in(&mut h, owner, "owner").await;
460 h.client.get("/p/test/chat").await;
461 let resp = h
462 .client
463 .post_form(
464 "/p/test/chat/moderation/timeout",
465 "username=member&seconds=600",
466 )
467 .await;
468
469 assert_eq!(resp.status, StatusCode::NO_CONTENT);
470 assert!(
471 mt_db::queries::is_user_muted(&h.db, id, member)
472 .await
473 .unwrap()
474 );
475 }
476
477 // The stream
478
479 #[sqlx::test]
480 async fn the_stream_is_refused_for_a_room_the_viewer_cannot_read(_pool: sqlx::PgPool) {
481 let mut h = TestHarness::new().await;
482 room(&mut h, ChatPolicy::Members).await;
483
484 assert_eq!(
485 h.client.get("/p/test/chat/stream").await.status,
486 StatusCode::NOT_FOUND
487 );
488 }
489
490 // The stream's happy path is deliberately not driven here: `TestClient`
491 // collects the full response body, and an SSE stream never ends, so the test
492 // would hang rather than fail. Frame format and backlog-then-live ordering are
493 // covered by the crate's own tests (`livechat::sse`, `livechat::stream`); the
494 // end-to-end path belongs with the client island.
495
496 // Health
497
498 #[sqlx::test]
499 async fn the_health_payload_reports_the_chat_connection_count(_pool: sqlx::PgPool) {
500 // It is the number that predicts the 512M cgroup cap, so PoM can watch it.
501 let mut h = TestHarness::new().await;
502
503 let resp = h.client.get("/api/health").await;
504 assert_eq!(resp.status, StatusCode::OK);
505
506 let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json");
507 assert_eq!(
508 body["chat_connections"], 0,
509 "present and zero with nobody connected"
510 );
511 }
512