use crate::error::AppError;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub use vti_common::config::{AuthConfig, LogConfig, LogFormat, MessagingConfig, StoreConfig};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppConfig {
pub vtc_did: Option<String>,
pub vta_did: Option<String>,
#[serde(alias = "community_name")]
pub vtc_name: Option<String>,
#[serde(alias = "community_description")]
pub vtc_description: Option<String>,
pub public_url: Option<String>,
#[serde(default = "default_server_config")]
pub server: ServerConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default = "default_store_config")]
pub store: StoreConfig,
pub messaging: Option<MessagingConfig>,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub routing: RoutingConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub registry: RegistryConfig,
#[serde(default)]
pub renewal: RenewalConfig,
#[serde(default)]
pub join_requests: crate::join::retention::JoinRequestsConfig,
#[serde(default)]
pub website: WebsiteConfig,
#[serde(default)]
pub admin_ui: AdminUiConfig,
#[serde(skip)]
pub config_path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct AdminUiConfig {
#[serde(default = "default_admin_ui_mode")]
pub mode: String,
#[serde(default)]
pub external_origin: Option<String>,
#[serde(default)]
pub rp_id: Option<String>,
#[serde(default)]
pub plugin_dir: Option<std::path::PathBuf>,
}
impl Default for AdminUiConfig {
fn default() -> Self {
Self {
mode: default_admin_ui_mode(),
external_origin: None,
rp_id: None,
plugin_dir: None,
}
}
}
fn default_admin_ui_mode() -> String {
"embedded".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct WebsiteConfig {
#[serde(default)]
pub root_dir: Option<PathBuf>,
#[serde(default = "default_deploy_mode")]
pub deploy_mode: String,
#[serde(default = "default_live_cache_ttl_seconds")]
pub live_cache_ttl_seconds: u64,
#[serde(default = "default_managed_generations_keep")]
pub managed_generations_keep: u32,
#[serde(default = "default_cache_control")]
pub cache_control: String,
#[serde(default = "default_executable_blocklist")]
pub executable_blocklist: Vec<String>,
#[serde(default = "default_max_bundle_size_mb")]
pub max_bundle_size_mb: u64,
#[serde(default = "default_max_file_size_mb")]
pub max_file_size_mb: u64,
#[serde(default = "default_csp_override_file")]
pub csp_override_file: String,
}
impl Default for WebsiteConfig {
fn default() -> Self {
Self {
root_dir: None,
deploy_mode: default_deploy_mode(),
live_cache_ttl_seconds: default_live_cache_ttl_seconds(),
managed_generations_keep: default_managed_generations_keep(),
cache_control: default_cache_control(),
executable_blocklist: default_executable_blocklist(),
max_bundle_size_mb: default_max_bundle_size_mb(),
max_file_size_mb: default_max_file_size_mb(),
csp_override_file: default_csp_override_file(),
}
}
}
fn default_deploy_mode() -> String {
"live".into()
}
fn default_live_cache_ttl_seconds() -> u64 {
5
}
fn default_managed_generations_keep() -> u32 {
5
}
fn default_cache_control() -> String {
"public, max-age=300".into()
}
fn default_executable_blocklist() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
fn default_max_bundle_size_mb() -> u64 {
50
}
fn default_max_file_size_mb() -> u64 {
10
}
fn default_csp_override_file() -> String {
".vtc-website.toml".into()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub struct RenewalConfig {
#[serde(default)]
pub on_personhood_fail: PersonhoodFailMode,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum PersonhoodFailMode {
#[default]
Downgrade,
Refuse,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RegistryConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_health_probe_interval")]
pub health_probe_interval_seconds: u64,
#[serde(default = "default_registry_http_timeout")]
pub http_timeout_seconds: u64,
#[serde(default = "default_rtbf_batch_window_hours")]
pub rtbf_batch_window_hours: u64,
}
fn default_health_probe_interval() -> u64 {
60
}
fn default_registry_http_timeout() -> u64 {
5
}
fn default_rtbf_batch_window_hours() -> u64 {
24
}
impl Default for RegistryConfig {
fn default() -> Self {
Self {
url: None,
health_probe_interval_seconds: default_health_probe_interval(),
http_timeout_seconds: default_registry_http_timeout(),
rtbf_batch_window_hours: default_rtbf_batch_window_hours(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RoutingConfig {
#[serde(default = "default_api_mount")]
pub api: MountConfig,
#[serde(default = "default_admin_ui_mount")]
pub admin_ui: MountConfig,
#[serde(default = "default_website_mount")]
pub website: MountConfig,
#[serde(default = "default_subdomain_mode_strict")]
pub subdomain_mode_strict: bool,
}
impl Default for RoutingConfig {
fn default() -> Self {
Self {
api: default_api_mount(),
admin_ui: default_admin_ui_mount(),
website: default_website_mount(),
subdomain_mode_strict: default_subdomain_mode_strict(),
}
}
}
fn default_subdomain_mode_strict() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct MountConfig {
pub mount: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
}
fn default_api_mount() -> MountConfig {
MountConfig {
mount: "/v1".into(),
host: None,
}
}
fn default_admin_ui_mount() -> MountConfig {
MountConfig {
mount: "/admin".into(),
host: None,
}
}
fn default_website_mount() -> MountConfig {
MountConfig {
mount: "/".into(),
host: None,
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct CorsConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub trust_xff: bool,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SecretsConfig {
pub secret: Option<String>,
pub aws_secret_name: Option<String>,
pub aws_region: Option<String>,
pub gcp_project: Option<String>,
pub gcp_secret_name: Option<String>,
pub azure_vault_url: Option<String>,
pub azure_secret_name: Option<String>,
#[serde(default = "default_keyring_service")]
pub keyring_service: String,
pub vault_addr: Option<String>,
#[serde(default = "default_vault_kv_mount")]
pub vault_kv_mount: String,
pub vault_secret_path: Option<String>,
#[serde(default = "default_vault_secret_key")]
pub vault_secret_key: String,
pub vault_namespace: Option<String>,
#[serde(default = "default_vault_auth_method")]
pub vault_auth_method: String,
pub vault_k8s_role: Option<String>,
#[serde(default = "default_vault_k8s_mount")]
pub vault_k8s_mount: String,
#[serde(default = "default_vault_k8s_jwt_path")]
pub vault_k8s_jwt_path: String,
pub vault_token: Option<String>,
pub vault_approle_role_id: Option<String>,
pub vault_approle_secret_id: Option<String>,
#[serde(default = "default_vault_approle_mount")]
pub vault_approle_mount: String,
#[serde(default)]
pub vault_skip_verify: bool,
pub k8s_secret_name: Option<String>,
pub k8s_namespace: Option<String>,
#[serde(default = "default_k8s_secret_key")]
pub k8s_secret_key: String,
}
fn default_keyring_service() -> String {
"vtc".to_string()
}
fn default_k8s_secret_key() -> String {
"secret".to_string()
}
fn default_vault_kv_mount() -> String {
"secret".to_string()
}
fn default_vault_secret_key() -> String {
"seed".to_string()
}
fn default_vault_auth_method() -> String {
"kubernetes".to_string()
}
fn default_vault_k8s_mount() -> String {
"kubernetes".to_string()
}
fn default_vault_k8s_jwt_path() -> String {
"/var/run/secrets/kubernetes.io/serviceaccount/token".to_string()
}
fn default_vault_approle_mount() -> String {
"approle".to_string()
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field("secret", &self.secret.as_ref().map(|_| "<redacted>"))
.field("aws_secret_name", &self.aws_secret_name)
.field("aws_region", &self.aws_region)
.field("gcp_project", &self.gcp_project)
.field("gcp_secret_name", &self.gcp_secret_name)
.field("azure_vault_url", &self.azure_vault_url)
.field("azure_secret_name", &self.azure_secret_name)
.field("keyring_service", &self.keyring_service)
.field("vault_addr", &self.vault_addr)
.field("vault_kv_mount", &self.vault_kv_mount)
.field("vault_secret_path", &self.vault_secret_path)
.field("vault_secret_key", &self.vault_secret_key)
.field("vault_namespace", &self.vault_namespace)
.field("vault_auth_method", &self.vault_auth_method)
.field("vault_k8s_role", &self.vault_k8s_role)
.field("vault_k8s_mount", &self.vault_k8s_mount)
.field("vault_k8s_jwt_path", &self.vault_k8s_jwt_path)
.field(
"vault_token",
&self.vault_token.as_ref().map(|_| "<redacted>"),
)
.field("vault_approle_role_id", &self.vault_approle_role_id)
.field(
"vault_approle_secret_id",
&self.vault_approle_secret_id.as_ref().map(|_| "<redacted>"),
)
.field("vault_approle_mount", &self.vault_approle_mount)
.field("vault_skip_verify", &self.vault_skip_verify)
.field("k8s_secret_name", &self.k8s_secret_name)
.field("k8s_namespace", &self.k8s_namespace)
.field("k8s_secret_key", &self.k8s_secret_key)
.finish()
}
}
impl Default for SecretsConfig {
fn default() -> Self {
Self {
secret: None,
aws_secret_name: None,
aws_region: None,
gcp_project: None,
gcp_secret_name: None,
azure_vault_url: None,
azure_secret_name: None,
keyring_service: default_keyring_service(),
vault_addr: None,
vault_kv_mount: default_vault_kv_mount(),
vault_secret_path: None,
vault_secret_key: default_vault_secret_key(),
vault_namespace: None,
vault_auth_method: default_vault_auth_method(),
vault_k8s_role: None,
vault_k8s_mount: default_vault_k8s_mount(),
vault_k8s_jwt_path: default_vault_k8s_jwt_path(),
vault_token: None,
vault_approle_role_id: None,
vault_approle_secret_id: None,
vault_approle_mount: default_vault_approle_mount(),
vault_skip_verify: false,
k8s_secret_name: None,
k8s_namespace: None,
k8s_secret_key: default_k8s_secret_key(),
}
}
}
fn default_host() -> String {
default_host_value()
}
fn default_port() -> u16 {
default_port_value()
}
pub(crate) fn default_host_value() -> String {
"0.0.0.0".to_string()
}
pub(crate) fn default_port_value() -> u16 {
8200
}
fn default_server_config() -> ServerConfig {
ServerConfig::default()
}
fn validate_routing(routing: &RoutingConfig) -> Result<(), AppError> {
for (name, m) in [
("api", &routing.api),
("admin_ui", &routing.admin_ui),
("website", &routing.website),
] {
if !m.mount.starts_with('/') {
return Err(AppError::Config(format!(
"routing.{name}.mount must start with '/': got '{}'",
m.mount
)));
}
}
if routing.admin_ui.host.is_none() && routing.admin_ui.mount == "/" {
return Err(AppError::Config(
"routing.admin_ui.mount = '/' would collapse the admin cookie scope; \
pick a non-root prefix (default: '/admin') or enable subdomain mode via \
routing.admin_ui.host"
.into(),
));
}
let path_mounts: Vec<(&str, &str)> = [
(
"api",
routing.api.host.as_deref(),
routing.api.mount.as_str(),
),
(
"admin_ui",
routing.admin_ui.host.as_deref(),
routing.admin_ui.mount.as_str(),
),
(
"website",
routing.website.host.as_deref(),
routing.website.mount.as_str(),
),
]
.into_iter()
.filter_map(|(name, host, mount)| host.is_none().then_some((name, mount)))
.collect();
for i in 0..path_mounts.len() {
for j in (i + 1)..path_mounts.len() {
let (a, am) = path_mounts[i];
let (b, bm) = path_mounts[j];
if am == bm {
return Err(AppError::Config(format!(
"routing.{a}.mount and routing.{b}.mount both = '{am}'; \
path-mode mounts must be unique",
)));
}
}
}
Ok(())
}
fn validate_website_isolation(
routing: &RoutingConfig,
website_on_filesystem: bool,
) -> Result<(), AppError> {
if !website_on_filesystem {
return Ok(());
}
let website_host = routing.website.host.as_deref().map(str::to_ascii_lowercase);
let Some(website_host) = website_host else {
return Err(AppError::Config(
"a filesystem website (website.root_dir) would share an origin with the \
admin/API surface, letting deployed website content ride the admin session \
cookie; set routing.website.host to a dedicated host, or remove \
website.root_dir to serve the trusted built-in landing page"
.into(),
));
};
for (name, surface) in [("api", &routing.api), ("admin_ui", &routing.admin_ui)] {
if surface.host.as_deref().map(str::to_ascii_lowercase) == Some(website_host.clone()) {
return Err(AppError::Config(format!(
"routing.website.host = '{website_host}' collides with routing.{name}.host; \
a filesystem website must be on a host distinct from the admin/API surface \
so deployed content can't ride the admin session cookie"
)));
}
}
Ok(())
}
fn validate_cors(cors: &CorsConfig) -> Result<(), AppError> {
for origin in &cors.allowed_origins {
let trimmed = origin.trim();
if trimmed.is_empty() {
return Err(AppError::Config(
"cors.allowed_origins contains an empty / whitespace entry".into(),
));
}
if trimmed == "*" || trimmed.contains('*') {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' uses a wildcard; \
spec §9.3 demands an exact-match allowlist"
)));
}
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
return Err(AppError::Config(format!(
"cors.allowed_origins entry '{trimmed}' must be a full origin \
(e.g., 'https://admin.example.com')"
)));
}
}
Ok(())
}
fn default_store_config() -> StoreConfig {
StoreConfig {
data_dir: PathBuf::from("data/vtc"),
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
trust_xff: false,
}
}
}
impl AppConfig {
pub fn load(config_path: Option<PathBuf>) -> Result<Self, AppError> {
let path = config_path
.or_else(|| std::env::var("VTC_CONFIG_PATH").ok().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("config.toml"));
if !path.exists() {
return Err(AppError::Config(format!(
"configuration file not found: {}",
path.display()
)));
}
let contents = std::fs::read_to_string(&path).map_err(AppError::Io)?;
let mut config = toml::from_str::<AppConfig>(&contents)
.map_err(|e| AppError::Config(format!("failed to parse {}: {e}", path.display())))?;
config.config_path = path.clone();
if let Ok(vtc_did) = std::env::var("VTC_DID") {
config.vtc_did = Some(vtc_did);
}
if let Ok(vta_did) = std::env::var("VTC_VTA_DID") {
config.vta_did = Some(vta_did);
}
if let Ok(host) = std::env::var("VTC_SERVER_HOST") {
config.server.host = host;
}
if let Ok(port) = std::env::var("VTC_SERVER_PORT") {
config.server.port = port
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_SERVER_PORT: {e}")))?;
}
if let Ok(level) = std::env::var("VTC_LOG_LEVEL") {
config.log.level = level;
}
if let Ok(format) = std::env::var("VTC_LOG_FORMAT") {
config.log.format = match format.to_lowercase().as_str() {
"json" => LogFormat::Json,
"text" => LogFormat::Text,
other => {
return Err(AppError::Config(format!(
"invalid VTC_LOG_FORMAT '{other}', expected 'text' or 'json'"
)));
}
};
}
if let Ok(public_url) = std::env::var("VTC_PUBLIC_URL") {
config.public_url = Some(public_url);
}
if let Ok(data_dir) = std::env::var("VTC_STORE_DATA_DIR") {
config.store.data_dir = PathBuf::from(data_dir);
}
match (
std::env::var("VTC_MESSAGING_MEDIATOR_URL"),
std::env::var("VTC_MESSAGING_MEDIATOR_DID"),
) {
(Ok(url), Ok(did)) => {
config.messaging = Some(MessagingConfig {
mediator_url: url,
mediator_did: did,
mediator_host: None,
});
}
(Ok(url), Err(_)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
});
messaging.mediator_url = url;
}
(Err(_), Ok(did)) => {
let messaging = config.messaging.get_or_insert(MessagingConfig {
mediator_url: String::new(),
mediator_did: String::new(),
mediator_host: None,
});
messaging.mediator_did = did;
}
(Err(_), Err(_)) => {}
}
if let Ok(secret) = std::env::var("VTC_SECRETS_SECRET") {
config.secrets.secret = Some(secret);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AWS_SECRET_NAME") {
config.secrets.aws_secret_name = Some(name);
}
if let Ok(region) = std::env::var("VTC_SECRETS_AWS_REGION") {
config.secrets.aws_region = Some(region);
}
if let Ok(project) = std::env::var("VTC_SECRETS_GCP_PROJECT") {
config.secrets.gcp_project = Some(project);
}
if let Ok(name) = std::env::var("VTC_SECRETS_GCP_SECRET_NAME") {
config.secrets.gcp_secret_name = Some(name);
}
if let Ok(url) = std::env::var("VTC_SECRETS_AZURE_VAULT_URL") {
config.secrets.azure_vault_url = Some(url);
}
if let Ok(name) = std::env::var("VTC_SECRETS_AZURE_SECRET_NAME") {
config.secrets.azure_secret_name = Some(name);
}
if let Ok(service) = std::env::var("VTC_SECRETS_KEYRING_SERVICE") {
config.secrets.keyring_service = service;
}
if let Ok(name) = std::env::var("VTC_SECRETS_K8S_SECRET_NAME") {
config.secrets.k8s_secret_name = Some(name);
}
if let Ok(ns) = std::env::var("VTC_SECRETS_K8S_NAMESPACE") {
config.secrets.k8s_namespace = Some(ns);
}
if let Ok(key) = std::env::var("VTC_SECRETS_K8S_SECRET_KEY") {
config.secrets.k8s_secret_key = key;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_ACCESS_EXPIRY") {
config.auth.access_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_ACCESS_EXPIRY: {e}")))?;
}
if let Ok(expiry) = std::env::var("VTC_AUTH_REFRESH_EXPIRY") {
config.auth.refresh_token_expiry = expiry
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_REFRESH_EXPIRY: {e}")))?;
}
if let Ok(ttl) = std::env::var("VTC_AUTH_CHALLENGE_TTL") {
config.auth.challenge_ttl = ttl
.parse()
.map_err(|e| AppError::Config(format!("invalid VTC_AUTH_CHALLENGE_TTL: {e}")))?;
}
if let Ok(interval) = std::env::var("VTC_AUTH_SESSION_CLEANUP_INTERVAL") {
config.auth.session_cleanup_interval = interval.parse().map_err(|e| {
AppError::Config(format!("invalid VTC_AUTH_SESSION_CLEANUP_INTERVAL: {e}"))
})?;
}
if let Ok(key) = std::env::var("VTC_AUTH_JWT_SIGNING_KEY") {
config.auth.jwt_signing_key = Some(key);
}
config.validate_routing_and_cors()?;
Ok(config)
}
pub fn validate_routing_and_cors(&self) -> Result<(), AppError> {
validate_routing(&self.routing)?;
validate_website_isolation(&self.routing, self.website.root_dir.is_some())?;
validate_cors(&self.cors)?;
Ok(())
}
pub fn save(&self) -> Result<(), AppError> {
self.validate_routing_and_cors()?;
let contents = toml::to_string_pretty(self)
.map_err(|e| AppError::Config(format!("failed to serialize config: {e}")))?;
std::fs::write(&self.config_path, contents).map_err(AppError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secrets_config_debug_redacts_secret() {
let cfg = SecretsConfig {
secret: Some("deadbeefdeadbeef".into()),
keyring_service: "vtc".into(),
vault_token: Some("hvs.SUPERSECRETTOKEN".into()),
vault_approle_secret_id: Some("APPROLE_SECRET_ID_XYZ".into()),
..Default::default()
};
let dbg = format!("{cfg:?}");
assert!(dbg.contains("<redacted>"), "got {dbg}");
assert!(!dbg.contains("deadbeef"), "secret leaked: {dbg}");
assert!(
!dbg.contains("SUPERSECRETTOKEN"),
"vault_token leaked: {dbg}"
);
assert!(
!dbg.contains("APPROLE_SECRET_ID_XYZ"),
"vault_approle_secret_id leaked: {dbg}"
);
assert!(dbg.contains("vtc"));
}
#[test]
fn empty_toml_parses_with_all_defaults() {
let config: AppConfig = toml::from_str("").expect("empty TOML must parse");
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8200, "VTC default port is 8200");
assert!(config.vtc_did.is_none());
assert!(config.vta_did.is_none());
assert!(config.messaging.is_none());
}
#[test]
fn minimal_toml_parses() {
let toml_src = r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("minimal TOML must parse");
assert_eq!(config.vtc_did.as_deref(), Some("did:key:zVTC"));
assert_eq!(config.vta_did.as_deref(), Some("did:key:zVTA"));
}
#[test]
fn community_name_alias_is_accepted() {
let toml_src = r#"
community_name = "Alpha Community"
community_description = "First VTC"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("alias must parse");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha Community"));
assert_eq!(config.vtc_description.as_deref(), Some("First VTC"));
}
#[test]
fn vtc_name_canonical_field_is_accepted() {
let toml_src = r#"
vtc_name = "Alpha"
vtc_description = "Canonical"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("canonical name parses");
assert_eq!(config.vtc_name.as_deref(), Some("Alpha"));
}
#[test]
fn invalid_toml_produces_config_error() {
let err = toml::from_str::<AppConfig>("server.port = \"not-a-number\"")
.expect_err("invalid port type must fail parse");
let msg = format!("{err}");
assert!(msg.contains("port"), "error must name the field: {msg}");
}
#[test]
fn server_port_bounds() {
let toml_src = r#"
[server]
port = 70000
"#;
let err =
toml::from_str::<AppConfig>(toml_src).expect_err("out-of-range port must not parse");
assert!(format!("{err}").contains("port"), "got {err}");
}
#[test]
fn secrets_config_keyring_service_defaults_to_vtc() {
let empty: AppConfig = toml::from_str("").unwrap();
assert_eq!(empty.secrets.keyring_service, "vtc");
}
#[test]
fn secrets_unknown_key_is_a_named_parse_error() {
let toml_src = r#"
[secrets]
aws_secretname = "prod/vtc"
"#;
let err = toml::from_str::<AppConfig>(toml_src)
.expect_err("unknown [secrets] key must not parse");
let msg = format!("{err}");
assert!(
msg.contains("aws_secretname"),
"error must name the offending key: {msg}"
);
}
#[test]
fn secrets_known_keys_still_parse() {
let toml_src = r#"
[secrets]
keyring_service = "vtc-test"
aws_secret_name = "prod/vtc"
aws_region = "us-east-1"
"#;
let config: AppConfig = toml::from_str(toml_src).expect("known keys parse");
assert_eq!(config.secrets.keyring_service, "vtc-test");
assert_eq!(config.secrets.aws_secret_name.as_deref(), Some("prod/vtc"));
}
#[test]
fn config_round_trip_preserves_fields() {
let original: AppConfig = toml::from_str(
r#"
vtc_did = "did:key:zVTC"
vta_did = "did:key:zVTA"
vtc_name = "Round Trip"
public_url = "https://vtc.example.com"
"#,
)
.unwrap();
let serialized = toml::to_string_pretty(&original).expect("serialize ok");
let parsed: AppConfig = toml::from_str(&serialized).expect("re-parse ok");
assert_eq!(parsed.vtc_did, original.vtc_did);
assert_eq!(parsed.vta_did, original.vta_did);
assert_eq!(parsed.vtc_name, original.vtc_name);
assert_eq!(parsed.public_url, original.public_url);
}
}