Skip to main content

max / makenotwork

27.0 KB · 767 lines History Blame Raw
1 //! Shared route helpers — validation, permission checks, markdown rendering, enforcement.
2
3 use axum::{
4 http::StatusCode,
5 response::{IntoResponse, Response},
6 };
7 use chrono::{DateTime, Duration, Utc};
8 use uuid::Uuid;
9
10 use mt_core::types::{CommunityRole, CommunityState, ModAction};
11
12 use crate::auth;
13 use crate::templates::*;
14 use crate::AppState;
15
16 // ============================================================================
17 // Rate limiting constants
18 // ============================================================================
19
20 /// Per-user rate limit: max posts per window (complements per-IP tower-governor).
21 pub(crate) const USER_POST_RATE_LIMIT: i64 = 15;
22 pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60;
23
24 // ============================================================================
25 // Markdown rendering
26 // ============================================================================
27
28 /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
29 ///
30 /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this
31 /// for content from non-Fan+ users. Fan+ subscribers get image embeds via
32 /// [`render_markdown_plus`].
33 pub(crate) fn render_markdown(input: &str) -> String {
34 docengine::render_strict(input)
35 }
36
37 /// Render markdown to HTML with image embeds permitted. Otherwise identical to
38 /// the strict preset — raw HTML is still stripped, dangerous schemes filtered,
39 /// links get `nofollow`. Use for Fan+ subscriber content.
40 pub(crate) fn render_markdown_plus(input: &str) -> String {
41 docengine::Renderer::strict()
42 .with_strip_images(false)
43 .render(input)
44 }
45
46 /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
47 pub(crate) fn render_markdown_with_mentions(
48 input: &str,
49 community_slug: &str,
50 valid_usernames: &std::collections::HashSet<String>,
51 allow_images: bool,
52 ) -> String {
53 let template = format!("/p/{community_slug}/u/{{username}}");
54 let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
55 if allow_images {
56 render_markdown_plus(&resolved)
57 } else {
58 docengine::render_strict(&resolved)
59 }
60 }
61
62 /// Reject submissions that contain image embeds. Used to give non-Fan+ users
63 /// a clear error rather than silently stripping their image at render time.
64 ///
65 /// Only matches the markdown image syntax `![alt](url)`. Raw HTML `<img>` /
66 /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is
67 /// true in both `render_strict` and `render_markdown_plus`), so we don't need
68 /// to reject them here — and rejecting them would surprise users pasting code
69 /// blocks containing HTML.
70 #[allow(clippy::result_large_err)]
71 pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> {
72 static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
73 regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap()
74 });
75 if EMBED_RE.is_match(body) {
76 return Err((
77 StatusCode::UNPROCESSABLE_ENTITY,
78 "Image embeds are a Fan+ feature.",
79 )
80 .into_response());
81 }
82 Ok(())
83 }
84
85 // ============================================================================
86 // Common helpers — reduce boilerplate in handlers
87 // ============================================================================
88
89 /// Fetch community by slug, returning 404/500 on failure.
90 #[tracing::instrument(skip_all)]
91 pub(crate) async fn get_community(
92 db: &sqlx::PgPool,
93 slug: &str,
94 ) -> Result<mt_db::queries::CommunityRow, Response> {
95 mt_db::queries::get_community_by_slug(db, slug)
96 .await
97 .map_err(|e| {
98 tracing::error!(error = ?e, "db error fetching community");
99 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
100 })?
101 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())
102 }
103
104 /// Fetch thread with breadcrumb, returning 404/500 on failure.
105 #[tracing::instrument(skip_all)]
106 pub(crate) async fn get_thread(
107 db: &sqlx::PgPool,
108 thread_id_str: &str,
109 ) -> Result<mt_db::queries::ThreadWithBreadcrumb, Response> {
110 let thread_id = parse_uuid(thread_id_str)?;
111 mt_db::queries::get_thread_with_breadcrumb(db, thread_id)
112 .await
113 .map_err(|e| {
114 tracing::error!(error = ?e, "db error fetching thread");
115 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
116 })?
117 .ok_or_else(|| (StatusCode::NOT_FOUND, "Not found").into_response())
118 }
119
120 /// Parse a UUID from a string, returning 404 on failure.
121 #[allow(clippy::result_large_err)]
122 pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
123 Uuid::parse_str(id_str).map_err(|_| (StatusCode::NOT_FOUND, "Not found").into_response())
124 }
125
126 /// Fetch a user's role in a community, returning 500 on DB error.
127 #[tracing::instrument(skip_all)]
128 pub(crate) async fn get_role(
129 db: &sqlx::PgPool,
130 user_id: Uuid,
131 community_id: Uuid,
132 ) -> Result<Option<CommunityRole>, Response> {
133 mt_db::queries::get_user_role(db, user_id, community_id)
134 .await
135 .map_err(|e| {
136 tracing::error!(error = ?e, "db error fetching role");
137 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
138 })
139 }
140
141 /// Look up a user by username, returning 422 if not found.
142 #[tracing::instrument(skip_all)]
143 pub(crate) async fn get_user_by_username(
144 db: &sqlx::PgPool,
145 username: &str,
146 ) -> Result<Uuid, Response> {
147 mt_db::queries::get_user_by_username(db, username)
148 .await
149 .map_err(|e| {
150 tracing::error!(error = ?e, "db error looking up user");
151 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
152 })?
153 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
154 }
155
156 /// Fire-and-forget mod log entry. Logs errors but never fails the request.
157 pub(crate) async fn log_mod_action(
158 db: &sqlx::PgPool,
159 community_id: Option<Uuid>,
160 actor_id: Uuid,
161 action: ModAction,
162 target_user: Option<Uuid>,
163 target_id: Option<Uuid>,
164 reason: Option<&str>,
165 ) {
166 if let Err(e) = mt_db::mutations::insert_mod_log(
167 db, community_id, actor_id, action, target_user, target_id, reason,
168 )
169 .await
170 {
171 tracing::error!(error = %e, "failed to insert mod log");
172 }
173 }
174
175 /// Convert a session user to a template session user.
176 pub(crate) fn template_user(
177 user: &auth::SessionUser,
178 platform_admin_id: Option<Uuid>,
179 ) -> TemplateSessionUser {
180 TemplateSessionUser {
181 is_platform_admin: platform_admin_id == Some(user.user_id),
182 username: user.username.clone(),
183 }
184 }
185
186 /// Validate a title field (1-256 chars).
187 #[allow(clippy::result_large_err)]
188 pub(crate) fn validate_title(text: &str) -> Result<&str, Response> {
189 let t = text.trim();
190 if t.is_empty() || t.len() > 256 {
191 return Err((
192 StatusCode::UNPROCESSABLE_ENTITY,
193 "Title must be between 1 and 256 characters.",
194 )
195 .into_response());
196 }
197 Ok(t)
198 }
199
200 /// Validate a body/content field (1 to max chars).
201 #[allow(clippy::result_large_err)]
202 pub(crate) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> {
203 let t = text.trim();
204 if t.is_empty() || t.len() > max {
205 return Err((
206 StatusCode::UNPROCESSABLE_ENTITY,
207 format!("{field} must be between 1 and {max} characters."),
208 )
209 .into_response());
210 }
211 Ok(t)
212 }
213
214 // ============================================================================
215 // Permission helpers
216 // ============================================================================
217
218 /// Is this user a moderator or owner in the community?
219 pub(crate) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool {
220 role.is_some_and(|r| r.is_mod_or_owner())
221 }
222
223 /// Is this user an owner of the community?
224 pub(crate) fn is_owner(role: &Option<CommunityRole>) -> bool {
225 role.is_some_and(|r| r.is_owner())
226 }
227
228 // ============================================================================
229 // Enforcement helpers
230 // ============================================================================
231
232 /// Check community suspension + user ban. For read handlers.
233 #[tracing::instrument(skip_all)]
234 pub(crate) async fn check_community_access(
235 db: &sqlx::PgPool,
236 community: &mt_db::queries::CommunityRow,
237 user_id: Option<Uuid>,
238 ) -> Result<(), Response> {
239 if community.suspended_at.is_some() {
240 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
241 }
242 if let Some(uid) = user_id {
243 let banned = mt_db::queries::is_user_banned(db, community.id, uid)
244 .await
245 .map_err(|e| {
246 tracing::error!(error = ?e, "db error checking ban status");
247 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
248 })?;
249 if banned {
250 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
251 }
252 }
253 Ok(())
254 }
255
256 /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
257 #[tracing::instrument(skip_all)]
258 pub(crate) async fn check_write_access(
259 db: &sqlx::PgPool,
260 community_id: Uuid,
261 user_id: Uuid,
262 community_suspended: bool,
263 ) -> Result<(), Response> {
264 if community_suspended {
265 return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
266 }
267 let suspended = mt_db::queries::is_user_suspended(db, user_id)
268 .await
269 .map_err(|e| {
270 tracing::error!(error = ?e, "db error checking user suspension");
271 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
272 })?;
273 if suspended {
274 return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
275 }
276 let banned = mt_db::queries::is_user_banned(db, community_id, user_id)
277 .await
278 .map_err(|e| {
279 tracing::error!(error = ?e, "db error checking ban status");
280 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
281 })?;
282 if banned {
283 return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
284 }
285 let muted = mt_db::queries::is_user_muted(db, community_id, user_id)
286 .await
287 .map_err(|e| {
288 tracing::error!(error = ?e, "db error checking mute status");
289 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
290 })?;
291 if muted {
292 return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
293 }
294 Ok(())
295 }
296
297 /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit.
298 #[tracing::instrument(skip_all)]
299 pub(crate) async fn check_user_post_rate(
300 db: &sqlx::PgPool,
301 user_id: Uuid,
302 ) -> Result<(), Response> {
303 let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS)
304 .await
305 .map_err(|e| {
306 tracing::error!(error = ?e, "db error checking user post rate");
307 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
308 })?;
309 if count >= USER_POST_RATE_LIMIT {
310 return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response());
311 }
312 Ok(())
313 }
314
315 /// Parse a ban duration string into an optional expiration datetime.
316 /// Returns `Err` for unrecognized durations to prevent accidental permanent bans.
317 pub(crate) fn parse_duration(duration: &str) -> Result<Option<DateTime<Utc>>, Response> {
318 match duration {
319 "permanent" => Ok(None),
320 "1h" => Ok(Some(Utc::now() + Duration::hours(1))),
321 "1d" => Ok(Some(Utc::now() + Duration::days(1))),
322 "7d" => Ok(Some(Utc::now() + Duration::days(7))),
323 "30d" => Ok(Some(Utc::now() + Duration::days(30))),
324 _ => Err((StatusCode::UNPROCESSABLE_ENTITY, "Invalid duration.").into_response()),
325 }
326 }
327
328 /// Helper: fetch community + verify owner role, returning 403 if not owner.
329 #[tracing::instrument(skip_all)]
330 pub(crate) async fn require_owner(
331 state: &AppState,
332 slug: &str,
333 user: &auth::SessionUser,
334 ) -> Result<mt_db::queries::CommunityRow, Response> {
335 let community = get_community(&state.db, slug).await?;
336 let role = get_role(&state.db, user.user_id, community.id).await?;
337 if !is_owner(&role) {
338 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
339 }
340 Ok(community)
341 }
342
343 /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
344 #[tracing::instrument(skip_all)]
345 pub(crate) async fn require_mod_or_owner(
346 state: &AppState,
347 slug: &str,
348 user: &auth::SessionUser,
349 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
350 let community = get_community(&state.db, slug).await?;
351 let role = get_role(&state.db, user.user_id, community.id).await?;
352 if !is_mod_or_owner(&role) {
353 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
354 }
355 Ok((community, role))
356 }
357
358 // ============================================================================
359 // Superadmin authorization
360 // ============================================================================
361
362 /// Whether `user` is the configured platform admin.
363 ///
364 /// Platform admin is a single user (env var `PLATFORM_ADMIN_ID`); a real
365 /// permissions system is deferred. See `docs/todo.md` § Community Moderation
366 /// Enforcement.
367 pub(crate) fn is_platform_admin(state: &AppState, user: &auth::SessionUser) -> bool {
368 state
369 .config
370 .platform_admin_id
371 .is_some_and(|id| id == user.user_id)
372 }
373
374 /// True if the user can perform mod actions in this community: either a
375 /// community Owner/Moderator, or the platform admin (who can act on any
376 /// community). Used by [`check_community_state`] and by the state-change route.
377 pub(crate) fn is_mod_or_superadmin(
378 state: &AppState,
379 user: &auth::SessionUser,
380 role: &Option<CommunityRole>,
381 ) -> bool {
382 is_mod_or_owner(role) || is_platform_admin(state, user)
383 }
384
385 /// Fetch community + verify the user is a mod, owner, or platform admin.
386 ///
387 /// Returns `(community, role)` — `role` is `None` when the user is the platform
388 /// admin but holds no role in this specific community.
389 #[tracing::instrument(skip_all)]
390 pub(crate) async fn require_mod_or_superadmin(
391 state: &AppState,
392 slug: &str,
393 user: &auth::SessionUser,
394 ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
395 let community = get_community(&state.db, slug).await?;
396 let role = get_role(&state.db, user.user_id, community.id).await?;
397 if !is_mod_or_superadmin(state, user, &role) {
398 return Err((StatusCode::FORBIDDEN, "Forbidden").into_response());
399 }
400 Ok((community, role))
401 }
402
403 // ============================================================================
404 // Community state enforcement
405 // ============================================================================
406
407 /// Whether a write attempt is starting a new thread or extending an existing
408 /// one. Restricted communities block `NewThread` for non-mods but still accept
409 /// `ContinueExisting` writes.
410 #[derive(Debug, Clone, Copy)]
411 pub(crate) enum WriteScope {
412 NewThread,
413 ContinueExisting,
414 }
415
416 /// Convenience: combine role lookup with [`check_community_state`]. Use this
417 /// in write handlers that don't already need the role for other purposes.
418 #[tracing::instrument(skip_all)]
419 pub(crate) async fn check_write_state(
420 state: &AppState,
421 community: &mt_db::queries::CommunityRow,
422 user: &auth::SessionUser,
423 scope: WriteScope,
424 ) -> Result<(), Response> {
425 let role = get_role(&state.db, user.user_id, community.id).await?;
426 let is_mod_or_super = is_mod_or_superadmin(state, user, &role);
427 check_community_state(community.state, scope, is_mod_or_super)
428 }
429
430 /// Gate a write against the community's [`CommunityState`].
431 ///
432 /// Mods/owners and the platform admin bypass all state restrictions. Members
433 /// follow the state's `allows_*` predicates. Returns 403 with a state-specific
434 /// message on denial — message text is what the user will see in the toast.
435 ///
436 /// Note: this is independent of [`check_write_access`] (which covers
437 /// suspension/ban/mute). Call both in write handlers.
438 /// Pure decision for [`check_community_state`]: returns `None` when the write
439 /// is allowed, `Some(message)` when denied (message is what the user sees).
440 ///
441 /// Mods/owners and the platform admin bypass restrictions. Members go through
442 /// the state's `allows_*` predicates.
443 pub(crate) fn community_state_denial_message(
444 community_state: CommunityState,
445 scope: WriteScope,
446 is_mod_or_super: bool,
447 ) -> Option<&'static str> {
448 if is_mod_or_super {
449 return None;
450 }
451 let allowed = match scope {
452 WriteScope::NewThread => community_state.allows_new_threads_for_members(),
453 WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
454 };
455 if allowed {
456 return None;
457 }
458 Some(match (community_state, scope) {
459 (CommunityState::Restricted, WriteScope::NewThread) => {
460 "New threads are restricted in this community."
461 }
462 (CommunityState::Frozen, _) => "This community is frozen.",
463 (CommunityState::Archived, _) => "This community is archived.",
464 _ => "Action not allowed in the community's current state.",
465 })
466 }
467
468 #[allow(clippy::result_large_err)]
469 pub(crate) fn check_community_state(
470 community_state: CommunityState,
471 scope: WriteScope,
472 is_mod_or_super: bool,
473 ) -> Result<(), Response> {
474 match community_state_denial_message(community_state, scope, is_mod_or_super) {
475 None => Ok(()),
476 Some(msg) => Err((StatusCode::FORBIDDEN, msg).into_response()),
477 }
478 }
479
480 #[cfg(test)]
481 mod validation_tests {
482 use super::*;
483
484 // ── validate_title (1..=256 chars after trim) ──
485
486 #[test]
487 fn title_rejects_empty() {
488 assert!(validate_title("").is_err());
489 }
490
491 #[test]
492 fn title_rejects_whitespace_only() {
493 // The function trims first, so whitespace-only collapses to empty.
494 assert!(validate_title(" \t\n ").is_err());
495 }
496
497 #[test]
498 fn title_accepts_single_char() {
499 assert_eq!(validate_title("x").unwrap(), "x");
500 }
501
502 #[test]
503 fn title_trims_surrounding_whitespace() {
504 // Pins the `.trim()` step: returned slice excludes leading/trailing whitespace.
505 assert_eq!(validate_title(" hello ").unwrap(), "hello");
506 }
507
508 #[test]
509 fn title_accepts_exactly_256_chars() {
510 let s = "a".repeat(256);
511 assert_eq!(validate_title(&s).unwrap().len(), 256);
512 }
513
514 #[test]
515 fn title_rejects_257_chars() {
516 // Pins `t.len() > 256` vs `>= 256`. At exactly 257, must reject.
517 let s = "a".repeat(257);
518 assert!(validate_title(&s).is_err());
519 }
520
521 // ── validate_body (1..=max chars after trim) ──
522
523 #[test]
524 fn body_rejects_empty() {
525 assert!(validate_body("", 100, "Body").is_err());
526 }
527
528 #[test]
529 fn body_accepts_one_char() {
530 assert_eq!(validate_body("x", 100, "Body").unwrap(), "x");
531 }
532
533 #[test]
534 fn body_accepts_at_exact_max() {
535 let s = "a".repeat(50);
536 assert_eq!(validate_body(&s, 50, "Body").unwrap().len(), 50);
537 }
538
539 #[test]
540 fn body_rejects_one_over_max() {
541 // Pins `t.len() > max` vs `>= max`. Length max+1 must reject.
542 let s = "a".repeat(51);
543 assert!(validate_body(&s, 50, "Body").is_err());
544 }
545
546 #[test]
547 fn body_trims_before_length_check() {
548 // Trimmed length, not raw length, is what counts. " a " (3 raw) → "a"
549 // (1 trimmed) fits within max=1.
550 assert_eq!(validate_body(" a ", 1, "Body").unwrap(), "a");
551 }
552
553 #[test]
554 fn body_or_chain_requires_either_condition() {
555 // Pins `is_empty() || len > max` vs `&&` (which would never reject).
556 // An empty body alone (under max) must reject.
557 assert!(validate_body(" ", 100, "Body").is_err());
558 // A too-long body alone (non-empty) must reject.
559 let s = "x".repeat(101);
560 assert!(validate_body(&s, 100, "Body").is_err());
561 }
562
563 // ── is_mod_or_owner / is_owner Option wrappers ──
564
565 #[test]
566 fn is_mod_or_owner_none_role_is_false() {
567 assert!(!is_mod_or_owner(&None));
568 }
569
570 #[test]
571 fn is_mod_or_owner_some_roles() {
572 assert!(is_mod_or_owner(&Some(CommunityRole::Owner)));
573 assert!(is_mod_or_owner(&Some(CommunityRole::Moderator)));
574 assert!(!is_mod_or_owner(&Some(CommunityRole::Member)));
575 }
576
577 #[test]
578 fn is_owner_none_role_is_false() {
579 assert!(!is_owner(&None));
580 }
581
582 #[test]
583 fn is_owner_some_roles() {
584 assert!(is_owner(&Some(CommunityRole::Owner)));
585 assert!(!is_owner(&Some(CommunityRole::Moderator)));
586 assert!(!is_owner(&Some(CommunityRole::Member)));
587 }
588
589 // ── community_state_denial_message ──
590
591 #[test]
592 fn state_denial_mod_bypasses_everything() {
593 // Pins the `if is_mod_or_super { return None; }` early return — a mod
594 // can write to Archived/Frozen/Restricted communities for any scope.
595 for state in [
596 CommunityState::Active,
597 CommunityState::Restricted,
598 CommunityState::Frozen,
599 CommunityState::Archived,
600 ] {
601 for scope in [WriteScope::NewThread, WriteScope::ContinueExisting] {
602 assert_eq!(
603 community_state_denial_message(state, scope, true),
604 None,
605 "mod must bypass: state={state:?} scope={scope:?}"
606 );
607 }
608 }
609 }
610
611 #[test]
612 fn state_denial_active_allows_members_both_scopes() {
613 assert_eq!(
614 community_state_denial_message(CommunityState::Active, WriteScope::NewThread, false),
615 None
616 );
617 assert_eq!(
618 community_state_denial_message(CommunityState::Active, WriteScope::ContinueExisting, false),
619 None
620 );
621 }
622
623 #[test]
624 fn state_denial_restricted_blocks_new_thread_only() {
625 // Restricted: members can reply but not start threads.
626 assert_eq!(
627 community_state_denial_message(CommunityState::Restricted, WriteScope::NewThread, false),
628 Some("New threads are restricted in this community.")
629 );
630 assert_eq!(
631 community_state_denial_message(
632 CommunityState::Restricted,
633 WriteScope::ContinueExisting,
634 false
635 ),
636 None,
637 "Restricted must allow replies"
638 );
639 }
640
641 #[test]
642 fn state_denial_frozen_blocks_all_member_writes() {
643 assert_eq!(
644 community_state_denial_message(CommunityState::Frozen, WriteScope::NewThread, false),
645 Some("This community is frozen.")
646 );
647 assert_eq!(
648 community_state_denial_message(CommunityState::Frozen, WriteScope::ContinueExisting, false),
649 Some("This community is frozen.")
650 );
651 }
652
653 #[test]
654 fn state_denial_archived_blocks_all_member_writes() {
655 assert_eq!(
656 community_state_denial_message(CommunityState::Archived, WriteScope::NewThread, false),
657 Some("This community is archived.")
658 );
659 assert_eq!(
660 community_state_denial_message(
661 CommunityState::Archived,
662 WriteScope::ContinueExisting,
663 false
664 ),
665 Some("This community is archived.")
666 );
667 }
668
669 // ── parse_duration ──
670
671 #[test]
672 fn parse_duration_permanent_returns_none() {
673 // Pins the `"permanent" => Ok(None)` arm; a mutation swapping arms
674 // would either reject "permanent" or produce a non-None expiry.
675 let r = parse_duration("permanent").unwrap();
676 assert!(r.is_none());
677 }
678
679 #[test]
680 fn parse_duration_known_strings_produce_offsets() {
681 // Each known string maps to a specific Duration arm. Assert the
682 // returned datetime is roughly the expected offset from now.
683 let before = Utc::now();
684 let h1 = parse_duration("1h").unwrap().unwrap();
685 let after = Utc::now();
686 let expected_min = before + Duration::hours(1);
687 let expected_max = after + Duration::hours(1);
688 assert!(h1 >= expected_min - Duration::seconds(2));
689 assert!(h1 <= expected_max + Duration::seconds(2));
690
691 let d1 = parse_duration("1d").unwrap().unwrap();
692 let d7 = parse_duration("7d").unwrap().unwrap();
693 let d30 = parse_duration("30d").unwrap().unwrap();
694 // Ordering must be strict (catches arm-swap mutations).
695 assert!(h1 < d1);
696 assert!(d1 < d7);
697 assert!(d7 < d30);
698 }
699
700 #[test]
701 fn parse_duration_arm_offsets_are_distinct() {
702 // Compare relative day gaps. From d1 to d7 should be ~6 days, from
703 // d7 to d30 should be ~23 days. Catches mutations swapping arms.
704 let d1 = parse_duration("1d").unwrap().unwrap();
705 let d7 = parse_duration("7d").unwrap().unwrap();
706 let d30 = parse_duration("30d").unwrap().unwrap();
707
708 let gap_1_to_7 = (d7 - d1).num_days();
709 let gap_7_to_30 = (d30 - d7).num_days();
710 assert!((5..=7).contains(&gap_1_to_7), "1d→7d gap was {gap_1_to_7}");
711 assert!((22..=24).contains(&gap_7_to_30), "7d→30d gap was {gap_7_to_30}");
712 }
713
714 #[test]
715 fn parse_duration_unknown_is_rejected() {
716 // Pins the `_ => Err(...)` arm: anything not in the allowlist must
717 // return Err rather than (e.g.) defaulting to permanent.
718 assert!(parse_duration("forever").is_err());
719 assert!(parse_duration("").is_err());
720 assert!(parse_duration("1d ").is_err(), "trailing whitespace must not match");
721 assert!(parse_duration("1H").is_err(), "case-sensitive: 1H is not 1h");
722 }
723
724 // ── parse_uuid ──
725
726 #[test]
727 fn parse_uuid_valid_string() {
728 let raw = "11111111-2222-3333-4444-555555555555";
729 let parsed = parse_uuid(raw).unwrap();
730 assert_eq!(parsed.to_string(), raw);
731 }
732
733 #[test]
734 fn parse_uuid_invalid_string_is_err() {
735 assert!(parse_uuid("not-a-uuid").is_err());
736 assert!(parse_uuid("").is_err());
737 assert!(parse_uuid("11111111-2222-3333-4444").is_err());
738 }
739
740 #[test]
741 fn state_denial_message_distinct_per_state() {
742 // Distinct error text per state — mutations that swap arms (e.g.
743 // Frozen → Archived) would surface here.
744 let frozen = community_state_denial_message(
745 CommunityState::Frozen,
746 WriteScope::NewThread,
747 false,
748 )
749 .unwrap();
750 let archived = community_state_denial_message(
751 CommunityState::Archived,
752 WriteScope::NewThread,
753 false,
754 )
755 .unwrap();
756 let restricted = community_state_denial_message(
757 CommunityState::Restricted,
758 WriteScope::NewThread,
759 false,
760 )
761 .unwrap();
762 assert_ne!(frozen, archived);
763 assert_ne!(frozen, restricted);
764 assert_ne!(archived, restricted);
765 }
766 }
767