Skip to main content

max / makenotwork

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