Skip to main content

max / makenotwork

Audit coverage push + docengine assumptions feature Test coverage (driven by _meta/remediation_todo.md gaps + cargo llvm-cov): MNW server +83 lib tests (1440 → 1504): - payments/checkout_metadata.rs +19 (FanPlus/CreatorTier/AppSync from_session) - payments/webhooks.rs +5 (is_full_refund boundary) - email/templates/notifications.rs +18 (local CapturingTransport mock) - routes/api/internal/cli_features.rs +16 (extracted features_for_project_type, slug_from_title; verification token shape) - scanning/archive.rs +7 (URL-encoded/absolute/null-byte traversal, magic-byte nested-archive detection for ZIP/gzip/7z/RAR) - tests/workflows/stripe_webhooks.rs +5 (fan_plus cascade branches in subscription_updated/deleted, invoice_payment_succeeded/failed) - tests/workflows/rate_limiting.rs: #[ignore] under fast-tests; documented --ignored --test-threads=1 run-locally recipe in file header multithreaded +80 lib tests (36 → 116): - templates/public.rs +10 + new Pagination::offset() method, dedup'd inline pagination math across views.rs/thread.rs/moderation.rs - routes/forum/posts.rs +15 (extracted find_quote_refs, extract_preceding_quote_text, compute_quote_hash) - routes/forum/actions.rs +12 (extracted check_footnote_permission, check_endorsement_permission + denial enums) - routes/helpers.rs +28 (validate_title/body boundary, role wrappers, new community_state_denial_message, parse_duration/uuid) - internal_auth.rs +15 (extracted compute_internal_signature, verify_internal_signature; clock-injectable; pins HMAC format and >60s window) - src/link_preview.rs: LinkPreviewFetcher enum (Http/Noop) on AppState; eliminates tokio::spawn leakage and cargo-mutants timeouts in tests docengine +26 boundary tests (tempfile dev-dep added): - code_spans.rs +6 (arithmetic boundary on space-fill formula) - render.rs +3 (? and # arms of has_dangerous_scheme, strip_raw_html toggle) - directives.rs +3 (alert-close loop bound, strip arms, UI-caption arithmetic) - doc_loader.rs +14 (tempdir fixtures for load/get/search_index/ resolve_ui_examples) synckit-client +5 (decrypt_change_multi_key key-selection arms) PoM +33: - alerts.rs: extracted 4 priority helpers + backup_status_detail; +5 tests - checks/http.rs +11 (drift detection, staleness boundary, range edges) - checks/whois.rs +12 (TLD coverage, first-match guards, alt expiry names) - checks/tls.rs +4 (days_remaining boundary, cert field population) Parallel in-flight (user-driven): - docengine: new `assumptions` feature (toml-driven {{key}} substitution in markdown), new filter system, doc_loader gains pre_process hook, code_spans gated behind mentions/test features - server/site-docs/public/{about,guide,support}/*: migrated tiers/pricing to {{assumption}} placeholders - server bumped to 0.5.22, deploy.sh updates - server/docs/testnot_work.md: burn-in preview environment plan Server line coverage measured at 67.45% via cargo-llvm-cov. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-16 19:06 UTC
Commit: d2997986237c2b4ddb471d06c3769080e1c2c5e8
Parent: 7f3e772
52 files changed, +5002 insertions, -238 deletions
@@ -35,58 +35,78 @@ impl FromRequest<AppState> for InternalAuth {
35 35 StatusCode::SERVICE_UNAVAILABLE.into_response()
36 36 })?;
37 37
38 - let timestamp_str = req
38 + let timestamp_header = req
39 39 .headers()
40 40 .get("X-Internal-Timestamp")
41 41 .and_then(|v| v.to_str().ok())
42 - .ok_or_else(|| {
43 - (StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp").into_response()
44 - })?
45 - .to_string();
46 -
47 - let signature = req
42 + .map(str::to_string);
43 + let signature_header = req
48 44 .headers()
49 45 .get("X-Internal-Signature")
50 46 .and_then(|v| v.to_str().ok())
51 - .ok_or_else(|| {
52 - (StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature").into_response()
53 - })?
54 - .to_string();
47 + .map(str::to_string);
55 48
56 - // Verify timestamp freshness
57 - let timestamp: i64 = timestamp_str.parse().map_err(|_| {
58 - (StatusCode::UNAUTHORIZED, "Invalid timestamp").into_response()
59 - })?;
60 -
61 - let now = chrono::Utc::now().timestamp();
62 - if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
63 - return Err(
64 - (StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future")
65 - .into_response(),
66 - );
67 - }
68 -
69 - // Read body
70 49 let body = Bytes::from_request(req, state).await.map_err(|e| {
71 50 tracing::error!(error = %e, "failed to read request body");
72 51 StatusCode::BAD_REQUEST.into_response()
73 52 })?;
74 53
75 - // Verify HMAC
76 - let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(&body).unwrap_or(""));
77 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
78 - .expect("HMAC-SHA256 accepts any key length");
79 - mac.update(message.as_bytes());
80 - let expected = hex::encode(mac.finalize().into_bytes());
81 -
82 - if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
83 - return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response());
84 - }
54 + verify_internal_signature(
55 + secret,
56 + timestamp_header.as_deref(),
57 + signature_header.as_deref(),
58 + &body,
59 + chrono::Utc::now().timestamp(),
60 + )
61 + .map_err(|(status, msg)| (status, msg).into_response())?;
85 62
86 63 Ok(InternalAuth(body))
87 64 }
88 65 }
89 66
67 + /// Compute the hex-encoded HMAC-SHA256 signature for an internal request.
68 + /// `secret` may be any length (HMAC-SHA256 accepts any key length).
69 + pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body: &[u8]) -> String {
70 + let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
71 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
72 + .expect("HMAC-SHA256 accepts any key length");
73 + mac.update(message.as_bytes());
74 + hex::encode(mac.finalize().into_bytes())
75 + }
76 +
77 + /// Pure verification: validate timestamp freshness against `now_unix`, then
78 + /// recompute the signature and constant-time compare.
79 + ///
80 + /// Headers are passed as `Option<&str>` so callers can extract them with any
81 + /// strategy (axum `HeaderMap`, manual `Bytes`, tests).
82 + pub(crate) fn verify_internal_signature(
83 + secret: &str,
84 + timestamp_header: Option<&str>,
85 + signature_header: Option<&str>,
86 + body: &[u8],
87 + now_unix: i64,
88 + ) -> Result<(), (StatusCode, &'static str)> {
89 + let timestamp_str = timestamp_header
90 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
91 + let signature = signature_header
92 + .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
93 +
94 + let timestamp: i64 = timestamp_str
95 + .parse()
96 + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
97 +
98 + if (now_unix - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
99 + return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
100 + }
101 +
102 + let expected = compute_internal_signature(secret, timestamp_str, body);
103 +
104 + if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
105 + return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
106 + }
107 + Ok(())
108 + }
109 +
90 110 /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor).
91 111 pub fn verify_hmac_headers(
92 112 state: &AppState,
@@ -102,36 +122,20 @@ pub fn verify_hmac_headers(
102 122 (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
103 123 })?;
104 124
105 - let timestamp_str = headers
125 + let timestamp_header = headers
106 126 .get("X-Internal-Timestamp")
107 - .and_then(|v| v.to_str().ok())
108 - .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
109 -
110 - let signature = headers
127 + .and_then(|v| v.to_str().ok());
128 + let signature_header = headers
111 129 .get("X-Internal-Signature")
112 - .and_then(|v| v.to_str().ok())
113 - .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
114 -
115 - let timestamp: i64 = timestamp_str
116 - .parse()
117 - .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
118 -
119 - let now = chrono::Utc::now().timestamp();
120 - if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
121 - return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
122 - }
123 -
124 - let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
125 - let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
126 - .expect("HMAC-SHA256 accepts any key length");
127 - mac.update(message.as_bytes());
128 - let expected = hex::encode(mac.finalize().into_bytes());
129 -
130 - if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
131 - return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
132 - }
130 + .and_then(|v| v.to_str().ok());
133 131
134 - Ok(())
132 + verify_internal_signature(
133 + secret,
134 + timestamp_header,
135 + signature_header,
136 + body,
137 + chrono::Utc::now().timestamp(),
138 + )
135 139 }
136 140
137 141 /// Constant-time byte comparison to prevent timing attacks.
@@ -174,4 +178,165 @@ mod tests {
174 178
175 179 assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes()));
176 180 }
181 +
182 + // ── compute_internal_signature pins HMAC message construction ──
183 +
184 + #[test]
185 + fn signature_is_64_hex_chars() {
186 + let sig = compute_internal_signature("secret", "100", b"body");
187 + assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars");
188 + assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
189 + }
190 +
191 + #[test]
192 + fn signature_changes_with_secret() {
193 + // Pins that the secret feeds into the MAC key.
194 + let s1 = compute_internal_signature("alpha", "100", b"body");
195 + let s2 = compute_internal_signature("beta", "100", b"body");
196 + assert_ne!(s1, s2);
197 + }
198 +
199 + #[test]
200 + fn signature_changes_with_timestamp() {
201 + // Pins the `format!("{}\n{}", timestamp_str, body)` ordering — a
202 + // mutation that drops the timestamp or swaps the order would make
203 + // these two signatures match.
204 + let s1 = compute_internal_signature("secret", "100", b"body");
205 + let s2 = compute_internal_signature("secret", "101", b"body");
206 + assert_ne!(s1, s2);
207 + }
208 +
209 + #[test]
210 + fn signature_changes_with_body() {
211 + let s1 = compute_internal_signature("secret", "100", b"hello");
212 + let s2 = compute_internal_signature("secret", "100", b"hello!");
213 + assert_ne!(s1, s2);
214 + }
215 +
216 + #[test]
217 + fn signature_separator_is_newline_not_concat() {
218 + // Pins `format!("{}\n{}", ...)` — without the `\n`, "1" + "00body"
219 + // would collide with "10" + "0body".
220 + let collision_a = compute_internal_signature("secret", "1", b"00body");
221 + let collision_b = compute_internal_signature("secret", "10", b"0body");
222 + assert_ne!(
223 + collision_a, collision_b,
224 + "missing newline separator allows length-ambiguity collision"
225 + );
226 + }
227 +
228 + // ── verify_internal_signature freshness + signature check ──
229 +
230 + fn valid(secret: &str, ts: &str, body: &[u8]) -> String {
231 + compute_internal_signature(secret, ts, body)
232 + }
233 +
234 + #[test]
235 + fn verify_accepts_valid_signature_at_now() {
236 + let secret = "s";
237 + let body = b"abc";
238 + let ts = "1000";
239 + let sig = valid(secret, ts, body);
240 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).is_ok());
241 + }
242 +
243 + #[test]
244 + fn verify_rejects_wrong_signature() {
245 + let secret = "s";
246 + let body = b"abc";
247 + let ts = "1000";
248 + // Tamper with one hex char.
249 + let mut sig = valid(secret, ts, body);
250 + let first = sig.remove(0);
251 + sig.insert(0, if first == '0' { '1' } else { '0' });
252 + let (status, _) =
253 + verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).unwrap_err();
254 + assert_eq!(status, StatusCode::UNAUTHORIZED);
255 + }
256 +
257 + #[test]
258 + fn verify_rejects_wrong_secret() {
259 + let body = b"abc";
260 + let ts = "1000";
261 + let sig = valid("real-secret", ts, body);
262 + assert!(
263 + verify_internal_signature("wrong-secret", Some(ts), Some(&sig), body, 1000).is_err()
264 + );
265 + }
266 +
267 + #[test]
268 + fn verify_rejects_tampered_body() {
269 + let secret = "s";
270 + let ts = "1000";
271 + let sig = valid(secret, ts, b"original");
272 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), b"tampered", 1000).is_err());
273 + }
274 +
275 + #[test]
276 + fn verify_at_window_boundary_accepts_inside_rejects_outside() {
277 + // Pins `(now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS` (60s).
278 + // Exactly at the boundary (abs diff == 60) must be accepted (since `>`
279 + // is strict). One second past must be rejected.
280 + let secret = "s";
281 + let body = b"abc";
282 + let ts = "1000";
283 + let sig = valid(secret, ts, body);
284 +
285 + // diff = 60 → accepted
286 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1060).is_ok());
287 + // diff = 61 → rejected (too old)
288 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1061).is_err());
289 + // diff = -60 → accepted
290 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 940).is_ok());
291 + // diff = -61 → rejected (too far in future)
292 + assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 939).is_err());
293 + }
294 +
295 + #[test]
296 + fn verify_rejects_missing_timestamp_header() {
297 + let secret = "s";
298 + let body = b"abc";
299 + let sig = valid(secret, "1000", body);
300 + let (status, msg) =
301 + verify_internal_signature(secret, None, Some(&sig), body, 1000).unwrap_err();
302 + assert_eq!(status, StatusCode::UNAUTHORIZED);
303 + assert!(msg.contains("Timestamp"));
304 + }
305 +
306 + #[test]
307 + fn verify_rejects_missing_signature_header() {
308 + let (status, msg) =
309 + verify_internal_signature("s", Some("1000"), None, b"abc", 1000).unwrap_err();
310 + assert_eq!(status, StatusCode::UNAUTHORIZED);
311 + assert!(msg.contains("Signature"));
312 + }
313 +
314 + #[test]
315 + fn verify_rejects_unparseable_timestamp() {
316 + let (status, msg) =
317 + verify_internal_signature("s", Some("not-an-int"), Some("zz"), b"", 1000).unwrap_err();
318 + assert_eq!(status, StatusCode::UNAUTHORIZED);
319 + assert!(msg.contains("Invalid timestamp"));
320 + }
321 +
322 + #[test]
323 + fn verify_check_order_missing_timestamp_first() {
324 + // Both headers missing: timestamp check fires first.
325 + let (_, msg) = verify_internal_signature("s", None, None, b"", 1000).unwrap_err();
326 + assert!(msg.contains("Timestamp"), "expected timestamp msg first, got: {msg}");
327 + }
328 +
329 + #[test]
330 + fn verify_check_order_freshness_before_signature() {
331 + // A stale timestamp must reject even when the (otherwise valid) sig
332 + // matches. Catches a mutation that runs the freshness check after
333 + // signature verification.
334 + let secret = "s";
335 + let body = b"abc";
336 + let ts = "1000";
337 + let sig = valid(secret, ts, body);
338 + let (_, msg) =
339 + verify_internal_signature(secret, Some(ts), Some(&sig), body, 9999).unwrap_err();
340 + assert!(msg.contains("Timestamp"), "expected freshness msg, got: {msg}");
341 + }
177 342 }
@@ -20,7 +20,8 @@ pub struct AppState {
20 20 pub db: PgPool,
21 21 pub config: Config,
22 22 pub http: reqwest::Client,
23 - /// SSRF-safe client for link preview fetching (validates URLs on redirects).
24 - pub preview_http: reqwest::Client,
23 + /// Link preview fetcher. `LinkPreviewFetcher::Http` in production with an
24 + /// SSRF-safe redirect policy; `LinkPreviewFetcher::Noop` in tests.
25 + pub link_preview: link_preview::LinkPreviewFetcher,
25 26 pub s3: Option<Arc<storage::S3Storage>>,
26 27 }
@@ -114,6 +114,24 @@ pub fn build_preview_client() -> reqwest::Client {
114 114 .expect("failed to build preview HTTP client")
115 115 }
116 116
117 + /// Strategy for fetching link previews. The `Noop` variant lets tests skip
118 + /// real HTTP without monkey-patching `tokio::spawn`; production constructs
119 + /// `Http(build_preview_client())`.
120 + #[derive(Clone)]
121 + pub enum LinkPreviewFetcher {
122 + Http(reqwest::Client),
123 + Noop,
124 + }
125 +
126 + impl LinkPreviewFetcher {
127 + pub async fn fetch(&self, url: &str) -> Option<(Option<String>, Option<String>)> {
128 + match self {
129 + Self::Http(client) => fetch_og_metadata(client, url).await,
130 + Self::Noop => None,
131 + }
132 + }
133 + }
134 +
117 135 /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
118 136 /// Best-effort: returns None on any error (timeout, too large, parse failure).
119 137 #[tracing::instrument(skip_all)]
@@ -356,4 +374,11 @@ mod tests {
356 374 assert!(validate_url("http://8.8.8.8"));
357 375 assert!(validate_url("https://93.184.216.34"));
358 376 }
377 +
378 + #[tokio::test]
379 + async fn noop_fetcher_returns_none_without_network() {
380 + let fetcher = LinkPreviewFetcher::Noop;
381 + // Any URL — would be a public host in production but here we expect no I/O.
382 + assert!(fetcher.fetch("https://example.com").await.is_none());
383 + }
359 384 }
@@ -64,7 +64,9 @@ async fn main() {
64 64 .connect_timeout(std::time::Duration::from_secs(5))
65 65 .build()
66 66 .expect("failed to build HTTP client"),
67 - preview_http: multithreaded::link_preview::build_preview_client(),
67 + link_preview: multithreaded::link_preview::LinkPreviewFetcher::Http(
68 + multithreaded::link_preview::build_preview_client(),
69 + ),
68 70 s3,
69 71 };
70 72
@@ -6,6 +6,7 @@ use axum::{
6 6 response::{IntoResponse, Redirect, Response},
7 7 Form,
8 8 };
9 + use uuid::Uuid;
9 10
10 11 use crate::auth::MaybeUser;
11 12 use crate::AppState;
@@ -16,6 +17,61 @@ use super::super::{
16 17 };
17 18 use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
18 19
20 + /// Why a footnote add was rejected. Pure predicate result; the handler
21 + /// translates each variant to an HTTP response.
22 + #[derive(Debug, PartialEq, Eq)]
23 + pub(super) enum FootnoteDenial {
24 + NotAuthor,
25 + PostRemoved,
26 + TooManyFootnotes,
27 + }
28 +
29 + /// Check whether `user_id` may add a footnote to a post. Pure — no I/O.
30 + pub(super) fn check_footnote_permission(
31 + user_id: Uuid,
32 + post_author_id: Uuid,
33 + post_removed: bool,
34 + existing_footnote_count: i64,
35 + ) -> Result<(), FootnoteDenial> {
36 + if user_id != post_author_id {
37 + return Err(FootnoteDenial::NotAuthor);
38 + }
39 + if post_removed {
40 + return Err(FootnoteDenial::PostRemoved);
41 + }
42 + if existing_footnote_count >= MAX_FOOTNOTES_PER_POST as i64 {
43 + return Err(FootnoteDenial::TooManyFootnotes);
44 + }
45 + Ok(())
46 + }
47 +
48 + /// Why an endorsement toggle was rejected.
49 + #[derive(Debug, PartialEq, Eq)]
50 + pub(super) enum EndorsementDenial {
51 + CannotEndorseOwn,
52 + PostRemoved,
53 + UserSuspended,
54 + }
55 +
56 + /// Check whether `user_id` may toggle an endorsement on a post. Pure — no I/O.
57 + pub(super) fn check_endorsement_permission(
58 + user_id: Uuid,
59 + post_author_id: Uuid,
60 + post_removed: bool,
61 + user_suspended: bool,
62 + ) -> Result<(), EndorsementDenial> {
63 + if user_id == post_author_id {
64 + return Err(EndorsementDenial::CannotEndorseOwn);
65 + }
66 + if post_removed {
67 + return Err(EndorsementDenial::PostRemoved);
68 + }
69 + if user_suspended {
70 + return Err(EndorsementDenial::UserSuspended);
71 + }
72 + Ok(())
73 + }
74 +
19 75 // ============================================================================
20 76 // Footnote handler
21 77 // ============================================================================
@@ -40,12 +96,6 @@ pub(in crate::routes) async fn add_footnote_handler(
40 96 })?
41 97 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
42 98
43 - // Only the post author can add footnotes
44 - if user.user_id != post_data.author_id {
45 - return Err(StatusCode::FORBIDDEN.into_response());
46 - }
47 -
48 - // Cannot add footnotes to removed posts
49 99 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
50 100 .bind(post_id)
51 101 .fetch_one(&state.db)
@@ -54,24 +104,25 @@ pub(in crate::routes) async fn add_footnote_handler(
54 104 tracing::error!(error = ?e, "db error checking removal status");
55 105 StatusCode::INTERNAL_SERVER_ERROR.into_response()
56 106 })?;
57 - if removed {
58 - return Err(StatusCode::FORBIDDEN.into_response());
59 - }
60 107
61 - // Cap footnotes per post
62 108 let footnote_count = mt_db::queries::count_footnotes_for_post(&state.db, post_id)
63 109 .await
64 110 .map_err(|e| {
65 111 tracing::error!(error = ?e, "db error counting footnotes");
66 112 StatusCode::INTERNAL_SERVER_ERROR.into_response()
67 113 })?;
68 - if footnote_count >= MAX_FOOTNOTES_PER_POST as i64 {
69 - return Err((
70 - StatusCode::UNPROCESSABLE_ENTITY,
71 - "Maximum footnotes reached for this post.",
72 - )
73 - .into_response());
74 - }
114 +
115 + check_footnote_permission(user.user_id, post_data.author_id, removed, footnote_count)
116 + .map_err(|denial| match denial {
117 + FootnoteDenial::NotAuthor | FootnoteDenial::PostRemoved => {
118 + StatusCode::FORBIDDEN.into_response()
119 + }
120 + FootnoteDenial::TooManyFootnotes => (
121 + StatusCode::UNPROCESSABLE_ENTITY,
122 + "Maximum footnotes reached for this post.",
123 + )
124 + .into_response(),
125 + })?;
75 126
76 127 // Check write access (suspension + ban + mute)
77 128 let community = get_community(&state.db, &slug).await?;
@@ -131,12 +182,6 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
131 182 })?
132 183 .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
133 184
134 - // Cannot endorse own post
135 - if user.user_id == post_data.author_id {
136 - return Err(StatusCode::FORBIDDEN.into_response());
137 - }
138 -
139 - // Cannot endorse a removed post
140 185 let removed: bool = sqlx::query_scalar("SELECT removed_at IS NOT NULL FROM posts WHERE id = $1")
141 186 .bind(post_id)
142 187 .fetch_one(&state.db)
@@ -145,9 +190,6 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
145 190 tracing::error!(error = ?e, "db error checking removal status");
146 191 StatusCode::INTERNAL_SERVER_ERROR.into_response()
147 192 })?;
148 - if removed {
149 - return Err(StatusCode::FORBIDDEN.into_response());
150 - }
151 193
152 194 // Check community access (suspension + ban) — no mute check since endorsing is not content
153 195 let community = get_community(&state.db, &slug).await?;
@@ -155,16 +197,22 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
155 197 // Endorsement is a write action, so it's blocked by Frozen/Archived state.
156 198 check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?;
157 199
158 - // Check platform suspension
159 200 let suspended = mt_db::queries::is_user_suspended(&state.db, user.user_id)
160 201 .await
161 202 .map_err(|e| {
162 203 tracing::error!(error = ?e, "db error checking user suspension");
163 204 StatusCode::INTERNAL_SERVER_ERROR.into_response()
164 205 })?;
165 - if suspended {
166 - return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
167 - }
206 +
207 + check_endorsement_permission(user.user_id, post_data.author_id, removed, suspended)
208 + .map_err(|denial| match denial {
209 + EndorsementDenial::CannotEndorseOwn | EndorsementDenial::PostRemoved => {
210 + StatusCode::FORBIDDEN.into_response()
211 + }
212 + EndorsementDenial::UserSuspended => {
213 + (StatusCode::FORBIDDEN, "Your account has been suspended.").into_response()
214 + }
215 + })?;
168 216
169 217 mt_db::mutations::toggle_endorsement(&state.db, post_id, user.user_id)
170 218 .await
@@ -177,3 +225,143 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
177 225 "/p/{slug}/{category_slug}/{thread_id_str}#post-{post_id_str}"
178 226 )))
179 227 }
228 +
229 + #[cfg(test)]
230 + mod permission_tests {
231 + use super::*;
232 +
233 + fn uid(b: u8) -> Uuid {
234 + Uuid::from_bytes([b; 16])
235 + }
236 +
237 + // ── check_footnote_permission ──
238 +
239 + #[test]
240 + fn footnote_author_can_add_when_not_removed_and_under_cap() {
241 + let me = uid(1);
242 + let result = check_footnote_permission(me, me, false, 0);
243 + assert!(result.is_ok());
244 + }
245 +
246 + #[test]
247 + fn footnote_non_author_is_rejected() {
248 + // Pins `user_id != post_author_id` vs `==`.
249 + let me = uid(1);
250 + let other = uid(2);
251 + assert_eq!(
252 + check_footnote_permission(me, other, false, 0),
253 + Err(FootnoteDenial::NotAuthor)
254 + );
255 + }
256 +
257 + #[test]
258 + fn footnote_on_removed_post_is_rejected_even_for_author() {
259 + let me = uid(1);
260 + assert_eq!(
261 + check_footnote_permission(me, me, true, 0),
262 + Err(FootnoteDenial::PostRemoved)
263 + );
264 + }
265 +
266 + #[test]
267 + fn footnote_at_cap_is_rejected() {
268 + // Pins `count >= MAX` vs `>`. At exactly MAX, must reject.
269 + let me = uid(1);
270 + let cap = MAX_FOOTNOTES_PER_POST as i64;
271 + assert_eq!(
272 + check_footnote_permission(me, me, false, cap),
273 + Err(FootnoteDenial::TooManyFootnotes)
274 + );
275 + }
276 +
277 + #[test]
278 + fn footnote_one_below_cap_is_allowed() {
279 + let me = uid(1);
280 + let just_below = MAX_FOOTNOTES_PER_POST as i64 - 1;
281 + assert!(check_footnote_permission(me, me, false, just_below).is_ok());
282 + }
283 +
284 + #[test]
285 + fn footnote_above_cap_is_rejected() {
286 + let me = uid(1);
287 + let over = MAX_FOOTNOTES_PER_POST as i64 + 1;
288 + assert_eq!(
289 + check_footnote_permission(me, me, false, over),
290 + Err(FootnoteDenial::TooManyFootnotes)
291 + );
292 + }
293 +
294 + #[test]
295 + fn footnote_check_order_author_then_removal_then_cap() {
296 + // The author check fires first — even on a removed post over the cap,
297 + // a non-author gets NotAuthor (not PostRemoved or TooMany).
298 + let me = uid(1);
299 + let other = uid(2);
300 + let cap = MAX_FOOTNOTES_PER_POST as i64;
301 + assert_eq!(
302 + check_footnote_permission(me, other, true, cap),
303 + Err(FootnoteDenial::NotAuthor)
304 + );
305 + // Removal check fires second.
306 + assert_eq!(
307 + check_footnote_permission(me, me, true, cap),
308 + Err(FootnoteDenial::PostRemoved)
309 + );
310 + }
311 +
312 + // ── check_endorsement_permission ──
313 +
314 + #[test]
315 + fn endorsement_other_user_on_live_post_is_allowed() {
316 + let me = uid(1);
317 + let author = uid(2);
318 + assert!(check_endorsement_permission(me, author, false, false).is_ok());
319 + }
320 +
321 + #[test]
322 + fn endorsement_self_is_rejected() {
323 + // Pins `user_id == post_author_id` vs `!=`.
324 + let me = uid(1);
325 + assert_eq!(
326 + check_endorsement_permission(me, me, false, false),
327 + Err(EndorsementDenial::CannotEndorseOwn)
328 + );
329 + }
330 +
331 + #[test]
332 + fn endorsement_on_removed_post_is_rejected() {
333 + let me = uid(1);
334 + let author = uid(2);
335 + assert_eq!(
336 + check_endorsement_permission(me, author, true, false),
337 + Err(EndorsementDenial::PostRemoved)
338 + );
339 + }
340 +
341 + #[test]
342 + fn endorsement_by_suspended_user_is_rejected() {
343 + let me = uid(1);
344 + let author = uid(2);
345 + assert_eq!(
346 + check_endorsement_permission(me, author, false, true),
347 + Err(EndorsementDenial::UserSuspended)
348 + );
349 + }
350 +
351 + #[test]
352 + fn endorsement_check_order_self_then_removal_then_suspension() {
353 + // Self-check fires first: even if removed AND suspended, self attempt
354 + // returns CannotEndorseOwn.
355 + let me = uid(1);
356 + assert_eq!(
357 + check_endorsement_permission(me, me, true, true),
358 + Err(EndorsementDenial::CannotEndorseOwn)
359 + );
360 + // With author check passing, removal fires before suspension.
361 + let author = uid(2);
362 + assert_eq!(
363 + check_endorsement_permission(me, author, true, true),
364 + Err(EndorsementDenial::PostRemoved)
365 + );
366 + }
367 + }
@@ -31,6 +31,64 @@ use super::super::{
31 31 pub(super) const MAX_QUOTES_PER_POST: usize = 10;
32 32 pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10;
33 33
34 + static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
35 + regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
36 + });
37 +
38 + /// A parsed `[quote:UUID:HASH]` marker in a body.
39 + #[derive(Debug, PartialEq, Eq)]
40 + pub(super) struct QuoteRef<'a> {
41 + pub post_id_str: &'a str,
42 + pub claimed_hash: &'a str,
43 + pub marker_start: usize,
44 + }
45 +
46 + /// Find every quote marker in `body`. Pure — no I/O.
47 + pub(super) fn find_quote_refs(body: &str) -> Vec<QuoteRef<'_>> {
48 + QUOTE_RE
49 + .captures_iter(body)
50 + .map(|caps| {
51 + let marker = caps.get(0).unwrap();
52 + QuoteRef {
53 + post_id_str: caps.get(1).unwrap().as_str(),
54 + claimed_hash: caps.get(2).unwrap().as_str(),
55 + marker_start: marker.start(),
56 + }
57 + })
58 + .collect()
59 + }
60 +
61 + /// Extract the `> `-prefixed lines immediately preceding the marker at
62 + /// `marker_start`, strip the prefix from each, join with newlines, and trim.
63 + /// Returns the empty string when no quoted lines precede the marker.
64 + pub(super) fn extract_preceding_quote_text(body: &str, marker_start: usize) -> String {
65 + let before_marker = &body[..marker_start];
66 + let quoted_lines: Vec<&str> = before_marker
67 + .lines()
68 + .rev()
69 + .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
70 + .collect::<Vec<_>>()
71 + .into_iter()
72 + .rev()
73 + .collect();
74 +
75 + quoted_lines
76 + .iter()
77 + .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
78 + .collect::<Vec<_>>()
79 + .join("\n")
80 + .trim()
81 + .to_string()
82 + }
83 +
84 + /// Compute the 8-hex-char quote hash (first 4 bytes of SHA-256, hex encoded).
85 + pub(super) fn compute_quote_hash(text: &str) -> String {
86 + let mut hasher = Sha256::new();
87 + hasher.update(text.as_bytes());
88 + let hash = hasher.finalize();
89 + hex::encode(&hash[..4])
90 + }
91 +
34 92 /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
35 93 /// Returns the IDs of quoted posts for attribution rendering.
36 94 #[tracing::instrument(skip_all)]
@@ -38,12 +96,8 @@ pub(super) async fn verify_quotes(
38 96 db: &sqlx::PgPool,
39 97 body: &str,
40 98 ) -> Result<Vec<Uuid>, Response> {
41 - static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
42 - regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
43 - });
44 -
45 - let match_count = QUOTE_RE.find_iter(body).count();
46 - if match_count > MAX_QUOTES_PER_POST {
99 + let refs = find_quote_refs(body);
100 + if refs.len() > MAX_QUOTES_PER_POST {
47 101 return Err((
48 102 StatusCode::UNPROCESSABLE_ENTITY,
49 103 "Too many quotes. Maximum is 10 per post.",
@@ -53,38 +107,15 @@ pub(super) async fn verify_quotes(
53 107
54 108 let mut quoted_post_ids = Vec::new();
55 109
56 - for caps in QUOTE_RE.captures_iter(body) {
57 - let post_id_str = &caps[1];
58 - let claimed_hash = &caps[2];
59 -
60 - let post_id = Uuid::parse_str(post_id_str)
110 + for q in refs {
111 + let post_id = Uuid::parse_str(q.post_id_str)
61 112 .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?;
62 113
63 - // Extract the quoted text: lines starting with `> ` immediately before the marker
64 - let marker = caps.get(0).unwrap();
65 - let before_marker = &body[..marker.start()];
66 - let quoted_lines: Vec<&str> = before_marker
67 - .lines()
68 - .rev()
69 - .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
70 - .collect::<Vec<_>>()
71 - .into_iter()
72 - .rev()
73 - .collect();
74 -
75 - let quoted_text: String = quoted_lines
76 - .iter()
77 - .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
78 - .collect::<Vec<_>>()
79 - .join("\n")
80 - .trim()
81 - .to_string();
82 -
114 + let quoted_text = extract_preceding_quote_text(body, q.marker_start);
83 115 if quoted_text.is_empty() {
84 116 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
85 117 }
86 118
87 - // Fetch original post body
88 119 let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
89 120 .await
90 121 .map_err(|e| {
@@ -93,18 +124,11 @@ pub(super) async fn verify_quotes(
93 124 })?
94 125 .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
95 126
96 - // Verify quoted text is a substring of the original
97 127 if !original_markdown.contains(&quoted_text) {
98 128 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
99 129 }
100 130
101 - // Verify hash
102 - let mut hasher = Sha256::new();
103 - hasher.update(quoted_text.as_bytes());
104 - let hash = hasher.finalize();
105 - let expected_hash = hex::encode(&hash[..4]);
106 -
107 - if claimed_hash != expected_hash {
131 + if q.claimed_hash != compute_quote_hash(&quoted_text) {
108 132 return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
109 133 }
110 134
@@ -178,7 +202,7 @@ fn spawn_link_preview_fetch(state: AppState, body: String, post_id: Uuid) {
178 202 async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
179 203 let urls = crate::link_preview::extract_urls(body);
180 204 for url in urls {
181 - match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await {
205 + match state.link_preview.fetch(&url).await {
182 206 Some((title, description)) => {
183 207 if let Err(e) = mt_db::mutations::insert_link_preview(
184 208 &state.db,
@@ -467,3 +491,134 @@ pub(in crate::routes) async fn delete_thread_handler(
467 491 "/p/{slug}/{category_slug}?toast=Thread+deleted"
468 492 )))
469 493 }
494 +
495 + #[cfg(test)]
496 + mod quote_tests {
497 + use super::*;
498 +
499 + // ── compute_quote_hash ──
500 +
501 + #[test]
502 + fn hash_is_eight_hex_chars() {
503 + let h = compute_quote_hash("hello world");
504 + assert_eq!(h.len(), 8, "hash must be 8 hex chars, got: {h}");
505 + assert!(h.chars().all(|c| c.is_ascii_hexdigit()), "non-hex char in {h}");
506 + }
507 +
508 + #[test]
509 + fn hash_is_stable_for_same_input() {
510 + assert_eq!(compute_quote_hash("abc"), compute_quote_hash("abc"));
511 + }
512 +
513 + #[test]
514 + fn hash_differs_for_different_input() {
515 + assert_ne!(compute_quote_hash("abc"), compute_quote_hash("abd"));
516 + }
517 +
518 + #[test]
519 + fn hash_takes_first_four_bytes_only() {
520 + // Pins `&hash[..4]` — known SHA-256("a") prefix is 0xca978112.
521 + assert_eq!(compute_quote_hash("a"), "ca978112");
522 + }
523 +
524 + // ── find_quote_refs ──
525 +
526 + #[test]
527 + fn find_quote_refs_finds_zero_markers() {
528 + assert!(find_quote_refs("body with no markers").is_empty());
529 + }
530 +
531 + #[test]
532 + fn find_quote_refs_extracts_post_id_and_hash() {
533 + let body = "> hi\n[quote:11111111-2222-3333-4444-555555555555:abcd1234]";
534 + let refs = find_quote_refs(body);
535 + assert_eq!(refs.len(), 1);
536 + assert_eq!(refs[0].post_id_str, "11111111-2222-3333-4444-555555555555");
537 + assert_eq!(refs[0].claimed_hash, "abcd1234");
538 + // marker_start points at the `[`.
539 + assert_eq!(&body[refs[0].marker_start..refs[0].marker_start + 1], "[");
540 + }
541 +
542 + #[test]
543 + fn find_quote_refs_finds_multiple_distinct_markers() {
544 + let body = "[quote:11111111-2222-3333-4444-555555555555:aaaaaaaa] and [quote:66666666-7777-8888-9999-000000000000:bbbbbbbb]";
545 + let refs = find_quote_refs(body);
546 + assert_eq!(refs.len(), 2);
547 + assert_eq!(refs[0].claimed_hash, "aaaaaaaa");
548 + assert_eq!(refs[1].claimed_hash, "bbbbbbbb");
549 + assert!(refs[0].marker_start < refs[1].marker_start);
550 + }
551 +
552 + #[test]
553 + fn find_quote_refs_rejects_malformed_marker() {
554 + // Hash too short → regex doesn't match.
555 + let body = "[quote:11111111-2222-3333-4444-555555555555:abc]";
556 + assert!(find_quote_refs(body).is_empty());
557 + // UUID wrong length → no match.
558 + let body2 = "[quote:short-uuid:abcd1234]";
559 + assert!(find_quote_refs(body2).is_empty());
560 + }
561 +
562 + // ── extract_preceding_quote_text ──
563 +
564 + #[test]
565 + fn extract_single_quoted_line() {
566 + let body = "> hello\nMARKER";
567 + let marker = body.find("MARKER").unwrap();
568 + assert_eq!(extract_preceding_quote_text(body, marker), "hello");
569 + }
570 +
571 + #[test]
572 + fn extract_multi_line_preserves_order() {
573 + let body = "> line one\n> line two\n> line three\nMARKER";
574 + let marker = body.find("MARKER").unwrap();
575 + assert_eq!(
576 + extract_preceding_quote_text(body, marker),
577 + "line one\nline two\nline three"
578 + );
579 + }
580 +
581 + #[test]
582 + fn extract_handles_bare_gt_prefix_without_space() {
583 + // Lines like `>foo` (no space) must also be stripped.
584 + let body = ">no-space\n> with space\nMARKER";
585 + let marker = body.find("MARKER").unwrap();
586 + assert_eq!(
587 + extract_preceding_quote_text(body, marker),
588 + "no-space\nwith space"
589 + );
590 + }
591 +
592 + #[test]
593 + fn extract_stops_at_first_non_quote_line() {
594 + // Quote lines only count if they are *immediately* before the marker.
595 + // The earlier "> ignored" must NOT be included because a plain line
596 + // breaks the run.
597 + let body = "> ignored\nplain text\n> kept\nMARKER";
598 + let marker = body.find("MARKER").unwrap();
599 + assert_eq!(extract_preceding_quote_text(body, marker), "kept");
600 + }
601 +
602 + #[test]
603 + fn extract_returns_empty_when_no_preceding_quote() {
604 + let body = "regular text\nMARKER";
605 + let marker = body.find("MARKER").unwrap();
606 + assert_eq!(extract_preceding_quote_text(body, marker), "");
607 + }
608 +
609 + #[test]
610 + fn extract_returns_empty_when_marker_is_at_start() {
611 + let body = "MARKER\nrest";
612 + let marker = body.find("MARKER").unwrap();
613 + assert_eq!(extract_preceding_quote_text(body, marker), "");
614 + }
615 +
616 + #[test]
617 + fn extract_trims_trailing_blank_quote_lines() {
618 + // A `>` with only the prefix becomes an empty line; final .trim()
619 + // strips surrounding whitespace.
620 + let body = "> real content\n>\nMARKER";
621 + let marker = body.find("MARKER").unwrap();
622 + assert_eq!(extract_preceding_quote_text(body, marker), "real content");
623 + }
624 + }
@@ -44,10 +44,8 @@ pub(in crate::routes) async fn thread(
44 44 StatusCode::INTERNAL_SERVER_ERROR.into_response()
45 45 })?;
46 46
47 - let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
48 - let total_pages = total_pages.max(1);
49 - let page = page_query.page.unwrap_or(1).max(1).min(total_pages);
50 - let offset = (page as i64 - 1) * per_page;
47 + let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
48 + let offset = pagination.offset(per_page);
51 49
52 50 let db_posts = mt_db::queries::list_posts_in_thread_paginated(
53 51 &state.db, thread_uuid, per_page, offset,
@@ -229,6 +227,6 @@ pub(in crate::routes) async fn thread(
229 227 can_mod_thread: mod_status,
230 228 is_tracked,
231 229 posts,
232 - pagination: Pagination::new(page, total, per_page),
230 + pagination,
233 231 })
234 232 }
@@ -207,10 +207,8 @@ pub(in crate::routes) async fn category(
207 207 StatusCode::INTERNAL_SERVER_ERROR.into_response()
208 208 })?;
209 209
210 - let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
211 - let total_pages = total_pages.max(1);
212 - let page = query.page.unwrap_or(1).max(1).min(total_pages);
213 - let offset = (page as i64 - 1) * per_page;
210 + let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, per_page);
211 + let offset = pagination.offset(per_page);
214 212
215 213 let db_threads = mt_db::queries::list_threads_in_category_sorted_filtered(
216 214 &state.db, &slug, &category_slug, sort.as_str(), order.as_str(), per_page, offset, tag_filter,
@@ -294,7 +292,7 @@ pub(in crate::routes) async fn category(
294 292 category_name: cat.name,
295 293 category_slug,
296 294 threads,
297 - pagination: Pagination::new(page, total, per_page),
295 + pagination,
298 296 sort_column: sort.as_str().to_string(),
299 297 sort_order: order.as_str().to_string(),
300 298 available_tags,
@@ -435,29 +435,332 @@ pub(crate) async fn check_write_state(
435 435 ///
436 436 /// Note: this is independent of [`check_write_access`] (which covers
437 437 /// suspension/ban/mute). Call both in write handlers.
438 - #[allow(clippy::result_large_err)]
439 - pub(crate) fn check_community_state(
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(
440 444 community_state: CommunityState,
441 445 scope: WriteScope,
442 446 is_mod_or_super: bool,
443 - ) -> Result<(), Response> {
447 + ) -> Option<&'static str> {
444 448 if is_mod_or_super {
445 - return Ok(());
449 + return None;
446 450 }
447 451 let allowed = match scope {
448 452 WriteScope::NewThread => community_state.allows_new_threads_for_members(),
449 453 WriteScope::ContinueExisting => community_state.allows_writes_for_members(),
450 454 };
451 455 if allowed {
452 - return Ok(());
456 + return None;
453 457 }
454 - let msg = match (community_state, scope) {
458 + Some(match (community_state, scope) {
455 459 (CommunityState::Restricted, WriteScope::NewThread) => {
456 460 "New threads are restricted in this community."
457 461 }
458 462 (CommunityState::Frozen, _) => "This community is frozen.",
459 463 (CommunityState::Archived, _) => "This community is archived.",
460 464 _ => "Action not allowed in the community's current state.",
461 - };
462 - Err((StatusCode::FORBIDDEN, msg).into_response())
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 + }
463 766 }
@@ -411,10 +411,8 @@ pub(super) async fn mod_log_page(
411 411 StatusCode::INTERNAL_SERVER_ERROR.into_response()
412 412 })?;
413 413
414 - let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
415 - let total_pages = total_pages.max(1);
416 - let page = page_query.page.unwrap_or(1).max(1).min(total_pages);
417 - let offset = (page as i64 - 1) * per_page;
414 + let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
415 + let offset = pagination.offset(per_page);
418 416
419 417 let db_entries = mt_db::queries::list_mod_log(&state.db, community.id, per_page, offset)
420 418 .await
@@ -441,6 +439,6 @@ pub(super) async fn mod_log_page(
441 439 community_name: community.name,
442 440 community_slug: slug,
443 441 entries,
444 - pagination: Pagination::new(page, total, per_page),
442 + pagination,
445 443 })
446 444 }
@@ -115,6 +115,122 @@ impl Pagination {
115 115 has_next: current_page < total_pages,
116 116 }
117 117 }
118 +
119 + /// SQL OFFSET for the current page. `(current_page - 1) * per_page`,
120 + /// saturating so a clamped current_page can never wrap.
121 + pub fn offset(&self, per_page: i64) -> i64 {
122 + (self.current_page.saturating_sub(1) as i64) * per_page
123 + }
124 + }
125 +
126 + #[cfg(test)]
127 + mod pagination_tests {
128 + use super::Pagination;
129 +
130 + #[test]
131 + fn first_page_of_many() {
132 + let p = Pagination::new(1, 100, 25);
133 + assert_eq!(p.current_page, 1);
134 + assert_eq!(p.total_pages, 4);
135 + assert!(!p.has_prev);
136 + assert!(p.has_next);
137 + assert_eq!(p.offset(25), 0);
138 + }
139 +
140 + #[test]
141 + fn middle_page() {
142 + let p = Pagination::new(2, 100, 25);
143 + assert_eq!(p.current_page, 2);
144 + assert_eq!(p.total_pages, 4);
145 + assert!(p.has_prev);
146 + assert!(p.has_next);
147 + assert_eq!(p.offset(25), 25);
148 + }
149 +
150 + #[test]
151 + fn last_page() {
152 + let p = Pagination::new(4, 100, 25);
153 + assert_eq!(p.current_page, 4);
154 + assert_eq!(p.total_pages, 4);
155 + assert!(p.has_prev);
156 + assert!(!p.has_next, "last page must have has_next=false");
157 + assert_eq!(p.offset(25), 75);
158 + }
159 +
160 + #[test]
161 + fn ceil_rounds_partial_final_page_up() {
162 + // 101 items at 25/page → ceil(101/25) = 5 pages, not 4.
163 + // Pins the `.ceil()` choice (vs `.floor()` or `.round()`).
164 + let p = Pagination::new(1, 101, 25);
165 + assert_eq!(p.total_pages, 5);
166 + let p_last = Pagination::new(5, 101, 25);
167 + assert_eq!(p_last.current_page, 5);
168 + assert!(!p_last.has_next);
169 + assert_eq!(p_last.offset(25), 100);
170 + }
171 +
172 + #[test]
173 + fn empty_collection_still_has_one_page() {
174 + // Pins the `.max(1)` floor on total_pages.
175 + let p = Pagination::new(1, 0, 25);
176 + assert_eq!(p.total_pages, 1);
177 + assert_eq!(p.current_page, 1);
178 + assert!(!p.has_prev);
179 + assert!(!p.has_next);
180 + assert_eq!(p.offset(25), 0);
181 + }
182 +
183 + #[test]
184 + fn page_beyond_total_is_clamped_to_last() {
185 + // Pins the `page.min(total_pages)` clamp. Request page 99 against 4
186 + // total pages should land on page 4, not panic and not skip past.
187 + let p = Pagination::new(99, 100, 25);
188 + assert_eq!(p.current_page, 4);
189 + assert_eq!(p.total_pages, 4);
190 + assert!(!p.has_next);
191 + assert_eq!(p.offset(25), 75);
192 + }
193 +
194 + #[test]
195 + fn has_prev_is_strict_greater_than_one() {
196 + // Pins `current_page > 1` vs `>=`. Page 1 must have has_prev=false.
197 + let p1 = Pagination::new(1, 100, 25);
198 + assert!(!p1.has_prev);
199 + let p2 = Pagination::new(2, 100, 25);
200 + assert!(p2.has_prev);
201 + }
202 +
203 + #[test]
204 + fn has_next_is_strict_less_than_total() {
205 + // Pins `current_page < total_pages` vs `<=`. The final page must not
206 + // be its own next page.
207 + let p = Pagination::new(4, 100, 25);
208 + assert_eq!(p.current_page, 4);
209 + assert_eq!(p.total_pages, 4);
210 + assert!(!p.has_next, "page == total_pages must yield has_next=false");
211 + }
212 +
213 + #[test]
214 + fn single_full_page() {
215 + // 25 items at 25/page → exactly 1 page. has_prev and has_next both false.
216 + let p = Pagination::new(1, 25, 25);
217 + assert_eq!(p.total_pages, 1);
218 + assert!(!p.has_prev);
219 + assert!(!p.has_next);
220 + }
221 +
222 + #[test]
223 + fn offset_for_clamped_page_does_not_wrap() {
224 + // If a caller passes page=0 (or page is otherwise clamped to 0),
225 + // offset() must not underflow. saturating_sub handles this.
226 + let p = Pagination {
227 + current_page: 0,
228 + total_pages: 1,
229 + has_prev: false,
230 + has_next: false,
231 + };
232 + assert_eq!(p.offset(25), 0);
233 + }
118 234 }
119 235
120 236 // ============================================================================
@@ -68,7 +68,7 @@ impl TestHarness {
68 68 db: pool.clone(),
69 69 config,
70 70 http: reqwest::Client::new(),
71 - preview_http: multithreaded::link_preview::build_preview_client(),
71 + link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
72 72 s3: None,
73 73 };
74 74
@@ -42,7 +42,7 @@ impl InternalTestHarness {
42 42 db: pool.clone(),
43 43 config,
44 44 http: reqwest::Client::new(),
45 - preview_http: multithreaded::link_preview::build_preview_client(),
45 + link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
46 46 s3: None,
47 47 };
48 48
M pom/src/alerts.rs +103 -14
@@ -10,6 +10,40 @@ use crate::config::AlertConfig;
10 10 use crate::db;
11 11 use crate::types::AlertCategory;
12 12
13 + /// WAM ticket priority for a transition into a non-operational health status.
14 + fn health_status_priority(to_status: &str) -> &'static str {
15 + match to_status {
16 + "error" | "unreachable" => "critical",
17 + "degraded" => "high",
18 + _ => "medium",
19 + }
20 + }
21 +
22 + /// WAM ticket priority for a TLS certificate that expires in `days`.
23 + fn tls_expiry_priority(days: i64) -> &'static str {
24 + if days <= 3 { "critical" } else if days <= 7 { "high" } else { "medium" }
25 + }
26 +
27 + /// WAM ticket priority for a domain registration that expires in `days`.
28 + fn whois_expiry_priority(days: i64) -> &'static str {
29 + if days <= 7 { "critical" } else if days <= 14 { "high" } else { "medium" }
30 + }
31 +
32 + /// WAM ticket priority for a stale/missing/error backup status.
33 + fn backup_status_priority(status: &str) -> &'static str {
34 + if status == "missing" { "critical" } else { "high" }
35 + }
36 +
37 + /// Human-readable detail text for a backup status alert.
38 + fn backup_status_detail(status: &str, age_hours: Option<i64>) -> String {
39 + match (status, age_hours) {
40 + ("stale", Some(hours)) => format!("last backup is {hours}h old"),
41 + ("missing", _) => "no backup files found".to_string(),
42 + ("error", _) => "backup check failed".to_string(),
43 + _ => format!("status: {status}"),
44 + }
45 + }
46 +
13 47 #[derive(Clone)]
14 48 pub struct Alerter {
15 49 config: AlertConfig,
@@ -58,11 +92,7 @@ impl Alerter {
58 92 }
59 93 body.push_str("\n- PoM");
60 94
61 - let priority = match to_status {
62 - "error" | "unreachable" => "critical",
63 - "degraded" => "high",
64 - _ => "medium",
65 - };
95 + let priority = health_status_priority(to_status);
66 96 self.wam_ticket(&subject, &body, priority, "pom-health", Some(target)).await;
67 97 self.record_alert(&alert_key, AlertCategory::Health, Some(from_status), Some(to_status), error).await;
68 98 }
@@ -118,7 +148,7 @@ impl Alerter {
118 148 chrono::Utc::now().to_rfc3339(),
119 149 );
120 150
121 - let priority = if days_remaining <= 3 { "critical" } else if days_remaining <= 7 { "high" } else { "medium" };
151 + let priority = tls_expiry_priority(days_remaining);
122 152 self.wam_ticket(&subject, &body, priority, "pom-tls", Some(&format!("{target}:{host}"))).await;
123 153 self.record_alert(&alert_key, AlertCategory::TlsExpiry, None, None, None).await;
124 154 }
@@ -374,7 +404,7 @@ impl Alerter {
374 404 chrono::Utc::now().to_rfc3339(),
375 405 );
376 406
377 - let priority = if days_remaining <= 7 { "critical" } else if days_remaining <= 14 { "high" } else { "medium" };
407 + let priority = whois_expiry_priority(days_remaining);
378 408 self.wam_ticket(&subject, &body, priority, "pom-whois", Some(&format!("{target}:{domain}"))).await;
379 409 self.record_alert(&alert_key, AlertCategory::WhoisExpiry, None, None, None).await;
380 410 }
@@ -567,12 +597,7 @@ impl Alerter {
567 597 return;
568 598 }
569 599
570 - let detail = match (status, age_hours) {
571 - ("stale", Some(hours)) => format!("last backup is {hours}h old"),
572 - ("missing", _) => "no backup files found".to_string(),
573 - ("error", _) => "backup check failed".to_string(),
574 - _ => format!("status: {status}"),
575 - };
600 + let detail = backup_status_detail(status, age_hours);
576 601
577 602 let subject = format!("[PoM] {label}: {database} backup {status}");
578 603 let body = format!(
@@ -587,7 +612,7 @@ impl Alerter {
587 612 chrono::Utc::now().to_rfc3339(),
588 613 );
589 614
590 - let priority = if status == "missing" { "critical" } else { "high" };
615 + let priority = backup_status_priority(status);
591 616 self.wam_ticket(&subject, &body, priority, "pom-backup", Some(&format!("{target}:{database}"))).await;
592 617 self.record_alert(&alert_key, AlertCategory::BackupStale, None, Some(status), None).await;
593 618 }
@@ -994,4 +1019,68 @@ mod tests {
994 1019 alerter.send_tls_recovery("mnw", "MakeNotWork", 90).await;
995 1020 assert!(!alerter.is_within_cooldown("tls:mnw").await);
996 1021 }
1022 +
1023 + // ── Pure priority/severity helpers (pin the <= boundaries) ──
1024 +
1025 + #[test]
1026 + fn tls_expiry_priority_boundaries() {
1027 + // critical: days <= 3
1028 + assert_eq!(tls_expiry_priority(-5), "critical", "negative days = already expired");
1029 + assert_eq!(tls_expiry_priority(0), "critical");
1030 + assert_eq!(tls_expiry_priority(3), "critical");
1031 + // high: 4..=7
1032 + assert_eq!(tls_expiry_priority(4), "high");
1033 + assert_eq!(tls_expiry_priority(7), "high");
1034 + // medium: > 7
1035 + assert_eq!(tls_expiry_priority(8), "medium");
1036 + assert_eq!(tls_expiry_priority(90), "medium");
1037 + }
1038 +
1039 + #[test]
1040 + fn whois_expiry_priority_boundaries() {
1041 + // critical: days <= 7
1042 + assert_eq!(whois_expiry_priority(-1), "critical");
1043 + assert_eq!(whois_expiry_priority(7), "critical");
1044 + // high: 8..=14
1045 + assert_eq!(whois_expiry_priority(8), "high");
1046 + assert_eq!(whois_expiry_priority(14), "high");
1047 + // medium: > 14
1048 + assert_eq!(whois_expiry_priority(15), "medium");
1049 + assert_eq!(whois_expiry_priority(180), "medium");
1050 + }
1051 +
1052 + #[test]
1053 + fn backup_status_priority_missing_is_critical() {
1054 + assert_eq!(backup_status_priority("missing"), "critical");
1055 + assert_eq!(backup_status_priority("stale"), "high");
1056 + assert_eq!(backup_status_priority("error"), "high");
1057 + assert_eq!(backup_status_priority("anything-else"), "high");
1058 + assert_eq!(backup_status_priority(""), "high");
1059 + }
1060 +
1061 + #[test]
1062 + fn backup_status_detail_arms() {
1063 + assert_eq!(
1064 + backup_status_detail("stale", Some(12)),
1065 + "last backup is 12h old"
1066 + );
1067 + // `stale` with no age falls through to the default arm.
1068 + assert_eq!(backup_status_detail("stale", None), "status: stale");
1069 + assert_eq!(backup_status_detail("missing", None), "no backup files found");
1070 + assert_eq!(backup_status_detail("missing", Some(5)), "no backup files found");
1071 + assert_eq!(backup_status_detail("error", None), "backup check failed");
1072 + assert_eq!(backup_status_detail("error", Some(99)), "backup check failed");
1073 + assert_eq!(backup_status_detail("weird", None), "status: weird");
1074 + }
1075 +
1076 + #[test]
1077 + fn health_status_priority_arms() {
1078 + assert_eq!(health_status_priority("error"), "critical");
1079 + assert_eq!(health_status_priority("unreachable"), "critical");
1080 + assert_eq!(health_status_priority("degraded"), "high");
1081 + // Anything else (operational, unknown values) falls through to medium.
1082 + assert_eq!(health_status_priority("operational"), "medium");
1083 + assert_eq!(health_status_priority("flapping"), "medium");
1084 + assert_eq!(health_status_priority(""), "medium");
1085 + }
997 1086 }
@@ -680,4 +680,123 @@ mod tests {
680 680 let result = compute_test_staleness(None, None, Some(&recent), 7);
681 681 assert!(!result.stale);
682 682 }
683 +
684 + #[test]
685 + fn staleness_at_threshold_is_stale() {
686 + // Pins `days >= staleness_days` (vs `>`). days == 7, threshold == 7
687 + // must report stale.
688 + let exactly = (chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339();
689 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&exactly), 7);
690 + assert!(result.stale, "days == threshold must be stale");
691 + }
692 +
693 + #[test]
694 + fn staleness_one_day_under_threshold_is_fresh() {
695 + let just_under = (chrono::Utc::now() - chrono::Duration::days(6) - chrono::Duration::hours(20)).to_rfc3339();
696 + let result = compute_test_staleness(Some("1.0.0"), Some("1.0.0"), Some(&just_under), 7);
697 + assert!(!result.stale, "days < threshold must not be stale");
698 + }
699 +
700 + // --- classify_non_json: 200..300 range boundaries ---
701 +
702 + #[test]
703 + fn classify_non_json_status_boundaries() {
704 + // Pins the `(200..300).contains(&status_code)` range.
705 + assert_eq!(classify_non_json(199), HealthStatus::Error, "199 is below 2xx");
706 + assert_eq!(classify_non_json(200), HealthStatus::Degraded, "200 is start of 2xx");
707 + assert_eq!(classify_non_json(299), HealthStatus::Degraded, "299 is end of 2xx");
708 + assert_eq!(classify_non_json(300), HealthStatus::Error, "300 is start of 3xx");
709 + }
710 +
711 + #[test]
712 + fn classify_json_unknown_status_3xx_is_error() {
713 + // Pins the `_ if (200..300).contains(&status_code)` guard in
714 + // classify_json_response: status_code 300 with unknown api_status
715 + // must fall through to Error, not Degraded.
716 + let json = serde_json::json!({ "status": "starting_up" });
717 + assert_eq!(classify_json_response(300, &json).0, HealthStatus::Error);
718 + assert_eq!(classify_json_response(199, &json).0, HealthStatus::Error);
719 + }
720 +
721 + // --- detect_test_duration_drift ---
722 +
723 + fn dur(name: &str, secs: i64) -> (String, i64) {
724 + (name.to_string(), secs)
725 + }
726 +
727 + #[test]
728 + fn duration_drift_all_recent_over_threshold() {
729 + // 3 recent + 4 baseline, all recent > 2x baseline avg → drift detected.
730 + // baseline_count=4, recent_count=3, total=7
731 + let durations = vec![
732 + dur("a", 200), dur("b", 210), dur("c", 220), // recent (most recent first)
733 + dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110), // baseline avg = 95
734 + ];
735 + let result = detect_test_duration_drift(&durations, 4, 3, 2.0);
736 + let msg = result.expect("drift should be detected");
737 + assert!(msg.contains("test duration drift"));
738 + assert!(msg.contains("last 3 runs"));
739 + }
740 +
741 + #[test]
742 + fn duration_drift_one_recent_under_threshold_no_drift() {
743 + // One recent (105) is below 2x baseline avg (95 * 2 = 190).
744 + let durations = vec![
745 + dur("a", 250), dur("b", 105), dur("c", 270),
746 + dur("d", 80), dur("e", 100), dur("f", 90), dur("g", 110),
747 + ];
748 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
749 + }
750 +
751 + #[test]
752 + fn duration_drift_insufficient_samples() {
753 + // Total samples < baseline_count + recent_count → no drift, return None.
754 + let durations = vec![dur("a", 500), dur("b", 500), dur("c", 100)];
755 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
756 + }
757 +
758 + #[test]
759 + fn duration_drift_at_threshold_is_not_drift() {
760 + // Pins `*d as f64 > drift_threshold` (strictly greater). At exactly
761 + // 2x baseline avg, must NOT report drift.
762 + // baseline avg = 100, threshold = 100 * 2.0 = 200. Recent values == 200.
763 + let durations = vec![
764 + dur("a", 200), dur("b", 200), dur("c", 200),
765 + dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
766 + ];
767 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_none());
768 + }
769 +
770 + #[test]
771 + fn duration_drift_just_over_threshold_detects() {
772 + let durations = vec![
773 + dur("a", 201), dur("b", 201), dur("c", 201),
774 + dur("d", 100), dur("e", 100), dur("f", 100), dur("g", 100),
775 + ];
776 + assert!(detect_test_duration_drift(&durations, 4, 3, 2.0).is_some());
777 + }
778 +
779 + #[test]
780 + fn duration_drift_zero_baseline_count_returns_none() {
781 + // Edge: baseline_count=0 means `baseline` slice is empty; should None
782 + // (pins the `if baseline.is_empty()` early return).
783 + let durations = vec![dur("a", 100), dur("b", 200)];
784 + assert!(detect_test_duration_drift(&durations, 0, 2, 2.0).is_none());
785 + }
786 +
787 + // --- resolve_json_path edge cases ---
788 +
789 + #[test]
790 + fn resolve_json_path_empty_path_segment_is_none() {
791 + // path "a..b" splits to ["a", "", "b"]; `.get("")` returns None.
792 + let json = serde_json::json!({"a": {"b": 1}});
793 + assert!(resolve_json_path(&json, "a..b").is_none());
794 + }
795 +
796 + #[test]
797 + fn resolve_json_path_through_non_object_is_none() {
798 + // Trying to descend into a string value should return None.
799 + let json = serde_json::json!({"name": "hello"});
800 + assert!(resolve_json_path(&json, "name.length").is_none());
801 + }
683 802 }
@@ -225,4 +225,79 @@ mod tests {
225 225 assert!(result.days_remaining <= 0, "expired cert should show 0 or negative days, got {}", result.days_remaining);
226 226 assert!(!result.valid, "expired cert should be invalid");
227 227 }
228 +
229 + // ── valid boundary at exactly +1 day pins `days_remaining > 0` ──
230 +
231 + #[test]
232 + fn tls_cert_one_day_remaining_is_valid() {
233 + // Pins `valid: days_remaining > 0` — 1 day must be valid (the only
234 + // way `>` and `>=` differ at the lower boundary is the 0 case which
235 + // is covered above; this confirms positive values flip to valid).
236 + let config = test_config();
237 + let now = chrono::Utc::now();
238 + let tomorrow = (now + chrono::Duration::days(1)).date_naive()
239 + .and_hms_opt(12, 0, 0).unwrap().and_utc();
240 + let not_before = now - chrono::Duration::days(30);
241 + let der = make_cert_der(not_before, tomorrow);
242 + let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
243 + assert!(result.valid);
244 + assert!(result.days_remaining >= 1);
245 + }
246 +
247 + // ── full-field population on a valid cert (subject/issuer) ──
248 +
249 + #[test]
250 + fn tls_cert_populates_subject_issuer_and_dates() {
251 + // Pins all the `cert.subject().to_string()`, `cert.issuer()...`,
252 + // not_before/not_after RFC3339 conversions. A mutation that
253 + // accidentally swapped subject and issuer, or returned empty strings,
254 + // would surface here.
255 + let config = test_config();
256 + let now = chrono::Utc::now();
257 + let not_before = now - chrono::Duration::days(30);
258 + let not_after = now + chrono::Duration::days(60);
259 + let der = make_cert_der(not_before, not_after);
260 + let result = parse_leaf_cert("test", &config, "2026-03-11T00:00:00Z", &der);
261 +
262 + assert!(result.valid);
263 + assert!(!result.subject.is_empty(), "subject must be populated");
264 + assert!(!result.issuer.is_empty(), "issuer must be populated");
265 + // rcgen self-signed certs use a default CN; we only assert non-empty.
266 + assert!(result.subject.contains("CN="), "subject should contain a CN: {}", result.subject);
267 + // not_before/not_after should be parseable RFC3339 strings.
268 + assert!(chrono::DateTime::parse_from_rfc3339(&result.not_before).is_ok(),
269 + "not_before should be RFC3339: {}", result.not_before);
270 + assert!(chrono::DateTime::parse_from_rfc3339(&result.not_after).is_ok(),
271 + "not_after should be RFC3339: {}", result.not_after);
272 + assert!(result.error.is_none());
273 + }
274 +
275 + #[test]
276 + fn tls_cert_far_future_has_many_days_remaining() {
277 + // Pins the arithmetic `(expiry_date - today).num_days()` — at +N days,
278 + // days_remaining must equal N within a 1-day tolerance.
279 + let config = test_config();
280 + let now = chrono::Utc::now();
281 + let plus_n = (now + chrono::Duration::days(100)).date_naive()
282 + .and_hms_opt(0, 0, 0).unwrap().and_utc();
283 + let der = make_cert_der(now - chrono::Duration::days(1), plus_n);
284 + let result = parse_leaf_cert("test", &config, &now.to_rfc3339(), &der);
285 + assert!((99..=100).contains(&result.days_remaining),
286 + "expected ~100 days, got {}", result.days_remaining);
287 + }
288 +
289 + #[test]
290 + fn tls_error_helper_returns_invalid_status() {
291 + // Pins each field initialised in `tls_error` — a mutation that
292 + // returned `valid: true` or non-zero `days_remaining` would surface.
293 + let config = test_config();
294 + let result = tls_error("svc", &config, "2026-03-11T00:00:00Z", "handshake failed");
295 + assert!(!result.valid);
296 + assert_eq!(result.days_remaining, 0);
297 + assert!(result.subject.is_empty());
298 + assert!(result.issuer.is_empty());
299 + assert!(result.not_before.is_empty());
300 + assert!(result.not_after.is_empty());
301 + assert_eq!(result.error.as_deref(), Some("handshake failed"));
302 + }
228 303 }
@@ -302,4 +302,103 @@ Name Server: dns2.registrar-servers.com
302 302 let parsed = parse_whois_response(response);
303 303 assert_eq!(parsed.nameservers.len(), 1);
304 304 }
305 +
306 + // ── extended TLD coverage ──
307 +
308 + #[test]
309 + fn whois_server_all_known_tlds() {
310 + // Pins every match arm — a mutation swapping or removing an arm would
311 + // surface as Some→None or wrong host.
312 + assert_eq!(whois_server_for_tld("x.dev"), Some("whois.nic.google"));
313 + assert_eq!(whois_server_for_tld("x.io"), Some("whois.nic.io"));
314 + assert_eq!(whois_server_for_tld("x.me"), Some("whois.nic.me"));
315 + assert_eq!(whois_server_for_tld("x.info"), Some("whois.afilias.net"));
316 + }
317 +
318 + #[test]
319 + fn whois_server_empty_domain_is_none() {
320 + // `rsplit('.').next()` on "" returns Some(""), which falls through to None.
321 + assert_eq!(whois_server_for_tld(""), None);
322 + }
323 +
324 + // ── first-match semantics (`is_none()` guards) ──
325 +
326 + #[test]
327 + fn parse_whois_takes_first_registrar_only() {
328 + // Pins `if registrar.is_none() && ...` — a duplicate Registrar line
329 + // must NOT overwrite the first value.
330 + let response = "Registrar: First, Inc.\nRegistrar: Second, LLC.\n";
331 + let parsed = parse_whois_response(response);
332 + assert_eq!(parsed.registrar.as_deref(), Some("First, Inc."));
333 + }
334 +
335 + #[test]
336 + fn parse_whois_takes_first_expiry_only() {
337 + let response = "Registry Expiry Date: 2025-01-01T00:00:00Z\n\
338 + Registry Expiry Date: 2099-12-31T00:00:00Z\n";
339 + let parsed = parse_whois_response(response);
340 + assert_eq!(parsed.expiry_date.as_deref(), Some("2025-01-01T00:00:00Z"));
341 + }
342 +
343 + // ── remaining alternative field names ──
344 +
345 + #[test]
346 + fn parse_whois_registrar_registration_expiration_date() {
347 + let response = "Registrar Registration Expiration Date: 2028-01-15T12:00:00Z\n";
348 + let parsed = parse_whois_response(response);
349 + assert_eq!(parsed.expiry_date.as_deref(), Some("2028-01-15T12:00:00Z"));
350 + }
351 +
352 + #[test]
353 + fn parse_whois_paid_till() {
354 + // Russian/Eastern European registry format
355 + let response = "paid-till: 2029-03-20T00:00:00Z\n";
356 + let parsed = parse_whois_response(response);
357 + assert_eq!(parsed.expiry_date.as_deref(), Some("2029-03-20T00:00:00Z"));
358 + }
359 +
360 + #[test]
361 + fn parse_whois_nserver_alias() {
362 + // Some registries use `nserver:` instead of `Name Server:`.
363 + let response = "nserver: ns1.example.org\nnserver: ns2.example.org\n";
364 + let parsed = parse_whois_response(response);
365 + assert_eq!(parsed.nameservers.len(), 2);
366 + assert!(parsed.nameservers.contains(&"ns1.example.org".to_string()));
367 + }
368 +
369 + // ── normalization on nameservers ──
370 +
371 + #[test]
372 + fn parse_whois_strips_trailing_dot_from_nameservers() {
373 + let response = "Name Server: NS1.EXAMPLE.COM.\n";
374 + let parsed = parse_whois_response(response);
375 + assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]);
376 + }
377 +
378 + #[test]
379 + fn parse_whois_lowercases_nameservers() {
380 + // Catches a mutation removing `.to_lowercase()`.
381 + let response = "Name Server: NS1.EXAMPLE.COM\nNAME SERVER: ns1.example.com\n";
382 + let parsed = parse_whois_response(response);
383 + // Both should normalise to the same value; dedup leaves 1.
384 + assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]);
385 + }
386 +
387 + // ── compute_days_remaining timezone/format edges ──
388 +
389 + #[test]
390 + fn compute_days_remaining_trims_whitespace_around_date_only() {
391 + // Pins the `.trim()` call inside the date-only fallback.
392 + let future = (chrono::Utc::now() + chrono::Duration::days(45))
393 + .format(" %Y-%m-%d ")
394 + .to_string();
395 + let days = compute_days_remaining(&future).unwrap();
396 + assert!((44..=45).contains(&days), "expected 44 or 45, got {days}");
397 + }
398 +
399 + #[test]
400 + fn compute_days_remaining_garbage_after_rfc3339_is_none() {
401 + // Neither format parses; falls through to None.
402 + assert!(compute_days_remaining("2027-06-15 NOT A TIME").is_none());
403 + }
305 404 }
@@ -1891,7 +1891,7 @@ dependencies = [
1891 1891
1892 1892 [[package]]
1893 1893 name = "docengine"
1894 - version = "0.3.1"
1894 + version = "0.3.4"
1895 1895 dependencies = [
1896 1896 "ammonia",
1897 1897 "pulldown-cmark",
@@ -3508,7 +3508,7 @@ dependencies = [
3508 3508
3509 3509 [[package]]
3510 3510 name = "makenotwork"
3511 - version = "0.5.19"
3511 + version = "0.5.22"
3512 3512 dependencies = [
3513 3513 "anyhow",
3514 3514 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.5.19"
3 + version = "0.5.22"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -101,7 +101,7 @@ metrics = "0.24"
101 101 metrics-exporter-prometheus = { version = "0.18.1", default-features = false }
102 102
103 103 # Markdown rendering + documentation engine
104 - docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls"] }
104 + docengine = { path = "../shared/docengine", features = ["doc-loader", "directives", "frontmatter", "media-urls", "assumptions"] }
105 105
106 106 # Tag standard
107 107 tagtree = { path = "../shared/tagtree" }
@@ -69,6 +69,9 @@ upload_config() {
69 69 rsync -az --delete site-docs/public/ $SERVER:$REMOTE_DIR/docs/public/
70 70 rsync -az --delete site-docs/examples/ $SERVER:$REMOTE_DIR/docs/examples/
71 71
72 + # Business assumptions (source-of-truth for substituted figures in docs)
73 + rsync -az docs/internal/business/assumptions.toml $SERVER:$REMOTE_DIR/docs/assumptions.toml
74 +
72 75 # Rustdoc (API reference for library crates)
73 76 echo "[config] Generating rustdoc..."
74 77 "$DEPLOY_DIR/generate-rustdoc.sh"