Skip to main content

max / makenotwork

24.0 KB · 697 lines History Blame Raw
1 //! Route handlers, MNW-integrated forum.
2
3 mod account;
4 mod admin;
5 mod chat;
6 mod flagging;
7 mod forum;
8 pub(crate) mod helpers;
9 pub mod internal;
10 mod moderation;
11 mod scope;
12 mod search;
13 mod settings;
14 mod tracking;
15 mod uploads;
16
17 // Re-export helpers so submodules can `use super::*`.
18 pub(crate) use helpers::*;
19 pub(crate) use scope::CommunityScope;
20
21 use axum::{
22 Json, Router,
23 http::StatusCode,
24 response::{IntoResponse, Response},
25 routing::{get, post},
26 };
27 use serde::Deserialize;
28 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
29 use tower_sessions::Session;
30
31 use crate::trusted_proxy::TrustedProxyKeyExtractor;
32
33 use crate::AppState;
34 use crate::auth::{self, MaybeUser};
35 use crate::csrf;
36 use crate::templates::Error404Template;
37
38 // Rate limiting, per-IP on write endpoints
39
40 /// Write endpoints: burst 10, then 2/sec (one token per 500ms).
41 const WRITE_RATE_LIMIT_MS: u64 = 500;
42 const WRITE_RATE_LIMIT_BURST: u32 = 10;
43
44 /// Search endpoint: burst 5, then 1/sec, full-text + trigram queries are expensive.
45 const SEARCH_RATE_LIMIT_MS: u64 = 1000;
46 const SEARCH_RATE_LIMIT_BURST: u32 = 5;
47
48 /// Upload request body cap: the image size limit plus headroom for multipart
49 /// framing. Bounds the in-memory buffer before the handler reads the field.
50 const MAX_UPLOAD_BODY_BYTES: usize = crate::storage::MAX_IMAGE_SIZE + 64 * 1024;
51
52 /// Auth endpoints: burst 10, then 1/sec. Throttles login/callback floods and
53 /// the `/auth/refresh` → MNW userinfo amplifier.
54 const AUTH_RATE_LIMIT_MS: u64 = 1000;
55 const AUTH_RATE_LIMIT_BURST: u32 = 10;
56
57 /// Image serve (`/uploads/{id}`): generous, since a single page legitimately
58 /// fans out one request per embedded `<img>`. Burst 60 covers an image-heavy
59 /// page load, then refills ~20/sec, enough to bound an unauthenticated
60 /// S3-egress proxy against a scraping/amplification flood (ultra-fuzz Mi1)
61 /// without throttling normal browsing.
62 const IMAGE_RATE_LIMIT_MS: u64 = 50;
63 const IMAGE_RATE_LIMIT_BURST: u32 = 60;
64
65 /// Build the forum route tree.
66 pub fn forum_routes(state: AppState) -> Router {
67 let write_rate_limit = std::sync::Arc::new(
68 GovernorConfigBuilder::default()
69 .key_extractor(TrustedProxyKeyExtractor::new(
70 state.config.trusted_proxies.clone(),
71 ))
72 .per_millisecond(WRITE_RATE_LIMIT_MS)
73 .burst_size(WRITE_RATE_LIMIT_BURST)
74 .finish()
75 .expect("rate limiter config"),
76 );
77
78 // POST-only routes, rate limited per IP
79 let write_routes = Router::new()
80 .route(
81 "/p/{slug}/settings",
82 post(settings::update_community_handler),
83 )
84 .route(
85 "/p/{slug}/settings/categories/new",
86 post(settings::create_category_handler),
87 )
88 .route(
89 "/p/{slug}/settings/categories/{cat_id}/edit",
90 post(settings::edit_category_handler),
91 )
92 .route(
93 "/p/{slug}/settings/categories/{cat_id}/move",
94 post(settings::move_category_handler),
95 )
96 .route(
97 "/p/{slug}/settings/tags/new",
98 post(settings::create_tag_handler),
99 )
100 .route(
101 "/p/{slug}/settings/tags/delete",
102 post(settings::delete_tag_handler),
103 )
104 .route(
105 "/p/{slug}/settings/state",
106 post(settings::set_community_state_handler),
107 )
108 .route(
109 "/p/{slug}/settings/chat",
110 post(settings::chat_settings_handler),
111 )
112 .route(
113 "/p/{slug}/settings/chat/wipe",
114 post(settings::wipe_chat_handler),
115 )
116 .route(
117 "/account/signature",
118 post(account::update_signature_handler),
119 )
120 .route(
121 "/p/{slug}/moderation/ban",
122 post(moderation::ban_user_handler),
123 )
124 .route(
125 "/p/{slug}/moderation/unban",
126 post(moderation::unban_user_handler),
127 )
128 .route(
129 "/p/{slug}/moderation/mute",
130 post(moderation::mute_user_handler),
131 )
132 .route(
133 "/p/{slug}/moderation/unmute",
134 post(moderation::unmute_user_handler),
135 )
136 .route(
137 "/p/{slug}/{category}/new",
138 post(forum::create_thread_handler),
139 )
140 .route(
141 "/p/{slug}/{category}/{thread_id}/reply",
142 post(forum::create_reply_handler),
143 )
144 .route(
145 "/p/{slug}/{category}/{thread_id}/edit",
146 post(forum::edit_thread_handler),
147 )
148 .route(
149 "/p/{slug}/{category}/{thread_id}/delete",
150 post(forum::delete_thread_handler),
151 )
152 .route(
153 "/p/{slug}/{category}/{thread_id}/pin",
154 post(moderation::pin_thread_handler),
155 )
156 .route(
157 "/p/{slug}/{category}/{thread_id}/lock",
158 post(moderation::lock_thread_handler),
159 )
160 .route(
161 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/footnote",
162 post(forum::add_footnote_handler),
163 )
164 .route(
165 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/endorse",
166 post(forum::toggle_endorsement_handler),
167 )
168 .route(
169 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/remove",
170 post(moderation::mod_remove_post_handler),
171 )
172 .route(
173 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/restore",
174 post(moderation::mod_restore_post_handler),
175 )
176 .route(
177 "/p/{slug}/{category}/{thread_id}/posts/{post_id}/flag",
178 post(flagging::flag_post_handler),
179 )
180 .route(
181 "/p/{slug}/moderation/flags/{flag_id}/dismiss",
182 post(flagging::dismiss_flag_handler),
183 )
184 .route(
185 "/p/{slug}/moderation/flags/{flag_id}/remove",
186 post(flagging::remove_flagged_post_handler),
187 )
188 .route(
189 "/p/{slug}/{category}/{thread_id}/track",
190 post(tracking::track_thread_handler),
191 )
192 .route(
193 "/p/{slug}/{category}/{thread_id}/untrack",
194 post(tracking::untrack_thread_handler),
195 )
196 .route("/tracked/stop-all", post(tracking::untrack_all_handler))
197 .route(
198 "/_admin/communities/{id}/suspend",
199 post(admin::suspend_community_handler),
200 )
201 .route(
202 "/_admin/communities/{id}/unsuspend",
203 post(admin::unsuspend_community_handler),
204 )
205 .route(
206 "/_admin/communities/{slug}/clean-slate",
207 post(admin::admin_community_clean_slate_handler),
208 )
209 .route(
210 "/_admin/users/{id}/suspend",
211 post(admin::suspend_user_handler),
212 )
213 .route(
214 "/_admin/users/{id}/unsuspend",
215 post(admin::unsuspend_user_handler),
216 )
217 .route(
218 "/p/{slug}/upload",
219 post(uploads::upload_image_handler)
220 .layer(axum::extract::DefaultBodyLimit::max(MAX_UPLOAD_BODY_BYTES)),
221 )
222 .route(
223 "/p/{slug}/uploads/{id}/remove",
224 post(uploads::remove_image_handler),
225 )
226 .route("/p/{slug}/chat/send", post(chat::chat_send))
227 .route(
228 "/p/{slug}/chat/messages/{message_id}/delete",
229 post(chat::chat_delete_message),
230 )
231 .route(
232 "/p/{slug}/chat/moderation/timeout",
233 post(chat::chat_timeout_user),
234 )
235 .route("/p/{slug}/chat/moderation/ban", post(chat::chat_ban_user))
236 .route_layer(GovernorLayer::new(write_rate_limit.clone()));
237
238 // Search, rate limited per IP (expensive full-text queries)
239 let search_rate_limit = std::sync::Arc::new(
240 GovernorConfigBuilder::default()
241 .key_extractor(TrustedProxyKeyExtractor::new(
242 state.config.trusted_proxies.clone(),
243 ))
244 .per_millisecond(SEARCH_RATE_LIMIT_MS)
245 .burst_size(SEARCH_RATE_LIMIT_BURST)
246 .finish()
247 .expect("search rate limiter config"),
248 );
249
250 let search_routes = Router::new()
251 .route("/search", get(search::search_handler))
252 .route_layer(GovernorLayer::new(search_rate_limit.clone()));
253
254 // Auth endpoints, rate limited per IP (login/callback flood + refresh
255 // amplifier against MNW).
256 let auth_rate_limit = std::sync::Arc::new(
257 GovernorConfigBuilder::default()
258 .key_extractor(TrustedProxyKeyExtractor::new(
259 state.config.trusted_proxies.clone(),
260 ))
261 .per_millisecond(AUTH_RATE_LIMIT_MS)
262 .burst_size(AUTH_RATE_LIMIT_BURST)
263 .finish()
264 .expect("auth rate limiter config"),
265 );
266
267 let auth_routes = Router::new()
268 .route("/auth/login", get(auth::login))
269 .route("/auth/reverify", get(auth::reverify))
270 .route("/auth/callback", get(auth::callback))
271 .route("/auth/logout", post(auth::logout))
272 .route("/auth/refresh", post(auth::refresh))
273 .route_layer(GovernorLayer::new(auth_rate_limit.clone()));
274
275 // Image serve, per-IP rate limited. `/uploads/{id}` is an unauthenticated
276 // S3-egress proxy (it streams bytes to any viewer who passes the community
277 // access check), so a generous governor bounds scraping/amplification floods
278 // (ultra-fuzz Mi1) while leaving image-heavy page loads unthrottled.
279 let image_rate_limit = std::sync::Arc::new(
280 GovernorConfigBuilder::default()
281 .key_extractor(TrustedProxyKeyExtractor::new(
282 state.config.trusted_proxies.clone(),
283 ))
284 .per_millisecond(IMAGE_RATE_LIMIT_MS)
285 .burst_size(IMAGE_RATE_LIMIT_BURST)
286 .finish()
287 .expect("image rate limiter config"),
288 );
289
290 let image_routes = Router::new()
291 .route("/uploads/{id}", get(uploads::serve_image_handler))
292 .route("/img-proxy", get(uploads::image_proxy_handler))
293 .route_layer(GovernorLayer::new(image_rate_limit.clone()));
294
295 // Periodically evict idle per-IP buckets from every rate limiter so the
296 // keyspace can't grow unbounded over the process lifetime (M-Pf3).
297 // Trusted-proxy keying already bounds keys to real client IPs, but a
298 // long-running server still accumulates one-off visitors; `retain_recent`
299 // drops buckets with no recent activity. Spawned here because `forum_routes`
300 // runs inside the tokio runtime at startup.
301 {
302 let limiters = [
303 write_rate_limit.limiter().clone(),
304 search_rate_limit.limiter().clone(),
305 auth_rate_limit.limiter().clone(),
306 image_rate_limit.limiter().clone(),
307 ];
308 tokio::spawn(async move {
309 let mut interval = tokio::time::interval(std::time::Duration::from_mins(5));
310 interval.tick().await; // consume the immediate first tick
311 loop {
312 interval.tick().await;
313 for limiter in &limiters {
314 limiter.retain_recent();
315 }
316 }
317 });
318 }
319
320 // GET routes + health, no rate limiting
321 let read_routes = Router::new()
322 .route("/", get(forum::forum_directory))
323 .route("/p/{slug}", get(forum::project_forum))
324 .route("/p/{slug}/members", get(forum::community_members))
325 .route("/p/{slug}/u/{username}", get(forum::user_profile))
326 .route("/account", get(account::account_settings))
327 .route("/p/{slug}/settings", get(settings::community_settings))
328 .route(
329 "/p/{slug}/settings/categories/{cat_id}/edit",
330 get(settings::edit_category_form),
331 )
332 .route("/p/{slug}/moderation", get(moderation::moderation_page))
333 .route("/p/{slug}/moderation/log", get(moderation::mod_log_page))
334 .route(
335 "/p/{slug}/moderation/deleted",
336 get(moderation::deleted_threads_page),
337 )
338 .route(
339 "/p/{slug}/moderation/threads/{thread_id}/restore",
340 post(moderation::restore_thread_handler),
341 )
342 .route("/p/{slug}/chat", get(chat::chat_page))
343 .route("/p/{slug}/chat/stream", get(chat::chat_stream))
344 .route("/p/{slug}/{category}", get(forum::category))
345 .route("/p/{slug}/{category}/new", get(forum::new_thread))
346 .route("/p/{slug}/{category}/{thread_id}", get(forum::thread))
347 .route(
348 "/p/{slug}/{category}/{thread_id}/edit",
349 get(forum::edit_thread_form),
350 )
351 .route("/tracked", get(tracking::tracked_threads_page))
352 .route("/about/tracking", get(tracking::tracking_info_page))
353 .route("/_admin", get(admin::admin_dashboard))
354 .route(
355 "/_admin/communities/{slug}",
356 get(admin::admin_community_detail),
357 )
358 .route("/api/user/{user_id}/summary", get(forum::user_summary_api))
359 .route("/api/health", get(health));
360
361 read_routes
362 .merge(search_routes)
363 .merge(auth_routes)
364 .merge(image_routes)
365 .merge(write_routes)
366 .fallback(not_found_handler)
367 .with_state(state)
368 }
369
370 // Form types
371
372 #[derive(Deserialize)]
373 pub(super) struct CreateThreadForm {
374 pub(super) title: String,
375 pub(super) body: String,
376 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
377 pub(super) tags: Vec<String>,
378 }
379
380 /// Deserialize a form field that may be a single string or a repeated-key sequence.
381 /// serde_urlencoded sends a single `tags=x` as a string, but `tags=x&tags=y` as a sequence.
382 fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
383 where
384 D: serde::Deserializer<'de>,
385 {
386 struct StringOrSeq;
387
388 impl<'de> serde::de::Visitor<'de> for StringOrSeq {
389 type Value = Vec<String>;
390
391 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
392 f.write_str("a string or sequence of strings")
393 }
394
395 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Vec<String>, E> {
396 Ok(vec![v.to_string()])
397 }
398
399 fn visit_seq<A: serde::de::SeqAccess<'de>>(
400 self,
401 mut seq: A,
402 ) -> Result<Vec<String>, A::Error> {
403 let mut v = Vec::new();
404 while let Some(s) = seq.next_element::<String>()? {
405 v.push(s);
406 }
407 Ok(v)
408 }
409 }
410
411 deserializer.deserialize_any(StringOrSeq)
412 }
413
414 #[derive(Deserialize)]
415 pub(super) struct CreateReplyForm {
416 pub(super) body: String,
417 }
418
419 #[derive(Deserialize)]
420 pub(super) struct FootnoteForm {
421 pub(super) body: String,
422 }
423
424 #[derive(Deserialize)]
425 pub(super) struct EditThreadForm {
426 pub(super) title: String,
427 }
428
429 #[derive(Deserialize)]
430 pub(super) struct UpdateCommunityForm {
431 pub(super) name: String,
432 pub(super) description: String,
433 pub(super) auto_hide_threshold: Option<String>,
434 }
435
436 /// `POST /p/{slug}/settings/chat`.
437 ///
438 /// Every field arrives as a string because the form posts them that way and the
439 /// handler is the one place that should decide what a bad value means. Parsing
440 /// them here as `i32` would turn "0 messages" and "not a number" into the same
441 /// 400 from axum, with no chance to say which bound was wrong.
442 #[derive(Deserialize)]
443 pub(super) struct ChatSettingsForm {
444 pub(super) chat_policy: String,
445 pub(super) retention_hours: String,
446 pub(super) max_messages: String,
447 }
448
449 /// `POST /_admin/communities/{slug}/clean-slate` confirmation form.
450 /// `confirm` must exactly match the community slug (typed-phrase pattern).
451 #[derive(Deserialize)]
452 pub(super) struct CleanSlateForm {
453 pub(super) confirm: String,
454 }
455
456 #[derive(Deserialize)]
457 pub(super) struct SignatureForm {
458 pub(super) signature: String,
459 /// `Some("1")` when the Clear button is pressed.
460 pub(super) clear: Option<String>,
461 }
462
463 #[derive(Deserialize)]
464 pub(super) struct SetCommunityStateForm {
465 /// Target state: `"active" | "restricted" | "frozen" | "archived"`.
466 pub(super) state: String,
467 }
468
469 #[derive(Deserialize)]
470 pub(super) struct CreateCategoryForm {
471 pub(super) name: String,
472 pub(super) slug: String,
473 pub(super) description: String,
474 }
475
476 #[derive(Deserialize)]
477 pub(super) struct EditCategoryFormData {
478 pub(super) name: String,
479 pub(super) description: String,
480 }
481
482 #[derive(Deserialize)]
483 pub(super) struct MoveCategoryForm {
484 pub(super) direction: String,
485 }
486
487 #[derive(Deserialize)]
488 pub(super) struct PageQuery {
489 pub(super) page: Option<u32>,
490 }
491
492 /// Query for `/` forum directory. `filter=archived` shows only archived
493 /// communities; otherwise default listing (archived hidden).
494 #[derive(Deserialize)]
495 pub(super) struct ForumDirectoryQuery {
496 pub(super) page: Option<u32>,
497 pub(super) filter: Option<String>,
498 }
499
500 #[derive(Deserialize)]
501 pub(super) struct CategoryQuery {
502 pub(super) page: Option<u32>,
503 pub(super) sort: Option<String>,
504 pub(super) order: Option<String>,
505 pub(super) tag: Option<String>,
506 }
507
508 #[derive(Deserialize)]
509 pub(super) struct BanForm {
510 pub(super) username: String,
511 pub(super) duration: String,
512 pub(super) reason: Option<String>,
513 }
514
515 #[derive(Deserialize)]
516 pub(super) struct UnbanForm {
517 pub(super) username: String,
518 }
519
520 #[derive(Deserialize)]
521 pub(super) struct AdminSearchQuery {
522 pub(super) q: Option<String>,
523 }
524
525 #[derive(Deserialize)]
526 pub(super) struct SuspendForm {
527 pub(super) reason: Option<String>,
528 }
529
530 #[derive(Deserialize)]
531 pub(super) struct CreateTagForm {
532 pub(super) name: String,
533 pub(super) slug: String,
534 }
535
536 #[derive(Deserialize)]
537 pub(super) struct DeleteTagForm {
538 pub(super) tag_id: String,
539 }
540
541 // Handlers
542
543 /// Health check, proves the service is responding and the database is reachable.
544 ///
545 /// Returns `200 OK` when the DB is reachable and `503 Service Unavailable` when
546 /// it is not, so a status-only uptime probe or load-balancer healthcheck can't
547 /// read a box that can't serve a single DB-backed page as healthy. PoM parses
548 /// the JSON body key-by-key and additionally expects `200` for the operational
549 /// case (`pom/deploy/pom-hetzner.toml`), which the OK branch satisfies.
550 #[tracing::instrument(skip_all)]
551 async fn health(axum::extract::State(state): axum::extract::State<AppState>) -> impl IntoResponse {
552 let db_ok = sqlx::query_scalar::<_, i32>("SELECT 1")
553 .fetch_one(&state.db)
554 .await
555 .is_ok();
556
557 (
558 health_status(db_ok),
559 Json(health_body(
560 db_ok,
561 crate::trust_store::anchors_ok(),
562 state.chat.hub().connection_count(),
563 )),
564 )
565 }
566
567 /// Map DB reachability to the HTTP status. Pure so the status contract can be
568 /// tested without a live DB (mirrors `health_body`).
569 fn health_status(db_ok: bool) -> StatusCode {
570 if db_ok {
571 StatusCode::OK
572 } else {
573 StatusCode::SERVICE_UNAVAILABLE
574 }
575 }
576
577 /// Build the JSON body for the `/api/health` response.
578 ///
579 /// Kept as a pure function (no AppState, no DB) so the schema-drift guard
580 /// test in this module can exercise it directly. PoM polls this endpoint
581 /// and runs key-by-key assertions from `pom/deploy/pom-hetzner.toml`; the
582 /// guard test validates that every asserted path still resolves here.
583 fn health_body(db_ok: bool, trust_anchors_ok: bool, chat_connections: usize) -> serde_json::Value {
584 let status = if db_ok { "operational" } else { "degraded" };
585 serde_json::json!({
586 "status": status,
587 "version": env!("CARGO_PKG_VERSION"),
588 // The commit this binary was built from (short sha, set by build.rs).
589 // `null` on a build without git metadata. Lets monitoring see a
590 // same-semver redeploy, which `version` alone cannot distinguish.
591 "git_sha": option_env!("GIT_HASH").filter(|h| !h.is_empty()),
592 "database": db_ok,
593 // Whether the host trust store yielded outbound TLS anchors
594 // (`crate::trust_store`). Deliberately does not move `status` or the
595 // HTTP code: mt still serves every page that does not leave the box,
596 // and a bad CA bundle is usually a whole-fleet condition, so failing
597 // the load-balancer check would turn a login outage into a total one.
598 // PoM asserts this field, which is what gets it monitored.
599 "tls_trust_anchors": trust_anchors_ok,
600 // Live chat listeners across every room in this process. Exposed
601 // because it is the number that predicts the 512M cgroup cap: each
602 // listener holds a task, a buffer and a broadcast receiver, and an OOM
603 // restarts the whole site rather than degrading chat. Like
604 // `tls_trust_anchors` it deliberately does not move `status` or the
605 // HTTP code; it is a trend for an operator to watch, and the hub
606 // already refuses connections past its own cap.
607 "chat_connections": chat_connections,
608 })
609 }
610
611 // 404 fallback
612
613 #[tracing::instrument(skip_all)]
614 async fn not_found_handler(
615 axum::extract::State(state): axum::extract::State<AppState>,
616 session: Session,
617 MaybeUser(session_user): MaybeUser,
618 ) -> Result<impl IntoResponse, Response> {
619 let csrf_token = Some(csrf::get_or_create_token(&session).await?);
620 let session_user = session_user
621 .as_ref()
622 .map(|u| template_user(u, state.config.platform_admin_id));
623 Ok((
624 StatusCode::NOT_FOUND,
625 Error404Template {
626 csrf_token,
627 session_user,
628 mnw_base_url: state.config.mnw_base_url.clone(),
629 },
630 ))
631 }
632
633 #[cfg(test)]
634 mod health_tests {
635 use super::{health_body, health_status};
636 use axum::http::StatusCode;
637
638 /// Schema-drift guard for the `mt` target. See `shared/pom-contract/`.
639 ///
640 /// Ignored by default because it is not a unit test: it asserts that this
641 /// crate and a *different repo* agree, by reading pom's deployed config off
642 /// disk. `cargo test` is scoped to one crate, so the claim is only
643 /// well-posed where both repos exist together. Anything that relocates this
644 /// crate — cargo-mutants copies it to /tmp — leaves the read pointing at
645 /// nothing, and the panic took multithreaded's entire mutation run with it.
646 ///
647 /// It still runs, every night: sweep materializes the whole ~/Code tree and
648 /// has a `pom-contract` check that invokes it by name with `--ignored`.
649 /// Ignored here means "run somewhere that can answer it", not "skipped".
650 #[test]
651 #[ignore = "cross-repo: run by sweep's pom-contract check, which materializes pom"]
652 fn pom_hetzner_health_expectations_resolve() {
653 let body = health_body(true, true, 0);
654 pom_contract::assert_health_expectations_resolve(
655 "../pom/deploy/pom-hetzner.toml",
656 "mt",
657 &body,
658 );
659 }
660
661 /// The `git_sha` key is what tells monitoring a same-semver redeploy
662 /// happened, so lock its presence. Its value varies per build (and is
663 /// `null` without git metadata), which is why it is asserted here rather
664 /// than in PoM's exact-match `json_fields`.
665 #[test]
666 fn health_body_carries_version_and_git_sha_keys() {
667 let body = health_body(true, true, 0);
668 assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
669 assert!(
670 body.get("git_sha").is_some(),
671 "git_sha key must be present (null is fine)"
672 );
673 }
674
675 /// The trust-anchor field is what PoM watches for a stale or thin host CA
676 /// bundle, so lock both that it is reported and that it does not move
677 /// `status`. A box with no anchors still serves every page that does not
678 /// leave it; degrading the whole target would hide that distinction.
679 #[test]
680 fn health_body_reports_trust_anchors_without_moving_status() {
681 assert_eq!(health_body(true, true, 0)["tls_trust_anchors"], true);
682
683 let body = health_body(true, false, 0);
684 assert_eq!(body["tls_trust_anchors"], false);
685 assert_eq!(body["status"], "operational");
686 assert_eq!(health_status(true), StatusCode::OK);
687 }
688
689 /// A reachable DB is `200`; an unreachable DB is `503` so status-only probes
690 /// don't read a degraded box as healthy. PoM expects `200` for operational.
691 #[test]
692 fn health_status_reflects_db_reachability() {
693 assert_eq!(health_status(true), StatusCode::OK);
694 assert_eq!(health_status(false), StatusCode::SERVICE_UNAVAILABLE);
695 }
696 }
697