Files
jwinterm 771a69b85e fix(prices,config): harden per-feed control + fail-closed gaps (adversarial review)
From a multi-lens adversarial review of the capabilities + prices work:

- PRICES_ASSETS="" now means NONE, not all (real bug): from_env used env_opt,
  which collapses unset and present-but-empty into None, so the documented
  "empty = none" silently served all five feeds. Read via env::var so unset =>
  all, present (even empty) => the listed subset or none.
- PRICES_CURRENCY is now validated against a curated SUPPORTED_PRICE_CURRENCIES
  allowlist. Previously a typo (e.g. "usdd") booted, advertised prices:true, and
  served an empty feed forever (the provider returns empty quotes for an unknown
  currency) — a fail-open path inconsistent with the adjacent provider/asset
  checks.
- PRICES_PROVIDER is lowercased at read, matching the asset/currency
  normalization (mixed-case no longer trips the allowlist).
- validate() now requires an Electrum URL for an enabled BTC/LTC chain (hard
  error in prod, warn in dev), symmetric with the xmr/wow/grin secret checks.
  Keeps /capabilities honest: a chain it can't serve won't boot advertising
  itself as available.

Tests: config regressions (currency, utxo-electrum); a PriceClient feed-subset
selection unit test; the /prices integration test now populates the snapshot and
asserts the served quotes + RFC-3339 timestamp (the prior PRICES_ASSETS env-var
was inert under the harness); capabilities asserts network presence/null across
all five chains.

Dismissed after verification: coingecko json() was already switched to a
streaming read_capped; fleet refresh-jitter (no API key, single-node,
self-healing on 429); prices capability staying a bare bool (/prices carries the
currency + per-asset map).
2026-06-25 17:25:52 -04:00

57 lines
2.4 KiB
Rust

//! L1 integration: the public prices endpoint, both feature-flag states.
//!
//! Drives `FEATURE_PRICES` explicitly and rebuilds the app for each state, so
//! both paths are deterministic without any network call. (The background
//! refresh task lives in `main`, not `build_router`, so under the harness the
//! enabled feed simply serves an empty, not-yet-populated snapshot.) The live
//! upstream fetch is covered by `infra::prices` unit tests and is an L3 concern.
mod common;
use axum::http::StatusCode;
#[tokio::test]
async fn prices_endpoint_respects_the_feature_flag() {
// Disabled → 404, so a client reading /capabilities never special-cases it.
std::env::set_var("FEATURE_PRICES", "false");
let Some(app) = common::try_app().await else {
return;
};
let (status, body) = app.request("GET", "/api/v1/prices", None, None).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "NOT_FOUND");
// Enabled, before any refresh → 200 with the documented empty shape (the
// background task lives in `main`, not the harness, so updated_at is null).
std::env::set_var("FEATURE_PRICES", "true");
let app = common::try_app()
.await
.expect("app builds with prices enabled");
let (status, body) = app.request("GET", "/api/v1/prices", None, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body["currency"].as_str().is_some(), "currency present");
assert!(body["prices"].is_object(), "prices is a map");
assert!(body["updated_at"].is_null(), "not yet refreshed");
// Enabled, with a populated snapshot → the handler serves the quotes and a
// non-null RFC 3339 timestamp. Drives the serving path the inert
// PRICES_ASSETS env-var never could; feed *selection* is unit-tested in
// infra::prices.
{
let mut snap = app.state.prices.write().await;
snap.prices.insert("btc".into(), 64000.0);
snap.prices.insert("xmr".into(), 150.0);
snap.updated_at = Some(chrono::Utc::now());
}
let (status, body) = app.request("GET", "/api/v1/prices", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["prices"]["btc"], 64000.0);
assert_eq!(body["prices"]["xmr"], 150.0);
assert!(
body["updated_at"].as_str().is_some(),
"timestamp serialized"
);
std::env::remove_var("FEATURE_PRICES");
}