v0.5.4: code-cleanup pass (AppError, figment config, encoding dedup, typed status, deps)

Final cleanup before hardware testing. No behaviour changes; 248 tests green,
clippy clean.

#1  AppError newtype (http-api/src/error.rs) with one IntoResponse mapping
    (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>.
    Converted the clearly-safe handlers (sso_put, unattended_upload,
    branding_clear) to `?`; intentionally left handlers with bespoke
    status semantics (Invalid→404 on category, 409 on duplicate share /
    open upload) explicit so no asserted status changes.
#2  figment-based Config::load (defaults → TOML → env). Keeps the historical
    flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested
    OPENPXE_SECTION__FIELD form; now covers every field (apply_env had
    silently skipped unattended_dir + bind addrs). 6 Jail tests prove
    backward-compat. Removed the hand-rolled apply_env.
#3  thiserror 1→2; dropped unused mime/mime_guess/once_cell deps.
#4  Re-evaluated: Duration::from_hours/from_mins are stable on the pinned
    1.95 toolchain and clippy prefers them — kept the readable form
    (the "unstable" premise didn't hold; MSRV is intentionally 1.95).
#5  insta snapshot of the rendered iPXE menu (version-filtered) + wiremock
    coverage of the SAML metadata-URL fetch (200 + non-2xx).
#6  api_status → typed StatusResponse struct (was a 25-key json! blob) with
    a full_flow guard test asserting every UI key + the started_at string
    shape. Deferred the /api/docs typed conversion (lowest value, highest
    churn, zero functional benefit).
#7  pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were
    copied across app.rs + the SAML modules). No new crates.
#8  UploadSessions registry → parking_lot::RwLock (sync, never held across
    .await); per-session lock stays tokio::Mutex.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-03 03:33:05 -04:00
