Skip to main content

max / multithreaded

21.1 KB · 669 lines History Blame Raw
1 use crate::harness::TestHarness;
2
3 #[tokio::test]
4 async fn create_thread_happy_path() {
5 let mut h = TestHarness::new().await;
6 let user_id = h.login_as("threadcreator").await;
7 let comm_id = h.create_community("Test", "test").await;
8 let _cat_id = h.create_category(comm_id, "General", "general").await;
9 h.add_membership(user_id, comm_id, "member").await;
10
11 // GET new thread form to get CSRF
12 h.client.get("/p/test/general/new").await;
13
14 let resp = h
15 .client
16 .post_form("/p/test/general/new", "title=My+Thread&body=Hello+world")
17 .await;
18
19 // Should redirect to the new thread
20 assert!(
21 resp.status.is_redirection() || resp.status.is_success(),
22 "Expected redirect, got {}",
23 resp.status
24 );
25 }
26
27 #[tokio::test]
28 async fn create_thread_requires_login() {
29 let mut h = TestHarness::new().await;
30 let comm_id = h.create_community("Test", "test").await;
31 let _cat_id = h.create_category(comm_id, "General", "general").await;
32
33 // GET page to get CSRF token (unauthenticated)
34 h.client.get("/p/test/general/new").await;
35
36 let resp = h
37 .client
38 .post_form("/p/test/general/new", "title=Nope&body=Should+fail")
39 .await;
40
41 // Should redirect to login
42 assert!(
43 resp.status.is_redirection(),
44 "Expected redirect to login, got {}",
45 resp.status
46 );
47 }
48
49 #[tokio::test]
50 async fn create_reply_happy_path() {
51 let mut h = TestHarness::new().await;
52 let user_id = h.login_as("replier").await;
53 let comm_id = h.create_community("Test", "test").await;
54 let cat_id = h.create_category(comm_id, "General", "general").await;
55 h.add_membership(user_id, comm_id, "member").await;
56
57 let thread_id = h
58 .create_thread_with_post(cat_id, user_id, "Test Thread", "OP content")
59 .await;
60
61 // GET thread page for CSRF
62 let thread_url = format!("/p/test/general/{}", thread_id);
63 h.client.get(&thread_url).await;
64
65 let reply_url = format!("/p/test/general/{}/reply", thread_id);
66 let resp = h
67 .client
68 .post_form(&reply_url, "body=Great+thread!")
69 .await;
70
71 assert!(
72 resp.status.is_redirection() || resp.status.is_success(),
73 "Expected redirect, got {}",
74 resp.status
75 );
76 }
77
78 #[tokio::test]
79 async fn reply_to_locked_thread_rejected() {
80 let mut h = TestHarness::new().await;
81 let user_id = h.login_as("lockedout").await;
82 let comm_id = h.create_community("Test", "test").await;
83 let cat_id = h.create_category(comm_id, "General", "general").await;
84 h.add_membership(user_id, comm_id, "member").await;
85
86 let thread_id = h
87 .create_thread_with_post(cat_id, user_id, "Locked Thread", "OP")
88 .await;
89
90 // Lock the thread
91 mt_db::mutations::set_thread_locked(&h.db, thread_id, true)
92 .await
93 .unwrap();
94
95 let thread_url = format!("/p/test/general/{}", thread_id);
96 h.client.get(&thread_url).await;
97
98 let reply_url = format!("/p/test/general/{}/reply", thread_id);
99 let resp = h.client.post_form(&reply_url, "body=Nope").await;
100
101 assert_eq!(resp.status.as_u16(), 403);
102 }
103
104 // ============================================================================
105 // Immutable posts — old edit/delete routes return 404
106 // ============================================================================
107
108 #[tokio::test]
109 async fn user_cannot_edit_post() {
110 let mut h = TestHarness::new().await;
111 let user_id = h.login_as("editor").await;
112 let comm_id = h.create_community("Test", "test").await;
113 let cat_id = h.create_category(comm_id, "General", "general").await;
114 h.add_membership(user_id, comm_id, "member").await;
115
116 let thread_id = h
117 .create_thread_with_post(cat_id, user_id, "Edit Test", "Original body")
118 .await;
119
120 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
121 .await
122 .unwrap();
123 let post_id = posts[0].id;
124
125 // GET edit form should 404 (route removed)
126 let edit_url = format!(
127 "/p/test/general/{}/posts/{}/edit",
128 thread_id, post_id
129 );
130 let resp = h.client.get(&edit_url).await;
131 assert_eq!(resp.status.as_u16(), 404, "Edit GET should be 404");
132
133 // POST edit should also 404
134 let resp = h.client.post_form(&edit_url, "body=Updated+body").await;
135 assert_eq!(resp.status.as_u16(), 404, "Edit POST should be 404");
136 }
137
138 #[tokio::test]
139 async fn user_cannot_delete_post() {
140 let mut h = TestHarness::new().await;
141 let user_id = h.login_as("deleter").await;
142 let comm_id = h.create_community("Test", "test").await;
143 let cat_id = h.create_category(comm_id, "General", "general").await;
144 h.add_membership(user_id, comm_id, "member").await;
145
146 let thread_id = h
147 .create_thread_with_post(cat_id, user_id, "Delete Test", "Will not be deleted")
148 .await;
149
150 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
151 .await
152 .unwrap();
153 let post_id = posts[0].id;
154
155 let thread_url = format!("/p/test/general/{}", thread_id);
156 h.client.get(&thread_url).await;
157
158 let delete_url = format!(
159 "/p/test/general/{}/posts/{}/delete",
160 thread_id, post_id
161 );
162 let resp = h.client.post_form(&delete_url, "").await;
163 assert_eq!(resp.status.as_u16(), 404, "Delete POST should be 404");
164 }
165
166 // ============================================================================
167 // Mod removal
168 // ============================================================================
169
170 #[tokio::test]
171 async fn mod_can_remove_post() {
172 let mut h = TestHarness::new().await;
173 let author_id = h.login_as("author").await;
174 let comm_id = h.create_community("Test", "test").await;
175 let cat_id = h.create_category(comm_id, "General", "general").await;
176 h.add_membership(author_id, comm_id, "member").await;
177
178 let thread_id = h
179 .create_thread_with_post(cat_id, author_id, "Mod Test", "Problematic content")
180 .await;
181
182 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
183 .await
184 .unwrap();
185 let post_id = posts[0].id;
186
187 // Log in as mod
188 let mod_id = h.login_as("moduser").await;
189 h.add_membership(mod_id, comm_id, "moderator").await;
190
191 let thread_url = format!("/p/test/general/{}", thread_id);
192 h.client.get(&thread_url).await;
193
194 let remove_url = format!(
195 "/p/test/general/{}/posts/{}/remove",
196 thread_id, post_id
197 );
198 let resp = h.client.post_form(&remove_url, "").await;
199 assert!(
200 resp.status.is_redirection(),
201 "Expected redirect, got {}",
202 resp.status
203 );
204
205 // Verify removed_at is set but content preserved
206 let post_data = mt_db::queries::get_post_for_edit(&h.db, post_id)
207 .await
208 .unwrap()
209 .unwrap();
210 assert_eq!(post_data.body_markdown, "Problematic content", "Content should be preserved");
211
212 // Verify removed_at via direct query
213 let removed_at: Option<chrono::DateTime<chrono::Utc>> = sqlx::query_scalar(
214 "SELECT removed_at FROM posts WHERE id = $1",
215 )
216 .bind(post_id)
217 .fetch_one(&h.db)
218 .await
219 .unwrap();
220 assert!(removed_at.is_some(), "removed_at should be set");
221 }
222
223 // ============================================================================
224 // Thread edit/delete restricted to mod/owner
225 // ============================================================================
226
227 #[tokio::test]
228 async fn mod_can_edit_thread_title() {
229 let mut h = TestHarness::new().await;
230 let author_id = h.login_as("threadauthor").await;
231 let comm_id = h.create_community("Test", "test").await;
232 let cat_id = h.create_category(comm_id, "General", "general").await;
233 h.add_membership(author_id, comm_id, "member").await;
234
235 let thread_id = h
236 .create_thread_with_post(cat_id, author_id, "Old Title", "Body")
237 .await;
238
239 // Log in as mod
240 let mod_id = h.login_as("modeditor").await;
241 h.add_membership(mod_id, comm_id, "moderator").await;
242
243 let edit_url = format!("/p/test/general/{}/edit", thread_id);
244 h.client.get(&edit_url).await;
245
246 let resp = h
247 .client
248 .post_form(&edit_url, "title=New+Title")
249 .await;
250
251 assert!(
252 resp.status.is_redirection() || resp.status.is_success(),
253 "Expected redirect, got {}",
254 resp.status
255 );
256 }
257
258 #[tokio::test]
259 async fn mod_can_delete_thread() {
260 let mut h = TestHarness::new().await;
261 let author_id = h.login_as("threadauthor2").await;
262 let comm_id = h.create_community("Test", "test").await;
263 let cat_id = h.create_category(comm_id, "General", "general").await;
264 h.add_membership(author_id, comm_id, "member").await;
265
266 let thread_id = h
267 .create_thread_with_post(cat_id, author_id, "To Delete", "Body")
268 .await;
269
270 // Log in as mod
271 let mod_id = h.login_as("moddeleter").await;
272 h.add_membership(mod_id, comm_id, "moderator").await;
273
274 let thread_url = format!("/p/test/general/{}", thread_id);
275 h.client.get(&thread_url).await;
276
277 let delete_url = format!("/p/test/general/{}/delete", thread_id);
278 let resp = h.client.post_form(&delete_url, "").await;
279
280 assert!(
281 resp.status.is_redirection(),
282 "Expected redirect, got {}",
283 resp.status
284 );
285 }
286
287 #[tokio::test]
288 async fn user_cannot_edit_thread_title() {
289 let mut h = TestHarness::new().await;
290 let user_id = h.login_as("regularuser").await;
291 let comm_id = h.create_community("Test", "test").await;
292 let cat_id = h.create_category(comm_id, "General", "general").await;
293 h.add_membership(user_id, comm_id, "member").await;
294
295 let thread_id = h
296 .create_thread_with_post(cat_id, user_id, "My Thread", "Body")
297 .await;
298
299 let edit_url = format!("/p/test/general/{}/edit", thread_id);
300 h.client.get(&edit_url).await;
301
302 let resp = h
303 .client
304 .post_form(&edit_url, "title=Hacked+Title")
305 .await;
306
307 assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403");
308 }
309
310 #[tokio::test]
311 async fn user_cannot_delete_thread() {
312 let mut h = TestHarness::new().await;
313 let user_id = h.login_as("regularuser2").await;
314 let comm_id = h.create_community("Test", "test").await;
315 let cat_id = h.create_category(comm_id, "General", "general").await;
316 h.add_membership(user_id, comm_id, "member").await;
317
318 let thread_id = h
319 .create_thread_with_post(cat_id, user_id, "My Thread", "Body")
320 .await;
321
322 let thread_url = format!("/p/test/general/{}", thread_id);
323 h.client.get(&thread_url).await;
324
325 let delete_url = format!("/p/test/general/{}/delete", thread_id);
326 let resp = h.client.post_form(&delete_url, "").await;
327
328 assert_eq!(resp.status.as_u16(), 403, "Regular user should get 403");
329 }
330
331 // ============================================================================
332 // Footnotes
333 // ============================================================================
334
335 #[tokio::test]
336 async fn add_footnote_by_author() {
337 let mut h = TestHarness::new().await;
338 let user_id = h.login_as("fnauthor").await;
339 let comm_id = h.create_community("Test", "test").await;
340 let cat_id = h.create_category(comm_id, "General", "general").await;
341 h.add_membership(user_id, comm_id, "member").await;
342
343 let thread_id = h
344 .create_thread_with_post(cat_id, user_id, "Footnote Test", "Original post")
345 .await;
346
347 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
348 .await
349 .unwrap();
350 let post_id = posts[0].id;
351
352 let thread_url = format!("/p/test/general/{}", thread_id);
353 h.client.get(&thread_url).await;
354
355 let footnote_url = format!(
356 "/p/test/general/{}/posts/{}/footnote",
357 thread_id, post_id
358 );
359 let resp = h
360 .client
361 .post_form(&footnote_url, "body=Correction:+I+meant+something+else")
362 .await;
363
364 assert!(
365 resp.status.is_redirection(),
366 "Expected redirect, got {}",
367 resp.status
368 );
369
370 // Verify footnote in DB
371 let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id])
372 .await
373 .unwrap();
374 assert_eq!(footnotes.len(), 1);
375 assert_eq!(footnotes[0].author_id, user_id);
376
377 // Verify footnote renders on page
378 let resp = h.client.get(&thread_url).await;
379 assert!(
380 resp.text.contains("Correction:"),
381 "Footnote should be visible on thread page"
382 );
383 }
384
385 #[tokio::test]
386 async fn add_footnote_non_author_rejected() {
387 let mut h = TestHarness::new().await;
388 let author_id = h.login_as("fnoriginal").await;
389 let comm_id = h.create_community("Test", "test").await;
390 let cat_id = h.create_category(comm_id, "General", "general").await;
391 h.add_membership(author_id, comm_id, "member").await;
392
393 let thread_id = h
394 .create_thread_with_post(cat_id, author_id, "No Footnote For You", "Original")
395 .await;
396
397 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
398 .await
399 .unwrap();
400 let post_id = posts[0].id;
401
402 // Log in as a different user
403 let other_id = h.login_as("fnother").await;
404 h.add_membership(other_id, comm_id, "member").await;
405
406 let thread_url = format!("/p/test/general/{}", thread_id);
407 h.client.get(&thread_url).await;
408
409 let footnote_url = format!(
410 "/p/test/general/{}/posts/{}/footnote",
411 thread_id, post_id
412 );
413 let resp = h
414 .client
415 .post_form(&footnote_url, "body=Uninvited+footnote")
416 .await;
417
418 assert_eq!(resp.status.as_u16(), 403, "Non-author should get 403");
419 }
420
421 #[tokio::test]
422 async fn multiple_footnotes_ordered() {
423 let mut h = TestHarness::new().await;
424 let user_id = h.login_as("fnmulti").await;
425 let comm_id = h.create_community("Test", "test").await;
426 let cat_id = h.create_category(comm_id, "General", "general").await;
427 h.add_membership(user_id, comm_id, "member").await;
428
429 let thread_id = h
430 .create_thread_with_post(cat_id, user_id, "Multi Footnote", "Post body")
431 .await;
432
433 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
434 .await
435 .unwrap();
436 let post_id = posts[0].id;
437
438 // Insert two footnotes
439 mt_db::mutations::insert_footnote(
440 &h.db, post_id, user_id, "First correction", "<p>First correction</p>",
441 )
442 .await
443 .unwrap();
444
445 mt_db::mutations::insert_footnote(
446 &h.db, post_id, user_id, "Second correction", "<p>Second correction</p>",
447 )
448 .await
449 .unwrap();
450
451 let footnotes = mt_db::queries::list_footnotes_for_posts(&h.db, &[post_id])
452 .await
453 .unwrap();
454
455 assert_eq!(footnotes.len(), 2);
456 assert!(
457 footnotes[0].created_at <= footnotes[1].created_at,
458 "Footnotes should be chronologically ordered"
459 );
460 }
461
462 #[tokio::test]
463 async fn footnote_on_removed_post_rejected() {
464 let mut h = TestHarness::new().await;
465 let author_id = h.login_as("fnremoved").await;
466 let comm_id = h.create_community("Test", "test").await;
467 let cat_id = h.create_category(comm_id, "General", "general").await;
468 h.add_membership(author_id, comm_id, "member").await;
469
470 let thread_id = h
471 .create_thread_with_post(cat_id, author_id, "Removed Post", "Content")
472 .await;
473
474 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
475 .await
476 .unwrap();
477 let post_id = posts[0].id;
478
479 // Mod-remove the post (use author_id since they're the only user in the DB)
480 mt_db::mutations::mod_remove_post(&h.db, post_id, author_id)
481 .await
482 .unwrap();
483
484 let thread_url = format!("/p/test/general/{}", thread_id);
485 h.client.get(&thread_url).await;
486
487 let footnote_url = format!(
488 "/p/test/general/{}/posts/{}/footnote",
489 thread_id, post_id
490 );
491 let resp = h
492 .client
493 .post_form(&footnote_url, "body=After+removal")
494 .await;
495
496 assert_eq!(resp.status.as_u16(), 403, "Footnote on removed post should be 403");
497 }
498
499 // ============================================================================
500 // Verified quoting
501 // ============================================================================
502
503 fn compute_quote_hash(text: &str) -> String {
504 use sha2::{Sha256, Digest};
505 let mut hasher = Sha256::new();
506 hasher.update(text.as_bytes());
507 let hash = hasher.finalize();
508 hex::encode(&hash[..4])
509 }
510
511 #[tokio::test]
512 async fn valid_quote_accepted() {
513 let mut h = TestHarness::new().await;
514 let user_id = h.login_as("quoter").await;
515 let comm_id = h.create_community("Test", "test").await;
516 let cat_id = h.create_category(comm_id, "General", "general").await;
517 h.add_membership(user_id, comm_id, "member").await;
518
519 let thread_id = h
520 .create_thread_with_post(cat_id, user_id, "Quote Test", "This is the original post content")
521 .await;
522
523 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
524 .await
525 .unwrap();
526 let post_id = posts[0].id;
527
528 let quoted_text = "This is the original post content";
529 let hash = compute_quote_hash(quoted_text);
530
531 let body = format!(
532 "> {}\n[quote:{}:{}]\n\nMy reply here",
533 quoted_text, post_id, hash
534 );
535 let encoded_body = urlencoding::encode(&body);
536
537 let thread_url = format!("/p/test/general/{}", thread_id);
538 h.client.get(&thread_url).await;
539
540 let reply_url = format!("/p/test/general/{}/reply", thread_id);
541 let resp = h
542 .client
543 .post_form(&reply_url, &format!("body={}", encoded_body))
544 .await;
545
546 assert!(
547 resp.status.is_redirection() || resp.status.is_success(),
548 "Valid quote should be accepted, got {}",
549 resp.status
550 );
551 }
552
553 #[tokio::test]
554 async fn fabricated_quote_rejected() {
555 let mut h = TestHarness::new().await;
556 let user_id = h.login_as("fabricator").await;
557 let comm_id = h.create_community("Test", "test").await;
558 let cat_id = h.create_category(comm_id, "General", "general").await;
559 h.add_membership(user_id, comm_id, "member").await;
560
561 let thread_id = h
562 .create_thread_with_post(cat_id, user_id, "Fabrication Test", "Actual original content")
563 .await;
564
565 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
566 .await
567 .unwrap();
568 let post_id = posts[0].id;
569
570 // Wrong hash (correct text)
571 let body = format!(
572 "> Actual original content\n[quote:{}:deadbeef]\n\nLying reply",
573 post_id
574 );
575 let encoded_body = urlencoding::encode(&body);
576
577 let thread_url = format!("/p/test/general/{}", thread_id);
578 h.client.get(&thread_url).await;
579
580 let reply_url = format!("/p/test/general/{}/reply", thread_id);
581 let resp = h
582 .client
583 .post_form(&reply_url, &format!("body={}", encoded_body))
584 .await;
585
586 assert_eq!(resp.status.as_u16(), 422, "Fabricated quote hash should be rejected");
587 }
588
589 #[tokio::test]
590 async fn altered_quote_rejected() {
591 let mut h = TestHarness::new().await;
592 let user_id = h.login_as("alterer").await;
593 let comm_id = h.create_community("Test", "test").await;
594 let cat_id = h.create_category(comm_id, "General", "general").await;
595 h.add_membership(user_id, comm_id, "member").await;
596
597 let thread_id = h
598 .create_thread_with_post(cat_id, user_id, "Alter Test", "The sky is blue")
599 .await;
600
601 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
602 .await
603 .unwrap();
604 let post_id = posts[0].id;
605
606 // Correct hash for "The sky is blue" but altered text
607 let original_hash = compute_quote_hash("The sky is blue");
608 let body = format!(
609 "> The sky is green\n[quote:{}:{}]\n\nMisquoting",
610 post_id, original_hash
611 );
612 let encoded_body = urlencoding::encode(&body);
613
614 let thread_url = format!("/p/test/general/{}", thread_id);
615 h.client.get(&thread_url).await;
616
617 let reply_url = format!("/p/test/general/{}/reply", thread_id);
618 let resp = h
619 .client
620 .post_form(&reply_url, &format!("body={}", encoded_body))
621 .await;
622
623 assert_eq!(resp.status.as_u16(), 422, "Altered quote should be rejected");
624 }
625
626 #[tokio::test]
627 async fn quote_renders_with_attribution() {
628 let mut h = TestHarness::new().await;
629 let user_id = h.login_as("quoterrender").await;
630 let comm_id = h.create_community("Test", "test").await;
631 let cat_id = h.create_category(comm_id, "General", "general").await;
632 h.add_membership(user_id, comm_id, "member").await;
633
634 let thread_id = h
635 .create_thread_with_post(cat_id, user_id, "Render Test", "Quotable content here")
636 .await;
637
638 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
639 .await
640 .unwrap();
641 let post_id = posts[0].id;
642
643 let quoted_text = "Quotable content here";
644 let hash = compute_quote_hash(quoted_text);
645
646 let body = format!(
647 "> {}\n[quote:{}:{}]\n\nGreat point!",
648 quoted_text, post_id, hash
649 );
650 let encoded_body = urlencoding::encode(&body);
651
652 let thread_url = format!("/p/test/general/{}", thread_id);
653 h.client.get(&thread_url).await;
654
655 let reply_url = format!("/p/test/general/{}/reply", thread_id);
656 let resp = h
657 .client
658 .post_form(&reply_url, &format!("body={}", encoded_body))
659 .await;
660 assert!(resp.status.is_redirection(), "Quote reply should succeed");
661
662 // Load the thread page and check for attribution
663 let resp = h.client.get(&thread_url).await;
664 assert!(
665 resp.text.contains("quote-attribution"),
666 "Thread page should contain quote attribution"
667 );
668 }
669