Skip to main content

kanade_shared/wire/
server_settings.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::MailSection;
4
5/// Upper bound on [`ServerSettings::agent_prune_days`] (100 years). A
6/// value this large already means "effectively never", and it keeps the
7/// cleanup task's `now - Duration::days(n)` subtraction comfortably inside
8/// `chrono::DateTime`'s representable range — `DateTime - Duration` panics
9/// on overflow, and an unbounded `u32` (~11.7 M years) would trip it.
10/// Enforced two ways: the PUT handler rejects a larger value, and
11/// [`ServerSettings::effective_agent_prune_days`] clamps to it so even a
12/// hand-written KV value can never panic the cleanup task.
13pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
14
15/// Built-in retention window (days) for the `collections` Object Store —
16/// the file bundles a `collect:` job uploads (#219). This is the value the
17/// bootstrap creates a fresh bucket with, and the fallback
18/// [`ServerSettings::effective_collect_retention_days`] resolves to when the
19/// operator hasn't overridden it. Kept here (not just in `bootstrap`) so the
20/// wire default and the bucket's initial `max_age` share one source of truth.
21pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
22
23/// Upper bound on [`ServerSettings::collect_retention_days`] (10 years).
24/// Bundles are debugging / audit artifacts, so an operator has no reason to
25/// keep them longer than this; the ceiling stops a fat-fingered value from
26/// pushing the `collections` Object Store's `max_age` to something absurd.
27/// (The bucket's `max_bytes` cap still bounds actual disk regardless.)
28/// Enforced by the PUT handler and clamped in
29/// [`ServerSettings::effective_collect_retention_days`] so even a
30/// hand-written KV value stays sane.
31pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
32
33/// Built-in default for [`ServerSettings::session_ttl_hours`] — how long a
34/// freshly-minted SPA/CLI login token stays valid when the operator hasn't
35/// overridden it. 24h balances "don't re-auth mid-workday" against a
36/// bounded window for a leaked token (the DB row is re-checked every
37/// request, so `disable` still takes effect immediately regardless).
38pub const DEFAULT_SESSION_TTL_HOURS: u32 = 24;
39
40/// Upper bound on [`ServerSettings::session_ttl_hours`] (365 days). Keeps
41/// login's `Utc::now() + Duration::hours(n)` comfortably inside `chrono`'s
42/// representable range (its nanosecond `i64` caps out ~292 years, so an
43/// unclamped `u32` of hours could overflow) and stops an operator pinning a
44/// practically-immortal session. Enforced by the PUT handler and clamped in
45/// [`ServerSettings::effective_session_ttl_hours`] so even a hand-written
46/// KV value stays bounded.
47pub const MAX_SESSION_TTL_HOURS: u32 = 8_760;
48
49/// Built-in default for [`ServerSettings::check_status_stale_days`] (#1032②) —
50/// how many days a `check_status` row may go without a fresh result before the
51/// Compliance view treats it as **stale** and hides it (a PC that stopped
52/// running a check: excluded from its target via a dynamic group, decommissioned,
53/// or its schedule removed). 30 days is comfortably longer than a typical daily
54/// / weekly compliance cadence, so an in-scope PC is never mistakenly hidden,
55/// while a genuinely out-of-scope PC's frozen row drops out within the month.
56/// A real default (like `collect_retention_days`) so the feature works out of
57/// the box; the operator shortens it for prompter hiding or sets `0` to disable.
58pub const DEFAULT_CHECK_STATUS_STALE_DAYS: u32 = 30;
59
60/// Upper bound on [`ServerSettings::check_status_stale_days`] (10 years).
61/// Staleness is purely a display cutoff (`now - Duration::days(n)` compared to a
62/// row's `recorded_at`), so the ceiling just keeps that subtraction inside
63/// `chrono::DateTime`'s range and stops an absurd value; enforced by the PUT
64/// handler and clamped in [`ServerSettings::effective_check_status_stale_days`].
65pub const MAX_CHECK_STATUS_STALE_DAYS: u32 = 3650;
66
67/// Value stored in the `server_settings` KV bucket under the single key
68/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
69/// server configuration that isn't per-agent (so it doesn't belong in
70/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
71/// agent watches (so it doesn't belong in `fleet_config`). Managed via
72/// the SPA Settings page's "server settings" tab.
73///
74/// Every field is `Option<_>`: `None` (the default / the JSON value
75/// `null` / the field simply absent) means **unset — fall back to the
76/// built-in default** ([`ServerSettings::defaults`]), exactly like the
77/// agent layered-config scopes. The SPA renders the built-in default as a
78/// faint placeholder so a blank field shows what it resolves to, and when
79/// a real default is introduced here it appears in the UI (and takes
80/// effect for already-deployed-but-unset fleets) for free.
81///
82/// `#[serde(default)]` on the container keeps the document backward/forward
83/// compatible: a freshly-created or missing key decodes to all-`None`
84/// (pre-feature behaviour), an older backend reading a newer document
85/// ignores unknown fields, and a newer backend reading an older document
86/// fills the missing field with `None`. Keep that invariant — never add a
87/// field whose `None` doesn't mean "behave as before".
88#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
89#[serde(default)]
90pub struct ServerSettings {
91    /// Days a dead agent (one whose heartbeat stopped arriving) may
92    /// linger in the `agents` registry before the backend cleanup task
93    /// prunes its row.
94    ///
95    /// `None` (unset) falls back to the built-in default; with no default
96    /// configured that resolves to pruning **disabled** (see
97    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
98    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
99    /// than that many days. The `agents` table is a projection of the
100    /// heartbeat stream, so a machine that's merely offline (not gone)
101    /// reappears on its next heartbeat (~30s cadence); only
102    /// genuinely-retired machines stay gone.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub agent_prune_days: Option<u32>,
105
106    /// Agent group whose members are the trusted **controller-tier**
107    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
108    /// fetches an external URL) is dispatched ONLY to members of this group;
109    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
110    /// so an external fetch never lands on an employee endpoint by accident).
111    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
112    /// configured, which is the safe default for a fresh deployment.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub controller_group: Option<String>,
115
116    /// Non-secret SMTP relay settings for outbound email (compliance-alert
117    /// notifications, account setup links, …). Lives here (#884) rather than
118    /// in `backend.toml` so an operator can edit it from the SPA without
119    /// shell access to the host.
120    ///
121    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
122    /// / NATS notification path is unaffected. The SMTP **password is
123    /// deliberately not here** (KV is
124    /// readable over NATS): it stays sourced from the `MailPassword`
125    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
126    /// settings when the backend builds its `Mailer`. Changes take effect on
127    /// the **next backend restart** (the backend builds the `Mailer` once at
128    /// startup — no live rebuild), which is acceptable per the #884
129    /// discussion.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub mail: Option<MailSection>,
132
133    /// Retention window (days) for collected file bundles — the `collect:`
134    /// job archives uploaded to the `collections` Object Store (#219).
135    ///
136    /// Unlike the other fields, this one has a **real built-in default**
137    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
138    /// to it, so a blank field preserves the historical behaviour rather than
139    /// disabling retention. A positive value tells the backend to reconcile
140    /// the Object Store's `max_age` to that many days (see
141    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
142    /// whenever this document is saved from the SPA. Clamped to
143    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
144    /// untouched, so extending the window can't make the store grow unbounded.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub collect_retention_days: Option<u32>,
147
148    /// How many hours a freshly-minted login token (SPA / CLI) stays valid
149    /// before the caller must re-authenticate. Read backend-side by the
150    /// login handler when it mints the JWT `exp`; changing it affects
151    /// **tokens minted after the change**, not already-issued ones.
152    ///
153    /// Like [`collect_retention_days`](Self::collect_retention_days) this
154    /// carries a **real built-in default** ([`DEFAULT_SESSION_TTL_HOURS`],
155    /// 24h): `None` (unset) falls back to it, so a blank field resolves to a
156    /// usable window rather than an instantly-expired token. Clamped to
157    /// `1..=`[`MAX_SESSION_TTL_HOURS`]. The SPA renders `24` as a faint
158    /// placeholder on a blank field.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub session_ttl_hours: Option<u32>,
161
162    /// Days a `check_status` row may go without a fresh result before the
163    /// Compliance page treats it as **stale** and hides it (#1032②). A PC that
164    /// stops running a check — excluded from its target via a dynamic group,
165    /// decommissioned, or its schedule removed — stops refreshing its row's
166    /// `recorded_at`; once that timestamp is older than this window the row is
167    /// omitted from the default `/api/checks` view and its counts, so a machine
168    /// no longer in scope stops showing as permanently failing a check it never
169    /// runs.
170    ///
171    /// Like [`collect_retention_days`](Self::collect_retention_days) this carries
172    /// a **real built-in default** ([`DEFAULT_CHECK_STATUS_STALE_DAYS`], 30d), so
173    /// the feature works with no configuration. `0` **disables** staleness
174    /// (every row shown, the pre-feature behaviour); a positive value is the
175    /// cutoff, clamped to [`MAX_CHECK_STATUS_STALE_DAYS`]. The row is never
176    /// deleted — hiding is non-destructive so history and the compliance-alert
177    /// prior-status are preserved.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub check_status_stale_days: Option<u32>,
180}
181
182impl ServerSettings {
183    /// Built-in defaults applied when a field is unset (`None`) in the
184    /// stored document. Most fields default to `None` (no fleet-meaningful
185    /// default — a blank prune window means "disabled" rather than some
186    /// arbitrary number of days). The exceptions carry real defaults so a
187    /// blank field still resolves to a sensible value:
188    /// [`collect_retention_days`](Self::collect_retention_days)
189    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], preserving the historical 30-day
190    /// retention) and [`session_ttl_hours`](Self::session_ttl_hours)
191    /// ([`DEFAULT_SESSION_TTL_HOURS`], 24h).
192    ///
193    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
194    /// these as faint placeholders (mirroring the agent layered-config
195    /// page's built-in floor). Introducing a real default is a one-line
196    /// change here that automatically shows up in the UI and applies to
197    /// every deployment that hasn't overridden the field.
198    pub fn defaults() -> Self {
199        Self {
200            agent_prune_days: None,
201            controller_group: None,
202            mail: None,
203            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
204            session_ttl_hours: Some(DEFAULT_SESSION_TTL_HOURS),
205            check_status_stale_days: Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
206        }
207    }
208
209    /// The configured controller-tier runner group, trimmed, or `None` when
210    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
211    pub fn effective_controller_group(&self) -> Option<&str> {
212        self.controller_group
213            .as_deref()
214            .map(str::trim)
215            .filter(|g| !g.is_empty())
216    }
217
218    /// The effective dead-agent prune window in days: the stored value if
219    /// set, else the built-in default, else `0` (= pruning disabled). The
220    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
221    /// user-facing default — the cleanup task treats `0` as "don't prune".
222    ///
223    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
224    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
225    /// the task), even if a value larger than the PUT handler allows was
226    /// written to the KV out-of-band.
227    pub fn effective_agent_prune_days(&self) -> u32 {
228        self.agent_prune_days
229            .or(Self::defaults().agent_prune_days)
230            .unwrap_or(0)
231            .min(MAX_AGENT_PRUNE_DAYS)
232    }
233
234    /// The effective collect-bundle retention window in days: the stored
235    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
236    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
237    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
238    /// The floor matters specifically because NATS treats `max_age: 0` as
239    /// **unlimited** retention, not "evict immediately": a stray `0` would
240    /// silently make bundles never expire (defeating the auto-expire intent),
241    /// so we coerce it to the shortest real window (1 day) instead. The PUT
242    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
243    /// path for a hand-edited KV value.
244    pub fn effective_collect_retention_days(&self) -> u32 {
245        self.collect_retention_days
246            .or(Self::defaults().collect_retention_days)
247            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
248            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
249    }
250
251    /// The effective login-token lifetime in hours: the stored value if
252    /// set, else the built-in [`DEFAULT_SESSION_TTL_HOURS`]. Floored at 1
253    /// and clamped to [`MAX_SESSION_TTL_HOURS`] so a zero/absent/out-of-band
254    /// value can never mint an already-expired or overflow-inducing token.
255    /// The PUT handler already rejects `0` / over-cap, so this is the
256    /// belt-and-braces path for a hand-edited KV value.
257    pub fn effective_session_ttl_hours(&self) -> u32 {
258        self.session_ttl_hours
259            .or(Self::defaults().session_ttl_hours)
260            .unwrap_or(DEFAULT_SESSION_TTL_HOURS)
261            .clamp(1, MAX_SESSION_TTL_HOURS)
262    }
263
264    /// The effective check-staleness window in days: the stored value if set,
265    /// else the built-in [`DEFAULT_CHECK_STATUS_STALE_DAYS`]. **`0` means
266    /// disabled** (no row is ever hidden as stale) — deliberately NOT floored to
267    /// 1 (unlike collect/session), because 0 is a meaningful "show everything"
268    /// value here, the same convention as [`agent_prune_days`](Self::agent_prune_days).
269    /// Clamped to [`MAX_CHECK_STATUS_STALE_DAYS`] so an out-of-band KV write
270    /// can't overflow the `now - Duration::days(n)` cutoff math.
271    pub fn effective_check_status_stale_days(&self) -> u32 {
272        self.check_status_stale_days
273            .or(Self::defaults().check_status_stale_days)
274            .unwrap_or(DEFAULT_CHECK_STATUS_STALE_DAYS)
275            .min(MAX_CHECK_STATUS_STALE_DAYS)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn default_is_unset() {
285        assert_eq!(ServerSettings::default().agent_prune_days, None);
286    }
287
288    #[test]
289    fn unset_resolves_to_disabled() {
290        // No stored value + no built-in default ⇒ effective 0 (disabled).
291        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
292    }
293
294    #[test]
295    fn stored_value_wins_over_default() {
296        let s = ServerSettings {
297            agent_prune_days: Some(30),
298            ..Default::default()
299        };
300        assert_eq!(s.effective_agent_prune_days(), 30);
301    }
302
303    #[test]
304    fn effective_clamps_to_max() {
305        // An out-of-band KV write larger than the PUT cap must not reach
306        // the cleanup task unclamped (else its DateTime subtraction panics).
307        let s = ServerSettings {
308            agent_prune_days: Some(u32::MAX),
309            ..Default::default()
310        };
311        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
312    }
313
314    #[test]
315    fn round_trips_through_json() {
316        let s = ServerSettings {
317            agent_prune_days: Some(30),
318            ..Default::default()
319        };
320        let json = serde_json::to_string(&s).unwrap();
321        assert_eq!(json, r#"{"agent_prune_days":30}"#);
322        let back: ServerSettings = serde_json::from_str(&json).unwrap();
323        assert_eq!(back, s);
324    }
325
326    #[test]
327    fn unset_serialises_to_empty_object() {
328        // `skip_serializing_if` keeps an all-unset doc minimal; it must
329        // round-trip back to all-`None`.
330        let s = ServerSettings::default();
331        let json = serde_json::to_string(&s).unwrap();
332        assert_eq!(json, "{}");
333        let back: ServerSettings = serde_json::from_str(&json).unwrap();
334        assert_eq!(back, s);
335    }
336
337    #[test]
338    fn explicit_null_decodes_to_unset() {
339        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
340        assert_eq!(s.agent_prune_days, None);
341    }
342
343    #[test]
344    fn empty_object_decodes_to_default() {
345        // A freshly-created key (or one written by an older backend that
346        // didn't know this field) must read back as the pre-feature
347        // behaviour, not fail to decode.
348        let s: ServerSettings = serde_json::from_str("{}").unwrap();
349        assert_eq!(s, ServerSettings::default());
350    }
351
352    #[test]
353    fn controller_group_effective_trims_and_blank_is_unset() {
354        assert_eq!(ServerSettings::default().effective_controller_group(), None);
355        let s = ServerSettings {
356            controller_group: Some("  feed-runners ".into()),
357            ..Default::default()
358        };
359        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
360        // A blank/whitespace value reads as unset (fail-safe: no runner).
361        let blank = ServerSettings {
362            controller_group: Some("   ".into()),
363            ..Default::default()
364        };
365        assert_eq!(blank.effective_controller_group(), None);
366    }
367
368    #[test]
369    fn controller_group_round_trips_and_omits_when_unset() {
370        let s = ServerSettings {
371            controller_group: Some("infra".into()),
372            ..Default::default()
373        };
374        let json = serde_json::to_string(&s).unwrap();
375        assert_eq!(json, r#"{"controller_group":"infra"}"#);
376        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
377        // Unset controller_group is omitted (skip_serializing_if).
378        assert_eq!(
379            serde_json::to_string(&ServerSettings::default()).unwrap(),
380            "{}"
381        );
382    }
383
384    #[test]
385    fn mail_round_trips_and_omits_when_unset() {
386        use crate::config::{MailEncryption, MailSection};
387
388        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
389        // stays minimal and decodes back to `None`.
390        assert_eq!(
391            serde_json::to_string(&ServerSettings::default()).unwrap(),
392            "{}"
393        );
394
395        let s = ServerSettings {
396            mail: Some(MailSection {
397                host: "smtp.example.com".into(),
398                port: 587,
399                encryption: MailEncryption::Starttls,
400                from: "kanade-noreply@example.com".into(),
401                username: Some("kanade-noreply".into()),
402            }),
403            ..Default::default()
404        };
405        let json = serde_json::to_string(&s).unwrap();
406        // Encryption serialises lowercase; the password is never present.
407        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
408        assert!(!json.contains("password"), "password must never serialise");
409        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
410    }
411
412    #[test]
413    fn mail_defaults_to_unset() {
414        assert_eq!(ServerSettings::default().mail, None);
415        // A doc written before this field existed (no `mail` key) decodes
416        // to `None` — email stays a no-op, the pre-feature behaviour.
417        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
418        assert_eq!(s.mail, None);
419        assert_eq!(s.agent_prune_days, Some(7));
420    }
421
422    #[test]
423    fn collect_retention_unset_resolves_to_builtin_default() {
424        // Blank (the derived Default) must preserve the historical 30-day
425        // window, not disable retention.
426        assert_eq!(ServerSettings::default().collect_retention_days, None);
427        assert_eq!(
428            ServerSettings::default().effective_collect_retention_days(),
429            DEFAULT_COLLECT_RETENTION_DAYS,
430        );
431        // The defaults() document surfaces the real default so the SPA can
432        // render it as a placeholder.
433        assert_eq!(
434            ServerSettings::defaults().collect_retention_days,
435            Some(DEFAULT_COLLECT_RETENTION_DAYS),
436        );
437    }
438
439    #[test]
440    fn collect_retention_stored_value_wins() {
441        let s = ServerSettings {
442            collect_retention_days: Some(90),
443            ..Default::default()
444        };
445        assert_eq!(s.effective_collect_retention_days(), 90);
446    }
447
448    #[test]
449    fn collect_retention_effective_clamps_out_of_band_writes() {
450        // A hand-written KV value past the PUT cap (or 0) must be clamped so
451        // the reconciled Object Store max_age stays sane.
452        let big = ServerSettings {
453            collect_retention_days: Some(u32::MAX),
454            ..Default::default()
455        };
456        assert_eq!(
457            big.effective_collect_retention_days(),
458            MAX_COLLECT_RETENTION_DAYS,
459        );
460        let zero = ServerSettings {
461            collect_retention_days: Some(0),
462            ..Default::default()
463        };
464        assert_eq!(zero.effective_collect_retention_days(), 1);
465    }
466
467    #[test]
468    fn collect_retention_round_trips_and_omits_when_unset() {
469        let s = ServerSettings {
470            collect_retention_days: Some(90),
471            ..Default::default()
472        };
473        let json = serde_json::to_string(&s).unwrap();
474        assert_eq!(json, r#"{"collect_retention_days":90}"#);
475        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
476        // Unset is omitted so a blank doc stays minimal.
477        assert!(
478            !serde_json::to_string(&ServerSettings::default())
479                .unwrap()
480                .contains("collect_retention_days")
481        );
482    }
483
484    #[test]
485    fn session_ttl_unset_resolves_to_builtin_default() {
486        // Blank (the derived Default) must resolve to the 24h default rather
487        // than 0 (which would mint already-expired tokens).
488        assert_eq!(ServerSettings::default().session_ttl_hours, None);
489        assert_eq!(
490            ServerSettings::default().effective_session_ttl_hours(),
491            DEFAULT_SESSION_TTL_HOURS,
492        );
493        // The defaults() document surfaces the real default so the SPA can
494        // render it as a placeholder.
495        assert_eq!(
496            ServerSettings::defaults().session_ttl_hours,
497            Some(DEFAULT_SESSION_TTL_HOURS),
498        );
499    }
500
501    #[test]
502    fn session_ttl_stored_value_wins() {
503        let s = ServerSettings {
504            session_ttl_hours: Some(72),
505            ..Default::default()
506        };
507        assert_eq!(s.effective_session_ttl_hours(), 72);
508    }
509
510    #[test]
511    fn session_ttl_effective_clamps_out_of_band_writes() {
512        // An out-of-band 0 must floor to 1 (else `now + 0h` is an
513        // instantly-expired token); a value past the cap clamps down so
514        // login's date math can't overflow.
515        let zero = ServerSettings {
516            session_ttl_hours: Some(0),
517            ..Default::default()
518        };
519        assert_eq!(zero.effective_session_ttl_hours(), 1);
520        let huge = ServerSettings {
521            session_ttl_hours: Some(u32::MAX),
522            ..Default::default()
523        };
524        assert_eq!(huge.effective_session_ttl_hours(), MAX_SESSION_TTL_HOURS);
525    }
526
527    #[test]
528    fn session_ttl_round_trips_and_omits_when_unset() {
529        let s = ServerSettings {
530            session_ttl_hours: Some(48),
531            ..Default::default()
532        };
533        let json = serde_json::to_string(&s).unwrap();
534        assert_eq!(json, r#"{"session_ttl_hours":48}"#);
535        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
536        // Unset is omitted so a blank doc stays minimal.
537        assert!(
538            !serde_json::to_string(&ServerSettings::default())
539                .unwrap()
540                .contains("session_ttl_hours")
541        );
542    }
543
544    #[test]
545    fn check_stale_unset_resolves_to_builtin_default() {
546        // Blank (the derived Default) resolves to the 30-day default — ON out
547        // of the box, so the feature works without configuration.
548        assert_eq!(ServerSettings::default().check_status_stale_days, None);
549        assert_eq!(
550            ServerSettings::default().effective_check_status_stale_days(),
551            DEFAULT_CHECK_STATUS_STALE_DAYS,
552        );
553        // defaults() surfaces the real default for the SPA placeholder.
554        assert_eq!(
555            ServerSettings::defaults().check_status_stale_days,
556            Some(DEFAULT_CHECK_STATUS_STALE_DAYS),
557        );
558    }
559
560    #[test]
561    fn check_stale_zero_disables() {
562        // Explicit 0 means "disable staleness" (show everything) — NOT floored
563        // to 1 like collect/session; same convention as agent_prune_days.
564        let s = ServerSettings {
565            check_status_stale_days: Some(0),
566            ..Default::default()
567        };
568        assert_eq!(s.effective_check_status_stale_days(), 0);
569    }
570
571    #[test]
572    fn check_stale_stored_value_wins_and_clamps() {
573        let s = ServerSettings {
574            check_status_stale_days: Some(7),
575            ..Default::default()
576        };
577        assert_eq!(s.effective_check_status_stale_days(), 7);
578        let big = ServerSettings {
579            check_status_stale_days: Some(u32::MAX),
580            ..Default::default()
581        };
582        assert_eq!(
583            big.effective_check_status_stale_days(),
584            MAX_CHECK_STATUS_STALE_DAYS,
585        );
586    }
587
588    #[test]
589    fn check_stale_round_trips_and_omits_when_unset() {
590        let s = ServerSettings {
591            check_status_stale_days: Some(14),
592            ..Default::default()
593        };
594        let json = serde_json::to_string(&s).unwrap();
595        assert_eq!(json, r#"{"check_status_stale_days":14}"#);
596        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
597        assert!(
598            !serde_json::to_string(&ServerSettings::default())
599                .unwrap()
600                .contains("check_status_stale_days")
601        );
602    }
603
604    #[test]
605    fn accepts_unknown_fields_for_forward_compat() {
606        // A newer backend may have added knobs this build doesn't know;
607        // decoding must drop them rather than error.
608        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
609        let s: ServerSettings = serde_json::from_str(json).unwrap();
610        assert_eq!(s.agent_prune_days, Some(7));
611    }
612}