use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::{Request, SendraError};
const CONFIG_FILE_NAME: &str = "config.yaml";
pub(crate) const PROJECT_DIR_NAME: &str = ".sendra";
const APP_DIR_NAME: &str = "sendra";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_REDIRECTS: u32 = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FollowRedirects {
Follow(u32),
Disabled,
}
impl Default for FollowRedirects {
fn default() -> Self {
FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
}
}
impl<'de> Deserialize<'de> for FollowRedirects {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct FollowRedirectsVisitor;
impl serde::de::Visitor<'_> for FollowRedirectsVisitor {
type Value = FollowRedirects;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("`true`, `false`, or a maximum number of redirects to follow")
}
fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
Ok(if value {
FollowRedirects::default()
} else {
FollowRedirects::Disabled
})
}
fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
u32::try_from(value)
.map(FollowRedirects::Follow)
.map_err(|_| E::custom("redirect limit is too large"))
}
fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
if value < 0 {
return Err(E::custom("redirect limit cannot be negative"));
}
self.visit_u64(value as u64)
}
}
deserializer.deserialize_any(FollowRedirectsVisitor)
}
}
impl Serialize for FollowRedirects {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
FollowRedirects::Disabled => serializer.serialize_bool(false),
FollowRedirects::Follow(max) => serializer.serialize_u32(*max),
}
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for FollowRedirects {
fn schema_name() -> std::borrow::Cow<'static, str> {
"FollowRedirects".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "Whether to follow redirects: `false` to report a 3xx response as-is, \
`true` to follow up to the default maximum, or a non-negative integer maximum \
number of hops.",
"anyOf": [
{ "type": "boolean" },
{ "type": "integer", "minimum": 0 }
]
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub follow_redirects: Option<FollowRedirects>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub insecure: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_cert: Option<ClientCertFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_jar: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct ClientCertFile {
pub cert: String,
pub key: String,
}
fn resolve_client_cert_paths(file: &mut ConfigFile, config_path: &Path) {
let Some(client_cert) = &mut file.client_cert else {
return;
};
let base = config_path
.parent()
.filter(|dir| !dir.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
for field in [&mut client_cert.cert, &mut client_cert.key] {
let candidate = Path::new(field.as_str());
if candidate.is_relative() {
*field = base.join(candidate).to_string_lossy().into_owned();
}
}
}
impl ConfigFile {
pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
Self::parse(yaml, SendraError::ParseStr)
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|source| SendraError::ConfigIo {
path: path.to_path_buf(),
source,
})?;
Self::parse(&raw, |source| SendraError::ConfigParse {
path: path.to_path_buf(),
source,
})
}
fn parse(
yaml: &str,
wrap: impl Fn(serde_yaml::Error) -> SendraError,
) -> Result<Self, SendraError> {
let probe: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(&wrap)?;
if probe.is_null() {
return Ok(Self::default());
}
serde_yaml::from_str(yaml).map_err(&wrap)
}
fn merge_over(self, base: Self) -> Self {
let mut headers = base.headers;
for (name, value) in self.headers {
insert_overriding(&mut headers, &name, &value);
}
Self {
headers,
timeout_seconds: self.timeout_seconds.or(base.timeout_seconds),
follow_redirects: self.follow_redirects.or(base.follow_redirects),
insecure: self.insecure.or(base.insecure),
proxy: self.proxy.or(base.proxy),
client_cert: self.client_cert.or(base.client_cert),
cookie_jar: self.cookie_jar.or(base.cookie_jar),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub headers: BTreeMap<String, String>,
pub timeout: Duration,
pub redirects: FollowRedirects,
pub insecure: bool,
pub proxy: Option<String>,
pub client_cert: Option<PathBuf>,
pub client_key: Option<PathBuf>,
pub cookie_jar: bool,
pub sources: Vec<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
headers: BTreeMap::new(),
timeout: DEFAULT_TIMEOUT,
redirects: FollowRedirects::default(),
insecure: false,
proxy: None,
client_cert: None,
client_key: None,
cookie_jar: false,
sources: Vec::new(),
}
}
}
impl Config {
pub fn resolve() -> Result<Self, SendraError> {
let cwd = std::env::current_dir().map_err(SendraError::CurrentDir)?;
Self::resolve_from(&cwd, global_config_path().as_deref())
}
pub fn resolve_from(
start_dir: &Path,
global_config: Option<&Path>,
) -> Result<Self, SendraError> {
let mut sources = Vec::new();
let mut merged = ConfigFile::default();
for path in [
global_config.map(Path::to_path_buf),
find_project_config(start_dir),
]
.into_iter()
.flatten()
{
if !path.is_file() {
continue;
}
let mut file = ConfigFile::from_path(&path)?;
resolve_client_cert_paths(&mut file, &path);
merged = file.merge_over(merged);
sources.push(path);
}
let (client_cert, client_key) = match merged.client_cert {
Some(client_cert) => (
Some(PathBuf::from(client_cert.cert)),
Some(PathBuf::from(client_cert.key)),
),
None => (None, None),
};
Ok(Self {
headers: merged.headers,
timeout: merged
.timeout_seconds
.map_or(DEFAULT_TIMEOUT, Duration::from_secs),
redirects: merged.follow_redirects.unwrap_or_default(),
insecure: merged.insecure.unwrap_or(false),
proxy: merged.proxy,
client_cert,
client_key,
cookie_jar: merged.cookie_jar.unwrap_or(false),
sources,
})
}
pub fn apply(&self, request: &Request) -> Request {
let mut applied = request.clone();
for (name, value) in &self.headers {
insert_if_absent(&mut applied.headers, name, value);
}
applied
}
}
pub(crate) fn insert_if_absent(headers: &mut Vec<(String, String)>, name: &str, value: &str) {
if headers
.iter()
.any(|(existing, _)| existing.eq_ignore_ascii_case(name))
{
return;
}
headers.push((name.to_string(), value.to_string()));
}
fn insert_overriding(headers: &mut BTreeMap<String, String>, name: &str, value: &str) {
headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
headers.insert(name.to_string(), value.to_string());
}
pub fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
start_dir
.ancestors()
.map(|dir| dir.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME))
.find(|candidate| candidate.is_file())
}
pub fn global_config_path() -> Option<PathBuf> {
let root = match std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
Some(dir) if dir.is_absolute() => dir,
_ => dirs::config_dir()?,
};
Some(root.join(APP_DIR_NAME).join(CONFIG_FILE_NAME))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Method;
fn write(path: &Path, contents: &str) {
std::fs::create_dir_all(path.parent().expect("a file has a parent")).unwrap();
std::fs::write(path, contents).unwrap();
}
fn project(dir: &Path, config: &str) -> PathBuf {
let root = dir.join("project");
write(&root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME), config);
root
}
fn global(dir: &Path, config: &str) -> PathBuf {
let path = dir.join("global").join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
write(&path, config);
path
}
fn request_with_headers(headers: &[(&str, &str)]) -> Request {
Request {
name: None,
method: Method::Get,
url: "https://example.com".to_string(),
headers: headers
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect(),
query: Vec::new(),
body: None,
json: None,
body_file: None,
form: Vec::new(),
multipart: Vec::new(),
auth: None,
assertions: None,
pre_request: None,
post_request: None,
capture: None,
retry: None,
}
}
#[test]
fn no_config_anywhere_falls_back_to_the_hardcoded_defaults() {
let temp = tempfile::tempdir().unwrap();
let missing = temp.path().join("nowhere").join(CONFIG_FILE_NAME);
let config = Config::resolve_from(temp.path(), Some(&missing))
.expect("no config file is not a failure");
assert_eq!(config, Config::default());
assert!(config.headers.is_empty());
assert_eq!(config.timeout, DEFAULT_TIMEOUT);
assert!(config.sources.is_empty(), "nothing was read");
}
#[test]
fn a_global_config_applies_when_there_is_no_project_config() {
let temp = tempfile::tempdir().unwrap();
let global = global(
temp.path(),
"headers:\n User-Agent: sendra-global\ntimeout_seconds: 5\n",
);
let elsewhere = temp.path().join("elsewhere");
std::fs::create_dir_all(&elsewhere).unwrap();
let config = Config::resolve_from(&elsewhere, Some(&global)).unwrap();
assert_eq!(
config.headers.get("User-Agent").map(String::as_str),
Some("sendra-global")
);
assert_eq!(config.timeout, Duration::from_secs(5));
assert_eq!(config.sources, vec![global]);
}
#[test]
fn a_project_config_applies_when_there_is_no_global_config() {
let temp = tempfile::tempdir().unwrap();
let root = project(
temp.path(),
"headers:\n X-Project: yes\ntimeout_seconds: 7\n",
);
let config = Config::resolve_from(&root, None).expect("no global config is fine");
assert_eq!(
config.headers.get("X-Project").map(String::as_str),
Some("yes")
);
assert_eq!(config.timeout, Duration::from_secs(7));
assert_eq!(
config.sources,
vec![root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME)]
);
}
#[test]
fn project_values_override_global_values_key_by_key_not_file_by_file() {
let temp = tempfile::tempdir().unwrap();
let global = global(
temp.path(),
"headers:\n User-Agent: sendra-global\n Accept: application/json\ntimeout_seconds: 60\n",
);
let root = project(temp.path(), "timeout_seconds: 3\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(config.timeout, Duration::from_secs(3));
assert_eq!(
config.headers.get("User-Agent").map(String::as_str),
Some("sendra-global")
);
assert_eq!(
config.headers.get("Accept").map(String::as_str),
Some("application/json")
);
assert_eq!(config.sources.len(), 2, "both files were read");
}
#[test]
fn header_maps_merge_per_key_too() {
let temp = tempfile::tempdir().unwrap();
let global = global(
temp.path(),
"headers:\n User-Agent: sendra-global\n Accept: application/json\n",
);
let root = project(temp.path(), "headers:\n User-Agent: sendra-project\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(
config.headers.get("User-Agent").map(String::as_str),
Some("sendra-project")
);
assert_eq!(
config.headers.get("Accept").map(String::as_str),
Some("application/json")
);
assert_eq!(config.timeout, DEFAULT_TIMEOUT);
}
#[test]
fn a_project_header_overrides_a_global_one_spelled_with_different_casing() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "headers:\n User-Agent: sendra-global\n");
let root = project(temp.path(), "headers:\n user-agent: sendra-project\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(config.headers.len(), 1, "got {:?}", config.headers);
assert_eq!(
config.headers.values().next().map(String::as_str),
Some("sendra-project")
);
}
#[test]
fn the_config_at_the_project_root_is_found_from_a_nested_subdirectory() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "headers:\n X-Project: yes\n");
let nested = root.join("crates").join("api").join("tests");
std::fs::create_dir_all(&nested).unwrap();
let found = find_project_config(&nested).expect("the walk-up must reach the root");
assert_eq!(
found,
root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
"the config at the project root should have been found from {}",
nested.display()
);
assert_eq!(
Config::resolve_from(&nested, None).unwrap().headers,
Config::resolve_from(&root, None).unwrap().headers
);
}
#[test]
fn the_nearest_project_config_wins_over_one_further_up() {
let temp = tempfile::tempdir().unwrap();
let outer = project(temp.path(), "headers:\n X-Which: outer\n");
let inner = outer.join("nested");
write(
&inner.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
"headers:\n X-Which: inner\n",
);
let config = Config::resolve_from(&inner, None).unwrap();
assert_eq!(
config.headers.get("X-Which").map(String::as_str),
Some("inner")
);
assert_eq!(config.sources.len(), 1, "only the nearest is read");
}
#[test]
fn malformed_yaml_in_a_config_file_is_a_typed_error_carrying_the_path() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "headers: [oops\n");
let err = Config::resolve_from(&root, None).expect_err("malformed config must error");
match err {
SendraError::ConfigParse { path, .. } => assert_eq!(
path,
root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
"the error should name the file to fix"
),
other => panic!("expected ConfigParse, got {other:?}"),
}
}
#[test]
fn an_unknown_config_key_is_rejected_rather_than_ignored() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout: 5\n");
let err = Config::resolve_from(&root, None).expect_err("a typo must not be ignored");
assert!(
matches!(err, SendraError::ConfigParse { .. }),
"got {err:?}"
);
}
#[test]
fn a_wrongly_typed_config_value_is_a_parse_error() {
let err = ConfigFile::from_yaml_str("timeout_seconds: soon\n")
.expect_err("seconds must be a number");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn no_follow_redirects_key_resolves_to_the_default_of_ten() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(
config.redirects,
FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
);
}
#[test]
fn follow_redirects_false_disables_them() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "follow_redirects: false\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(config.redirects, FollowRedirects::Disabled);
}
#[test]
fn follow_redirects_true_is_the_same_default_maximum() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "follow_redirects: true\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(
config.redirects,
FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
);
}
#[test]
fn follow_redirects_as_a_number_sets_a_custom_maximum() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "follow_redirects: 3\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(config.redirects, FollowRedirects::Follow(3));
}
#[test]
fn a_project_follow_redirects_overrides_a_global_one_wholesale() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "follow_redirects: false\n");
let root = project(temp.path(), "follow_redirects: 2\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(config.redirects, FollowRedirects::Follow(2));
}
#[test]
fn a_negative_follow_redirects_number_is_a_parse_error() {
let err = ConfigFile::from_yaml_str("follow_redirects: -1\n")
.expect_err("a negative redirect count makes no sense");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn a_follow_redirects_value_that_is_neither_bool_nor_number_says_so() {
let err = ConfigFile::from_yaml_str("follow_redirects: sometimes\n")
.expect_err("a string is not a valid value");
let message = err.to_string();
assert!(
message.contains("could not parse"),
"got {message}: {err:?}"
);
}
#[test]
fn no_insecure_key_resolves_to_false() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, None).unwrap();
assert!(!config.insecure);
}
#[test]
fn insecure_true_resolves_to_true() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "insecure: true\n");
let config = Config::resolve_from(&root, None).unwrap();
assert!(config.insecure);
}
#[test]
fn a_project_insecure_overrides_a_global_one_wholesale() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "insecure: true\n");
let root = project(temp.path(), "insecure: false\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert!(!config.insecure, "the project's explicit false must win");
}
#[test]
fn a_global_insecure_applies_when_the_project_says_nothing() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "insecure: true\n");
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert!(config.insecure);
}
#[test]
fn no_cookie_jar_key_resolves_to_false() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, None).unwrap();
assert!(!config.cookie_jar);
}
#[test]
fn cookie_jar_true_resolves_to_true() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "cookie_jar: true\n");
let config = Config::resolve_from(&root, None).unwrap();
assert!(config.cookie_jar);
}
#[test]
fn a_project_cookie_jar_overrides_a_global_one_wholesale() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "cookie_jar: true\n");
let root = project(temp.path(), "cookie_jar: false\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert!(!config.cookie_jar, "the project's explicit false must win");
}
#[test]
fn a_global_cookie_jar_applies_when_the_project_says_nothing() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "cookie_jar: true\n");
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert!(config.cookie_jar);
}
#[test]
fn no_proxy_key_resolves_to_none() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(config.proxy, None);
}
#[test]
fn proxy_resolves_to_the_configured_url() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "proxy: http://proxy.example.com:8080\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(
config.proxy.as_deref(),
Some("http://proxy.example.com:8080")
);
}
#[test]
fn a_proxy_url_with_embedded_credentials_round_trips_unchanged() {
let temp = tempfile::tempdir().unwrap();
let root = project(
temp.path(),
"proxy: http://user:pass@proxy.example.com:8080\n",
);
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(
config.proxy.as_deref(),
Some("http://user:pass@proxy.example.com:8080")
);
}
#[test]
fn a_project_proxy_overrides_a_global_one_wholesale() {
let temp = tempfile::tempdir().unwrap();
let global = global(temp.path(), "proxy: http://global-proxy:8080\n");
let root = project(temp.path(), "proxy: http://project-proxy:8080\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(config.proxy.as_deref(), Some("http://project-proxy:8080"));
}
#[test]
fn no_client_cert_key_resolves_to_neither_cert_nor_key() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(config.client_cert, None);
assert_eq!(config.client_key, None);
}
#[test]
fn a_relative_client_cert_resolves_against_the_project_configs_own_directory() {
let temp = tempfile::tempdir().unwrap();
let root = project(
temp.path(),
"client_cert:\n cert: ./client.pem\n key: ./client-key.pem\n",
);
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(
config.client_cert,
Some(root.join(PROJECT_DIR_NAME).join("client.pem"))
);
assert_eq!(
config.client_key,
Some(root.join(PROJECT_DIR_NAME).join("client-key.pem"))
);
}
#[test]
fn a_relative_client_cert_resolves_against_the_global_configs_own_directory_not_the_project() {
let temp = tempfile::tempdir().unwrap();
let global = global(
temp.path(),
"client_cert:\n cert: ./g.pem\n key: ./g-key.pem\n",
);
let root = project(temp.path(), "timeout_seconds: 5\n");
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(
config.client_cert,
Some(global.parent().unwrap().join("g.pem"))
);
assert_eq!(
config.client_key,
Some(global.parent().unwrap().join("g-key.pem"))
);
}
#[test]
fn an_absolute_client_cert_path_is_left_unchanged() {
let temp = tempfile::tempdir().unwrap();
let absolute = temp.path().join("elsewhere").join("client.pem");
let root = project(
temp.path(),
&format!(
"client_cert:\n cert: {}\n key: ./client-key.pem\n",
absolute.display()
),
);
let config = Config::resolve_from(&root, None).unwrap();
assert_eq!(config.client_cert, Some(absolute));
}
#[test]
fn a_project_client_cert_overrides_a_global_one_wholesale() {
let temp = tempfile::tempdir().unwrap();
let global = global(
temp.path(),
"client_cert:\n cert: ./g.pem\n key: ./g-key.pem\n",
);
let root = project(
temp.path(),
"client_cert:\n cert: ./p.pem\n key: ./p-key.pem\n",
);
let config = Config::resolve_from(&root, Some(&global)).unwrap();
assert_eq!(
config.client_cert,
Some(root.join(PROJECT_DIR_NAME).join("p.pem"))
);
assert_eq!(
config.client_key,
Some(root.join(PROJECT_DIR_NAME).join("p-key.pem"))
);
}
#[test]
fn client_cert_with_only_a_cert_key_is_a_parse_error() {
let err = ConfigFile::from_yaml_str("client_cert:\n cert: ./c.pem\n")
.expect_err("`key` is required alongside `cert`");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn an_unknown_key_inside_client_cert_is_rejected() {
let err = ConfigFile::from_yaml_str(
"client_cert:\n cert: ./c.pem\n key: ./k.pem\n password: hunter2\n",
)
.expect_err("`password` is not a known field of `client_cert`");
assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
}
#[test]
fn an_unknown_config_key_near_proxy_or_insecure_is_still_rejected() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "insecur: true\n");
let err = Config::resolve_from(&root, None).expect_err("a typo must not be ignored");
assert!(matches!(err, SendraError::ConfigParse { .. }), "{err:?}");
}
#[test]
fn an_empty_config_file_is_an_empty_config_not_an_error() {
let temp = tempfile::tempdir().unwrap();
let root = project(temp.path(), "# nothing set yet\n");
let config = Config::resolve_from(&root, None).expect("an empty file is valid");
assert_eq!(config.headers, BTreeMap::new());
assert_eq!(config.timeout, DEFAULT_TIMEOUT);
assert_eq!(config.sources.len(), 1);
}
#[test]
fn config_headers_are_added_to_a_request_that_does_not_set_them() {
let config = Config {
headers: BTreeMap::from([("User-Agent".to_string(), "sendra".to_string())]),
..Config::default()
};
let applied = config.apply(&request_with_headers(&[("Accept", "text/plain")]));
assert_eq!(applied.header("User-Agent"), Some("sendra"));
assert_eq!(applied.header("Accept"), Some("text/plain"));
}
#[test]
fn a_request_header_beats_the_config_default_of_the_same_name() {
let config = Config {
headers: BTreeMap::from([("User-Agent".to_string(), "from-config".to_string())]),
..Config::default()
};
let applied = config.apply(&request_with_headers(&[("User-Agent", "from-request")]));
assert_eq!(applied.header("User-Agent"), Some("from-request"));
}
#[test]
fn a_request_header_beats_a_config_default_spelled_with_different_casing() {
let config = Config {
headers: BTreeMap::from([("User-Agent".to_string(), "from-config".to_string())]),
..Config::default()
};
let applied = config.apply(&request_with_headers(&[("user-agent", "from-request")]));
assert_eq!(applied.headers.len(), 1, "got {:?}", applied.headers);
assert_eq!(applied.header("user-agent"), Some("from-request"));
}
#[test]
fn a_config_default_is_suppressed_even_when_the_request_repeats_that_name() {
let config = Config {
headers: BTreeMap::from([("X-Tag".to_string(), "from-config".to_string())]),
..Config::default()
};
let mut request = request_with_headers(&[]);
request.headers = vec![
("X-Tag".to_string(), "one".to_string()),
("X-Tag".to_string(), "two".to_string()),
];
let applied = config.apply(&request);
assert_eq!(
applied.headers,
vec![
("X-Tag".to_string(), "one".to_string()),
("X-Tag".to_string(), "two".to_string()),
],
"got {:?}",
applied.headers
);
}
#[test]
fn a_request_that_repeats_a_header_keeps_both_after_config_is_applied() {
let config = Config {
headers: BTreeMap::from([("User-Agent".to_string(), "sendra".to_string())]),
..Config::default()
};
let mut request = request_with_headers(&[]);
request.headers = vec![
("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
];
let applied = config.apply(&request);
let forwarded: Vec<&str> = applied
.headers
.iter()
.filter(|(name, _)| name == "X-Forwarded-For")
.map(|(_, value)| value.as_str())
.collect();
assert_eq!(forwarded, vec!["1.2.3.4", "5.6.7.8"]);
assert_eq!(applied.header("User-Agent"), Some("sendra"));
}
#[test]
fn applying_a_config_changes_nothing_else_about_the_request() {
let config = Config {
headers: BTreeMap::from([("X-Added".to_string(), "1".to_string())]),
..Config::default()
};
let request = Request {
name: Some("Create".to_string()),
method: Method::Post,
url: "https://example.com/things".to_string(),
headers: Vec::new(),
query: Vec::new(),
body: Some("{}".to_string()),
json: None,
body_file: None,
form: Vec::new(),
multipart: Vec::new(),
auth: None,
assertions: Some(crate::Assertions {
status: Some(200),
..crate::Assertions::default()
}),
pre_request: Some(
"request.url = request.url;
"
.to_string(),
),
post_request: Some(
"// nothing
"
.to_string(),
),
capture: Some(
[(
"id".to_string(),
crate::CaptureSource::JsonPath("$.id".to_string()),
)]
.into_iter()
.collect(),
),
retry: None,
};
let applied = config.apply(&request);
assert_eq!(applied.name, request.name);
assert_eq!(applied.method, request.method);
assert_eq!(applied.url, request.url);
assert_eq!(applied.body, request.body);
assert_eq!(applied.pre_request, request.pre_request);
assert_eq!(applied.post_request, request.post_request);
assert_eq!(applied.assertions, request.assertions);
}
#[test]
fn the_default_config_leaves_a_request_untouched() {
let request = request_with_headers(&[("Accept", "application/json")]);
assert_eq!(Config::default().apply(&request), request);
}
#[test]
fn the_global_config_path_ends_where_it_should() {
let Some(path) = global_config_path() else {
return;
};
assert!(
path.ends_with(Path::new(APP_DIR_NAME).join(CONFIG_FILE_NAME)),
"got {}",
path.display()
);
assert!(path.is_absolute(), "got {}", path.display());
}
}