Skip to main content

max / makenotwork

13.9 KB · 434 lines History Blame Raw
1 //! Tests for the owner's chat settings: policy, retention, and wipe-now.
2 //!
3 //! Separate from `chat_routes` because these are not routes into the room. They
4 //! are the two writes that decide what the room *is*, and both are owner-only:
5 //! a moderator moderates people, and neither reshaping retention nor destroying
6 //! everyone's messages at once is that.
7 //!
8 //! The three properties worth holding on to here are the ones that are quiet
9 //! when they break. Retention that shortens without restamping leaves the old
10 //! window in force on every message already sent. A bound that is only checked
11 //! by migration 039's CHECK gives the owner a 500 instead of a sentence. And a
12 //! wipe that lands without a `mod_log` row is content destruction with no
13 //! record of who did it.
14
15 use axum::http::StatusCode;
16 use mt_core::types::ChatPolicy;
17 use uuid::Uuid;
18
19 use crate::harness::TestHarness;
20
21 async fn user(h: &TestHarness, username: &str) -> Uuid {
22 let id = Uuid::new_v4();
23 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $3)")
24 .bind(id)
25 .bind(username)
26 .bind(username)
27 .execute(&h.db)
28 .await
29 .expect("insert user");
30 id
31 }
32
33 async fn sign_in(h: &mut TestHarness, id: Uuid, username: &str) {
34 h.client.get("/").await;
35 h.client
36 .post_json(
37 "/_test/login",
38 &serde_json::json!({ "user_id": id.to_string(), "username": username }).to_string(),
39 )
40 .await;
41 }
42
43 /// A community with an owner signed in and the settings page loaded, which is
44 /// what mints the CSRF token every write below needs.
45 async fn owned_community(h: &mut TestHarness) -> (Uuid, Uuid) {
46 let id = h.create_community("Test", "test").await;
47 h.create_category(id, "General", "general").await;
48 let owner = user(h, "owner").await;
49 h.add_membership(owner, id, "owner").await;
50 sign_in(h, owner, "owner").await;
51 h.client.get("/p/test/settings").await;
52 (id, owner)
53 }
54
55 async fn chat_columns(h: &TestHarness, community: Uuid) -> (String, i32, i32) {
56 sqlx::query_as::<_, (String, i32, i32)>(
57 "SELECT chat_policy, chat_retention_hours, chat_max_messages FROM communities WHERE id = $1",
58 )
59 .bind(community)
60 .fetch_one(&h.db)
61 .await
62 .expect("read chat columns")
63 }
64
65 fn form(policy: &str, hours: &str, messages: &str) -> String {
66 format!("chat_policy={policy}&retention_hours={hours}&max_messages={messages}")
67 }
68
69 // Saving the settings
70
71 #[sqlx::test]
72 async fn an_owner_turns_chat_on_and_the_room_becomes_reachable(_pool: sqlx::PgPool) {
73 let mut h = TestHarness::new().await;
74 let (id, _owner) = owned_community(&mut h).await;
75
76 // Off by default, including for a community that predates chat: migration
77 // 037 backfills `off` rather than handing every owner a live room.
78 assert_eq!(chat_columns(&h, id).await.0, "off");
79 assert_eq!(
80 h.client.get("/p/test/chat").await.status,
81 StatusCode::NOT_FOUND
82 );
83
84 let resp = h
85 .client
86 .post_form("/p/test/settings/chat", &form("members", "72", "1000"))
87 .await;
88 assert_eq!(resp.status, StatusCode::SEE_OTHER);
89
90 assert_eq!(chat_columns(&h, id).await, ("members".into(), 72, 1_000));
91 assert_eq!(h.client.get("/p/test/chat").await.status, StatusCode::OK);
92 }
93
94 #[sqlx::test]
95 async fn the_settings_page_offers_every_policy(_pool: sqlx::PgPool) {
96 // The form is generated from `ChatPolicy::ALL`, so a fifth variant appears
97 // without anyone editing the template. This is what pins that.
98 let mut h = TestHarness::new().await;
99 let (_id, _owner) = owned_community(&mut h).await;
100
101 let page = h.client.get("/p/test/settings").await;
102 assert_eq!(page.status, StatusCode::OK);
103 for policy in ChatPolicy::ALL {
104 assert!(
105 page.text
106 .contains(&format!("value=\"{}\"", policy.as_str())),
107 "{} is not offered",
108 policy.as_str()
109 );
110 }
111 }
112
113 #[sqlx::test]
114 async fn shortening_retention_reaches_back_over_messages_already_sent(_pool: sqlx::PgPool) {
115 // The one that is silent when it breaks. Expiry is stamped at insert to
116 // make the sweep one indexed delete, so a shortened window that does not
117 // restamp applies only to future messages and the owner keeps holding the
118 // backlog they just asked to be rid of.
119 let mut h = TestHarness::new().await;
120 let (id, owner) = owned_community(&mut h).await;
121
122 h.client
123 .post_form("/p/test/settings/chat", &form("members", "720", "5000"))
124 .await;
125 mt_db::mutations::insert_chat_message(&h.db, id, owner, "old", 720)
126 .await
127 .unwrap();
128
129 h.client
130 .post_form("/p/test/settings/chat", &form("members", "1", "5000"))
131 .await;
132
133 let hours: f64 = sqlx::query_scalar(
134 "SELECT (EXTRACT(EPOCH FROM (expires_at - created_at)) / 3600)::FLOAT8 FROM chat_messages
135 WHERE community_id = $1",
136 )
137 .bind(id)
138 .fetch_one(&h.db)
139 .await
140 .unwrap();
141
142 assert!(
143 (hours - 1.0).abs() < 0.01,
144 "the existing message still expires in {hours} hours, not 1"
145 );
146 }
147
148 #[sqlx::test]
149 async fn a_bound_past_the_crate_ceiling_is_a_sentence_not_a_500(_pool: sqlx::PgPool) {
150 let mut h = TestHarness::new().await;
151 let (id, _owner) = owned_community(&mut h).await;
152
153 for (hours, messages) in [
154 ("721", "5000"),
155 ("168", "20001"),
156 ("0", "5000"),
157 ("168", "0"),
158 ] {
159 let resp = h
160 .client
161 .post_form("/p/test/settings/chat", &form("members", hours, messages))
162 .await;
163 assert_eq!(
164 resp.status,
165 StatusCode::UNPROCESSABLE_ENTITY,
166 "{hours}h / {messages} messages should be refused"
167 );
168 }
169
170 // Migration 039's CHECKs never came into it, and nothing was written.
171 assert_eq!(chat_columns(&h, id).await, ("off".into(), 168, 5_000));
172 }
173
174 #[sqlx::test]
175 async fn a_bound_that_is_not_a_number_is_refused(_pool: sqlx::PgPool) {
176 let mut h = TestHarness::new().await;
177 let (_id, _owner) = owned_community(&mut h).await;
178
179 let resp = h
180 .client
181 .post_form("/p/test/settings/chat", &form("members", "lots", "5000"))
182 .await;
183 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
184 }
185
186 #[sqlx::test]
187 async fn an_unknown_policy_is_refused_rather_than_stored(_pool: sqlx::PgPool) {
188 let mut h = TestHarness::new().await;
189 let (id, _owner) = owned_community(&mut h).await;
190
191 let resp = h
192 .client
193 .post_form("/p/test/settings/chat", &form("everyone", "168", "5000"))
194 .await;
195
196 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
197 assert_eq!(chat_columns(&h, id).await.0, "off");
198 }
199
200 #[sqlx::test]
201 async fn a_moderator_cannot_change_the_chat_settings(_pool: sqlx::PgPool) {
202 let mut h = TestHarness::new().await;
203 let id = h.create_community("Test", "test").await;
204 h.create_category(id, "General", "general").await;
205 let owner = user(&h, "owner").await;
206 h.add_membership(owner, id, "owner").await;
207 let moderator = user(&h, "mod").await;
208 h.add_membership(moderator, id, "moderator").await;
209
210 sign_in(&mut h, moderator, "mod").await;
211 h.client.get("/p/test").await;
212
213 let resp = h
214 .client
215 .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
216 .await;
217
218 assert_eq!(resp.status, StatusCode::FORBIDDEN);
219 assert_eq!(chat_columns(&h, id).await.0, "off");
220 }
221
222 // Wipe now
223
224 #[sqlx::test]
225 async fn an_owner_empties_the_room_and_it_is_logged(_pool: sqlx::PgPool) {
226 let mut h = TestHarness::new().await;
227 let (id, owner) = owned_community(&mut h).await;
228 h.client
229 .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
230 .await;
231
232 let member = user(&h, "member").await;
233 h.add_membership(member, id, "member").await;
234 for author in [owner, member] {
235 mt_db::mutations::insert_chat_message(&h.db, id, author, "hi", 168)
236 .await
237 .unwrap();
238 }
239
240 let resp = h
241 .client
242 .post_form("/p/test/settings/chat/wipe", "confirm=test")
243 .await;
244 assert_eq!(resp.status, StatusCode::SEE_OTHER);
245
246 assert!(
247 mt_db::queries::recent_backlog(&h.db, id, 10)
248 .await
249 .unwrap()
250 .is_empty(),
251 "a wipe takes everyone's messages, not the owner's"
252 );
253
254 // Content destruction without an audit row is the combination the
255 // moderation impl writes on one transaction to make impossible.
256 let logged: i64 = sqlx::query_scalar(
257 "SELECT count(*) FROM mod_log WHERE community_id = $1 AND action = 'chat_wipe'",
258 )
259 .bind(id)
260 .fetch_one(&h.db)
261 .await
262 .unwrap();
263 assert_eq!(logged, 1);
264 }
265
266 #[sqlx::test]
267 async fn a_wipe_works_after_chat_is_turned_off(_pool: sqlx::PgPool) {
268 // The likeliest order of events: an owner shuts the room, then wants the
269 // backlog gone. Refusing here would mean turning chat back on to clear it.
270 let mut h = TestHarness::new().await;
271 let (id, owner) = owned_community(&mut h).await;
272 h.client
273 .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
274 .await;
275 mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168)
276 .await
277 .unwrap();
278
279 h.client
280 .post_form("/p/test/settings/chat", &form("off", "168", "5000"))
281 .await;
282
283 let resp = h
284 .client
285 .post_form("/p/test/settings/chat/wipe", "confirm=test")
286 .await;
287 assert_eq!(resp.status, StatusCode::SEE_OTHER);
288 assert!(
289 mt_db::queries::recent_backlog(&h.db, id, 10)
290 .await
291 .unwrap()
292 .is_empty()
293 );
294 }
295
296 #[sqlx::test]
297 async fn a_moderator_cannot_wipe_the_room(_pool: sqlx::PgPool) {
298 let mut h = TestHarness::new().await;
299 let id = h.create_community("Test", "test").await;
300 h.create_category(id, "General", "general").await;
301 let owner = user(&h, "owner").await;
302 h.add_membership(owner, id, "owner").await;
303 let moderator = user(&h, "mod").await;
304 h.add_membership(moderator, id, "moderator").await;
305 sqlx::query("UPDATE communities SET chat_policy = 'members' WHERE id = $1")
306 .bind(id)
307 .execute(&h.db)
308 .await
309 .unwrap();
310 mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168)
311 .await
312 .unwrap();
313
314 sign_in(&mut h, moderator, "mod").await;
315 h.client.get("/p/test/chat").await;
316
317 let resp = h
318 .client
319 .post_form("/p/test/settings/chat/wipe", "confirm=test")
320 .await;
321
322 assert_eq!(resp.status, StatusCode::FORBIDDEN);
323 assert_eq!(
324 mt_db::queries::recent_backlog(&h.db, id, 10)
325 .await
326 .unwrap()
327 .len(),
328 1,
329 "the room is intact"
330 );
331 }
332
333 #[sqlx::test]
334 async fn a_wipe_without_the_typed_slug_does_nothing(_pool: sqlx::PgPool) {
335 // The misclick guard. Checked server-side because a confirmation the client
336 // owns is a suggestion, and this destroys everyone's messages at once.
337 let mut h = TestHarness::new().await;
338 let (id, owner) = owned_community(&mut h).await;
339 h.client
340 .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
341 .await;
342 mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168)
343 .await
344 .unwrap();
345
346 // Case is significant, so the community's own name in title case is not it.
347 for body in ["confirm=", "confirm=Test", "confirm=wrong"] {
348 let resp = h.client.post_form("/p/test/settings/chat/wipe", body).await;
349 assert_eq!(
350 resp.status,
351 StatusCode::UNPROCESSABLE_ENTITY,
352 "{body:?} should not have been accepted"
353 );
354 }
355
356 assert_eq!(
357 mt_db::queries::recent_backlog(&h.db, id, 10)
358 .await
359 .unwrap()
360 .len(),
361 1,
362 "the room is intact"
363 );
364 }
365
366 #[sqlx::test]
367 async fn wiping_without_a_csrf_token_is_refused(_pool: sqlx::PgPool) {
368 let mut h = TestHarness::new().await;
369 let (id, owner) = owned_community(&mut h).await;
370 sqlx::query("UPDATE communities SET chat_policy = 'members' WHERE id = $1")
371 .bind(id)
372 .execute(&h.db)
373 .await
374 .unwrap();
375 mt_db::mutations::insert_chat_message(&h.db, id, owner, "hi", 168)
376 .await
377 .unwrap();
378
379 let resp = h
380 .client
381 .post_form_no_csrf("/p/test/settings/chat/wipe", "confirm=test")
382 .await;
383
384 assert_eq!(resp.status, StatusCode::FORBIDDEN);
385 assert_eq!(
386 mt_db::queries::recent_backlog(&h.db, id, 10)
387 .await
388 .unwrap()
389 .len(),
390 1
391 );
392 }
393
394 // The entry point
395
396 #[sqlx::test]
397 async fn the_community_page_links_to_chat_only_when_the_room_is_reachable(_pool: sqlx::PgPool) {
398 // `off` must be total: no route, no hub room, and no affordance. The link
399 // is the affordance, and before this it did not exist at all, which made
400 // the room unreachable except by typing the URL.
401 let mut h = TestHarness::new().await;
402 let (_id, _owner) = owned_community(&mut h).await;
403
404 assert!(
405 !h.client.get("/p/test").await.text.contains("/p/test/chat"),
406 "chat is off, so nothing should point at the room"
407 );
408
409 h.client
410 .post_form("/p/test/settings/chat", &form("members", "168", "5000"))
411 .await;
412
413 assert!(
414 h.client.get("/p/test").await.text.contains("/p/test/chat"),
415 "chat is on and the room is not linked from anywhere"
416 );
417 }
418
419 #[sqlx::test]
420 async fn a_logged_out_visitor_is_offered_a_public_room(_pool: sqlx::PgPool) {
421 // Nobody signs in here: `public_read` exists so a stranger can read the
422 // room, and a link they cannot see is the same as no link.
423 let mut h = TestHarness::new().await;
424 let id = h.create_community("Test", "test").await;
425 h.create_category(id, "General", "general").await;
426 sqlx::query("UPDATE communities SET chat_policy = 'public_read' WHERE id = $1")
427 .bind(id)
428 .execute(&h.db)
429 .await
430 .unwrap();
431
432 assert!(h.client.get("/p/test").await.text.contains("/p/test/chat"));
433 }
434