use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use crate::config::AppConfig;
use crate::credentials::trusted_witnesses::WITNESSES_TRUSTED;
const STORAGE_PREFIX: &[u8] = b"config:override:";
fn storage_key(key: &str) -> Vec<u8> {
let mut out = STORAGE_PREFIX.to_vec();
out.extend_from_slice(key.as_bytes());
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConfigSource {
Env,
Db,
Toml,
Default,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigKeyKind {
String,
U64,
U64Range { min: u64, max: u64 },
CountRange { min: u64, max: u64 },
Bool,
StringEnum(&'static [&'static str]),
PathAllowlist(&'static [&'static str]),
TrustedWitnessList,
}
#[derive(Debug, Clone, Copy)]
pub struct ConfigKeyDef {
pub key: &'static str,
pub kind: ConfigKeyKind,
pub requires_restart: bool,
pub sensitive: bool,
pub trust_semantics: bool,
}
const DEFAULT_ADMIN_IDLE_TIMEOUT: u64 = 900;
pub const UNRESTRICTED_ADMIN_CONSENT_THRESHOLD: &str = "acl.unrestricted_admin_consent_threshold";
pub const ACTION_LIFETIME: &str = "acl.action_lifetime";
pub const ACTION_MAX_OPEN_PER_REQUESTER: &str = "acl.action_max_open_per_requester";
pub const ACTION_MAX_OPEN: &str = "acl.action_max_open";
pub const ACTION_DECLINE_COOLDOWN: &str = "acl.action_decline_cooldown";
pub const REMOVAL_COOLING_OFF: &str = "acl.removal_cooling_off";
pub const CONSENT_REQUEST_PUSH: &str = "acl.consent_request_push";
fn action_key_value(key: &str, cfg: &AppConfig) -> Option<u64> {
Some(match key {
ACTION_LIFETIME => cfg.acl.action_lifetime,
ACTION_MAX_OPEN_PER_REQUESTER => cfg.acl.action_max_open_per_requester,
ACTION_MAX_OPEN => cfg.acl.action_max_open,
ACTION_DECLINE_COOLDOWN => cfg.acl.action_decline_cooldown,
REMOVAL_COOLING_OFF => cfg.acl.removal_cooling_off,
_ => return None,
})
}
fn action_key_default(key: &str) -> Option<u64> {
Some(match key {
ACTION_LIFETIME => crate::config::default_action_lifetime(),
ACTION_MAX_OPEN_PER_REQUESTER => crate::config::default_action_max_open_per_requester(),
ACTION_MAX_OPEN => crate::config::default_action_max_open(),
ACTION_DECLINE_COOLDOWN => crate::config::default_action_decline_cooldown(),
REMOVAL_COOLING_OFF => crate::config::default_removal_cooling_off(),
_ => return None,
})
}
fn action_key_env(key: &str) -> Option<&'static str> {
Some(match key {
ACTION_LIFETIME => "VTC_ACL_ACTION_LIFETIME",
ACTION_MAX_OPEN_PER_REQUESTER => "VTC_ACL_ACTION_MAX_OPEN_PER_REQUESTER",
ACTION_MAX_OPEN => "VTC_ACL_ACTION_MAX_OPEN",
ACTION_DECLINE_COOLDOWN => "VTC_ACL_ACTION_DECLINE_COOLDOWN",
REMOVAL_COOLING_OFF => "VTC_ACL_REMOVAL_COOLING_OFF",
_ => return None,
})
}
pub(crate) fn set_action_key(cfg: &mut AppConfig, key: &str, n: u64) -> bool {
match key {
ACTION_LIFETIME => cfg.acl.action_lifetime = n,
ACTION_MAX_OPEN_PER_REQUESTER => cfg.acl.action_max_open_per_requester = n,
ACTION_MAX_OPEN => cfg.acl.action_max_open = n,
ACTION_DECLINE_COOLDOWN => cfg.acl.action_decline_cooldown = n,
REMOVAL_COOLING_OFF => cfg.acl.removal_cooling_off = n,
_ => return false,
}
true
}
pub(crate) fn action_key_live(cfg: &AppConfig, key: &str) -> Option<u64> {
action_key_value(key, cfg)
}
pub async fn live_action_setting(
key: &str,
cfg: &AppConfig,
db: &ConfigStore,
) -> Result<u64, AppError> {
let fallback = action_key_value(key, cfg)
.ok_or_else(|| AppError::Internal(format!("{key} is not an action-list setting")))?;
let layered = match env_layer_value(key) {
Some(v) => Some(v),
None => db.get(key).await?,
};
let in_bounds = |n: &u64| match lookup(key).map(|d| d.kind) {
Some(ConfigKeyKind::U64Range { min, max } | ConfigKeyKind::CountRange { min, max }) => {
(min..=max).contains(n)
}
_ => true,
};
Ok(layered
.and_then(|v| v.as_u64())
.filter(in_bounds)
.unwrap_or(fallback))
}
pub const REGISTRY: &[ConfigKeyDef] = &[
ConfigKeyDef {
key: "server.host",
kind: ConfigKeyKind::String,
requires_restart: true,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: "server.port",
kind: ConfigKeyKind::U64,
requires_restart: true,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: "log.level",
kind: ConfigKeyKind::StringEnum(&["trace", "debug", "info", "warn", "error"]),
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: "public_url",
kind: ConfigKeyKind::String,
requires_restart: true,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: "auth.admin_idle_timeout",
kind: ConfigKeyKind::U64Range {
min: 60,
max: 86_400,
},
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: UNRESTRICTED_ADMIN_CONSENT_THRESHOLD,
kind: ConfigKeyKind::CountRange { min: 1, max: 16 },
requires_restart: false,
sensitive: false,
trust_semantics: true,
},
ConfigKeyDef {
key: ACTION_LIFETIME,
kind: ConfigKeyKind::U64Range {
min: 15 * 60,
max: 14 * 24 * 3600,
},
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: ACTION_MAX_OPEN_PER_REQUESTER,
kind: ConfigKeyKind::CountRange { min: 1, max: 20 },
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: ACTION_MAX_OPEN,
kind: ConfigKeyKind::CountRange { min: 10, max: 500 },
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: ACTION_DECLINE_COOLDOWN,
kind: ConfigKeyKind::U64Range {
min: 0,
max: 24 * 3600,
},
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: REMOVAL_COOLING_OFF,
kind: ConfigKeyKind::U64Range {
min: 0,
max: 7 * 24 * 3600,
},
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: CONSENT_REQUEST_PUSH,
kind: ConfigKeyKind::Bool,
requires_restart: false,
sensitive: false,
trust_semantics: false,
},
ConfigKeyDef {
key: WITNESSES_TRUSTED,
kind: ConfigKeyKind::TrustedWitnessList,
requires_restart: false,
sensitive: false,
trust_semantics: true,
},
];
pub const SINGLE_ADMIN_MODE: &str = "acl.single_admin_mode";
pub fn host_only_refusal(key: &str) -> Option<String> {
(key == SINGLE_ADMIN_MODE).then(|| {
format!(
"{SINGLE_ADMIN_MODE} is host configuration and cannot be set or cleared through the operation surface (VTI-APV-022). With access to the host, set `[acl] single_admin_mode = true` (or remove it) in config.toml and restart the daemon"
)
})
}
pub fn lookup(key: &str) -> Option<&'static ConfigKeyDef> {
REGISTRY.iter().find(|d| d.key == key)
}
#[derive(Clone)]
pub struct ConfigStore {
ks: KeyspaceHandle,
}
impl ConfigStore {
pub fn new(ks: KeyspaceHandle) -> Self {
Self { ks }
}
pub async fn get(&self, key: &str) -> Result<Option<Value>, AppError> {
self.ks.get(storage_key(key)).await
}
pub async fn put(&self, key: &str, value: &Value) -> Result<(), AppError> {
self.ks.insert(storage_key(key), value).await?;
crate::admin_events::notify(crate::admin_events::Topic::Config);
Ok(())
}
pub async fn delete(&self, key: &str) -> Result<(), AppError> {
self.ks.remove(storage_key(key)).await?;
crate::admin_events::notify(crate::admin_events::Topic::Config);
Ok(())
}
pub async fn snapshot(&self) -> Result<HashMap<String, Value>, AppError> {
let pairs = self.ks.prefix_iter_raw(STORAGE_PREFIX.to_vec()).await?;
let prefix_len = STORAGE_PREFIX.len();
let mut out = HashMap::with_capacity(pairs.len());
for (key_bytes, value_bytes) in pairs {
let Some(rest) = key_bytes.get(prefix_len..) else {
continue;
};
let Ok(name) = String::from_utf8(rest.to_vec()) else {
tracing::warn!("skipping non-UTF-8 config-override key");
continue;
};
let Ok(value) = serde_json::from_slice::<Value>(&value_bytes) else {
tracing::warn!(key = %name, "skipping unparseable config-override value");
continue;
};
out.insert(name, value);
}
Ok(out)
}
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EffectiveField {
pub key: String,
pub value: Value,
pub source: ConfigSource,
pub requires_restart: bool,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct EffectiveConfig {
pub fields: Vec<EffectiveField>,
}
fn toml_layer_value(key: &str, cfg: &AppConfig) -> Option<Value> {
use crate::config::default_host_value;
use crate::config::default_port_value;
match key {
"server.host" => {
if cfg.server.host == default_host_value() {
None
} else {
Some(Value::String(cfg.server.host.clone()))
}
}
"server.port" => {
if cfg.server.port == default_port_value() {
None
} else {
Some(Value::Number(serde_json::Number::from(cfg.server.port)))
}
}
"log.level" => {
if cfg.log.level == "info" {
None
} else {
Some(Value::String(cfg.log.level.clone()))
}
}
"public_url" => cfg.public_url.clone().map(Value::String),
UNRESTRICTED_ADMIN_CONSENT_THRESHOLD => {
let n = cfg.acl.unrestricted_admin_consent_threshold;
(n != crate::config::default_unrestricted_admin_consent_threshold())
.then(|| Value::Number(serde_json::Number::from(n)))
}
CONSENT_REQUEST_PUSH => cfg.acl.consent_request_push.then_some(Value::Bool(true)),
WITNESSES_TRUSTED => (!cfg.witnesses.is_empty())
.then(|| serde_json::to_value(&cfg.witnesses.trusted).unwrap_or(Value::Null)),
"auth.admin_idle_timeout" => {
if cfg.auth.admin_idle_timeout == DEFAULT_ADMIN_IDLE_TIMEOUT {
None
} else {
Some(Value::Number(serde_json::Number::from(
cfg.auth.admin_idle_timeout,
)))
}
}
other => {
let n = action_key_value(other, cfg)?;
(Some(n) != action_key_default(other)).then(|| Value::Number(n.into()))
}
}
}
fn default_layer_value(key: &str) -> Value {
use crate::config::default_host_value;
use crate::config::default_port_value;
match key {
"server.host" => Value::String(default_host_value()),
"server.port" => Value::Number(serde_json::Number::from(default_port_value())),
"log.level" => Value::String("info".into()),
"public_url" => Value::Null,
UNRESTRICTED_ADMIN_CONSENT_THRESHOLD => Value::Number(serde_json::Number::from(
crate::config::default_unrestricted_admin_consent_threshold(),
)),
"auth.admin_idle_timeout" => {
Value::Number(serde_json::Number::from(DEFAULT_ADMIN_IDLE_TIMEOUT))
}
CONSENT_REQUEST_PUSH => Value::Bool(false),
WITNESSES_TRUSTED => Value::Array(Vec::new()),
other => match action_key_default(other) {
Some(n) => Value::Number(n.into()),
None => Value::Null, },
}
}
fn env_layer_value(key: &str) -> Option<Value> {
match key {
"server.host" => std::env::var("VTC_SERVER_HOST").ok().map(Value::String),
"server.port" => std::env::var("VTC_SERVER_PORT")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|n| Value::Number(serde_json::Number::from(n))),
"log.level" => std::env::var("VTC_LOG_LEVEL").ok().map(Value::String),
"public_url" => std::env::var("VTC_PUBLIC_URL").ok().map(Value::String),
UNRESTRICTED_ADMIN_CONSENT_THRESHOLD => {
std::env::var("VTC_ACL_UNRESTRICTED_ADMIN_CONSENT_THRESHOLD")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|n| Value::Number(serde_json::Number::from(n)))
}
CONSENT_REQUEST_PUSH => std::env::var("VTC_ACL_CONSENT_REQUEST_PUSH")
.ok()
.and_then(|s| s.parse::<bool>().ok())
.map(Value::Bool),
WITNESSES_TRUSTED => None,
"auth.admin_idle_timeout" => std::env::var("VTC_AUTH_ADMIN_IDLE_TIMEOUT")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|n| Value::Number(serde_json::Number::from(n))),
other => std::env::var(action_key_env(other)?)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map(|n| Value::Number(serde_json::Number::from(n))),
}
}
pub async fn live_consent_request_push(
cfg: &AppConfig,
db: &ConfigStore,
) -> Result<bool, AppError> {
let layered = match env_layer_value(CONSENT_REQUEST_PUSH) {
Some(v) => Some(v),
None => db.get(CONSENT_REQUEST_PUSH).await?,
};
Ok(layered
.and_then(|v| v.as_bool())
.unwrap_or(cfg.acl.consent_request_push))
}
pub async fn live_consent_threshold(fallback: u64, db: &ConfigStore) -> Result<u64, AppError> {
let layered = match env_layer_value(UNRESTRICTED_ADMIN_CONSENT_THRESHOLD) {
Some(v) => Some(v),
None => db.get(UNRESTRICTED_ADMIN_CONSENT_THRESHOLD).await?,
};
Ok(layered
.and_then(|v| v.as_u64())
.filter(|n| *n >= 1)
.unwrap_or(fallback.max(1)))
}
pub async fn compute_effective_config(
cfg: &AppConfig,
db: &ConfigStore,
) -> Result<EffectiveConfig, AppError> {
let db_snapshot = db.snapshot().await?;
let mut fields = Vec::with_capacity(REGISTRY.len());
for def in REGISTRY {
let (value, source) = if let Some(v) = env_layer_value(def.key) {
(v, ConfigSource::Env)
} else if let Some(v) = db_snapshot.get(def.key) {
(v.clone(), ConfigSource::Db)
} else if let Some(v) = toml_layer_value(def.key, cfg) {
(v, ConfigSource::Toml)
} else {
(default_layer_value(def.key), ConfigSource::Default)
};
fields.push(EffectiveField {
key: def.key.into(),
value,
source,
requires_restart: def.requires_restart,
});
}
Ok(EffectiveConfig { fields })
}
fn set_app_config_field(cfg: &mut AppConfig, key: &str, value: &Value) {
match key {
"server.host" => {
if let Some(s) = value.as_str() {
cfg.server.host = s.to_string();
}
}
"server.port" => match value.as_u64().and_then(|n| u16::try_from(n).ok()) {
Some(p) => cfg.server.port = p,
None => tracing::warn!(
%value,
"config override `server.port` is not a valid u16 — ignored"
),
},
"log.level" => {
if let Some(s) = value.as_str() {
cfg.log.level = s.to_string();
}
}
"public_url" => {
cfg.public_url = value.as_str().map(str::to_string);
}
UNRESTRICTED_ADMIN_CONSENT_THRESHOLD => match value.as_u64() {
Some(n) if n >= 1 => cfg.acl.unrestricted_admin_consent_threshold = n,
_ => tracing::warn!(
%value,
"config override `{UNRESTRICTED_ADMIN_CONSENT_THRESHOLD}` is not a count of at \
least 1 — ignored"
),
},
CONSENT_REQUEST_PUSH => match value.as_bool() {
Some(b) => cfg.acl.consent_request_push = b,
None => tracing::warn!(
%value,
"config override `{CONSENT_REQUEST_PUSH}` is not a boolean — ignored"
),
},
WITNESSES_TRUSTED => {
match crate::credentials::trusted_witnesses::parse_list(value, cfg.vtc_did.as_deref()) {
Ok(list) => cfg.witnesses.trusted = list,
Err(e) => tracing::warn!(
error = %e,
"config override `{WITNESSES_TRUSTED}` is not a valid witness list — ignored"
),
}
}
"auth.admin_idle_timeout" => match value.as_u64() {
Some(n) => cfg.auth.admin_idle_timeout = n,
None => tracing::warn!(
%value,
"config override `auth.admin_idle_timeout` is not a u64 — ignored"
),
},
other => {
if action_key_value(other, cfg).is_some() {
match value.as_u64() {
Some(n) => {
set_action_key(cfg, other, n);
}
None => tracing::warn!(
%value,
key = other,
"config override is not a u64 — ignored"
),
}
}
}
}
}
pub async fn apply_overrides(cfg: &mut AppConfig, db: &ConfigStore) -> Result<(), AppError> {
let eff = compute_effective_config(cfg, db).await?;
for field in eff.fields {
set_app_config_field(cfg, &field.key, &field.value);
}
Ok(())
}
pub fn validate_value(def: &ConfigKeyDef, value: &Value) -> Result<(), AppError> {
match def.kind {
ConfigKeyKind::String => {
if !value.is_string() {
return Err(AppError::Validation(format!(
"{} must be a string",
def.key
)));
}
Ok(())
}
ConfigKeyKind::U64 => match value.as_u64() {
Some(_) => Ok(()),
None => Err(AppError::Validation(format!(
"{} must be an unsigned integer",
def.key
))),
},
ConfigKeyKind::U64Range { min, max } => match value.as_u64() {
Some(n) if (min..=max).contains(&n) => Ok(()),
Some(n) => Err(AppError::Validation(format!(
"{} must be between {min} and {max} seconds, got {n}",
def.key
))),
None => Err(AppError::Validation(format!(
"{} must be an unsigned integer",
def.key
))),
},
ConfigKeyKind::CountRange { min, max } => match value.as_u64() {
Some(n) if (min..=max).contains(&n) => Ok(()),
Some(n) => Err(AppError::Validation(format!(
"{} must be between {min} and {max}, got {n}",
def.key
))),
None => Err(AppError::Validation(format!(
"{} must be an unsigned integer",
def.key
))),
},
ConfigKeyKind::Bool => match value.as_bool() {
Some(_) => Ok(()),
None => Err(AppError::Validation(format!(
"{} must be true or false",
def.key
))),
},
ConfigKeyKind::StringEnum(allowed) => {
let s = value
.as_str()
.ok_or_else(|| AppError::Validation(format!("{} must be a string", def.key)))?;
if allowed.contains(&s) {
Ok(())
} else {
Err(AppError::Validation(format!(
"{} must be one of {:?}, got {:?}",
def.key, allowed, s
)))
}
}
ConfigKeyKind::PathAllowlist(prefixes) => {
let s = value
.as_str()
.ok_or_else(|| AppError::Validation(format!("{} must be a string", def.key)))?;
if prefixes.iter().any(|p| s.starts_with(p)) {
Ok(())
} else {
Err(AppError::Validation(format!(
"{} must start with one of {:?}",
def.key, prefixes
)))
}
}
ConfigKeyKind::TrustedWitnessList => {
crate::credentials::trusted_witnesses::parse_list(value, None)
.map(|_| ())
.map_err(|e| AppError::Validation(format!("{}: {e}", def.key)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn temp_store() -> (ConfigStore, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
let ks = store.keyspace("config-test").expect("ks");
(ConfigStore::new(ks), dir)
}
fn default_app_config() -> AppConfig {
toml::from_str("").expect("empty TOML parses")
}
#[test]
fn registry_default_matches_the_real_config_default() {
let cfg = default_app_config();
assert_eq!(
cfg.auth.admin_idle_timeout, DEFAULT_ADMIN_IDLE_TIMEOUT,
"DEFAULT_ADMIN_IDLE_TIMEOUT has drifted from vti_common's default"
);
}
#[test]
fn idle_timeout_rejects_values_outside_the_bounds() {
let def = lookup("auth.admin_idle_timeout").expect("key is registered");
assert!(validate_value(def, &json!(59)).is_err());
assert!(validate_value(def, &json!(86_401)).is_err());
assert!(validate_value(def, &json!(0)).is_err());
assert!(validate_value(def, &json!("900")).is_err());
assert!(validate_value(def, &json!(60)).is_ok());
assert!(validate_value(def, &json!(86_400)).is_ok());
assert!(validate_value(def, &json!(1800)).is_ok());
}
#[test]
fn idle_timeout_error_names_the_bounds_it_wants() {
let def = lookup("auth.admin_idle_timeout").expect("key is registered");
let err = validate_value(def, &json!(5)).unwrap_err().to_string();
assert!(err.contains("60"), "{err}");
assert!(err.contains("86400"), "{err}");
}
#[tokio::test]
async fn idle_timeout_db_override_reaches_the_live_config() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
assert_eq!(cfg.auth.admin_idle_timeout, DEFAULT_ADMIN_IDLE_TIMEOUT);
store
.put("auth.admin_idle_timeout", &json!(1800))
.await
.unwrap();
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.auth.admin_idle_timeout, 1800);
}
#[tokio::test]
async fn idle_timeout_is_hot_reloadable() {
let def = lookup("auth.admin_idle_timeout").expect("key is registered");
assert!(
!def.requires_restart,
"the console's Save does PATCH + reload; a restart-only key would \
report success and change nothing until the daemon bounced"
);
}
#[tokio::test]
async fn put_then_get_returns_value() {
let (store, _dir) = temp_store();
store.put("log.level", &json!("debug")).await.unwrap();
let got = store.get("log.level").await.unwrap();
assert_eq!(got, Some(json!("debug")));
}
#[tokio::test]
async fn delete_removes_value() {
let (store, _dir) = temp_store();
store.put("log.level", &json!("debug")).await.unwrap();
store.delete("log.level").await.unwrap();
let got = store.get("log.level").await.unwrap();
assert!(got.is_none());
}
#[tokio::test]
async fn snapshot_returns_all_overrides() {
let (store, _dir) = temp_store();
store.put("log.level", &json!("debug")).await.unwrap();
store.put("server.host", &json!("10.0.0.1")).await.unwrap();
let snap = store.snapshot().await.unwrap();
assert_eq!(snap.len(), 2);
assert_eq!(snap.get("log.level"), Some(&json!("debug")));
assert_eq!(snap.get("server.host"), Some(&json!("10.0.0.1")));
}
#[test]
fn validate_string_kind() {
let def = lookup("server.host").unwrap();
assert!(validate_value(def, &json!("0.0.0.0")).is_ok());
assert!(validate_value(def, &json!(42)).is_err());
}
#[test]
fn validate_u64_kind() {
let def = lookup("server.port").unwrap();
assert!(validate_value(def, &json!(8200)).is_ok());
assert!(validate_value(def, &json!(-1)).is_err());
assert!(validate_value(def, &json!("8200")).is_err());
}
#[test]
fn validate_string_enum_kind() {
let def = lookup("log.level").unwrap();
for lvl in ["trace", "debug", "info", "warn", "error"] {
assert!(
validate_value(def, &json!(lvl)).is_ok(),
"{lvl} should pass"
);
}
assert!(validate_value(def, &json!("verbose")).is_err());
assert!(validate_value(def, &json!(42)).is_err());
}
#[tokio::test]
async fn effective_returns_defaults_when_no_overrides() {
let (store, _dir) = temp_store();
let cfg = default_app_config();
let eff = compute_effective_config(&cfg, &store).await.unwrap();
let by_key: HashMap<_, _> = eff.fields.iter().map(|f| (&*f.key, f)).collect();
assert_eq!(by_key["server.host"].source, ConfigSource::Default);
assert_eq!(by_key["server.host"].value, json!("0.0.0.0"));
assert_eq!(by_key["server.port"].source, ConfigSource::Default);
assert_eq!(by_key["server.port"].value, json!(8200));
assert_eq!(by_key["log.level"].source, ConfigSource::Default);
assert_eq!(by_key["log.level"].value, json!("info"));
}
#[tokio::test]
async fn db_layer_beats_toml() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.log.level = "debug".into();
store.put("log.level", &json!("warn")).await.unwrap();
let eff = compute_effective_config(&cfg, &store).await.unwrap();
let f = eff.fields.iter().find(|f| f.key == "log.level").unwrap();
assert_eq!(f.source, ConfigSource::Db);
assert_eq!(f.value, json!("warn"));
}
#[tokio::test]
async fn toml_layer_used_when_no_db_override() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.log.level = "debug".into();
let eff = compute_effective_config(&cfg, &store).await.unwrap();
let f = eff.fields.iter().find(|f| f.key == "log.level").unwrap();
assert_eq!(f.source, ConfigSource::Toml);
assert_eq!(f.value, json!("debug"));
}
#[tokio::test]
async fn requires_restart_flag_propagates_from_registry() {
let (store, _dir) = temp_store();
let cfg = default_app_config();
let eff = compute_effective_config(&cfg, &store).await.unwrap();
let host = eff.fields.iter().find(|f| f.key == "server.host").unwrap();
assert!(host.requires_restart);
let level = eff.fields.iter().find(|f| f.key == "log.level").unwrap();
assert!(!level.requires_restart);
}
#[tokio::test]
async fn apply_overrides_writes_db_layer_into_config() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
assert_eq!(cfg.server.host, "0.0.0.0");
assert_eq!(cfg.server.port, 8200);
store.put("server.host", &json!("10.0.0.5")).await.unwrap();
store.put("server.port", &json!(9100)).await.unwrap();
store.put("log.level", &json!("debug")).await.unwrap();
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.server.host, "10.0.0.5");
assert_eq!(cfg.server.port, 9100);
assert_eq!(cfg.log.level, "debug");
}
#[tokio::test]
async fn apply_overrides_preserves_toml_when_no_db_override() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.server.host = "192.168.1.1".into(); cfg.server.port = 8443;
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.server.host, "192.168.1.1");
assert_eq!(cfg.server.port, 8443);
}
#[tokio::test]
async fn apply_overrides_db_beats_toml() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.server.port = 8443; store.put("server.port", &json!(9100)).await.unwrap();
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.server.port, 9100, "db override must win over toml");
}
#[tokio::test]
async fn apply_overrides_ignores_out_of_range_port() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.server.port = 8443;
store.put("server.port", &json!(70_000)).await.unwrap();
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.server.port, 8443, "out-of-range port override ignored");
}
#[tokio::test]
async fn apply_overrides_public_url_db_beats_toml_and_preserves_toml() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
cfg.public_url = Some("https://toml.example".into());
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.public_url.as_deref(), Some("https://toml.example"));
store
.put("public_url", &json!("https://db.example"))
.await
.unwrap();
apply_overrides(&mut cfg, &store).await.unwrap();
assert_eq!(cfg.public_url.as_deref(), Some("https://db.example"));
}
#[tokio::test]
async fn apply_overrides_public_url_unset_stays_none() {
let (store, _dir) = temp_store();
let mut cfg = default_app_config();
assert!(cfg.public_url.is_none());
apply_overrides(&mut cfg, &store).await.unwrap();
assert!(
cfg.public_url.is_none(),
"no override → public_url stays unset"
);
}
}