co-authored by Claude Opus 4.8
parent 7358013093
commit 674a69f93b
18 changed files with 807 additions and 199 deletions
+6 -2
View File
@@ -33,8 +33,6 @@ thiserror.workspace = true
anyhow.workspace = true
bytes.workspace = true
futures.workspace = true
mime.workspace = true
mime_guess.workspace = true
uuid.workspace = true
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
parking_lot.workspace = true
@@ -61,3 +59,9 @@ image = { version = "0.25", default-features = false, features = ["png"] }
# replay, and IdP-initiated-gating flows exercise real signatures.
rcgen = "0.13"
bergshamra = { workspace = true }
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
# dropped line, reordered item) is caught and reviewed, not silently shipped.
insta = "1.40"
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
# fetch path (previously untested because it did a real network GET).
wiremock = "0.6"
+102 -76
View File
@@ -14,6 +14,7 @@
//! | `/api/*` | JSON/HTML API for the web UI |
use crate::auth as auth_api;
use crate::error::AppError;
use crate::ipxe_script::{
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
render_queue_entry, render_shell, render_tools_menu, render_util,
@@ -31,15 +32,15 @@ use axum::{
Json, Router,
};
use openpxe_core::{
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig,
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
UnattendedMeta,
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, SmbState,
UnattendedKind, UnattendedMeta,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::net::SocketAddr;
use std::time::Duration;
@@ -241,12 +242,12 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
Json(state.sso.snapshot())
}
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
match state.sso.replace(body) {
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
async fn api_sso_put(
State(state): State<AppState>,
Json(body): Json<SsoConfig>,
) -> Result<Json<SsoConfig>, AppError> {
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
Ok(Json(state.sso.replace(body)?))
}
// ─── UI ────────────────────────────────────────────────────────────────────
@@ -1198,14 +1199,12 @@ async fn api_branding_upload(
async fn api_branding_clear(
State(state): State<AppState>,
AxumPath(slot): AxumPath<String>,
) -> Response {
) -> Result<Response, AppError> {
let Some(slot) = LogoSlot::parse(&slot) else {
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response();
return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
};
match state.branding.clear_logo(slot) {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
}
state.branding.clear_logo(slot)?;
Ok(StatusCode::NO_CONTENT.into_response())
}
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
@@ -1221,7 +1220,9 @@ async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::
async fn api_unattended_upload(
State(state): State<AppState>,
mut multipart: Multipart,
) -> Response {
) -> Result<Response, AppError> {
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
// The multipart-shape 400s (missing field/filename) stay explicit.
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "file" && name != "unattended" {
@@ -1229,19 +1230,18 @@ async fn api_unattended_upload(
}
let filename = field.file_name().map(str::to_string).unwrap_or_default();
if filename.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response();
return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
}
let bytes = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
};
return match state.unattended.add(&filename, &bytes).await {
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
Err(e) => {
return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
}
};
let meta = state.unattended.add(&filename, &bytes).await?;
return Ok((StatusCode::CREATED, Json(meta)).into_response());
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
}
async fn api_unattended_delete(
@@ -1379,23 +1379,7 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
out
}
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
/// passes through; everything else becomes `%XX`).
fn pct_encode(s: &str) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
@@ -1948,7 +1932,49 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
Json(json!({ "clients": state.clients.list() }))
}
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
/// Per-theme branding presence, nested under [`StatusResponse::branding`].
#[derive(Serialize)]
struct BrandingStatus {
light: bool,
dark: bool,
client: bool,
rev: u64,
}
/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
/// `json!` blob — the typed struct makes the contract with the WebUI
/// compile-checked. Field names ARE the JSON keys; do not rename without
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
/// the wire shape is byte-identical to the previous `json!` output.
#[derive(Serialize)]
struct StatusResponse {
version: &'static str,
public_base_url: String,
iso_count: usize,
client_count: usize,
queue_count: usize,
imaging_count: usize,
waiting_count: usize,
ipxe_assets: Vec<String>,
settings: Settings,
smb: Option<SmbState>,
smb_share_count: usize,
smb_share_reachable: usize,
nfs_share_count: usize,
nfs_share_reachable: usize,
host_bindings: usize,
custom_logo: bool,
branding: BrandingStatus,
unattended_count: usize,
uptime_secs: i64,
started_at: time::OffsetDateTime,
nic_name: String,
subnet_mask: String,
gateway: String,
}
async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
let smb = state.smb.as_ref().map(|s| s.snapshot());
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
@@ -1981,39 +2007,39 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
state.metrics.record_http(openpxe_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": clients.len(),
"queue_count": queue_entries.len(),
"imaging_count": imaging,
"waiting_count": waiting,
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
"settings": state.settings.snapshot(),
"smb": smb,
"smb_share_count": smb_shares.len(),
"smb_share_reachable": smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
// with the SMB counts above ("N shares reachable") so the
// top-line metric works regardless of protocol mix.
"nfs_share_count": nfs_shares.len(),
"nfs_share_reachable": nfs_reachable,
"host_bindings": state.hosts.len(),
"custom_logo": state.branding.has_any_web_logo(),
"branding": {
"light": state.branding.has_logo(LogoSlot::Light),
"dark": state.branding.has_logo(LogoSlot::Dark),
"client": state.branding.has_logo(LogoSlot::Client),
"rev": state.branding.logo_rev(),
Json(StatusResponse {
version: env!("CARGO_PKG_VERSION"),
public_base_url: state.public_base_url.clone(),
iso_count: isos.len(),
client_count: clients.len(),
queue_count: queue_entries.len(),
imaging_count: imaging,
waiting_count: waiting,
ipxe_assets: openpxe_ipxe_assets::list_assets(),
settings: state.settings.snapshot(),
smb,
smb_share_count: smb_shares.len(),
smb_share_reachable: smb_reachable,
// v0.4.67: NFSv3 share counts. The dashboard tile sums these with
// the SMB counts above ("N shares reachable") so the top-line
// metric works regardless of protocol mix.
nfs_share_count: nfs_shares.len(),
nfs_share_reachable: nfs_reachable,
host_bindings: state.hosts.len(),
custom_logo: state.branding.has_any_web_logo(),
branding: BrandingStatus {
light: state.branding.has_logo(LogoSlot::Light),
dark: state.branding.has_logo(LogoSlot::Dark),
client: state.branding.has_logo(LogoSlot::Client),
rev: state.branding.logo_rev(),
},
"unattended_count": state.unattended.len(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
"subnet_mask": state.subnet_mask,
"gateway": state.gateway,
}))
unattended_count: state.unattended.len(),
uptime_secs,
started_at: state.started_at,
nic_name: state.nic_name.clone(),
subnet_mask: state.subnet_mask.clone(),
gateway: state.gateway.clone(),
})
}
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
+92
View File
@@ -0,0 +1,92 @@
//! Uniform HTTP error mapping for the API layer (v0.5.4).
//!
//! Before this, ~40 handlers in `app.rs` hand-wrote
//! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`,
//! and the `openpxe_core::Error` → status mapping drifted between them
//! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps
//! `openpxe_core::Error` so a handler can return `Result<T, AppError>` and
//! `?` its way out, getting one consistent status + body. The body stays
//! plain-text (matching the previous `(StatusCode, String)` responses) so
//! existing clients and tests see no shape change; 5xx detail is logged
//! and returned verbatim exactly as before.
//!
//! Handlers with *intentional* domain-specific statuses (e.g. a duplicate
//! share → 409, a still-open chunked upload → 409) keep their explicit
//! returns — `AppError` is for the common case, not a straitjacket.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use openpxe_core::Error as CoreError;
/// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`].
#[derive(Debug)]
pub struct AppError(pub CoreError);
impl From<CoreError> for AppError {
fn from(e: CoreError) -> Self {
AppError(e)
}
}
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError(CoreError::Io(e))
}
}
impl AppError {
/// The HTTP status this error maps to. Public so handlers (and tests)
/// can reason about the mapping in one place.
#[must_use]
pub fn status(&self) -> StatusCode {
match self.0 {
CoreError::NotFound(_) => StatusCode::NOT_FOUND,
CoreError::Invalid(_) => StatusCode::BAD_REQUEST,
CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = self.status();
// Match the prior hand-written responses: the 4xx arms returned the
// bare inner message (not the `Display` prefix), so a UI showing
// `await r.text()` reads "metadata too long", not "invalid input:
// metadata too long". 5xx keeps the full `Display` string.
let body = match &self.0 {
CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(),
other => other.to_string(),
};
if status.is_server_error() {
// Log the full detail server-side; the body still carries it
// (unchanged from the prior `format!("{e}")` behaviour), but the
// log line is what an operator greps for.
tracing::error!(target: "openpxe::http", error = %self.0, "request failed");
}
(status, body).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_mapping_is_consistent() {
assert_eq!(
AppError(CoreError::NotFound("x".into())).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
AppError(CoreError::Invalid("x".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
AppError(CoreError::Config("x".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
}
+16
View File
@@ -693,6 +693,22 @@ mod password_tests {
assert!(s.contains("arm64 UEFI"), "{s}");
}
// v0.5.4: a full snapshot of the rendered top menu. The fragment
// `assert!`s above check specific invariants; this catches *any* other
// drift (a reordered item, a dropped line, changed spacing) so it's
// reviewed deliberately. The OpenPXE version is filtered out so the
// snapshot doesn't churn on every release bump.
#[test]
fn render_menu_snapshot() {
// Normalize the compile-time version so the snapshot doesn't churn
// on every release bump (no insta `filters` feature needed).
let rendered = render_menu(&[], &Settings::default(), "http://10.0.0.5").replace(
concat!("OpenPXE v", env!("CARGO_PKG_VERSION")),
"OpenPXE vX.Y.Z",
);
insta::assert_snapshot!(rendered);
}
#[test]
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
let settings = Settings::default();
+1
View File
@@ -15,6 +15,7 @@
pub mod app;
pub mod auth;
pub mod error;
pub mod ipxe_script;
pub mod iso_fs;
pub mod log_stream;
+32
View File
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> Response {
IntoResponse::into_response,
)
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
// previously this path did a real network GET and had no coverage.
#[tokio::test]
async fn fetch_metadata_returns_body_on_200() {
let server = MockServer::start().await;
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
.mount(&server)
.await;
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
assert_eq!(got, xml);
}
#[tokio::test]
async fn fetch_metadata_errors_on_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_metadata(&server.uri()).await.unwrap_err();
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
}
}
@@ -0,0 +1,36 @@
---
source: crates/http-api/src/ipxe_script.rs
expression: rendered
---
#!ipxe
# OpenPXE top-level menu - auto-generated, do not edit
set base-url http://10.0.0.5
set esc:hex 1b
set cls ${esc:string}[2J
console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
set arch-label ${buildarch} ${platform}
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
:menu
menu OpenPXE - network boot menu
item --gap
item --gap -- ------------------------- Default -------------------------
item local Boot from Local HDD
item --gap -- ----------------------- Installers -----------------------
item --gap -- (no Linux ISOs uploaded)
item --gap -- (Windows support disabled in Settings)
item --gap -- -------------------------- Tools --------------------------
item tools Tools >
item --gap -- ---------------------- Queued Deployment ---------------------
item queue Queued Deployment (join queue)
item --gap
item --key x exit Exit iPXE
item --gap
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
choose --default queue --timeout 600000 target || goto menu
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
iseq ${target} exit && exit || goto menu
goto menu
+12 -8
View File
@@ -9,6 +9,7 @@
use bytes::Bytes;
use openpxe_core::{Error, Result};
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
use parking_lot::RwLock;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
#[derive(Clone, Default)]
pub struct UploadSessions {
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
// v0.5.4: the registry is a sync `parking_lot::RwLock` — it's only ever
// briefly read/inserted/removed to look up a session, never held across
// an `.await`. The per-session lock below stays a `tokio::sync::Mutex`
// because `write_chunk` / `finish` are awaited while it's held.
inner: Arc<RwLock<HashMap<String, Arc<Mutex<UploadSession>>>>>,
}
struct UploadSession {
@@ -67,8 +72,7 @@ impl UploadSessions {
};
self.inner
.lock()
.await
.write()
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
Ok(UploadStarted {
@@ -88,7 +92,7 @@ impl UploadSessions {
chunk: Bytes,
complete: bool,
) -> Result<UploadAppend> {
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
@@ -119,7 +123,7 @@ impl UploadSessions {
if let Err(e) = handle.write_chunk(&chunk).await {
let handle = session.handle.take();
drop(session);
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
if let Some(handle) = handle {
let _ = handle.abort().await;
}
@@ -156,11 +160,11 @@ impl UploadSessions {
let meta = match handle.finish(store).await {
Ok(meta) => meta,
Err(e) => {
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
return Err(e);
}
};
self.inner.lock().await.remove(upload_id);
self.inner.write().remove(upload_id);
Ok(UploadAppend::Complete {
offset: new_offset,
iso: Box::new(meta),
@@ -168,7 +172,7 @@ impl UploadSessions {
}
pub async fn abort(&self, upload_id: &str) -> Result<()> {
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
let Some(session_lock) = self.inner.write().remove(upload_id) else {
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
};
let mut session = session_lock.lock().await;
+54
View File
@@ -1536,6 +1536,60 @@ async fn status_exposes_custom_logo_flag() {
);
}
/// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI
/// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops
/// or renames one, the dashboard silently breaks — this catches it.
#[tokio::test]
async fn status_contract_has_all_ui_keys() {
let (state, _dir) = build_state().await;
let app = build_router(state);
let (s, body) = get(&app, "/api/status").await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
for key in [
"version",
"public_base_url",
"iso_count",
"client_count",
"queue_count",
"imaging_count",
"waiting_count",
"ipxe_assets",
"settings",
"smb_share_count",
"smb_share_reachable",
"nfs_share_count",
"nfs_share_reachable",
"host_bindings",
"custom_logo",
"branding",
"unattended_count",
"uptime_secs",
"started_at",
"nic_name",
"subnet_mask",
"gateway",
] {
assert!(
v.get(key).is_some(),
"/api/status missing UI key '{key}': {v}"
);
}
// Nested branding presence the Settings tab reads.
for key in ["light", "dark", "client", "rev"] {
assert!(
v["branding"].get(key).is_some(),
"/api/status branding missing '{key}': {v}"
);
}
// started_at must remain an RFC3339 string (the UI does fmtUptime on
// uptime_secs but renders started_at as text), not a serialized struct.
assert!(
v["started_at"].is_string(),
"started_at should serialize as a string: {v}"
);
}
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
let res = router
.clone()