use super::*;
use std::{
collections::HashMap,
path::PathBuf,
time::{Duration, SystemTime},
};
use http::{Method, StatusCode};
use serde_json::{json, Value};
use crate::{
automation::{CollectionSummary, RunOutcome, VerificationResult},
parser::{SyntaxRequestSummary, SyntaxSummary},
request::{
ArtifactMetadata, AssertionOutcome, AssertionStatus, ExecutionArtifact,
ExecutionRecord, ExecutionTraceEntry, ExecutionTraceKind, HttpArtifactMetadata,
RequestExecution, RequestProtocol,
},
};
#[test]
fn verification_result_json_matches_golden_fixture() {
let result = VerificationResult {
path: Some(PathBuf::from("/workspace/collection.hen")),
summary: SyntaxSummary {
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
available_environments: vec!["local".to_string(), "staging".to_string()],
requests: vec![
SyntaxRequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
protocol: "http".to_string(),
protocol_context: None,
},
SyntaxRequestSummary {
index: 1,
description: "Get two".to_string(),
method: "POST".to_string(),
url: "https://example.com/two".to_string(),
protocol: "http".to_string(),
protocol_context: None,
},
],
},
required_inputs: vec![
crate::automation::PromptRequirement {
name: "api_token".to_string(),
default: None,
},
crate::automation::PromptRequirement {
name: "region".to_string(),
default: Some("us-east-1".to_string()),
},
],
};
assert_json_fixture(
verification_result_json(&result),
include_str!("../../tests/fixtures/golden/verify_hen_syntax.json"),
);
}
#[test]
fn run_outcome_json_matches_golden_fixture() {
let result = RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
available_environments: vec!["local".to_string(), "staging".to_string()],
selected_environment: Some("staging".to_string()),
requests: vec![crate::automation::RequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
protocol: "http".to_string(),
protocol_context: None,
dependencies: vec![],
sensitive_values: vec![],
}],
required_inputs: vec![crate::automation::PromptRequirement {
name: "api_token".to_string(),
default: None,
}],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![ExecutionRecord {
index: 0,
description: "Get one".to_string(),
method: Method::GET,
url: "https://example.com/one".to_string(),
sensitive_values: vec![],
sensitive_header_names: vec![],
execution: RequestExecution {
output: "hello world".to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Http,
status: StatusCode::OK,
headers: Default::default(),
body: "hello world".to_string(),
json: None,
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![],
transcripts: vec![],
retained_artifacts: vec![],
},
assertions: vec![
AssertionOutcome {
assertion: "^ & body.ok == true".to_string(),
status: AssertionStatus::Passed,
message: None,
mismatch: None,
diff: None,
},
AssertionOutcome {
assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
status: AssertionStatus::Skipped,
message: Some("guard evaluated to false".to_string()),
mismatch: None,
diff: None,
},
],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
duration: Duration::from_millis(25),
}],
failures: vec![],
trace: vec![],
execution_failed: false,
interrupted: None,
};
assert_json_fixture(
run_outcome_json(
&result,
BodyReportOptions {
include_body: true,
max_body_chars: Some(5),
},
),
include_str!("../../tests/fixtures/golden/run_hen.json"),
);
}
#[test]
fn run_outcome_ndjson_emits_summary_and_record_lines() {
let result = sample_run_outcome();
let lines = run_outcome_ndjson(
&result,
BodyReportOptions {
include_body: true,
max_body_chars: None,
},
)
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["type"], "run");
assert_eq!(lines[0]["collection"]["selectedEnvironment"], "staging");
assert_eq!(lines[0]["collection"]["availableEnvironments"][0], "local");
assert_eq!(lines[0]["collection"]["availableEnvironments"][1], "staging");
assert_eq!(lines[0]["interrupted"], false);
assert_eq!(lines[0]["interruptSignal"], Value::Null);
assert_eq!(lines[0]["traceCount"], 0);
assert_eq!(lines[1]["type"], "record");
assert_eq!(lines[1]["protocol"], "http");
assert_eq!(lines[1]["protocolContext"], Value::Null);
assert_eq!(lines[1]["status"], 200);
assert_eq!(lines[1]["assertions"][0]["status"], "passed");
assert_eq!(lines[1]["assertions"][1]["status"], "skipped");
}
#[test]
fn run_outcome_ndjson_emits_trace_lines() {
let mut result = sample_run_outcome();
result.trace = vec![ExecutionTraceEntry {
seq: 0,
kind: ExecutionTraceKind::Started,
request_index: Some(0),
request: Some("Get one".to_string()),
dependencies: vec![],
waiting_on: vec![],
protocol: Some(RequestProtocol::Http),
protocol_context: None,
sensitive_values: vec![],
sensitive_header_names: vec![],
duration: None,
reason: None,
related_request: None,
group: None,
cause: None,
message: None,
signal: None,
}];
let lines = run_outcome_ndjson(&result, BodyReportOptions::default())
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0]["traceCount"], 1);
assert_eq!(lines[2]["type"], "trace");
assert_eq!(lines[2]["kind"], "started");
assert_eq!(lines[2]["request"], "Get one");
}
#[test]
fn run_outcome_json_redacts_collection_summary_requests_and_trace_entries() {
let result = RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
available_environments: vec![],
selected_environment: None,
requests: vec![crate::automation::RequestSummary {
index: 0,
description: "Get token".to_string(),
method: "GET".to_string(),
url: "https://example.com?token=super-secret-token".to_string(),
protocol: "mcp".to_string(),
protocol_context: Some(json!({
"tool": "super-secret-token"
})),
dependencies: vec!["Bootstrap super-secret-token".to_string()],
sensitive_values: vec!["super-secret-token".to_string()],
}],
required_inputs: vec![],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![],
failures: vec![],
trace: vec![ExecutionTraceEntry {
seq: 0,
kind: ExecutionTraceKind::Failed,
request_index: Some(0),
request: Some("Get token super-secret-token".to_string()),
dependencies: vec!["Bootstrap super-secret-token".to_string()],
waiting_on: vec!["Bootstrap super-secret-token".to_string()],
protocol: Some(RequestProtocol::Mcp),
protocol_context: Some(json!({
"tool": "super-secret-token"
})),
sensitive_values: vec!["super-secret-token".to_string()],
sensitive_header_names: vec![],
duration: None,
reason: Some("execution_failed".to_string()),
related_request: Some("Bootstrap super-secret-token".to_string()),
group: Some("group super-secret-token".to_string()),
cause: Some("cause super-secret-token".to_string()),
message: Some("failed because super-secret-token".to_string()),
signal: None,
}],
execution_failed: true,
interrupted: None,
};
let json = run_outcome_json(&result, BodyReportOptions::default());
assert_eq!(
json["collection"]["requests"][0]["url"],
"https://example.com?token=[redacted]"
);
assert_eq!(
json["collection"]["requests"][0]["protocolContext"]["tool"],
"[redacted]"
);
assert_eq!(
json["collection"]["requests"][0]["dependencies"][0],
"Bootstrap [redacted]"
);
assert_eq!(json["trace"][0]["request"], "Get token [redacted]");
assert_eq!(json["trace"][0]["message"], "failed because [redacted]");
assert_eq!(json["trace"][0]["protocolContext"]["tool"], "[redacted]");
assert_eq!(json["trace"][0]["relatedRequest"], "Bootstrap [redacted]");
}
#[test]
fn run_record_json_includes_graphql_protocol_context() {
let record = ExecutionRecord {
index: 0,
description: "Get user via GraphQL".to_string(),
method: Method::POST,
url: "https://example.com/graphql".to_string(),
sensitive_values: vec![],
sensitive_header_names: vec![],
execution: RequestExecution {
output: r#"{"data":{"user":{"id":"123"}},"errors":null}"#.to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Graphql,
status: StatusCode::OK,
headers: Default::default(),
body: r#"{"data":{"user":{"id":"123"}},"errors":null}"#.to_string(),
json: Some(serde_json::json!({
"data": { "user": { "id": "123" } },
"errors": null
})),
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![],
transcripts: vec![crate::request::ArtifactTranscript {
direction: crate::request::ArtifactTranscriptDirection::Outgoing,
label: "graphql.request".to_string(),
body: String::new(),
attributes: HashMap::from([
("operationName".to_string(), "GetUser".to_string()),
("variables".to_string(), r#"{"id":"123"}"#.to_string()),
]),
}],
retained_artifacts: vec![],
},
assertions: vec![],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH,
duration: Duration::from_millis(10),
};
let json = run_record_json(&record, BodyReportOptions::default());
assert_eq!(json["protocol"], "graphql");
assert_eq!(json["protocolContext"]["operationName"], "GetUser");
assert_eq!(json["protocolContext"]["variables"]["id"], "123");
assert_eq!(json["timing"]["totalMs"], 10);
assert_eq!(json["timing"]["phases"], json!([]));
assert_eq!(json["transcripts"][0]["direction"], "outgoing");
assert_eq!(json["transcripts"][0]["label"], "graphql.request");
assert_eq!(json["transcripts"][0]["attributes"]["operationName"], "GetUser");
assert_eq!(json["retainedArtifacts"], json!([]));
}
#[test]
fn verification_result_ndjson_emits_available_environments() {
let result = VerificationResult {
path: Some(PathBuf::from("/workspace/collection.hen")),
summary: SyntaxSummary {
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
available_environments: vec!["local".to_string(), "staging".to_string()],
requests: vec![],
},
required_inputs: vec![],
};
let lines = verification_result_ndjson(&result)
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("ndjson line should parse"))
.collect::<Vec<_>>();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0]["type"], "verify");
assert_eq!(lines[0]["availableEnvironments"][0], "local");
assert_eq!(lines[0]["availableEnvironments"][1], "staging");
}
#[test]
fn run_record_json_applies_body_options_to_artifacts() {
let record = ExecutionRecord {
index: 1,
description: "Artifact export".to_string(),
method: Method::POST,
url: "https://example.com/artifacts".to_string(),
sensitive_values: vec![],
sensitive_header_names: vec![],
execution: RequestExecution {
output: "response body".to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Http,
status: StatusCode::OK,
headers: Default::default(),
body: "response body".to_string(),
json: None,
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![crate::request::ArtifactTimingPhase::new(
"bodyRead",
Duration::from_millis(2),
)],
transcripts: vec![crate::request::ArtifactTranscript {
direction: crate::request::ArtifactTranscriptDirection::Outgoing,
label: "http.request".to_string(),
body: "abcdef".to_string(),
attributes: HashMap::new(),
}],
retained_artifacts: vec![crate::request::RetainedArtifact {
name: "trace.txt".to_string(),
content_type: "text/plain".to_string(),
body: "uvwxyz".to_string(),
}],
},
assertions: vec![],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH,
duration: Duration::from_millis(5),
};
let json = run_record_json(
&record,
BodyReportOptions {
include_body: false,
max_body_chars: Some(3),
},
);
assert_eq!(json["body"], Value::Null);
assert_eq!(json["timing"]["totalMs"], 5);
assert_eq!(json["timing"]["phases"][0]["name"], "bodyRead");
assert_eq!(json["timing"]["phases"][0]["durationMs"], 2);
assert_eq!(json["transcripts"][0]["body"], Value::Null);
assert_eq!(json["transcripts"][0]["bodyChars"], 6);
assert_eq!(json["transcripts"][0]["bodyCharLimit"], 3);
assert_eq!(json["retainedArtifacts"][0]["body"], Value::Null);
assert_eq!(json["retainedArtifacts"][0]["bodyChars"], 6);
assert_eq!(json["retainedArtifacts"][0]["bodyCharLimit"], 3);
}
#[test]
fn run_record_json_redacts_sensitive_values_from_output_surfaces() {
let record = ExecutionRecord {
index: 2,
description: "Use secret token".to_string(),
method: Method::POST,
url: "https://example.com/data?token=super-secret-token".to_string(),
sensitive_values: vec!["super-secret-token".to_string()],
sensitive_header_names: vec![],
execution: RequestExecution {
output: "token=super-secret-token".to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Http,
status: StatusCode::OK,
headers: Default::default(),
body: "token=super-secret-token".to_string(),
json: None,
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![],
transcripts: vec![crate::request::ArtifactTranscript {
direction: crate::request::ArtifactTranscriptDirection::Outgoing,
label: "http.request".to_string(),
body: "authorization=Bearer super-secret-token".to_string(),
attributes: HashMap::from([(
"token".to_string(),
"super-secret-token".to_string(),
)]),
}],
retained_artifacts: vec![crate::request::RetainedArtifact {
name: "trace.txt".to_string(),
content_type: "text/plain".to_string(),
body: "super-secret-token".to_string(),
}],
},
assertions: vec![AssertionOutcome {
assertion: "^ body.token == {{ TOKEN }}".to_string(),
status: AssertionStatus::Failed,
message: Some("actual token was super-secret-token".to_string()),
mismatch: None,
diff: Some(crate::request::AssertionDiff {
format: crate::request::AssertionDiffFormat::Unified,
path: Some("$.token".to_string()),
rendered: "- super-secret-token\n+ other".to_string(),
}),
}],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH,
duration: Duration::from_millis(5),
};
let json = run_record_json(&record, BodyReportOptions::default());
assert_eq!(json["url"], "https://example.com/data?token=[redacted]");
assert_eq!(json["body"], "token=[redacted]");
assert_eq!(json["transcripts"][0]["body"], "authorization=Bearer [redacted]");
assert_eq!(json["transcripts"][0]["attributes"]["token"], "[redacted]");
assert_eq!(json["retainedArtifacts"][0]["body"], "[redacted]");
assert_eq!(json["assertions"][0]["message"], "actual token was [redacted]");
assert_eq!(json["assertions"][0]["diff"]["text"], "- [redacted]\n+ other");
}
#[test]
fn run_record_json_redacts_default_sensitive_headers_without_secret_origins() {
let record = ExecutionRecord {
index: 3,
description: "Inspect headers".to_string(),
method: Method::GET,
url: "https://example.com/headers".to_string(),
sensitive_values: vec![],
sensitive_header_names: vec![],
execution: RequestExecution {
output: "Set-Cookie: session=abc\nX-Trace: keep".to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Http,
status: StatusCode::OK,
headers: Default::default(),
body: "Set-Cookie: session=abc\nX-Trace: keep".to_string(),
json: None,
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![],
transcripts: vec![crate::request::ArtifactTranscript {
direction: crate::request::ArtifactTranscriptDirection::Outgoing,
label: "http.request".to_string(),
body: "Authorization: Bearer dynamic-token\nX-Trace: keep".to_string(),
attributes: HashMap::from([
(
"Authorization".to_string(),
"Bearer dynamic-token".to_string(),
),
("Cookie".to_string(), "session=abc".to_string()),
("X-Trace".to_string(), "keep".to_string()),
]),
}],
retained_artifacts: vec![crate::request::RetainedArtifact {
name: "headers.txt".to_string(),
content_type: "text/plain".to_string(),
body: "Proxy-Authorization: Basic abc123".to_string(),
}],
},
assertions: vec![],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH,
duration: Duration::from_millis(5),
};
let json = run_record_json(&record, BodyReportOptions::default());
assert_eq!(json["body"], "Set-Cookie: [redacted]\nX-Trace: keep");
assert_eq!(
json["transcripts"][0]["body"],
"Authorization: [redacted]\nX-Trace: keep"
);
assert_eq!(json["transcripts"][0]["attributes"]["Authorization"], "[redacted]");
assert_eq!(json["transcripts"][0]["attributes"]["Cookie"], "[redacted]");
assert_eq!(json["transcripts"][0]["attributes"]["X-Trace"], "keep");
assert_eq!(
json["retainedArtifacts"][0]["body"],
"Proxy-Authorization: [redacted]"
);
}
#[test]
fn run_outcome_junit_redacts_sensitive_values() {
let outcome = RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: String::new(),
available_environments: vec![],
selected_environment: None,
requests: vec![],
required_inputs: vec![],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![ExecutionRecord {
index: 0,
description: "Get token".to_string(),
method: Method::GET,
url: "https://example.com?token=super-secret-token".to_string(),
sensitive_values: vec!["super-secret-token".to_string()],
sensitive_header_names: vec![],
execution: RequestExecution {
output: String::new(),
export_env: HashMap::new(),
artifact: ExecutionArtifact::default(),
assertions: vec![AssertionOutcome {
assertion: "^ body.token == {{ TOKEN }}".to_string(),
status: AssertionStatus::Failed,
message: Some("expected super-secret-token".to_string()),
mismatch: None,
diff: None,
}],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH,
duration: Duration::from_millis(1),
}],
failures: vec![],
trace: vec![],
execution_failed: true,
interrupted: None,
};
let junit = run_outcome_junit(&outcome);
assert!(junit.contains("[redacted]"));
assert!(!junit.contains("super-secret-token"));
}
#[test]
fn run_outcome_junit_renders_testcase() {
let junit = run_outcome_junit(&sample_run_outcome());
assert!(junit.contains("<testsuite"));
assert!(junit.contains(
"<testcase classname=\"Fixture Collection\" name=\"#0 GET https://example.com/one :: ^ & body.ok == true\""
));
assert!(junit.contains("tests=\"2\""));
assert!(junit.contains("skipped=\"1\""));
assert!(junit.contains("<skipped message=\"guard evaluated to false\"/>"));
}
#[test]
fn run_outcome_junit_includes_interruption_case() {
let mut result = sample_run_outcome();
result.execution_failed = true;
result.interrupted = Some(crate::request::InterruptSignal::Sigterm);
let junit = run_outcome_junit(&result);
assert!(junit.contains("errors=\"1\""));
assert!(junit.contains("run interrupted by SIGTERM"));
assert!(junit.contains("Execution interrupted by SIGTERM"));
}
fn sample_run_outcome() -> RunOutcome {
RunOutcome {
collection: CollectionSummary {
path: PathBuf::from("/workspace/collection.hen"),
name: "Fixture Collection".to_string(),
description: "Collection used for structured output fixtures.".to_string(),
available_environments: vec!["local".to_string(), "staging".to_string()],
selected_environment: Some("staging".to_string()),
requests: vec![crate::automation::RequestSummary {
index: 0,
description: "Get one".to_string(),
method: "GET".to_string(),
url: "https://example.com/one".to_string(),
protocol: "http".to_string(),
protocol_context: None,
dependencies: vec![],
sensitive_values: vec![],
}],
required_inputs: vec![crate::automation::PromptRequirement {
name: "api_token".to_string(),
default: None,
}],
},
plan: vec![0],
selected_requests: vec![0],
primary_target: Some(0),
records: vec![ExecutionRecord {
index: 0,
description: "Get one".to_string(),
method: Method::GET,
url: "https://example.com/one".to_string(),
sensitive_values: vec![],
sensitive_header_names: vec![],
execution: RequestExecution {
output: "hello world".to_string(),
export_env: HashMap::new(),
artifact: ExecutionArtifact {
protocol: RequestProtocol::Http,
status: StatusCode::OK,
headers: Default::default(),
body: "hello world".to_string(),
json: None,
metadata: ArtifactMetadata::Http(HttpArtifactMetadata {
status: StatusCode::OK,
headers: Default::default(),
}),
timing_phases: vec![],
transcripts: vec![],
retained_artifacts: vec![],
},
assertions: vec![
AssertionOutcome {
assertion: "^ & body.ok == true".to_string(),
status: AssertionStatus::Passed,
message: None,
mismatch: None,
diff: None,
},
AssertionOutcome {
assertion: "[ true == false ] ^ & body.service == 'hen'".to_string(),
status: AssertionStatus::Skipped,
message: Some("guard evaluated to false".to_string()),
mismatch: None,
diff: None,
},
],
sensitive_values: vec![],
sensitive_export_values: vec![],
},
started_at: SystemTime::UNIX_EPOCH + Duration::from_millis(1_704_067_200_000),
duration: Duration::from_millis(25),
}],
failures: vec![],
trace: vec![],
execution_failed: false,
interrupted: None,
}
}
fn assert_json_fixture(value: Value, expected: &str) {
let actual = serde_json::to_string_pretty(&value).expect("json should serialize");
assert_eq!(actual.trim(), expected.trim());
}