mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(admin,landing): admin-plane hardening + public landing [operator §2]
Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
(localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
since rebinding must supply one and the bind is the primary control) to defeat
DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.
Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
by serializing config/state). Default projection is minimal { software.name,
status }; version (coarse major.minor), chains, price_feed, and uptime (bool
`up` only) are each individually opt-in. The enabled-feature tuple is NEVER
emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.
Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.
Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
This commit is contained in:
+80
-2
@@ -16,8 +16,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::HeaderMap,
|
||||
extract::{ConnectInfo, Path, Request, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -78,6 +80,58 @@ fn pubkey_prefix(pubkey: &str) -> String {
|
||||
pubkey.chars().take(PUBKEY_PREFIX_LEN).collect()
|
||||
}
|
||||
|
||||
/// Whether a request `Host` is allowed on the admin plane. A missing Host is
|
||||
/// permitted (a DNS-rebinding attack must supply the attacker's Host, and the
|
||||
/// loopback bind is the primary control); a PRESENT host must be loopback or the
|
||||
/// configured onion. Defeats DNS-rebinding against the loopback socket.
|
||||
fn host_allowed(host: &str, onion: Option<&str>) -> bool {
|
||||
if host.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let h = host.to_ascii_lowercase();
|
||||
// IPv6 loopback (with or without :port), e.g. "[::1]" / "[::1]:8081".
|
||||
if h.starts_with("[::1]") || h == "::1" {
|
||||
return true;
|
||||
}
|
||||
let bare = h.split(':').next().unwrap_or(&h);
|
||||
if matches!(bare, "localhost" | "127.0.0.1") {
|
||||
return true;
|
||||
}
|
||||
if let Some(o) = onion {
|
||||
let o = o.to_ascii_lowercase();
|
||||
if !o.is_empty() && (bare == o || h == o) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Admin-plane middleware: Host allowlist (anti DNS-rebinding) + anti-clickjacking
|
||||
/// response headers. The loopback bind is the primary control; these are
|
||||
/// defense-in-depth for the Tor/tunnel paths and any browser-reachable case.
|
||||
pub async fn admin_plane_guard(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let host = req
|
||||
.headers()
|
||||
.get(axum::http::header::HOST)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !host_allowed(host, state.config.admin.onion.as_deref()) {
|
||||
return (StatusCode::FORBIDDEN, "forbidden").into_response();
|
||||
}
|
||||
let mut resp = next.run(req).await;
|
||||
let h = resp.headers_mut();
|
||||
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
h.insert(
|
||||
"content-security-policy",
|
||||
HeaderValue::from_static("frame-ancestors 'none'"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Validate an x-only secp256k1 pubkey: exactly 64 lowercase hex chars.
|
||||
fn validate_admin_pubkey(pubkey: &str) -> Result<(), AppError> {
|
||||
let ok = pubkey.len() == 64
|
||||
@@ -611,3 +665,27 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::host_allowed;
|
||||
|
||||
#[test]
|
||||
fn host_allowlist() {
|
||||
// Missing host is allowed (rebinding must supply a host; bind is primary).
|
||||
assert!(host_allowed("", None));
|
||||
// Loopback forms.
|
||||
assert!(host_allowed("localhost", None));
|
||||
assert!(host_allowed("localhost:8081", None));
|
||||
assert!(host_allowed("127.0.0.1", None));
|
||||
assert!(host_allowed("127.0.0.1:8081", None));
|
||||
assert!(host_allowed("[::1]:8081", None));
|
||||
// A foreign host (DNS-rebinding) is rejected.
|
||||
assert!(!host_allowed("evil.example", None));
|
||||
assert!(!host_allowed("attacker.test:8081", None));
|
||||
// The configured onion is allowed; others still rejected.
|
||||
assert!(host_allowed("abc.onion", Some("abc.onion")));
|
||||
assert!(host_allowed("abc.onion:80", Some("abc.onion")));
|
||||
assert!(!host_allowed("evil.onion", Some("abc.onion")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Public landing page + `server-info` read model (operator §2.3).
|
||||
//!
|
||||
//! Treated as ONE fingerprint surface, not N independent fields. Default-OFF
|
||||
//! (`PUBLIC_LANDING_ENABLED`); when on, the default public set is minimal —
|
||||
//! `{ software.name, status }` — and every other field is individually opt-in.
|
||||
//! Built from a hand-written allowlist struct (never by serializing config or
|
||||
//! state), so a secret can't leak by accident. The enabled-feature tuple is
|
||||
//! NEVER emitted anonymously here (connected clients use `/capabilities`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Software {
|
||||
pub name: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PriceFeedInfo {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// The public projection. Optional fields are omitted unless explicitly opted in,
|
||||
/// so the default JSON is exactly `{ "software": { "name": ... }, "status": ... }`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ServerInfo {
|
||||
pub software: Software,
|
||||
pub status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chains: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub price_feed: Option<PriceFeedInfo>,
|
||||
/// Boolean `up` only (never an uptime duration — reboot timing is recon).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub up: Option<bool>,
|
||||
}
|
||||
|
||||
/// Coarse `major.minor` (patch is recon-only).
|
||||
fn coarse_version() -> String {
|
||||
let v = env!("CARGO_PKG_VERSION");
|
||||
let mut parts = v.split('.');
|
||||
match (parts.next(), parts.next()) {
|
||||
(Some(major), Some(minor)) => format!("{major}.{minor}"),
|
||||
_ => v.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled_chain_symbols(config: &Config) -> Vec<String> {
|
||||
let c = &config.features.chains;
|
||||
[
|
||||
("btc", c.btc),
|
||||
("ltc", c.ltc),
|
||||
("xmr", c.xmr),
|
||||
("wow", c.wow),
|
||||
("grin", c.grin),
|
||||
]
|
||||
.iter()
|
||||
.filter(|(_, on)| *on)
|
||||
.map(|(sym, _)| sym.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the public projection from config, honoring each per-field toggle.
|
||||
pub fn build_server_info(config: &Config) -> ServerInfo {
|
||||
let l = &config.landing;
|
||||
ServerInfo {
|
||||
software: Software {
|
||||
name: "smirk-backend-core",
|
||||
version: l.expose_version.then(coarse_version),
|
||||
},
|
||||
status: "ok",
|
||||
title: l.title.clone(),
|
||||
chains: l.expose_chains.then(|| enabled_chain_symbols(config)),
|
||||
price_feed: l.expose_price_feed.then_some(PriceFeedInfo {
|
||||
enabled: config.features.prices,
|
||||
}),
|
||||
up: l.expose_uptime.then_some(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/v1/server-info` — the landing read model. `404` when landing is off
|
||||
/// (so it does not hint that an admin plane exists).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Result<Json<ServerInfo>, AppError> {
|
||||
if !state.config.landing.enabled {
|
||||
return Err(AppError::NotFound("not found".into()));
|
||||
}
|
||||
Ok(Json(build_server_info(&state.config)))
|
||||
}
|
||||
|
||||
/// `GET /` — minimal HTML rendered from the read model; bare `404` when off.
|
||||
pub async fn root(State(state): State<Arc<AppState>>) -> Response {
|
||||
if !state.config.landing.enabled {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let title = state
|
||||
.config
|
||||
.landing
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or("smirk-backend-core");
|
||||
let t = html_escape(title);
|
||||
let html = format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
|
||||
<title>{t}</title></head><body><h1>{t}</h1><p>status: ok</p></body></html>"
|
||||
);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
/// Minimal HTML entity escaping for the operator-supplied title.
|
||||
fn html_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `server-info` route, RELATIVE to the `/api/v1` mount point. Public.
|
||||
pub fn routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/server-info", get(server_info))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn html_escape_neutralizes_script() {
|
||||
assert_eq!(
|
||||
html_escape("<script>alert(1)</script>"),
|
||||
"<script>alert(1)</script>"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod capabilities;
|
||||
pub mod health;
|
||||
pub mod landing;
|
||||
pub mod middleware;
|
||||
pub mod nip05;
|
||||
pub mod openapi;
|
||||
|
||||
@@ -271,12 +271,26 @@ pub struct AdminConfig {
|
||||
pub pubkeys: Vec<String>,
|
||||
pub max_keys: u32,
|
||||
pub pending_key_ttl_days: u32,
|
||||
/// Allow the admin plane to bind a non-loopback address (default false →
|
||||
/// startup refuses a public bind; confidentiality is by socket).
|
||||
pub allow_public_bind: bool,
|
||||
/// Tor onion host the admin plane is reached at (added to the Host allowlist;
|
||||
/// never logged or surfaced publicly).
|
||||
pub onion: Option<String>,
|
||||
}
|
||||
|
||||
/// Public landing page. Off by default; full + per-field tunable when enabled.
|
||||
#[derive(Clone)]
|
||||
pub struct LandingConfig {
|
||||
pub enabled: bool,
|
||||
/// Operator free text (HTML-escaped on render). `None` = omitted.
|
||||
pub title: Option<String>,
|
||||
/// Emit a coarse (major.minor) version. Default off (version is recon).
|
||||
pub expose_version: bool,
|
||||
/// Emit the enabled chain symbols. Default off.
|
||||
pub expose_chains: bool,
|
||||
/// Emit `price_feed.enabled`. Default off.
|
||||
pub expose_price_feed: bool,
|
||||
pub expose_features: bool,
|
||||
pub expose_uptime: bool,
|
||||
pub stats_enabled: bool,
|
||||
@@ -420,9 +434,15 @@ impl Config {
|
||||
pubkeys: env_list("ADMIN_PUBKEYS"),
|
||||
max_keys: env_parse("ADMIN_MAX_KEYS", 8u32)?,
|
||||
pending_key_ttl_days: env_parse("ADMIN_PENDING_KEY_TTL_DAYS", 7u32)?,
|
||||
allow_public_bind: env_bool("ADMIN_ALLOW_PUBLIC_BIND", false),
|
||||
onion: env_opt("TOR_ADMIN_ONION"),
|
||||
},
|
||||
landing: LandingConfig {
|
||||
enabled: env_bool("PUBLIC_LANDING_ENABLED", false),
|
||||
title: env_opt("PUBLIC_LANDING_TITLE"),
|
||||
expose_version: env_bool("PUBLIC_EXPOSE_VERSION", false),
|
||||
expose_chains: env_bool("PUBLIC_EXPOSE_CHAINS", false),
|
||||
expose_price_feed: env_bool("PUBLIC_EXPOSE_PRICE_FEED", false),
|
||||
expose_features: env_bool("PUBLIC_EXPOSE_FEATURES", false),
|
||||
expose_uptime: env_bool("PUBLIC_EXPOSE_UPTIME", false),
|
||||
stats_enabled: env_bool("PUBLIC_STATS_ENABLED", false),
|
||||
@@ -677,9 +697,15 @@ mod tests {
|
||||
pubkeys: vec![],
|
||||
max_keys: 8,
|
||||
pending_key_ttl_days: 7,
|
||||
allow_public_bind: false,
|
||||
onion: None,
|
||||
},
|
||||
landing: LandingConfig {
|
||||
enabled: false,
|
||||
title: None,
|
||||
expose_version: false,
|
||||
expose_chains: false,
|
||||
expose_price_feed: false,
|
||||
expose_features: false,
|
||||
expose_uptime: false,
|
||||
stats_enabled: false,
|
||||
|
||||
@@ -105,11 +105,13 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.merge(api::wallet::routes())
|
||||
.merge(api::capabilities::routes())
|
||||
.merge(api::prices::routes())
|
||||
.merge(api::landing::routes())
|
||||
.layer(normal),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(api::health::health))
|
||||
.route("/", get(api::landing::root))
|
||||
.merge(api::nip05::routes())
|
||||
.nest("/api/v1", api_v1)
|
||||
.with_state(state)
|
||||
@@ -133,6 +135,11 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
pub fn admin_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.merge(api::admin::routes())
|
||||
// Host allowlist (anti DNS-rebinding) + anti-clickjacking headers.
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
api::admin::admin_plane_guard,
|
||||
))
|
||||
.with_state(state)
|
||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||
.layer(TimeoutLayer::new(REQUEST_TIMEOUT))
|
||||
|
||||
+11
-5
@@ -171,11 +171,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// to the configured (loopback-default) address and warn if it isn't local.
|
||||
if state.config.admin.enabled {
|
||||
let admin_addr = state.config.admin.bind.clone();
|
||||
if !admin_addr.starts_with("127.") && !admin_addr.starts_with("[::1]") {
|
||||
tracing::warn!(
|
||||
bind = %admin_addr,
|
||||
"admin plane is not bound to loopback — ensure this is intentional"
|
||||
);
|
||||
// Fail-closed bind guard: refuse a non-loopback admin bind unless the
|
||||
// operator explicitly opts in (confidentiality is by socket).
|
||||
let is_loopback = admin_addr.starts_with("127.")
|
||||
|| admin_addr.starts_with("[::1]")
|
||||
|| admin_addr.starts_with("localhost");
|
||||
if !is_loopback && !state.config.admin.allow_public_bind {
|
||||
return Err(format!(
|
||||
"ADMIN_BIND {admin_addr} is not loopback; set ADMIN_ALLOW_PUBLIC_BIND=true to override \
|
||||
(and front it with Tor client-auth / a trusted proxy)"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let admin_app = admin_router(state.clone());
|
||||
let admin_listener = tokio::net::TcpListener::bind(&admin_addr).await?;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! L1 integration: admin-plane posture (operator §2.1/§2.2) — Host allowlist
|
||||
//! (anti DNS-rebinding) + anti-clickjacking headers. Uses a raw oneshot so it can
|
||||
//! set the Host header and read response headers.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn admin_req(host: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/me")
|
||||
.header("host", host)
|
||||
.extension(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))))
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_allowlist_and_anti_clickjacking() {
|
||||
std::env::set_var("ADMIN_ENABLED", "true");
|
||||
std::env::set_var(
|
||||
"ADMIN_JWT_SECRET",
|
||||
"admin-jwt-secret-at-least-32-bytes-long!!",
|
||||
);
|
||||
std::env::set_var(
|
||||
"ADMIN_KEY_INTEGRITY_SECRET",
|
||||
"admin-integrity-secret-at-least-32-bytes!",
|
||||
);
|
||||
std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081");
|
||||
|
||||
let Some(app) = common::try_app().await else {
|
||||
return;
|
||||
};
|
||||
let admin = app.admin_router();
|
||||
|
||||
// A foreign Host (DNS-rebinding) is rejected before auth even runs.
|
||||
let resp = admin
|
||||
.clone()
|
||||
.oneshot(admin_req("evil.example"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"rebinding host rejected"
|
||||
);
|
||||
|
||||
// A loopback Host reaches the guard (401 unauthenticated, not 403) and the
|
||||
// response carries the anti-clickjacking headers.
|
||||
let resp = admin.oneshot(admin_req("127.0.0.1:8081")).await.unwrap();
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"loopback host accepted"
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers().get("x-frame-options").map(|v| v.as_bytes()),
|
||||
Some(b"DENY".as_ref())
|
||||
);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get("content-security-policy")
|
||||
.map(|v| v.as_bytes()),
|
||||
Some(b"frame-ancestors 'none'".as_ref())
|
||||
);
|
||||
|
||||
for k in [
|
||||
"ADMIN_ENABLED",
|
||||
"ADMIN_JWT_SECRET",
|
||||
"ADMIN_KEY_INTEGRITY_SECRET",
|
||||
"ADMIN_PUBLIC_URL",
|
||||
] {
|
||||
std::env::remove_var(k);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! L1 integration: the public landing surface (operator §2.3). Default-OFF; when
|
||||
//! on, the default projection is the minimal `{ software.name, status }` and `/`
|
||||
//! serves HTML. One sequential test (it toggles process env).
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[tokio::test]
|
||||
async fn landing_off_then_on_minimal() {
|
||||
// Off (default): both the read model and `/` are 404 (no admin-plane hint).
|
||||
std::env::remove_var("PUBLIC_LANDING_ENABLED");
|
||||
let Some(app) = common::try_app().await else {
|
||||
return;
|
||||
};
|
||||
let (st, _) = app.request("GET", "/api/v1/server-info", None, None).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
let (st, _) = app.request("GET", "/", None, None).await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// On, default toggles: minimal projection — only software.name + status.
|
||||
std::env::set_var("PUBLIC_LANDING_ENABLED", "true");
|
||||
let app = common::try_app().await.expect("app");
|
||||
let (st, body) = app.request("GET", "/api/v1/server-info", None, None).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
let obj = body.as_object().expect("object");
|
||||
assert_eq!(obj.len(), 2, "only software + status by default: {body}");
|
||||
assert_eq!(body["software"]["name"], "smirk-backend-core");
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert!(
|
||||
body["software"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("version")
|
||||
.is_none(),
|
||||
"version omitted by default"
|
||||
);
|
||||
for hidden in ["chains", "price_feed", "up", "features"] {
|
||||
assert!(obj.get(hidden).is_none(), "{hidden} omitted by default");
|
||||
}
|
||||
|
||||
let (st, _) = app.request("GET", "/", None, None).await;
|
||||
assert_eq!(st, StatusCode::OK, "landing HTML served when enabled");
|
||||
|
||||
std::env::remove_var("PUBLIC_LANDING_ENABLED");
|
||||
}
|
||||
Reference in New Issue
Block a user