use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Location {
pub file: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HttpTransportConfig {
pub proxy: Option<String>,
pub no_proxy: Option<String>,
pub cacert: Option<String>,
pub cert: Option<String>,
pub key: Option<String>,
pub insecure: bool,
pub http_version: Option<HttpVersionPreference>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpVersionPreference {
Http1_1,
Http2,
}
impl HttpTransportConfig {
pub fn merge(project: &Self, cli: &Self) -> Self {
Self {
proxy: cli.proxy.clone().or_else(|| project.proxy.clone()),
no_proxy: cli.no_proxy.clone().or_else(|| project.no_proxy.clone()),
cacert: cli.cacert.clone().or_else(|| project.cacert.clone()),
cert: cli.cert.clone().or_else(|| project.cert.clone()),
key: cli.key.clone().or_else(|| project.key.clone()),
insecure: cli.insecure || project.insecure,
http_version: cli.http_version.or(project.http_version),
}
}
pub fn has_custom_transport(&self) -> bool {
self.proxy.is_some()
|| self.no_proxy.is_some()
|| self.cacert.is_some()
|| self.cert.is_some()
|| self.key.is_some()
|| self.insecure
|| self.http_version.is_some()
}
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
pub struct RedactionConfig {
#[serde(default = "default_redacted_headers")]
pub headers: Vec<String>,
#[serde(default = "default_redaction_replacement")]
pub replacement: String,
#[serde(default, rename = "env")]
pub env_vars: Vec<String>,
#[serde(default)]
pub captures: Vec<String>,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
headers: default_redacted_headers(),
replacement: default_redaction_replacement(),
env_vars: Vec::new(),
captures: Vec::new(),
}
}
}
impl RedactionConfig {
pub fn merge_headers<I, S>(&mut self, extra: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for name in extra {
let trimmed = name.as_ref().trim();
if trimmed.is_empty() {
continue;
}
let normalized = trimmed.to_ascii_lowercase();
if !self
.headers
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&normalized))
{
self.headers.push(normalized);
}
}
}
}
fn default_redacted_headers() -> Vec<String> {
vec![
"authorization".into(),
"cookie".into(),
"set-cookie".into(),
"x-api-key".into(),
"x-auth-token".into(),
]
}
fn default_redaction_replacement() -> String {
"***".into()
}
#[derive(Debug, Deserialize, Clone)]
pub struct TestFile {
pub version: Option<String>,
pub name: String,
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(alias = "redact")]
pub redaction: Option<RedactionConfig>,
pub defaults: Option<Defaults>,
#[serde(default)]
pub setup: Vec<Step>,
#[serde(default)]
pub teardown: Vec<Step>,
#[serde(default)]
pub tests: IndexMap<String, TestGroup>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub cookies: Option<CookieMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CookieMode {
#[default]
Auto,
Off,
PerTest,
}
impl<'de> Deserialize<'de> for CookieMode {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
match value.as_str() {
"auto" => Ok(CookieMode::Auto),
"off" => Ok(CookieMode::Off),
"per-test" => Ok(CookieMode::PerTest),
other => Err(serde::de::Error::custom(format!(
"cookies must be \"auto\", \"off\", or \"per-test\" (got \"{}\")",
other
))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct TestGroup {
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub steps: Vec<Step>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Step {
pub name: String,
pub request: Request,
#[serde(default)]
pub capture: HashMap<String, CaptureSpec>,
#[serde(rename = "assert")]
pub assertions: Option<Assertion>,
#[serde(default)]
pub retries: Option<u32>,
pub timeout: Option<u64>,
#[serde(alias = "connect-timeout")]
pub connect_timeout: Option<u64>,
#[serde(alias = "follow-redirects")]
pub follow_redirects: Option<bool>,
#[serde(alias = "max-redirs")]
pub max_redirs: Option<u32>,
pub delay: Option<String>,
pub poll: Option<PollConfig>,
pub script: Option<String>,
pub cookies: Option<StepCookies>,
#[serde(skip)]
pub location: Option<Location>,
#[serde(skip)]
pub assertion_locations: HashMap<String, Location>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StepCookies {
Enabled(bool),
Named(String),
}
impl<'de> Deserialize<'de> for StepCookies {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
match value {
serde_yaml::Value::Bool(b) => Ok(StepCookies::Enabled(b)),
serde_yaml::Value::String(s) => Ok(StepCookies::Named(s)),
_ => Err(serde::de::Error::custom(
"cookies must be true, false, or a jar name string",
)),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum CaptureSpec {
JsonPath(String),
Extended(ExtendedCapture),
}
#[derive(Debug, Deserialize, Clone)]
pub struct ExtendedCapture {
pub header: Option<String>,
pub cookie: Option<String>,
pub jsonpath: Option<String>,
pub body: Option<bool>,
pub status: Option<bool>,
pub url: Option<bool>,
pub regex: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PollConfig {
pub until: Assertion,
pub interval: String,
pub max_attempts: u32,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Request {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub auth: Option<AuthConfig>,
pub body: Option<serde_json::Value>,
#[serde(default)]
pub form: Option<IndexMap<String, String>>,
pub graphql: Option<GraphqlRequest>,
pub multipart: Option<MultipartBody>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AuthConfig {
pub bearer: Option<String>,
pub basic: Option<BasicAuthConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BasicAuthConfig {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GraphqlRequest {
pub query: String,
#[serde(default)]
pub variables: Option<serde_json::Value>,
pub operation_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MultipartBody {
#[serde(default)]
pub fields: Vec<FormField>,
#[serde(default)]
pub files: Vec<FileField>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct FormField {
pub name: String,
pub value: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct FileField {
pub name: String,
pub path: String,
pub content_type: Option<String>,
pub filename: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Defaults {
#[serde(default)]
pub headers: HashMap<String, String>,
pub auth: Option<AuthConfig>,
pub timeout: Option<u64>,
#[serde(alias = "connect-timeout")]
pub connect_timeout: Option<u64>,
#[serde(alias = "follow-redirects")]
pub follow_redirects: Option<bool>,
#[serde(alias = "max-redirs")]
pub max_redirs: Option<u32>,
pub retries: Option<u32>,
pub delay: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Assertion {
pub status: Option<StatusAssertion>,
pub duration: Option<String>,
pub redirect: Option<RedirectAssertion>,
pub headers: Option<HashMap<String, String>>,
pub body: Option<IndexMap<String, serde_yaml::Value>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RedirectAssertion {
pub url: Option<String>,
pub count: Option<u32>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StatusAssertion {
Exact(u16),
Shorthand(String),
Complex(StatusSpec),
}
#[derive(Debug, Deserialize, Clone)]
pub struct StatusSpec {
#[serde(rename = "in")]
pub in_set: Option<Vec<u16>>,
pub gte: Option<u16>,
pub gt: Option<u16>,
pub lte: Option<u16>,
pub lt: Option<u16>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_minimal_test_file() {
let yaml = r#"
name: Health check
steps:
- name: GET /health
request:
method: GET
url: "http://localhost:3000/health"
assert:
status: 200
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.name, "Health check");
assert_eq!(tf.steps.len(), 1);
assert_eq!(tf.steps[0].name, "GET /health");
assert_eq!(tf.steps[0].request.method, "GET");
assert_eq!(tf.steps[0].request.url, "http://localhost:3000/health");
assert!(matches!(
tf.steps[0].assertions.as_ref().unwrap().status,
Some(StatusAssertion::Exact(200))
));
}
#[test]
fn deserialize_full_test_file() {
let yaml = r#"
version: "1"
name: "User CRUD"
description: "Tests CRUD lifecycle"
tags: [crud, users]
env:
base_url: "http://localhost:3000"
defaults:
headers:
Content-Type: "application/json"
timeout: 5000
setup:
- name: Auth
request:
method: POST
url: "http://localhost:3000/auth"
body:
email: "admin@test.com"
capture:
token: "$.token"
assert:
status: 200
teardown:
- name: Cleanup
request:
method: POST
url: "http://localhost:3000/cleanup"
tests:
create_user:
description: "Create a user"
tags: [smoke]
steps:
- name: Create
request:
method: POST
url: "http://localhost:3000/users"
headers:
Authorization: "Bearer token"
body:
name: "Jane"
capture:
user_id: "$.id"
assert:
status: 201
duration: "< 500ms"
headers:
content-type: contains "application/json"
body:
"$.name": "Jane"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.version, Some("1".into()));
assert_eq!(tf.name, "User CRUD");
assert_eq!(tf.description, Some("Tests CRUD lifecycle".into()));
assert_eq!(tf.tags, vec!["crud", "users"]);
assert_eq!(tf.env.get("base_url").unwrap(), "http://localhost:3000");
let defaults = tf.defaults.as_ref().unwrap();
assert_eq!(
defaults.headers.get("Content-Type").unwrap(),
"application/json"
);
assert_eq!(defaults.timeout, Some(5000));
assert_eq!(tf.setup.len(), 1);
assert_eq!(tf.setup[0].name, "Auth");
assert!(matches!(
tf.setup[0].capture.get("token"),
Some(CaptureSpec::JsonPath(p)) if p == "$.token"
));
assert_eq!(tf.teardown.len(), 1);
assert_eq!(tf.tests.len(), 1);
let test = tf.tests.get("create_user").unwrap();
assert_eq!(test.description, Some("Create a user".into()));
assert_eq!(test.tags, vec!["smoke"]);
assert_eq!(test.steps.len(), 1);
let step = &test.steps[0];
assert_eq!(step.name, "Create");
assert_eq!(step.request.method, "POST");
assert!(step.request.body.is_some());
assert!(matches!(
step.capture.get("user_id"),
Some(CaptureSpec::JsonPath(p)) if p == "$.id"
));
let assertions = step.assertions.as_ref().unwrap();
assert!(matches!(
assertions.status,
Some(StatusAssertion::Exact(201))
));
assert_eq!(assertions.duration, Some("< 500ms".into()));
assert!(assertions.headers.is_some());
assert!(assertions.body.is_some());
}
#[test]
fn deserialize_step_without_assertions() {
let yaml = r#"
name: Fire and forget
steps:
- name: Trigger webhook
request:
method: POST
url: "http://localhost:3000/webhook"
body:
event: "deploy"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.steps.len(), 1);
assert!(tf.steps[0].assertions.is_none());
}
#[test]
fn deserialize_redirect_assertion() {
let yaml = r#"
name: Redirect assertions
steps:
- name: Follow chain
request:
method: GET
url: "http://localhost:3000/redirect"
assert:
redirect:
url: "http://localhost:3000/final"
count: 2
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let redirect = tf.steps[0]
.assertions
.as_ref()
.and_then(|a| a.redirect.as_ref())
.unwrap();
assert_eq!(redirect.url.as_deref(), Some("http://localhost:3000/final"));
assert_eq!(redirect.count, Some(2));
}
#[test]
fn deserialize_empty_optional_fields() {
let yaml = r#"
name: Minimal
steps:
- name: Simple GET
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert!(tf.version.is_none());
assert!(tf.description.is_none());
assert!(tf.tags.is_empty());
assert!(tf.env.is_empty());
assert!(tf.defaults.is_none());
assert!(tf.setup.is_empty());
assert!(tf.teardown.is_empty());
assert!(tf.tests.is_empty());
}
#[test]
fn deserialize_request_with_headers_and_body() {
let yaml = r#"
name: test
steps:
- name: POST with JSON body
request:
method: POST
url: "http://localhost:3000/users"
headers:
Authorization: "Bearer xyz"
X-Custom: "hello"
body:
name: "Alice"
tags: ["a", "b"]
nested:
key: "value"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let req = &tf.steps[0].request;
assert_eq!(req.headers.get("Authorization").unwrap(), "Bearer xyz");
assert_eq!(req.headers.get("X-Custom").unwrap(), "hello");
let body = req.body.as_ref().unwrap();
assert_eq!(body["name"], "Alice");
assert_eq!(body["tags"][0], "a");
assert_eq!(body["nested"]["key"], "value");
}
#[test]
fn deserialize_request_with_auth_helper() {
let yaml = r#"
name: auth
steps:
- name: GET
request:
method: GET
url: "http://localhost:3000/me"
auth:
bearer: "{{ env.token }}"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let auth = tf.steps[0].request.auth.as_ref().unwrap();
assert_eq!(auth.bearer.as_deref(), Some("{{ env.token }}"));
assert!(auth.basic.is_none());
}
#[test]
fn deserialize_defaults_with_basic_auth_helper() {
let yaml = r#"
name: auth
defaults:
auth:
basic:
username: "demo"
password: "{{ env.password }}"
steps:
- name: GET
request:
method: GET
url: "http://localhost:3000/me"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let auth = tf.defaults.as_ref().unwrap().auth.as_ref().unwrap();
let basic = auth.basic.as_ref().unwrap();
assert_eq!(basic.username, "demo");
assert_eq!(basic.password, "{{ env.password }}");
}
#[test]
fn tests_preserve_insertion_order() {
let yaml = r#"
name: Order test
tests:
third_test:
steps:
- name: step
request:
method: GET
url: "http://localhost:3000"
first_test:
steps:
- name: step
request:
method: GET
url: "http://localhost:3000"
second_test:
steps:
- name: step
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let keys: Vec<&String> = tf.tests.keys().collect();
assert_eq!(keys, vec!["third_test", "first_test", "second_test"]);
}
#[test]
fn deserialize_body_assertions_with_various_types() {
let yaml = r#"
name: Assertion types
steps:
- name: Check types
request:
method: GET
url: "http://localhost:3000"
assert:
status: 200
body:
"$.string": "hello"
"$.number": 42
"$.bool": true
"$.null_field": null
"$.complex":
type: string
contains: "sub"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let body = tf.steps[0]
.assertions
.as_ref()
.unwrap()
.body
.as_ref()
.unwrap();
assert_eq!(body.len(), 5);
assert!(body.contains_key("$.string"));
assert!(body.contains_key("$.number"));
assert!(body.contains_key("$.bool"));
assert!(body.contains_key("$.null_field"));
assert!(body.contains_key("$.complex"));
}
#[test]
fn deserialize_header_capture() {
let yaml = r#"
name: Header capture test
steps:
- name: Login
request:
method: POST
url: "http://localhost:3000/login"
capture:
session_token:
header: "set-cookie"
regex: "session_token=([^;]+)"
user_id: "$.id"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let cap = &tf.steps[0].capture;
assert!(matches!(cap.get("user_id"), Some(CaptureSpec::JsonPath(p)) if p == "$.id"));
match cap.get("session_token") {
Some(CaptureSpec::Extended(ext)) => {
assert_eq!(ext.header.as_deref(), Some("set-cookie"));
assert_eq!(ext.cookie, None);
assert_eq!(ext.body, None);
assert_eq!(ext.status, None);
assert_eq!(ext.url, None);
assert_eq!(ext.regex.as_deref(), Some("session_token=([^;]+)"));
}
other => panic!("Expected Extended capture, got {:?}", other),
}
}
#[test]
fn deserialize_status_capture() {
let yaml = r#"
name: Status capture test
steps:
- name: Health
request:
method: GET
url: "http://localhost:3000/health"
capture:
status_code:
status: true
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let cap = &tf.steps[0].capture;
match cap.get("status_code") {
Some(CaptureSpec::Extended(ext)) => {
assert_eq!(ext.header, None);
assert_eq!(ext.cookie, None);
assert_eq!(ext.jsonpath, None);
assert_eq!(ext.body, None);
assert_eq!(ext.status, Some(true));
assert_eq!(ext.url, None);
assert_eq!(ext.regex, None);
}
other => panic!("Expected Extended capture, got {:?}", other),
}
}
#[test]
fn deserialize_url_capture() {
let yaml = r#"
name: URL capture test
steps:
- name: Follow redirect
request:
method: GET
url: "http://localhost:3000/redirect"
capture:
final_url:
url: true
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let cap = &tf.steps[0].capture;
match cap.get("final_url") {
Some(CaptureSpec::Extended(ext)) => {
assert_eq!(ext.header, None);
assert_eq!(ext.cookie, None);
assert_eq!(ext.jsonpath, None);
assert_eq!(ext.body, None);
assert_eq!(ext.status, None);
assert_eq!(ext.url, Some(true));
assert_eq!(ext.regex, None);
}
other => panic!("Expected Extended capture, got {:?}", other),
}
}
#[test]
fn deserialize_cookie_and_body_capture() {
let yaml = r#"
name: Cookie capture test
steps:
- name: Cookies
request:
method: GET
url: "http://localhost:3000/cookies"
capture:
session_cookie:
cookie: "session"
body_word:
body: true
regex: "plain (text)"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let cap = &tf.steps[0].capture;
match cap.get("session_cookie") {
Some(CaptureSpec::Extended(ext)) => {
assert_eq!(ext.header, None);
assert_eq!(ext.cookie.as_deref(), Some("session"));
assert_eq!(ext.jsonpath, None);
assert_eq!(ext.body, None);
assert_eq!(ext.status, None);
assert_eq!(ext.url, None);
assert_eq!(ext.regex, None);
}
other => panic!("Expected cookie Extended capture, got {:?}", other),
}
match cap.get("body_word") {
Some(CaptureSpec::Extended(ext)) => {
assert_eq!(ext.header, None);
assert_eq!(ext.cookie, None);
assert_eq!(ext.jsonpath, None);
assert_eq!(ext.body, Some(true));
assert_eq!(ext.status, None);
assert_eq!(ext.url, None);
assert_eq!(ext.regex.as_deref(), Some("plain (text)"));
}
other => panic!("Expected body Extended capture, got {:?}", other),
}
}
#[test]
fn deserialize_status_shorthand() {
let yaml = r#"
name: Status range
steps:
- name: Check 2xx
request:
method: GET
url: "http://localhost:3000"
assert:
status: "2xx"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(
tf.steps[0].assertions.as_ref().unwrap().status,
Some(StatusAssertion::Shorthand(ref s)) if s == "2xx"
));
}
#[test]
fn deserialize_status_complex_in() {
let yaml = r#"
name: Status set
steps:
- name: Check set
request:
method: GET
url: "http://localhost:3000"
assert:
status:
in: [200, 201, 204]
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
match &tf.steps[0].assertions.as_ref().unwrap().status {
Some(StatusAssertion::Complex(spec)) => {
assert_eq!(spec.in_set.as_ref().unwrap(), &vec![200, 201, 204]);
}
other => panic!("Expected Complex status, got {:?}", other),
}
}
#[test]
fn deserialize_status_complex_range() {
let yaml = r#"
name: Status range
steps:
- name: Check 4xx range
request:
method: GET
url: "http://localhost:3000"
assert:
status:
gte: 400
lt: 500
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
match &tf.steps[0].assertions.as_ref().unwrap().status {
Some(StatusAssertion::Complex(spec)) => {
assert_eq!(spec.gte, Some(400));
assert_eq!(spec.lt, Some(500));
}
other => panic!("Expected Complex status, got {:?}", other),
}
}
#[test]
fn deserialize_multipart_request() {
let yaml = r#"
name: Upload test
steps:
- name: Upload photo
request:
method: POST
url: "http://localhost:3000/upload"
multipart:
fields:
- name: "title"
value: "My Photo"
files:
- name: "photo"
path: "./fixtures/test.jpg"
content_type: "image/jpeg"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let mp = tf.steps[0].request.multipart.as_ref().unwrap();
assert_eq!(mp.fields.len(), 1);
assert_eq!(mp.fields[0].name, "title");
assert_eq!(mp.fields[0].value, "My Photo");
assert_eq!(mp.files.len(), 1);
assert_eq!(mp.files[0].name, "photo");
assert_eq!(mp.files[0].path, "./fixtures/test.jpg");
assert_eq!(mp.files[0].content_type.as_deref(), Some("image/jpeg"));
}
#[test]
fn deserialize_form_request() {
let yaml = r#"
name: Form test
steps:
- name: Submit form
request:
method: POST
url: "http://localhost:3000/login"
form:
email: "user@example.com"
password: "secret"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let form = tf.steps[0].request.form.as_ref().unwrap();
assert_eq!(
form.get("email").map(String::as_str),
Some("user@example.com")
);
assert_eq!(form.get("password").map(String::as_str), Some("secret"));
}
#[test]
fn deserialize_defaults_with_delay() {
let yaml = r#"
name: Delay test
defaults:
delay: "100ms"
timeout: 5000
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
tf.defaults.as_ref().unwrap().delay.as_deref(),
Some("100ms")
);
}
#[test]
fn deserialize_step_cookies_false() {
let yaml = r#"
name: Step cookies test
steps:
- name: No cookies step
cookies: false
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(false)));
}
#[test]
fn deserialize_step_cookies_true() {
let yaml = r#"
name: Step cookies test
steps:
- name: With cookies
cookies: true
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(true)));
}
#[test]
fn deserialize_step_cookies_named_jar() {
let yaml = r#"
name: Step cookies test
steps:
- name: Admin step
cookies: "admin"
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
tf.steps[0].cookies,
Some(StepCookies::Named("admin".to_string()))
);
}
#[test]
fn deserialize_step_cookies_default_none() {
let yaml = r#"
name: Step cookies test
steps:
- name: Default step
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.steps[0].cookies, None);
}
#[test]
fn deserialize_cookies_off() {
let yaml = r#"
name: No cookies
cookies: "off"
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.cookies, Some(CookieMode::Off));
}
#[test]
fn deserialize_cookies_auto() {
let yaml = r#"
name: Auto cookies
cookies: "auto"
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.cookies, Some(CookieMode::Auto));
}
#[test]
fn deserialize_cookies_per_test() {
let yaml = r#"
name: Per-test cookies
cookies: "per-test"
tests:
login:
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.cookies, Some(CookieMode::PerTest));
}
#[test]
fn deserialize_cookies_invalid_value_is_rejected() {
let yaml = r#"
name: Bad cookies
cookies: "sometimes"
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let err = serde_yaml::from_str::<TestFile>(yaml).unwrap_err();
assert!(
err.to_string().contains("per-test"),
"error should mention the valid options, got: {err}"
);
}
#[test]
fn deserialize_cookies_default_is_none() {
let yaml = r#"
name: Default cookies
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
assert_eq!(tf.cookies, None);
}
#[test]
fn deserialize_redaction_config() {
let yaml = r#"
name: Redaction config
redaction:
headers:
- authorization
- x-session-token
env:
- api_token
captures:
- session
replacement: "[redacted]"
steps:
- name: test
request:
method: GET
url: "http://localhost:3000"
"#;
let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
let redaction = tf.redaction.unwrap();
assert_eq!(redaction.headers, vec!["authorization", "x-session-token"]);
assert_eq!(redaction.env_vars, vec!["api_token"]);
assert_eq!(redaction.captures, vec!["session"]);
assert_eq!(redaction.replacement, "[redacted]");
}
#[test]
fn merge_headers_widens_list_case_insensitively() {
let mut redaction = RedactionConfig {
headers: vec!["authorization".into()],
..RedactionConfig::default()
};
redaction.merge_headers(["X-Custom-Token", "x-debug"]);
assert_eq!(
redaction.headers,
vec!["authorization", "x-custom-token", "x-debug"]
);
}
#[test]
fn merge_headers_skips_duplicates_ignoring_case() {
let mut redaction = RedactionConfig {
headers: vec!["authorization".into(), "x-api-key".into()],
..RedactionConfig::default()
};
redaction.merge_headers(["Authorization", "X-API-KEY", "x-new"]);
assert_eq!(
redaction.headers,
vec!["authorization", "x-api-key", "x-new"]
);
}
#[test]
fn merge_headers_trims_and_drops_empty_entries() {
let mut redaction = RedactionConfig::default();
let baseline_len = redaction.headers.len();
redaction.merge_headers(["", " ", " X-Trim "]);
assert_eq!(redaction.headers.len(), baseline_len + 1);
assert!(redaction.headers.iter().any(|h| h == "x-trim"));
}
#[test]
fn merge_headers_never_narrows_existing_list() {
let mut redaction = RedactionConfig {
headers: vec!["authorization".into(), "cookie".into()],
..RedactionConfig::default()
};
redaction.merge_headers(std::iter::empty::<String>());
assert_eq!(redaction.headers, vec!["authorization", "cookie"]);
}
}