582 lines
20 KiB
Rust
582 lines
20 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use crank_config::{
|
|
ConfigSource, DiagnosticCode, FieldMode, ProcessKind, ProcessScope, field_registry,
|
|
parse_migrator, parse_process,
|
|
};
|
|
|
|
const TEST_MASTER_KEY: &str = "test-master-key-00000000000000000000000000000000";
|
|
|
|
fn required_admin() -> BTreeMap<String, String> {
|
|
[
|
|
("CRANK_MASTER_KEY", TEST_MASTER_KEY),
|
|
("CRANK_SESSION_SECRET", "session"),
|
|
("CRANK_PASSWORD_PEPPER", "pepper"),
|
|
("CRANK_BOOTSTRAP_ADMIN_EMAIL", "owner@example.test"),
|
|
("CRANK_BOOTSTRAP_ADMIN_PASSWORD", "password"),
|
|
]
|
|
.into_iter()
|
|
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
fn required_mcp() -> BTreeMap<String, String> {
|
|
[("CRANK_MASTER_KEY", TEST_MASTER_KEY)]
|
|
.into_iter()
|
|
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_projection_requires_only_database_configuration() {
|
|
let config = parse_migrator(ConfigSource::from_utf8(BTreeMap::new()))
|
|
.expect("database defaults are sufficient for the controlled migration job");
|
|
assert_eq!(config.database.host, "postgres");
|
|
assert_eq!(config.database.port, 5432);
|
|
assert_eq!(config.fingerprint().len(), 64);
|
|
let debug = format!("{config:?}");
|
|
assert!(
|
|
!debug.contains("crank"),
|
|
"database password must remain redacted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn migrator_ignores_service_configuration_and_rejects_database_typos() {
|
|
let mut values = BTreeMap::from([
|
|
("CRANK_MASTER_KEY".to_owned(), "secret-canary".to_owned()),
|
|
(
|
|
"CRANK_SESSION_SECRET".to_owned(),
|
|
"secret-canary".to_owned(),
|
|
),
|
|
("CRANK_MCP_REFRESH_MS".to_owned(), "invalid".to_owned()),
|
|
]);
|
|
parse_migrator(ConfigSource::from_utf8(values.clone()))
|
|
.expect("service fields are outside the database-only projection");
|
|
values.insert("POSTGRES_PORRT".to_owned(), "5432".to_owned());
|
|
let error = parse_migrator(ConfigSource::from_utf8(values)).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.code == DiagnosticCode::UnknownField)
|
|
);
|
|
assert!(!error.to_string().contains("secret-canary"));
|
|
}
|
|
|
|
fn source_for(
|
|
field: &crank_config::FieldSpec,
|
|
value: String,
|
|
) -> (ProcessKind, BTreeMap<String, String>) {
|
|
let kind = match field.process {
|
|
ProcessScope::McpServer => ProcessKind::McpServer,
|
|
ProcessScope::Shared | ProcessScope::AdminApi => ProcessKind::AdminApi,
|
|
};
|
|
let mut vars = match kind {
|
|
ProcessKind::AdminApi => required_admin(),
|
|
ProcessKind::McpServer => required_mcp(),
|
|
};
|
|
vars.insert(field.env_name.to_owned(), value);
|
|
match field.env_name {
|
|
"POSTGRES_MAX_CONNECTIONS" => {
|
|
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "0".into());
|
|
}
|
|
"POSTGRES_MIN_CONNECTIONS" => {
|
|
vars.insert("POSTGRES_MAX_CONNECTIONS".into(), "1024".into());
|
|
}
|
|
"CRANK_ADMIN_RATE_LIMIT_RPS" => {
|
|
vars.insert("CRANK_ADMIN_RATE_LIMIT_BURST".into(), "1000000".into());
|
|
}
|
|
"CRANK_ADMIN_RATE_LIMIT_BURST" => {
|
|
vars.insert("CRANK_ADMIN_RATE_LIMIT_RPS".into(), "1".into());
|
|
}
|
|
"CRANK_MCP_RATE_LIMIT_RPS" => {
|
|
vars.insert("CRANK_MCP_RATE_LIMIT_BURST".into(), "1000000".into());
|
|
}
|
|
"CRANK_MCP_RATE_LIMIT_BURST" => {
|
|
vars.insert("CRANK_MCP_RATE_LIMIT_RPS".into(), "1".into());
|
|
}
|
|
"OTEL_BSP_MAX_QUEUE_SIZE" => {
|
|
vars.insert("OTEL_BSP_MAX_EXPORT_BATCH_SIZE".into(), "1".into());
|
|
}
|
|
"OTEL_BSP_MAX_EXPORT_BATCH_SIZE" => {
|
|
vars.insert("OTEL_BSP_MAX_QUEUE_SIZE".into(), "65536".into());
|
|
}
|
|
_ => {}
|
|
}
|
|
(kind, vars)
|
|
}
|
|
|
|
#[test]
|
|
fn registry_covers_exactly_the_59_observed_runtime_names() {
|
|
let registry = field_registry();
|
|
assert_eq!(registry.len(), 59);
|
|
let unique = registry
|
|
.iter()
|
|
.map(|field| field.env_name)
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_eq!(unique.len(), registry.len());
|
|
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_WINDOW"));
|
|
assert!(!unique.contains("CRANK_RUNTIME_MAX_CONCURRENT_JOBS"));
|
|
for field in registry {
|
|
assert!(!field.semantic_path.is_empty());
|
|
assert!(!field.env_name.is_empty());
|
|
assert!(!field.value_type.is_empty());
|
|
if let (Some(minimum), Some(maximum)) = (field.minimum, field.maximum) {
|
|
assert!(minimum <= maximum, "{}", field.env_name);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
registry
|
|
.iter()
|
|
.find(|field| field.env_name == "CRANK_CACHE_DEFAULT_TTL_MS")
|
|
.unwrap()
|
|
.mode,
|
|
FieldMode::DeprecatedNoEffect
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_are_preserved_and_invalid_values_never_fall_back() {
|
|
let valid = parse_process(
|
|
ProcessKind::AdminApi,
|
|
ConfigSource::from_utf8(required_admin()),
|
|
)
|
|
.expect("minimal admin config");
|
|
let admin = valid.admin().expect("admin projection");
|
|
assert_eq!(admin.database.port, 5432);
|
|
assert_eq!(admin.session_ttl_hours, 24);
|
|
assert_eq!(admin.rate_limit.requests_per_second, 30);
|
|
|
|
for (name, value) in [
|
|
("POSTGRES_PORT", "bad"),
|
|
("CRANK_MASTER_KEY", "too-short"),
|
|
("CRANK_SESSION_TTL_HOURS", "bad"),
|
|
("CRANK_ADMIN_RATE_LIMIT_RPS", "bad"),
|
|
("CRANK_TRUSTED_PROXY_IPS", "not-an-ip"),
|
|
] {
|
|
let mut vars = required_admin();
|
|
vars.insert(name.to_owned(), value.to_owned());
|
|
let error =
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
let path = field_registry()
|
|
.iter()
|
|
.find(|field| field.env_name == name)
|
|
.unwrap()
|
|
.semantic_path;
|
|
assert!(error.diagnostics().iter().any(|item| item.field == path));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn database_forms_conflict_and_owned_typos_fail_closed() {
|
|
let mut vars = required_admin();
|
|
vars.insert(
|
|
"CRANK_DATABASE_URL".into(),
|
|
"postgres://user:secret@db/crank".into(),
|
|
);
|
|
vars.insert("POSTGRES_HOST".into(), "db".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.code == DiagnosticCode::Conflict)
|
|
);
|
|
|
|
let mut vars = required_admin();
|
|
vars.insert("CRANK_SESION_SECRET".into(), "canary".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.code == DiagnosticCode::UnknownField)
|
|
);
|
|
assert!(!error.to_string().contains("canary"));
|
|
|
|
for ghost in [
|
|
"CRANK_RUNTIME_MAX_CONCURRENT_WINDOW",
|
|
"CRANK_RUNTIME_MAX_CONCURRENT_JOBS",
|
|
] {
|
|
let mut vars = required_admin();
|
|
vars.insert(ghost.into(), "4".into());
|
|
let error =
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(error.diagnostics().iter().any(|item| {
|
|
item.code == DiagnosticCode::UnknownField && item.field == "environment.unknown"
|
|
}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exact_deployment_only_names_are_known_but_never_runtime_fields() {
|
|
let mut vars = required_admin();
|
|
for name in crank_config::deployment_field_registry() {
|
|
vars.insert((*name).to_owned(), "deployment-value".to_owned());
|
|
}
|
|
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
|
assert!(config.admin().is_some());
|
|
assert!(
|
|
field_registry()
|
|
.iter()
|
|
.all(|field| { !crank_config::deployment_field_registry().contains(&field.env_name) })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parsed_but_unused_cache_ttl_is_an_explicit_non_pass_contract() {
|
|
let mut vars = required_admin();
|
|
vars.insert("CRANK_CACHE_DEFAULT_TTL_MS".into(), "5000".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(error.diagnostics().iter().any(|item| {
|
|
item.code == DiagnosticCode::DeprecatedNoEffect && item.field == "cache.default_ttl_ms"
|
|
}));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn os_source_rejects_non_utf8_without_echoing_bytes() {
|
|
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
|
|
|
let error = ConfigSource::from_os_iter([(
|
|
OsString::from("CRANK_MASTER_KEY"),
|
|
OsString::from_vec(vec![0xff, b'S', b'E', b'C', b'R', b'E', b'T']),
|
|
)])
|
|
.unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.code == DiagnosticCode::InvalidEncoding)
|
|
);
|
|
assert!(!error.to_string().contains("SECRET"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn os_source_ignores_unrelated_invalid_or_unbounded_values() {
|
|
use std::{ffi::OsString, os::unix::ffi::OsStringExt};
|
|
|
|
ConfigSource::from_os_iter([
|
|
(
|
|
OsString::from_vec(vec![0xff]),
|
|
OsString::from_vec(vec![0xff]),
|
|
),
|
|
(
|
|
OsString::from("JAVA_TOOL_OPTIONS"),
|
|
OsString::from("x".repeat(20_000)),
|
|
),
|
|
(OsString::from("LANG"), OsString::from_vec(vec![0xff, b'x'])),
|
|
])
|
|
.expect("unrelated OS state is outside the runtime contract");
|
|
}
|
|
|
|
#[test]
|
|
fn process_specific_fields_and_zero_ports_fail_closed() {
|
|
let mut mcp = required_mcp();
|
|
mcp.insert("CRANK_SESSION_SECRET".into(), "wrong-process".into());
|
|
let error = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp)).unwrap_err();
|
|
assert!(error.diagnostics().iter().any(|item| {
|
|
item.code == DiagnosticCode::UnknownField && item.field == "admin.session.secret"
|
|
}));
|
|
|
|
for (kind, name, required) in [
|
|
(ProcessKind::AdminApi, "CRANK_ADMIN_BIND", required_admin()),
|
|
(
|
|
ProcessKind::AdminApi,
|
|
"CRANK_ADMIN_METRICS_BIND",
|
|
required_admin(),
|
|
),
|
|
(ProcessKind::McpServer, "CRANK_MCP_BIND", required_mcp()),
|
|
(
|
|
ProcessKind::McpServer,
|
|
"CRANK_MCP_METRICS_BIND",
|
|
required_mcp(),
|
|
),
|
|
] {
|
|
let mut vars = required;
|
|
vars.insert(name.into(), "127.0.0.1:0".into());
|
|
let error = parse_process(kind, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
let path = field_registry()
|
|
.iter()
|
|
.find(|field| field.env_name == name)
|
|
.unwrap()
|
|
.semantic_path;
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| { item.code == DiagnosticCode::OutOfRange && item.field == path })
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn production_admin_non_loopback_requires_https_base_url() {
|
|
let mut vars = required_admin();
|
|
vars.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
|
vars.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
|
vars.insert("CRANK_BASE_URL".into(), "http://crank.example.test".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(error.diagnostics().iter().any(|item| {
|
|
item.code == DiagnosticCode::UnsafeCombination && item.field == "admin.exposure.tls"
|
|
}));
|
|
|
|
let mut safe = required_admin();
|
|
safe.insert("CRANK_ENVIRONMENT".into(), "production".into());
|
|
safe.insert("CRANK_ADMIN_BIND".into(), "0.0.0.0:3001".into());
|
|
safe.insert("CRANK_BASE_URL".into(), "https://crank.example.test".into());
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(safe)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_secrets_and_consumer_invalid_values_fail_in_the_leaf_parser() {
|
|
for name in [
|
|
"CRANK_MASTER_KEY",
|
|
"CRANK_SESSION_SECRET",
|
|
"CRANK_PASSWORD_PEPPER",
|
|
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
|
|
] {
|
|
let mut vars = required_admin();
|
|
vars.insert(name.into(), " ".into());
|
|
assert!(
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).is_err(),
|
|
"{name}"
|
|
);
|
|
}
|
|
for (name, value) in [
|
|
("CRANK_OUTBOUND_ALLOWED_HOSTS", "example.test:443"),
|
|
("CRANK_DATABASE_URL", "postgres://db/crank?sslmode=bogus"),
|
|
("CRANK_ENVIRONMENT", "bad environment"),
|
|
("CRANK_SENTRY_DSN", "not-a-dsn"),
|
|
("OTEL_EXPORTER_OTLP_HEADERS", "bad name=value"),
|
|
(
|
|
"CRANK_BASE_URL",
|
|
"https://user:secret@example.test/path?token=x",
|
|
),
|
|
] {
|
|
let mut vars = required_admin();
|
|
vars.insert(name.into(), value.into());
|
|
let error =
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.code == DiagnosticCode::InvalidType),
|
|
"{name}: {error}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cross_field_and_typed_boundaries_fail_closed() {
|
|
let cases = [
|
|
("POSTGRES_MAX_CONNECTIONS", "1025"),
|
|
("CRANK_ADMIN_RATE_LIMIT_RPS", "100001"),
|
|
("CRANK_ADMIN_RATE_LIMIT_BURST", "0"),
|
|
("CRANK_OUTBOUND_MAX_REQUEST_BYTES", "67108865"),
|
|
("CRANK_OUTBOUND_MAX_RESPONSE_BYTES", "67108865"),
|
|
("OTEL_BSP_SCHEDULE_DELAY", "bad"),
|
|
("OTEL_BSP_EXPORT_TIMEOUT", "300001"),
|
|
("CRANK_BASE_URL", "file:///tmp/config"),
|
|
];
|
|
for (name, value) in cases {
|
|
let mut vars = required_admin();
|
|
vars.insert(name.to_owned(), value.to_owned());
|
|
let error =
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).expect_err(name);
|
|
assert!(
|
|
error.diagnostics().iter().any(|item| item.field
|
|
== field_registry()
|
|
.iter()
|
|
.find(|field| field.env_name == name)
|
|
.unwrap()
|
|
.semantic_path),
|
|
"{name}: {error}"
|
|
);
|
|
}
|
|
|
|
let mut vars = required_admin();
|
|
vars.insert("POSTGRES_MIN_CONNECTIONS".into(), "21".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.code == DiagnosticCode::UnsafeCombination)
|
|
);
|
|
|
|
let mut vars = required_admin();
|
|
vars.insert("CRANK_CACHE_BACKEND".into(), "valkey".into());
|
|
vars.insert("CRANK_CACHE_URL".into(), "https://not-a-cache.test".into());
|
|
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap_err();
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.field == "cache.url")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn inclusive_edges_and_legacy_boolean_spellings_are_explicit() {
|
|
let mut vars = required_admin();
|
|
vars.extend([
|
|
("POSTGRES_PORT".into(), "65535".into()),
|
|
("POSTGRES_MAX_CONNECTIONS".into(), "1024".into()),
|
|
("POSTGRES_MIN_CONNECTIONS".into(), "0".into()),
|
|
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "1".into()),
|
|
("CRANK_OUTBOUND_MAX_REQUEST_BYTES".into(), "67108864".into()),
|
|
(
|
|
"CRANK_OUTBOUND_MAX_RESPONSE_BYTES".into(),
|
|
"67108864".into(),
|
|
),
|
|
("CRANK_TRUSTED_PROXY_IPS".into(), "127.0.0.1,::1".into()),
|
|
("CRANK_DEMO_SEED".into(), "off".into()),
|
|
]);
|
|
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
|
let admin = config.admin().unwrap();
|
|
assert_eq!(admin.database.port, 65_535);
|
|
assert_eq!(admin.database.pool.max_connections, 1024);
|
|
assert_eq!(admin.database.pool.min_connections, 0);
|
|
assert_eq!(admin.runtime.max_concurrent_unary, 1);
|
|
assert_eq!(admin.trusted_proxy_ips.len(), 2);
|
|
assert!(!admin.demo_seed);
|
|
}
|
|
|
|
#[test]
|
|
fn admin_and_mcp_share_one_normalized_foundation() {
|
|
let mut vars = required_admin();
|
|
vars.extend([
|
|
("CRANK_BASE_URL".into(), "https://crank.example.test".into()),
|
|
("CRANK_RUNTIME_MAX_CONCURRENT_UNARY".into(), "72".into()),
|
|
(
|
|
"CRANK_OUTBOUND_ALLOWED_HOSTS".into(),
|
|
"api.example.test".into(),
|
|
),
|
|
("POSTGRES_MAX_CONNECTIONS".into(), "24".into()),
|
|
]);
|
|
let admin =
|
|
parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars.clone())).unwrap();
|
|
let mut mcp_vars = vars;
|
|
for key in [
|
|
"CRANK_SESSION_SECRET",
|
|
"CRANK_PASSWORD_PEPPER",
|
|
"CRANK_BOOTSTRAP_ADMIN_EMAIL",
|
|
"CRANK_BOOTSTRAP_ADMIN_PASSWORD",
|
|
] {
|
|
mcp_vars.remove(key);
|
|
}
|
|
let mcp = parse_process(ProcessKind::McpServer, ConfigSource::from_utf8(mcp_vars)).unwrap();
|
|
let admin = admin.admin().unwrap();
|
|
let mcp = mcp.mcp().unwrap();
|
|
assert_eq!(admin.database.host, mcp.database.host);
|
|
assert_eq!(
|
|
admin.database.pool.max_connections,
|
|
mcp.database.pool.max_connections
|
|
);
|
|
assert_eq!(admin.runtime.base_url, mcp.runtime.base_url);
|
|
assert_eq!(
|
|
admin.runtime.max_concurrent_unary,
|
|
mcp.runtime.max_concurrent_unary
|
|
);
|
|
assert_eq!(
|
|
admin.runtime.outbound.allowed_hosts,
|
|
mcp.runtime.outbound.allowed_hosts
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_bounded_numeric_field_accepts_edges_and_rejects_outside_values() {
|
|
for field in field_registry().iter().filter(|field| {
|
|
field.mode == FieldMode::Effective && field.minimum.is_some() && field.maximum.is_some()
|
|
}) {
|
|
let minimum = field.minimum.unwrap();
|
|
let maximum = field.maximum.unwrap();
|
|
for accepted in [minimum, maximum] {
|
|
let (kind, vars) = source_for(field, accepted.to_string());
|
|
parse_process(kind, ConfigSource::from_utf8(vars))
|
|
.unwrap_or_else(|error| panic!("{}={accepted}: {error}", field.env_name));
|
|
}
|
|
for rejected in [
|
|
if minimum == 0 {
|
|
"-1".to_owned()
|
|
} else {
|
|
(minimum - 1).to_string()
|
|
},
|
|
(maximum + 1).to_string(),
|
|
] {
|
|
let (kind, vars) = source_for(field, rejected);
|
|
let error =
|
|
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.field == field.semantic_path),
|
|
"{}: {error}",
|
|
field.env_name
|
|
);
|
|
}
|
|
|
|
for malformed in ["-1", "184467440737095516160", "1.5", " 1", "1\n"] {
|
|
let (kind, vars) = source_for(field, malformed.to_owned());
|
|
let error =
|
|
parse_process(kind, ConfigSource::from_utf8(vars)).expect_err(field.env_name);
|
|
assert!(
|
|
error
|
|
.diagnostics()
|
|
.iter()
|
|
.any(|item| item.field == field.semantic_path),
|
|
"{}={malformed:?}: {error}",
|
|
field.env_name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compatibility_values_and_otel_precedence_remain_explicit() {
|
|
let mut vars = required_admin();
|
|
vars.extend([
|
|
("CRANK_CACHE_BACKEND".into(), "redis".into()),
|
|
(
|
|
"CRANK_CACHE_URL".into(),
|
|
"redis://cache.example.test:6379".into(),
|
|
),
|
|
(
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT".into(),
|
|
"https://generic.example.test".into(),
|
|
),
|
|
(
|
|
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT".into(),
|
|
"https://traces.example.test".into(),
|
|
),
|
|
("OTEL_EXPORTER_OTLP_TIMEOUT".into(), "10000".into()),
|
|
("OTEL_EXPORTER_OTLP_TRACES_TIMEOUT".into(), "5000".into()),
|
|
]);
|
|
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(vars)).unwrap();
|
|
let admin = config.admin().unwrap();
|
|
assert_eq!(
|
|
admin.runtime.cache.backend,
|
|
crank_config::CacheBackend::Redis
|
|
);
|
|
assert_eq!(
|
|
admin.observability.otlp.endpoint.as_deref(),
|
|
Some("https://generic.example.test")
|
|
);
|
|
assert_eq!(
|
|
admin.observability.otlp.traces_endpoint.as_deref(),
|
|
Some("https://traces.example.test")
|
|
);
|
|
assert_eq!(admin.observability.otlp.timeout.as_deref(), Some("10000"));
|
|
assert_eq!(
|
|
admin.observability.otlp.traces_timeout.as_deref(),
|
|
Some("5000")
|
|
);
|
|
assert_eq!(config.deprecations().len(), 1);
|
|
assert_eq!(config.deprecations()[0].field, "cache.backend");
|
|
}
|