Skip to main content

max / multithreaded

Add expired ban cleanup + close test coverage gaps Expired bans: opportunistic cleanup_expired_bans() runs when mods view the moderation page, deleting rows with expires_at in the past. New tests (7): mute/unban/unmute via HTTP handlers, category edit and reorder via settings, expired ban access check, expired ban cleanup verification. All cold spots from audit resolved. 106 tests total (65 integration + 25 unit lib + 16 unit mt-core). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 18:45 UTC
Commit: c2c5747eb6b30170abe55fa7d875b0ddb5f42679
Parent: 65ddffc
5 files changed, +349 insertions, -20 deletions
@@ -278,6 +278,19 @@ pub async fn create_community_ban(
278 278 Ok(row.0)
279 279 }
280 280
281 + /// Delete all bans/mutes whose expiration has passed.
282 + #[tracing::instrument(skip_all)]
283 + pub async fn cleanup_expired_bans(pool: &PgPool, community_id: Uuid) -> Result<u64, sqlx::Error> {
284 + let result = sqlx::query(
285 + "DELETE FROM community_bans
286 + WHERE community_id = $1 AND expires_at IS NOT NULL AND expires_at <= now()",
287 + )
288 + .bind(community_id)
289 + .execute(pool)
290 + .await?;
291 + Ok(result.rows_affected())
292 + }
293 +
281 294 /// Remove a ban or mute.
282 295 #[tracing::instrument(skip_all)]
283 296 pub async fn remove_community_ban(
@@ -145,6 +145,11 @@ pub(super) async fn moderation_page(
145 145 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
146 146 }
147 147
148 + // Opportunistic cleanup of expired bans/mutes
149 + if let Err(e) = mt_db::mutations::cleanup_expired_bans(&state.db, community.id).await {
150 + tracing::error!(error = %e, "failed to clean up expired bans");
151 + }
152 +
148 153 let db_bans = mt_db::queries::list_community_bans(&state.db, community.id)
149 154 .await
150 155 .map_err(|e| {
@@ -1,5 +1,7 @@
1 1 //! Tests for community-level bans and mutes.
2 2
3 + use axum::http::StatusCode;
4 +
3 5 use crate::harness::TestHarness;
4 6
5 7 #[sqlx::test]
@@ -15,7 +17,7 @@ async fn banned_user_cannot_view_community(_pool: sqlx::PgPool) {
15 17 h.ban_user(community_id, member, owner, "ban").await;
16 18
17 19 let resp = h.client.get("/p/test").await;
18 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
20 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
19 21 }
20 22
21 23 #[sqlx::test]
@@ -36,7 +38,7 @@ async fn banned_user_cannot_create_thread(_pool: sqlx::PgPool) {
36 38 "/p/test/general/new",
37 39 "title=Hello&body=World",
38 40 ).await;
39 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
41 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
40 42 }
41 43
42 44 #[sqlx::test]
@@ -57,7 +59,7 @@ async fn banned_user_cannot_reply(_pool: sqlx::PgPool) {
57 59 &format!("/p/test/general/{thread_id}/reply"),
58 60 "body=My+reply",
59 61 ).await;
60 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
62 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
61 63 }
62 64
63 65 #[sqlx::test]
@@ -73,7 +75,7 @@ async fn muted_user_can_view_pages(_pool: sqlx::PgPool) {
73 75 h.ban_user(community_id, member, owner, "mute").await;
74 76
75 77 let resp = h.client.get("/p/test").await;
76 - assert_eq!(resp.status, axum::http::StatusCode::OK);
78 + assert_eq!(resp.status, StatusCode::OK);
77 79 }
78 80
79 81 #[sqlx::test]
@@ -93,7 +95,7 @@ async fn muted_user_cannot_create_thread(_pool: sqlx::PgPool) {
93 95 "/p/test/general/new",
94 96 "title=Hello&body=World",
95 97 ).await;
96 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
98 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
97 99 }
98 100
99 101 #[sqlx::test]
@@ -114,7 +116,7 @@ async fn muted_user_cannot_reply(_pool: sqlx::PgPool) {
114 116 &format!("/p/test/general/{thread_id}/reply"),
115 117 "body=My+reply",
116 118 ).await;
117 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
119 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
118 120 }
119 121
120 122 #[sqlx::test]
@@ -146,7 +148,7 @@ async fn mod_can_ban_member(_pool: sqlx::PgPool) {
146 148 "/p/test/moderation/ban",
147 149 "username=member&duration=permanent&reason=spam",
148 150 ).await;
149 - assert!(resp.status.is_redirection() || resp.status == axum::http::StatusCode::OK);
151 + assert!(resp.status.is_redirection() || resp.status == StatusCode::OK);
150 152 }
151 153
152 154 #[sqlx::test]
@@ -177,7 +179,7 @@ async fn mod_cannot_ban_other_mod(_pool: sqlx::PgPool) {
177 179 "/p/test/moderation/ban",
178 180 "username=mod2&duration=permanent",
179 181 ).await;
180 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
182 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
181 183 }
182 184
183 185 #[sqlx::test]
@@ -205,7 +207,7 @@ async fn owner_can_ban_mod(_pool: sqlx::PgPool) {
205 207 "/p/test/moderation/ban",
206 208 "username=moduser&duration=permanent&reason=abuse",
207 209 ).await;
208 - assert!(resp.status.is_redirection() || resp.status == axum::http::StatusCode::OK);
210 + assert!(resp.status.is_redirection() || resp.status == StatusCode::OK);
209 211 }
210 212
211 213 #[sqlx::test]
@@ -233,7 +235,7 @@ async fn nobody_can_ban_owner(_pool: sqlx::PgPool) {
233 235 "/p/test/moderation/ban",
234 236 "username=theowner&duration=permanent",
235 237 ).await;
236 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
238 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
237 239 }
238 240
239 241 #[sqlx::test]
@@ -250,7 +252,7 @@ async fn unban_restores_access(_pool: sqlx::PgPool) {
250 252
251 253 // Verify banned
252 254 let resp = h.client.get("/p/test").await;
253 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
255 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
254 256
255 257 // Unban via direct SQL
256 258 sqlx::query("DELETE FROM community_bans WHERE community_id = $1 AND user_id = $2 AND ban_type = 'ban'")
@@ -261,7 +263,7 @@ async fn unban_restores_access(_pool: sqlx::PgPool) {
261 263 .unwrap();
262 264
263 265 let resp = h.client.get("/p/test").await;
264 - assert_eq!(resp.status, axum::http::StatusCode::OK);
266 + assert_eq!(resp.status, StatusCode::OK);
265 267 }
266 268
267 269 #[sqlx::test]
@@ -278,7 +280,7 @@ async fn unmute_restores_write_access(_pool: sqlx::PgPool) {
278 280
279 281 // Verify muted (can read)
280 282 let resp = h.client.get("/p/test").await;
281 - assert_eq!(resp.status, axum::http::StatusCode::OK);
283 + assert_eq!(resp.status, StatusCode::OK);
282 284
283 285 // Verify muted (cannot write)
284 286 h.client.get("/").await;
@@ -286,7 +288,7 @@ async fn unmute_restores_write_access(_pool: sqlx::PgPool) {
286 288 "/p/test/general/new",
287 289 "title=Hello&body=World",
288 290 ).await;
289 - assert_eq!(resp.status, axum::http::StatusCode::FORBIDDEN);
291 + assert_eq!(resp.status, StatusCode::FORBIDDEN);
290 292
291 293 // Unmute via direct SQL
292 294 sqlx::query("DELETE FROM community_bans WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute'")
@@ -302,5 +304,227 @@ async fn unmute_restores_write_access(_pool: sqlx::PgPool) {
302 304 "/p/test/general/new",
303 305 "title=Hello&body=World",
304 306 ).await;
305 - assert!(resp.status.is_redirection() || resp.status == axum::http::StatusCode::OK);
307 + assert!(resp.status.is_redirection() || resp.status == StatusCode::OK);
308 + }
309 +
310 + // ============================================================================
311 + // Handler-based moderation tests (mute, unban, unmute via HTTP POST)
312 + // ============================================================================
313 +
314 + #[sqlx::test]
315 + async fn mod_can_mute_member_via_handler(_pool: sqlx::PgPool) {
316 + let mut h = TestHarness::new().await;
317 +
318 + let owner = h.login_as("owner").await;
319 + let community_id = h.create_community("Test", "test").await;
320 + h.add_membership(owner, community_id, "owner").await;
321 + h.create_category(community_id, "General", "general").await;
322 +
323 + // Create target member via direct SQL
324 + let member_id = uuid::Uuid::new_v4();
325 + sqlx::query(
326 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'target', 'Target')",
327 + )
328 + .bind(member_id)
329 + .execute(&h.db)
330 + .await
331 + .unwrap();
332 + h.add_membership(member_id, community_id, "member").await;
333 +
334 + // Log in as mod, mute target
335 + let moduser = h.login_as("moduser").await;
336 + h.add_membership(moduser, community_id, "moderator").await;
337 +
338 + h.client.get("/p/test/moderation").await;
339 + let resp = h.client.post_form(
340 + "/p/test/moderation/mute",
341 + "username=target&duration=1d&reason=spam",
342 + ).await;
343 + assert!(resp.status.is_redirection(), "Expected redirect, got {}", resp.status);
344 +
345 + // Verify mute row was created in DB
346 + let is_muted = mt_db::queries::is_user_muted(&h.db, community_id, member_id)
347 + .await
348 + .unwrap();
349 + assert!(is_muted, "Target should be muted after handler call");
350 + }
351 +
352 + #[sqlx::test]
353 + async fn mod_can_unban_member_via_handler(_pool: sqlx::PgPool) {
354 + let mut h = TestHarness::new().await;
355 +
356 + let owner = h.login_as("owner").await;
357 + let community_id = h.create_community("Test", "test").await;
358 + h.add_membership(owner, community_id, "owner").await;
359 +
360 + // Create and ban target via direct SQL
361 + let member_id = uuid::Uuid::new_v4();
362 + sqlx::query(
363 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'banned', 'Banned')",
364 + )
365 + .bind(member_id)
366 + .execute(&h.db)
367 + .await
368 + .unwrap();
369 + h.add_membership(member_id, community_id, "member").await;
370 + h.ban_user(community_id, member_id, owner, "ban").await;
371 +
372 + // Verify banned via DB
373 + let is_banned = mt_db::queries::is_user_banned(&h.db, community_id, member_id)
374 + .await
375 + .unwrap();
376 + assert!(is_banned, "User should be banned before unban");
377 +
378 + // Log in as mod, unban via handler
379 + let moduser = h.login_as("moduser").await;
380 + h.add_membership(moduser, community_id, "moderator").await;
381 + h.client.get("/p/test/moderation").await;
382 + let resp = h.client.post_form(
383 + "/p/test/moderation/unban",
384 + "username=banned",
385 + ).await;
386 + assert!(resp.status.is_redirection(), "Expected redirect, got {}", resp.status);
387 +
388 + // Verify unbanned via DB
389 + let is_banned = mt_db::queries::is_user_banned(&h.db, community_id, member_id)
390 + .await
391 + .unwrap();
392 + assert!(!is_banned, "User should not be banned after unban handler");
393 + }
394 +
395 + #[sqlx::test]
396 + async fn mod_can_unmute_member_via_handler(_pool: sqlx::PgPool) {
397 + let mut h = TestHarness::new().await;
398 +
399 + let owner = h.login_as("owner").await;
400 + let community_id = h.create_community("Test", "test").await;
401 + h.add_membership(owner, community_id, "owner").await;
402 +
403 + // Create and mute target via direct SQL
404 + let member_id = uuid::Uuid::new_v4();
405 + sqlx::query(
406 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'muted', 'Muted')",
407 + )
408 + .bind(member_id)
409 + .execute(&h.db)
410 + .await
411 + .unwrap();
412 + h.add_membership(member_id, community_id, "member").await;
413 + h.ban_user(community_id, member_id, owner, "mute").await;
414 +
415 + // Verify muted via DB
416 + let is_muted = mt_db::queries::is_user_muted(&h.db, community_id, member_id)
417 + .await
418 + .unwrap();
419 + assert!(is_muted, "User should be muted before unmute");
420 +
421 + // Log in as mod, unmute via handler
422 + let moduser = h.login_as("moduser").await;
423 + h.add_membership(moduser, community_id, "moderator").await;
424 + h.client.get("/p/test/moderation").await;
425 + let resp = h.client.post_form(
426 + "/p/test/moderation/unmute",
427 + "username=muted",
428 + ).await;
429 + assert!(resp.status.is_redirection(), "Expected redirect, got {}", resp.status);
430 +
431 + // Verify unmuted via DB
432 + let is_muted = mt_db::queries::is_user_muted(&h.db, community_id, member_id)
433 + .await
434 + .unwrap();
435 + assert!(!is_muted, "User should not be muted after unmute handler");
436 + }
437 +
438 + // ============================================================================
439 + // Expired ban tests
440 + // ============================================================================
441 +
442 + #[sqlx::test]
443 + async fn expired_ban_does_not_block_access(_pool: sqlx::PgPool) {
444 + let mut h = TestHarness::new().await;
445 + let owner = h.login_as("owner").await;
446 + let community_id = h.create_community("Test", "test").await;
447 + h.add_membership(owner, community_id, "owner").await;
448 + h.create_category(community_id, "General", "general").await;
449 +
450 + let member = h.login_as("member").await;
451 + h.add_membership(member, community_id, "member").await;
452 +
453 + // Insert an already-expired ban directly
454 + sqlx::query(
455 + "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
456 + VALUES ($1, $2, $3, 'ban', now() - interval '1 hour')",
457 + )
458 + .bind(community_id)
459 + .bind(member)
460 + .bind(owner)
461 + .execute(&h.db)
462 + .await
463 + .unwrap();
464 +
465 + // User should still be able to access the community
466 + let resp = h.client.get("/p/test").await;
467 + assert_eq!(resp.status, StatusCode::OK);
468 +
469 + // User should be able to create a thread
470 + h.client.get("/p/test/general/new").await;
471 + let resp = h.client.post_form(
472 + "/p/test/general/new",
473 + "title=Hello&body=Not+banned",
474 + ).await;
475 + assert!(resp.status.is_redirection() || resp.status == StatusCode::OK);
476 + }
477 +
478 + #[sqlx::test]
479 + async fn expired_bans_cleaned_on_moderation_view(_pool: sqlx::PgPool) {
480 + let mut h = TestHarness::new().await;
481 + let owner = h.login_as("owner").await;
482 + let community_id = h.create_community("Test", "test").await;
483 + h.add_membership(owner, community_id, "owner").await;
484 +
485 + // Create target user
486 + let member_id = uuid::Uuid::new_v4();
487 + sqlx::query(
488 + "INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, 'expired', 'Expired')",
489 + )
490 + .bind(member_id)
491 + .execute(&h.db)
492 + .await
493 + .unwrap();
494 + h.add_membership(member_id, community_id, "member").await;
495 +
496 + // Insert an already-expired ban
497 + sqlx::query(
498 + "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, expires_at)
499 + VALUES ($1, $2, $3, 'ban', now() - interval '1 hour')",
500 + )
501 + .bind(community_id)
502 + .bind(member_id)
503 + .bind(owner)
504 + .execute(&h.db)
505 + .await
506 + .unwrap();
507 +
508 + // Verify the row exists
509 + let count: i64 = sqlx::query_scalar(
510 + "SELECT COUNT(*) FROM community_bans WHERE community_id = $1",
511 + )
512 + .bind(community_id)
513 + .fetch_one(&h.db)
514 + .await
515 + .unwrap();
516 + assert_eq!(count, 1, "Expired ban row should exist before cleanup");
517 +
518 + // Visit moderation page — triggers cleanup
519 + h.client.get("/p/test/moderation").await;
520 +
521 + // Verify row was cleaned up
522 + let count: i64 = sqlx::query_scalar(
523 + "SELECT COUNT(*) FROM community_bans WHERE community_id = $1",
524 + )
525 + .bind(community_id)
526 + .fetch_one(&h.db)
527 + .await
528 + .unwrap();
529 + assert_eq!(count, 0, "Expired ban row should be deleted after moderation page view");
306 530 }
@@ -160,3 +160,90 @@ async fn create_category_via_settings() {
160 160 "New category should appear in settings page"
161 161 );
162 162 }
163 +
164 + #[tokio::test]
165 + async fn edit_category_via_settings() {
166 + let mut h = TestHarness::new().await;
167 + let owner_id = h.login_as("cateditor").await;
168 + let comm_id = h.create_community("Test", "test").await;
169 + h.add_membership(owner_id, comm_id, "owner").await;
170 + let cat_id = h.create_category(comm_id, "Old Name", "general").await;
171 +
172 + // GET edit form for CSRF
173 + h.client
174 + .get(&format!("/p/test/settings/categories/{cat_id}/edit"))
175 + .await;
176 +
177 + let resp = h
178 + .client
179 + .post_form(
180 + &format!("/p/test/settings/categories/{cat_id}/edit"),
181 + "name=New+Name&description=Updated",
182 + )
183 + .await;
184 +
185 + assert!(
186 + resp.status.is_redirection(),
187 + "Expected redirect, got {}",
188 + resp.status
189 + );
190 +
191 + // Verify the name was saved
192 + let cat = mt_db::queries::get_category_by_id(&h.db, cat_id)
193 + .await
194 + .unwrap()
195 + .unwrap();
196 + assert_eq!(cat.name, "New Name");
197 + }
198 +
199 + #[tokio::test]
200 + async fn reorder_categories_via_settings() {
201 + let mut h = TestHarness::new().await;
202 + let owner_id = h.login_as("catmover").await;
203 + let comm_id = h.create_community("Test", "test").await;
204 + h.add_membership(owner_id, comm_id, "owner").await;
205 +
206 + // Create two categories with explicit sort order
207 + let cat_a = h.create_category(comm_id, "Alpha", "alpha").await;
208 + // Update sort_order so cat_a=0 (default from create_category)
209 + sqlx::query("UPDATE categories SET sort_order = 1 WHERE id = $1")
210 + .bind(cat_a)
211 + .execute(&h.db)
212 + .await
213 + .unwrap();
214 + let cat_b = h.create_category(comm_id, "Beta", "beta").await;
215 + sqlx::query("UPDATE categories SET sort_order = 2 WHERE id = $1")
216 + .bind(cat_b)
217 + .execute(&h.db)
218 + .await
219 + .unwrap();
220 +
221 + // Verify initial order: Alpha(1), Beta(2)
222 + let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id)
223 + .await
224 + .unwrap();
225 + assert_eq!(cats[0].name, "Alpha");
226 + assert_eq!(cats[1].name, "Beta");
227 +
228 + // Move Alpha down (swap with Beta)
229 + h.client.get("/p/test/settings").await;
230 + let resp = h
231 + .client
232 + .post_form(
233 + &format!("/p/test/settings/categories/{cat_a}/move"),
234 + "direction=down",
235 + )
236 + .await;
237 + assert!(
238 + resp.status.is_redirection(),
239 + "Expected redirect, got {}",
240 + resp.status
241 + );
242 +
243 + // Verify new order: Beta(1), Alpha(2)
244 + let cats = mt_db::queries::list_categories_for_settings(&h.db, comm_id)
245 + .await
246 + .unwrap();
247 + assert_eq!(cats[0].name, "Beta", "Beta should be first after move");
248 + assert_eq!(cats[1].name, "Alpha", "Alpha should be second after move");
249 + }
M todo.md +5 -5
@@ -1,6 +1,6 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: Phases 0-11. 99 tests (58 integration + 25 unit lib + 16 unit mt-core). v0.2.0 deployed to astra (PLATFORM_ADMIN_ID set). Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting added (tower-governor, per-IP on write endpoints). Initial git commit done.
3 + Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.0 deployed to astra (PLATFORM_ADMIN_ID set). Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting added (tower-governor, per-IP on write endpoints). Expired ban cleanup (opportunistic on moderation page view). Test coverage gaps closed (mute/unban/unmute handlers, category edit/reorder, expired ban behavior). Initial git commit done.
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6
@@ -12,8 +12,8 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj
12 12
13 13 ### Remaining
14 14
15 - - [ ] Caddy config (reverse proxy, public domain when ready)
16 - - [ ] Deploy to production (currently Tailscale-only on astra)
15 + - [ ] Caddy config on alpha-west-1 (reverse proxy alongside MNW, public domain when ready)
16 + - [ ] Deploy to alpha-west-1 (hetzner, alongside live MNW deployment)
17 17
18 18 ---
19 19
@@ -51,9 +51,9 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj
51 51 - [x] Deploy v0.2.0 to astra (version bump, build, upload, restart)
52 52 - [x] Set PLATFORM_ADMIN_ID in production .env
53 53 - [ ] Manual moderation testing (ban a user, verify 403; mute, verify read-only; unban/unmute; mod log entries; admin dashboard)
54 - - [ ] Caddy config (reverse proxy, public domain when ready)
54 + - [ ] Caddy config on alpha-west-1 (reverse proxy alongside MNW, public domain when ready)
55 55 - [x] Rate limiting (per-IP on write endpoints, tower-governor, burst 10 / 2/sec)
56 - - [ ] Expired ban cleanup (scheduled task or on-read expiry check)
56 + - [x] Expired ban cleanup (opportunistic on moderation page view, `cleanup_expired_bans` in mutations.rs)
57 57
58 58 ---
59 59