use std::fs;
use std::path::{Path, PathBuf};
use clap::Parser;
use decdn_cli::commands::config as commands;
use decdn_common::cli::{ConfigValidateArgs, RunArgs};
use decdn_common::config::{
ResolvedBlockchain, ResolvedCache, ResolvedConfig, ResolvedIdentity, ResolvedNetwork,
ResolvedObservability, ResolvedPayment, ResolvedSecurity,
};
use tempfile::TempDir;
#[derive(Parser, Debug)]
struct RunArgsWrap {
#[command(flatten)]
run: RunArgs,
}
const VALID_CONFIG: &str = r#"
[identity]
region = "us"
[blockchain]
rpc_url = "https://sepolia-rollup.arbitrum.io/rpc"
payment_pool_address = "0x0000000000000000000000000000000000000001"
capacity_bond_address = "0x0000000000000000000000000000000000000002"
slash_judge_address = "0x0000000000000000000000000000000000000003"
content_blacklist_address = "0x0000000000000000000000000000000000000004"
"#;
const MISSING_RPC: &str = r#"
[blockchain]
payment_pool_address = "0x0000000000000000000000000000000000000001"
capacity_bond_address = "0x0000000000000000000000000000000000000002"
"#;
fn unknown_var_config(var_name: &str) -> String {
format!(
r#"
[blockchain]
rpc_url = "${{{var_name}}}"
payment_pool_address = "0x0000000000000000000000000000000000000001"
capacity_bond_address = "0x0000000000000000000000000000000000000002"
"#
)
}
fn args(data_dir: &Path) -> anyhow::Result<ConfigValidateArgs> {
let data_dir = data_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-utf8 tempdir path"))?;
let wrap = RunArgsWrap::try_parse_from(["decdn", "--data-dir", data_dir])?;
Ok(ConfigValidateArgs { run: wrap.run })
}
fn write_config(dir: &TempDir, body: &str) -> anyhow::Result<PathBuf> {
let path = dir.path().join("node.toml");
fs::write(&path, body)?;
Ok(path)
}
#[test]
fn validate_passes_for_complete_config() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let path = write_config(&dir, VALID_CONFIG)?;
fs::write(dir.path().join("keystore.json"), "")?;
commands::config_validate(Some(&path), &args(dir.path())?)
}
#[test]
fn validate_fails_for_malformed_relay_url() -> anyhow::Result<()> {
let body = format!(
"{VALID_CONFIG}\n[network]\nrelay_urls = [\"https://ok.example\", \"not a url\"]\n"
);
let dir = TempDir::new()?;
let path = write_config(&dir, &body)?;
fs::write(dir.path().join("keystore.json"), "")?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("network.relay_urls[1]") && msg.contains("not a url"),
"error should name the malformed relay entry: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_for_malformed_discovery_peer_addr() -> anyhow::Result<()> {
let id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let body = format!(
"{VALID_CONFIG}\n[network.discovery.peers.{id}]\naddrs = [\"not-a-socket-addr\"]\n"
);
let dir = TempDir::new()?;
let path = write_config(&dir, &body)?;
fs::write(dir.path().join("keystore.json"), "")?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains(&format!("network.discovery.peers[{id}].addrs[0]"))
&& msg.contains("not-a-socket-addr"),
"error should name the malformed peer address: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_when_required_field_missing() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let path = write_config(&dir, MISSING_RPC)?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("rpc_url"),
"error should mention rpc_url: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_for_unknown_field_in_section() -> anyhow::Result<()> {
let body = format!("{VALID_CONFIG}\n[security]\nmax_concurrent_handlerss = 10\n");
let dir = TempDir::new()?;
let path = write_config(&dir, &body)?;
fs::write(dir.path().join("keystore.json"), "")?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("max_concurrent_handlerss"),
"error should name the unknown key: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_for_typoed_whole_section() -> anyhow::Result<()> {
let body = format!("{VALID_CONFIG}\n[netork]\nrelay_urls = [\"https://ok.example\"]\n");
let dir = TempDir::new()?;
let path = write_config(&dir, &body)?;
fs::write(dir.path().join("keystore.json"), "")?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("netork"),
"error should name the unknown section: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_when_env_var_unset() -> anyhow::Result<()> {
let var_name = format!("DECDN_TEST_UNSET_{}", std::process::id());
anyhow::ensure!(
std::env::var_os(&var_name).is_none(),
"test precondition: {var_name} must not be set"
);
let dir = TempDir::new()?;
let path = write_config(&dir, &unknown_var_config(&var_name))?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains(&var_name),
"error should name the missing env var: {msg}"
);
Ok(())
}
#[test]
fn validate_fails_when_config_flag_points_at_missing_file() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let missing = dir.path().join("does-not-exist.toml");
let err = commands::config_validate(Some(&missing), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("does-not-exist.toml"),
"error should name the missing file: {msg}"
);
anyhow::ensure!(
!msg.contains("problem(s):"),
"file-load failure must not be wrapped in the aggregate header: {msg}"
);
Ok(())
}
const MULTI_ERROR: &str = r#"
[blockchain]
payment_pool_address = "0x0000000000000000000000000000000000000001"
capacity_bond_address = "0x0000000000000000000000000000000000000002"
slash_judge_address = "0x0000000000000000000000000000000000000003"
[cache]
cache_size_mb = 100
max_blob_size_mb = 500
[payment]
rate_per_mb = 0
"#;
#[test]
fn validate_emits_all_problems_at_once() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let path = write_config(&dir, MULTI_ERROR)?;
fs::write(dir.path().join("keystore.json"), "")?;
let err = commands::config_validate(Some(&path), &args(dir.path())?)
.err()
.ok_or_else(|| anyhow::anyhow!("expected validation to fail"))?;
let msg = format!("{err:#}");
anyhow::ensure!(
msg.contains("configuration has 4 problem(s):"),
"expected aggregated header: {msg}"
);
for needle in [
"rpc_url",
"content_blacklist_address",
"max_blob_size_mb",
"rate_per_mb",
] {
anyhow::ensure!(
msg.contains(needle),
"aggregated error should name {needle}: {msg}"
);
}
Ok(())
}
#[test]
fn effective_source_prefers_explicit_path() -> anyhow::Result<()> {
let explicit = PathBuf::from("/tmp/decdn-test-explicit.toml");
let resolved = commands::effective_source(Some(&explicit), || {
Some(PathBuf::from("/tmp/decdn-test-default.toml"))
})?;
anyhow::ensure!(resolved == Some(explicit), "explicit path must win");
Ok(())
}
#[test]
fn effective_source_falls_back_to_default_when_present() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let default = dir.path().join("node.toml");
fs::write(&default, "")?;
let resolved = commands::effective_source(None, || Some(default.clone()))?;
anyhow::ensure!(
resolved.as_deref() == Some(default.as_path()),
"default path should be reported when it exists",
);
Ok(())
}
#[test]
fn effective_source_returns_none_when_default_missing() -> anyhow::Result<()> {
let dir = TempDir::new()?;
let default = dir.path().join("absent.toml");
let resolved = commands::effective_source(None, || Some(default))?;
anyhow::ensure!(
resolved.is_none(),
"absent default path should yield None, not a lie"
);
Ok(())
}
#[test]
fn effective_source_returns_none_when_no_default_available() -> anyhow::Result<()> {
let resolved = commands::effective_source(None, || None)?;
anyhow::ensure!(resolved.is_none());
Ok(())
}
#[allow(clippy::too_many_lines)] fn sample_resolved(overrides: impl FnOnce(&mut ResolvedConfig)) -> ResolvedConfig {
let mut cfg = ResolvedConfig {
identity: ResolvedIdentity {
data_dir: PathBuf::from("/var/lib/decdn"),
region: None,
},
network: ResolvedNetwork {
bind_port: 4433,
relay_urls: Vec::new(),
discovery: decdn_common::config::ResolvedDiscovery::default(),
},
blockchain: ResolvedBlockchain {
origin_assignment_address: None,
origin_directory_positive_ttl_sec:
decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_POSITIVE_TTL_SEC,
origin_directory_negative_ttl_sec:
decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_NEGATIVE_TTL_SEC,
origin_directory_cache_capacity:
decdn_common::config::DEFAULT_ORIGIN_DIRECTORY_CACHE_CAPACITY,
publisher_registry_address: None,
rpc_url: "https://rpc.example/SECRET_TOKEN_abc123".to_string(),
eth_keystore: PathBuf::from("/var/lib/decdn/keystore.json"),
keystore_password_file: None,
payment_pool_address: "0x0000000000000000000000000000000000000001".to_string(),
capacity_bond_address: "0x0000000000000000000000000000000000000002".to_string(),
rpc_watchdog_interval_sec: 30,
event_poll_interval_ms: 7000,
get_logs_max_block_span: decdn_common::config::DEFAULT_GET_LOGS_MAX_BLOCK_SPAN,
fee_shares_poll_interval_sec: 3600,
redeem_threshold_micro_usdc: 1_000_000,
redeem_max_vouchers_per_tx: 300,
redeem_interval_secs: 300,
buyer_working_deposit_micro_usdc: 10_000_000,
buyer_max_approve: true,
pool_min_remaining_deposit_micro_usdc: 100_000,
pool_floor_signer_live_windows: 8,
slash_judge_address: "0x0000000000000000000000000000000000000003".to_string(),
content_blacklist_address: None,
content_blacklist_poll_interval_sec: 600,
chain_staleness_grace_sec: 1800,
chain_id: decdn_common::config::DEFAULT_CHAIN_ID,
},
cache: ResolvedCache {
cache_dir: PathBuf::from("/var/lib/decdn/cache"),
cache_size_mb: 10_240,
disk_headroom_mb: 8192,
max_blob_size_mb: 1_024,
max_rate_per_mb: 0,
origins: Vec::new(),
pinned_hashes: decdn_cache::PinnedHashes::empty(),
origin_retry: decdn_cache::RetryPolicy::default(),
circuit_breaker: decdn_cache::CircuitBreakerPolicy::default(),
user_agent: decdn_cache::DEFAULT_USER_AGENT.to_string(),
gc_interval_sec: 300,
fs_rescan_interval_sec: 60,
origin_probe_ttl_sec: decdn_common::config::DEFAULT_ORIGIN_PROBE_TTL_SEC,
origin_probe_negative_ttl_sec:
decdn_common::config::DEFAULT_ORIGIN_PROBE_NEGATIVE_TTL_SEC,
origin_probe_fault_ttl_sec: decdn_common::config::DEFAULT_ORIGIN_PROBE_FAULT_TTL_SEC,
origin_probe_timeout_ms: decdn_common::config::DEFAULT_ORIGIN_PROBE_TIMEOUT_MS,
origin_probe_memo_capacity: decdn_common::config::DEFAULT_ORIGIN_PROBE_MEMO_CAPACITY,
eviction_high_water_pct: 90,
eviction_target_pct: 80,
eviction_per_sweep_budget: 16,
eviction_tick_secs: 1,
max_probe_holds: decdn_common::config::DEFAULT_MAX_PROBE_HOLDS,
stake_lane_reserved_holds: decdn_common::config::DEFAULT_STAKE_LANE_RESERVED_HOLDS,
node_to_node_pull_through_enabled: false,
relay_foreign_namespaces: decdn_common::config::DEFAULT_RELAY_FOREIGN_NAMESPACES,
node_pull_probe_fanout: decdn_common::config::DEFAULT_NODE_PULL_PROBE_FANOUT,
node_pull_timeout_sec: decdn_common::config::DEFAULT_NODE_PULL_TIMEOUT_SEC,
node_pull_stall_window_sec: decdn_common::config::DEFAULT_NODE_PULL_STALL_WINDOW_SEC,
node_pull_min_throughput_bps:
decdn_common::config::DEFAULT_NODE_PULL_MIN_THROUGHPUT_BPS,
eviction_policy: decdn_common::config::DEFAULT_EVICTION_POLICY.to_string(),
admission_policy: decdn_common::config::DEFAULT_ADMISSION_POLICY.to_string(),
tinylfu: decdn_common::config::ResolvedTinyLfu {
sketch_bytes: decdn_common::config::DEFAULT_TINYLFU_SKETCH_BYTES,
promotion_threshold: decdn_common::config::DEFAULT_TINYLFU_PROMOTION_THRESHOLD,
probation_target_pct: decdn_common::config::DEFAULT_TINYLFU_PROBATION_TARGET_PCT,
aging_halflife_sec: decdn_common::config::DEFAULT_TINYLFU_AGING_HALFLIFE_SEC,
},
serve_economics: decdn_common::config::ResolvedServeEconomics {
policy: decdn_common::config::DEFAULT_SERVE_ECONOMICS_POLICY.to_string(),
discount_bps: decdn_common::config::DEFAULT_SERVE_ECONOMICS_DISCOUNT_BPS,
n_max: decdn_common::config::DEFAULT_SERVE_ECONOMICS_N_MAX,
warming_budget: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_BUDGET,
warming_refill: decdn_common::config::DEFAULT_SERVE_ECONOMICS_WARMING_REFILL,
},
},
payment: ResolvedPayment {
frame_target_bytes: decdn_common::config::DEFAULT_FRAME_TARGET_BYTES,
rate_per_mb: 10,
credit_max: decdn_common::config::DEFAULT_CREDIT_MAX,
credit_ramp_divisor: decdn_common::config::DEFAULT_CREDIT_RAMP_DIVISOR,
voucher_commit_interval_ms: decdn_common::config::DEFAULT_VOUCHER_COMMIT_INTERVAL_MS,
},
observability: ResolvedObservability {
log_level: decdn_common::cli::common::LogLevel::Info,
log_format: decdn_common::cli::LogFormat::Pretty,
metrics_port: 9090,
metrics_bind: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
admin_port: Some(9191),
otlp_endpoint: None,
},
security: ResolvedSecurity {
max_concurrent_handlers: 256,
per_source_rate_per_sec: 100.0,
per_source_burst: 200,
max_tracked_sources: 4096,
},
load_shed: decdn_common::config::ResolvedLoadShed::default(),
dht: decdn_common::config::ResolvedDht::default(),
probe: decdn_common::config::ResolvedProbe::default(),
receipts: decdn_common::config::ResolvedReceipts::default(),
content: decdn_common::config::ResolvedContent::default(),
};
overrides(&mut cfg);
cfg
}
fn render(source: Option<&Path>, cfg: &ResolvedConfig) -> anyhow::Result<String> {
render_with_notices(source, cfg, &[])
}
fn render_with_notices(
source: Option<&Path>,
cfg: &ResolvedConfig,
notices: &[decdn_common::config::ConfigNotice],
) -> anyhow::Result<String> {
let mut buf = Vec::new();
commands::write_validate_summary(&mut buf, source, cfg, notices)?;
Ok(String::from_utf8(buf)?)
}
#[test]
fn summary_reports_the_pull_through_deadlines_when_enabled() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.cache.node_to_node_pull_through_enabled = true;
c.cache.node_pull_timeout_sec = 25;
c.cache.node_pull_stall_window_sec = 15;
c.cache.node_pull_min_throughput_bps = 2048;
});
let out = render(None, &cfg)?;
anyhow::ensure!(
out.contains("pull_timeout_sec=25"),
"the summary must report the resolved stream-open budget: {out}"
);
anyhow::ensure!(
out.contains("stall_window_sec=15"),
"the summary must report the resolved throughput-floor window — it is the primary \
health signal, and it is a term of the derived outer deadline: {out}"
);
anyhow::ensure!(
out.contains("min_throughput_bps=2048"),
"the summary must report the resolved throughput floor: {out}"
);
Ok(())
}
#[test]
fn summary_reports_the_origin_rescan_knob() -> anyhow::Result<()> {
let out = render(
None,
&sample_resolved(|c| c.cache.fs_rescan_interval_sec = 45),
)?;
anyhow::ensure!(
out.contains("fs_rescan_interval_sec: 45"),
"the summary must report the resolved rescan cadence: {out}"
);
let out = render(
None,
&sample_resolved(|c| c.cache.fs_rescan_interval_sec = 0),
)?;
anyhow::ensure!(
out.contains("fs_rescan_interval_sec: disabled"),
"a zero cadence must render as disabled, not as the bare number 0: {out}"
);
Ok(())
}
#[test]
fn summary_reports_discovery_without_leaking_secrets() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.network.discovery = decdn_common::config::ResolvedDiscovery {
pkarr_url: Some("https://user:PKARR_SECRET_xyz@pkarr.example/".to_string()),
dns_origin: Some("discovery.example.".to_string()),
peers: vec![decdn_common::config::ResolvedDiscoveryPeer {
node_id: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.to_string(),
relay_url: None,
addrs: vec!["203.0.113.4:4433".to_string()],
}],
};
});
let out = render(None, &cfg)?;
anyhow::ensure!(
out.contains("discovery.pkarr_url:"),
"summary should emit the pkarr_url label: {out}"
);
anyhow::ensure!(
out.contains("discovery.dns_origin: discovery.example."),
"summary should name dns_origin: {out}"
);
anyhow::ensure!(
out.contains("discovery.peers: 1"),
"summary should report the peer count: {out}"
);
anyhow::ensure!(
!out.contains("PKARR_SECRET_xyz"),
"pkarr_url credentials must never appear in summary: {out}"
);
anyhow::ensure!(
out.contains("***@pkarr.example/"),
"pkarr_url userinfo should be redacted with the host preserved: {out}"
);
anyhow::ensure!(
!out.contains("203.0.113.4:4433"),
"peer addresses must not appear in summary: {out}"
);
Ok(())
}
#[test]
fn summary_omits_discovery_lines_when_unset() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let out = render(None, &cfg)?;
anyhow::ensure!(
!out.contains("discovery."),
"no discovery lines should appear when discovery is unset: {out}"
);
Ok(())
}
#[test]
fn summary_redacts_rpc_url_value() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let out = render(None, &cfg)?;
anyhow::ensure!(
!out.contains("SECRET_TOKEN_abc123"),
"rpc_url value must never appear in summary: {out}"
);
anyhow::ensure!(
out.contains("rpc_url: <redacted>"),
"rpc_url should be labeled <redacted>: {out}"
);
Ok(())
}
#[test]
fn summary_redacts_otlp_endpoint_value() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.observability.otlp_endpoint = Some("http://otel-secret-xyz.example:4317".into());
});
let out = render(None, &cfg)?;
anyhow::ensure!(
!out.contains("otel-secret-xyz"),
"otlp_endpoint value must never appear in summary: {out}"
);
anyhow::ensure!(
out.contains("otlp_endpoint: <redacted>"),
"otlp_endpoint should be labeled <redacted>: {out}"
);
Ok(())
}
#[test]
fn summary_omits_optional_fields_when_unset() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {}); let out = render(None, &cfg)?;
for label in ["region:", "relay_url:", "otlp_endpoint:"] {
anyhow::ensure!(
!out.contains(label),
"unset optional field {label} should not appear: {out}"
);
}
Ok(())
}
#[test]
fn summary_prints_optional_fields_when_set() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.identity.region = Some("US".into());
c.network.relay_urls = vec!["https://relay.iroh.network".into()];
c.observability.otlp_endpoint = Some("http://otel.example:4317".into());
});
let out = render(None, &cfg)?;
anyhow::ensure!(out.contains("region: US"), "{out}");
anyhow::ensure!(
out.contains("relay_url: https://relay.iroh.network"),
"{out}"
);
anyhow::ensure!(out.contains("otlp_endpoint:"), "{out}");
Ok(())
}
#[test]
fn summary_prints_one_line_per_relay() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.network.relay_urls = vec![
"https://relay-a.example".into(),
"https://relay-b.example".into(),
];
});
let out = render(None, &cfg)?;
anyhow::ensure!(
out.contains("relay_url: https://relay-a.example"),
"{out}"
);
anyhow::ensure!(
out.contains("relay_url: https://relay-b.example"),
"{out}"
);
Ok(())
}
#[test]
fn summary_redacts_relay_url_credentials() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.network.relay_urls = vec!["https://user:RELAY_SECRET_xyz@relay.example:7842".into()];
});
let out = render(None, &cfg)?;
anyhow::ensure!(
!out.contains("RELAY_SECRET_xyz"),
"relay credentials must never appear in summary: {out}"
);
anyhow::ensure!(
out.contains("***@relay.example:7842"),
"relay userinfo should be redacted with the host preserved: {out}"
);
Ok(())
}
#[test]
fn summary_reports_receipt_log_path_and_rotation_cap() -> anyhow::Result<()> {
let cfg = sample_resolved(|c| {
c.receipts.max_file_bytes = 64 * 1024 * 1024;
c.receipts.retained_files = 7;
});
let out = render(None, &cfg)?;
let expected_path = cfg
.identity
.data_dir
.join(decdn_common::config::RECEIPT_LOG_FILE)
.display()
.to_string();
anyhow::ensure!(
out.contains(&format!("receipts.log_path: {expected_path}")),
"summary should surface the derived receipt log path: {out}"
);
anyhow::ensure!(
out.contains(&format!("receipts.max_file_bytes: {}", 64 * 1024 * 1024)),
"summary should reflect the resolved rotation cap: {out}"
);
anyhow::ensure!(
out.contains("receipts.retained_files: 7"),
"summary should reflect the resolved retained-backup count: {out}"
);
Ok(())
}
#[test]
fn summary_reports_receipt_defaults() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let out = render(None, &cfg)?;
anyhow::ensure!(
out.contains(&format!(
"receipts.max_file_bytes: {}",
decdn_common::config::DEFAULT_RECEIPT_MAX_FILE_BYTES
)),
"summary should report the default rotation cap: {out}"
);
anyhow::ensure!(
out.contains(&format!(
"receipts.retained_files: {}",
decdn_common::config::DEFAULT_RECEIPT_RETAINED_FILES
)),
"summary should report the default retained-backup count: {out}"
);
Ok(())
}
#[test]
fn summary_reports_explicit_source_path() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let src = PathBuf::from("/etc/decdn/node.toml");
let out = render(Some(&src), &cfg)?;
anyhow::ensure!(
out.contains("source: /etc/decdn/node.toml"),
"{out}"
);
Ok(())
}
#[test]
fn summary_reports_defaults_only_when_no_source() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let out = render(None, &cfg)?;
anyhow::ensure!(
out.contains("source: (defaults + env only"),
"{out}"
);
Ok(())
}
#[test]
fn summary_renders_resolve_notices_under_the_field_list() -> anyhow::Result<()> {
use decdn_common::config::{ConfigNotice, ConfigNoticeLevel};
let cfg = sample_resolved(|_| {});
let notices = vec![
ConfigNotice {
level: ConfigNoticeLevel::Warn,
field: "security.max_tracked_sources".to_string(),
message: "0: rate-limit bookkeeping map is unbounded".to_string(),
},
ConfigNotice {
level: ConfigNoticeLevel::Info,
field: "security.per_source_rate_per_sec".to_string(),
message: "0: per-source rate-limit disabled".to_string(),
},
];
let out = render_with_notices(None, &cfg, ¬ices)?;
anyhow::ensure!(out.contains("notices (2):"), "{out}");
anyhow::ensure!(
out.contains(
"- warning: security.max_tracked_sources: 0: rate-limit bookkeeping map is unbounded"
),
"{out}"
);
anyhow::ensure!(
out.contains("- info: security.per_source_rate_per_sec: 0: per-source rate-limit disabled"),
"{out}"
);
anyhow::ensure!(out.starts_with("config valid"), "{out}");
Ok(())
}
#[test]
fn summary_omits_the_notices_block_when_there_are_none() -> anyhow::Result<()> {
let cfg = sample_resolved(|_| {});
let out = render(None, &cfg)?;
anyhow::ensure!(!out.contains("notices"), "{out}");
Ok(())
}
#[test]
fn config_validate_renders_the_notices_the_resolver_recorded() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let cfg = dir.path().join("node.toml");
fs::write(
&cfg,
format!("{MINIMAL_VALID_CONFIG}\n[security]\nmax_tracked_sources = 0\n"),
)?;
fs::write(dir.path().join("keystore.json"), "")?;
let out = std::process::Command::new(env!("CARGO_BIN_EXE_decdn"))
.args([
"--config",
cfg.to_str().unwrap_or_default(),
"config",
"validate",
"--data-dir",
dir.path().to_str().unwrap_or_default(),
])
.output()?;
let stdout = String::from_utf8(out.stdout)?;
anyhow::ensure!(out.status.success(), "a notice must not fail validation");
anyhow::ensure!(stdout.starts_with("config valid"), "{stdout}");
anyhow::ensure!(stdout.contains("notices (1):"), "{stdout}");
anyhow::ensure!(
stdout.contains("- warning: security.max_tracked_sources: 0: rate-limit bookkeeping"),
"{stdout}"
);
Ok(())
}
const MINIMAL_VALID_CONFIG: &str = r#"
[blockchain]
rpc_url = "http://127.0.0.1:8545"
payment_pool_address = "0x0000000000000000000000000000000000000001"
capacity_bond_address = "0x0000000000000000000000000000000000000002"
slash_judge_address = "0x0000000000000000000000000000000000000003"
content_blacklist_address = "0x0000000000000000000000000000000000000004"
[cache]
cache_size_mb = 1024
"#;