use super::*;
use crate::{
collection::{Authentication, Profile, ProfileId, RecipeId},
test_util::{TestPrompter, by_id, header_map, http_engine, invalid_utf8},
};
use indexmap::{IndexMap, indexmap};
use pretty_assertions::assert_eq;
use reqwest::{Body, StatusCode, header};
use rstest::rstest;
use serde_json::json;
use slumber_util::{Factory, assert_err, assert_result, test_data_dir};
use std::{cell::RefCell, path, ptr};
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
thread_local! {
pub static MULTIPART_BOUNDARY: RefCell<String> = RefCell::default();
}
fn template_context(recipe: Recipe, host: Option<&str>) -> TemplateContext {
let profile_data = indexmap! {
"host".into() => host.unwrap_or("http://localhost").into(),
"mode".into() => "sudo".into(),
"user_id".into() => "1".into(),
"group_id".into() => "3".into(),
"username".into() => "user".into(),
"password".into() => "hunter2".into(),
"token".into() => "tokenzzz".into(),
"test_data_dir".into() => test_data_dir().to_str().unwrap().into(),
"prompt".into() => "{{ prompt() }}".into(),
"stream".into() => "{{ file('data.json') }}".into(),
"stream_prompt".into() => "{{ file(concat([prompt(), '.txt'])) }}".into(),
"stream_compound".into() => "inner: {{ file('first.txt') }}".into(),
"error".into() => "{{ fake_fn() }}".into(),
};
let profile = Profile {
data: profile_data,
..Profile::factory(())
};
TemplateContext {
prompter: Box::new(TestPrompter::new(["first", "second"])),
root_dir: test_data_dir(),
..TemplateContext::factory((by_id([profile]), by_id([recipe])))
}
}
fn seed(context: &TemplateContext, build_options: BuildOptions) -> RequestSeed {
RequestSeed::new(
context.collection.first_recipe_id().clone(),
build_options,
)
}
#[rstest]
#[case::safe("safe", false)]
#[case::danger("danger", true)]
fn test_get_client(
http_engine: HttpEngine,
#[case] hostname: &str,
#[case] expected_danger: bool,
) {
let client =
http_engine.get_client(&format!("http://{hostname}/").parse().unwrap());
if expected_danger {
assert!(ptr::eq(
client,
&raw const http_engine.danger_client.as_ref().unwrap().0
));
} else {
assert!(ptr::eq(client, &raw const http_engine.client));
}
}
#[rstest]
#[tokio::test]
async fn test_build_request(http_engine: HttpEngine) {
let recipe = Recipe {
method: HttpMethod::Post,
url: "{{ host }}/users/{{ user_id }}".into(),
query: indexmap! {
"mode".into() => "{{ mode }}".into(),
"fast".into() => "true".into(),
},
headers: indexmap! {
"Accept".into() => "application/json".into(),
"Content-Type".into() => "application/json".into(),
},
body: Some("{\"group_id\":\"{{ group_id }}\"}".into()),
..Recipe::factory(())
};
let recipe_id = recipe.id.clone();
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
let expected_url: Url = "http://localhost/users/1?mode=sudo&fast=true"
.parse()
.unwrap();
let expected_headers = header_map([
("content-type", "application/json"),
("accept", "application/json"),
]);
let expected_body = b"{\"group_id\":\"3\"}";
let request = &ticket.request;
assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.url(), &expected_url);
assert_eq!(request.headers(), &expected_headers);
assert_eq!(
request.body().and_then(Body::as_bytes),
Some(expected_body.as_slice())
);
assert_eq!(
*ticket.record,
RequestRecord {
id: ticket.record.id,
profile_id: Some(context.collection.first_profile_id().clone()),
recipe_id,
method: HttpMethod::Post,
http_version: HttpVersion::Http11,
url: expected_url,
body: expected_body.as_slice().into(),
headers: expected_headers,
}
);
}
#[rstest]
#[case::none(None, RequestBody::None)]
#[case::empty(Some("".into()), b"".as_slice().into())]
#[case::present(Some("data!".into()), b"data!".as_slice().into())]
#[case::stream(
Some(RecipeBody::Stream("{{ stream }}".into())), RequestBody::Stream,
)]
#[case::too_large(
Some("this body length is way over 30 bytes!".into()),
RequestBody::TooLarge,
)]
#[tokio::test]
async fn test_request_record_body(
mut http_engine: HttpEngine,
#[case] body: Option<RecipeBody>,
#[case] expected_body: RequestBody,
) {
http_engine.large_body_size = 30; let recipe = Recipe {
method: HttpMethod::Post,
body,
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(ticket.record.body, expected_body);
}
#[rstest]
fn test_rebuild_request(http_engine: HttpEngine) {
let recipe_id: RecipeId = "recipe1".into();
let profile_id: Option<ProfileId> = Some("profile1".into());
let url: Url = "http://localhost/users/1?mode=sudo&fast=true"
.parse()
.unwrap();
let headers = header_map([
("content-type", "application/json"),
("accept", "application/json"),
]);
let body = b"abc123".as_slice();
let mut old = RequestRecord {
id: RequestId::new(),
profile_id,
recipe_id,
method: HttpMethod::Post,
http_version: HttpVersion::Http11,
url: url.clone(),
body: body.into(),
headers: headers.clone(),
};
let ticket = http_engine.rebuild(&old).unwrap();
let request = &ticket.request;
assert_eq!(request.method(), reqwest::Method::POST);
assert_eq!(request.url(), &url);
assert_eq!(request.headers(), &headers);
assert_eq!(request.body().and_then(Body::as_bytes), Some(body));
assert_ne!(old.id, ticket.record.id, "New ticket should have a new ID");
old.id = ticket.record.id;
assert_eq!(*ticket.record, old);
}
#[rstest]
fn test_rebuild_request_lost_body(http_engine: HttpEngine) {
let recipe_id: RecipeId = "recipe1".into();
let profile_id: Option<ProfileId> = Some("profile1".into());
let url: Url = "http://localhost/users/1?mode=sudo&fast=true"
.parse()
.unwrap();
let old = RequestRecord {
id: RequestId::new(),
profile_id,
recipe_id,
method: HttpMethod::Post,
http_version: HttpVersion::Http11,
url,
body: RequestBody::Stream, headers: header_map([
("content-type", "application/json"),
("accept", "application/json"),
]),
};
assert_err(http_engine.rebuild(&old), "Cannot resend request");
}
#[rstest]
#[tokio::test]
async fn test_build_url(http_engine: HttpEngine) {
let recipe = Recipe {
url: "{{ host }}/users/{{ user_id }}?inline=1#fragment".into(),
query: indexmap! {
"mode".into() => ["{{ mode }}", "user"].into(),
"fast".into() => ["true", "false"].into(),
},
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let url = http_engine.build_url(seed, &context).await.unwrap();
assert_eq!(
url.as_str(),
"http://localhost/users/1\
?inline=1&mode=sudo&mode=user&fast=true&fast=false\
#fragment"
);
}
#[rstest]
#[case::raw(
RecipeBody::Raw(r#"{"group_id":"{{ group_id }}"}"#.into()),
br#"{"group_id":"3"}"#
)]
#[case::json(
RecipeBody::json(json!({"group_id": "{{ group_id }}"})).unwrap(),
br#"{"group_id":"3"}"#,
)]
#[case::binary(RecipeBody::Raw(invalid_utf8()), b"\xc3\x28")]
#[tokio::test]
async fn test_build_body(
http_engine: HttpEngine,
#[case] body: RecipeBody,
#[case] expected_body: &[u8],
) {
let context = template_context(
Recipe {
body: Some(body),
..Recipe::factory(())
},
None,
);
let seed = seed(&context, BuildOptions::default());
let body = http_engine.build_body(seed, &context).await.unwrap();
assert_eq!(body.as_deref(), Some(expected_body));
}
#[rstest]
#[case::basic(
Authentication::Basic {
username: "{{ username }}".into(),
password: Some("{{ password }}".into()),
},
"Basic dXNlcjpodW50ZXIy"
)]
#[case::basic_no_password(
Authentication::Basic {
username: "{{ username }}".into(),
password: None,
},
"Basic dXNlcjo="
)]
#[case::bearer(Authentication::Bearer { token: "{{ token }}".into() }, "Bearer tokenzzz")]
#[tokio::test]
async fn test_authentication(
http_engine: HttpEngine,
#[case] authentication: Authentication,
#[case] expected_header: &str,
) {
let recipe = Recipe {
headers: indexmap! {"Authorization".into() => "bogus".into()},
authentication: Some(authentication),
..Recipe::factory(())
};
let recipe_id = recipe.id.clone();
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
*ticket.record,
RequestRecord {
id: ticket.record.id,
profile_id: Some(context.collection.first_profile_id().clone()),
recipe_id,
method: HttpMethod::Get,
http_version: HttpVersion::Http11,
url: "http://localhost/url".parse().unwrap(),
headers: header_map([
("authorization", "bogus"),
("authorization", expected_header)
]),
body: RequestBody::None,
}
);
}
#[rstest]
#[case::text(RecipeBody::Raw("hello!".into()), None, None, "hello!")]
#[case::json(
RecipeBody::json(json!({"group_id": "{{ group_id }}"})).unwrap(),
None,
Some("application/json"),
r#"{"group_id":"3"}"#,
)]
#[case::json_content_type_override(
RecipeBody::json(json!({"group_id": "{{ group_id }}"})).unwrap(),
Some("text/plain"),
Some("text/plain"),
r#"{"group_id":"3"}"#,
)]
#[case::json_unpack(
// Single-chunk templates should get unpacked to the actual JSON value
// instead of returned as a string
RecipeBody::json(json!("{{ [1,2,3] }}")).unwrap(),
None,
Some("application/json"),
"[1,2,3]",
)]
#[case::json_no_unpack(
// This template doesn't get unpacked because it is multiple chunks
RecipeBody::json(json!("no: {{ [1,2,3] }}")).unwrap(),
None,
Some("application/json"),
// Spaces are added because this uses the template Value stringification
// instead of serde_json stringification
r#""no: [1, 2, 3]""#,
)]
#[case::json_string_from_file(
// JSON data is loaded as a string and NOT unpacked. file() returns bytes
// which automatically get interpreted as a string.
RecipeBody::json(json!(
"{{ file(concat([test_data_dir, '/data.json'])) }}"
)).unwrap(),
None,
Some("application/json"),
r#""{ \"a\": 1, \"b\": 2 }""#,
)]
#[case::json_from_file_parsed(
// Pipe to json_parse() to parse it
RecipeBody::json(json!(
"{{ file(concat([test_data_dir, '/data.json'])) | json_parse() }}"
)).unwrap(),
None,
Some("application/json"),
r#"{"a":1,"b":2}"#,
)]
#[case::form_urlencoded(
RecipeBody::FormUrlencoded(indexmap! {
"user_id".into() => "{{ user_id }}".into(),
"token".into() => "{{ token }}".into()
}),
None,
Some("application/x-www-form-urlencoded"),
"user_id=1&token=tokenzzz",
)]
// reqwest sets the content type when initializing the body, so make sure
// that doesn't override the user's value
#[case::form_urlencoded_content_type_override(
RecipeBody::FormUrlencoded(Default::default()),
Some("text/plain"),
Some("text/plain"),
""
)]
#[tokio::test]
async fn test_body(
http_engine: HttpEngine,
#[case] body: RecipeBody,
#[case] content_type: Option<&'static str>,
// Expected value of the request's Content-Type header
#[case] expected_content_type: Option<&str>,
// Expected value of the request body
#[case] expected_body: &'static str,
) {
let headers = if let Some(content_type) = content_type {
indexmap! {"content-type".into() => content_type.into()}
} else {
IndexMap::default()
};
let recipe = Recipe {
method: HttpMethod::Post,
url: "{{ host }}/post".into(),
headers,
body: Some(body),
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
let request = ticket.record;
assert_eq!(
request
.headers
.get("Content-Type")
.map(|value| value.to_str().unwrap()),
expected_content_type
);
// Convert body to text for comparison, because it gives better errors
let body = request.body.bytes().expect("Expected request body");
let body_text = std::str::from_utf8(body).unwrap();
assert_eq!(body_text, expected_body);
}
/// Test request bodies that are streamed. Streaming means the body is never
/// loaded entirely into memory at once.
#[rstest]
#[case::stream_static(
RecipeBody::Stream("static string".into()),
None,
"static string",
)]
#[case::stream_file(
RecipeBody::Stream("{{ file('data.json') }}".into()),
None, // Content-Type is intentionally *not* inferred from the extension
r#"{ "a": 1, "b": 2 }"#,
)]
#[case::stream_command(
RecipeBody::Stream("{{ command(['cat', 'data.json']) }}".into()),
None,
r#"{ "a": 1, "b": 2 }"#,
)]
#[case::stream_profile(
// Profile field should *not* eagerly resolve the stream
RecipeBody::Stream("{{ stream }}".into()),
None,
r#"{ "a": 1, "b": 2 }"#,
)]
#[case::stream_compound(
// This gets streamed one chunk at a time
RecipeBody::Stream(r#"{ "data": {{ file('data.json') }} }"#.into()),
None,
r#"{ "data": { "a": 1, "b": 2 } }"#,
)]
// Stream multiple chunks of data via a profile field
#[case::stream_compound_nested(
RecipeBody::Stream("outer: {{ stream_compound }}".into()),
None,
"outer: inner: first",
)]
#[case::form_multipart(
RecipeBody::FormMultipart(indexmap! {
"user_id".into() => "{{ user_id }}".into(),
}),
// Normally the boundary is random, but we make it static for testing
Some("multipart/form-data; boundary={BOUNDARY}"),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"user_id\"\r
\r
1\r
--{BOUNDARY}--\r
",
)]
#[case::form_multipart_file(
RecipeBody::FormMultipart(indexmap! {
"file".into() => "{{ file('data.json') }}".into(),
}),
Some("multipart/form-data; boundary={BOUNDARY}"),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"file\"; filename=\"data.json\"\r
Content-Type: application/json\r
\r
{ \"a\": 1, \"b\": 2 }\r
--{BOUNDARY}--\r
",
)]
#[case::form_multipart_file_multichunk(
RecipeBody::FormMultipart(indexmap! {
// This body gets streamed, but it does *not* use native file support
// because it's not *just* the file
"file".into() => "data: {{ file('data.json') }}".into(),
}),
Some("multipart/form-data; boundary={BOUNDARY}"),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"file\"\r
\r
data: { \"a\": 1, \"b\": 2 }\r
--{BOUNDARY}--\r
",
)]
#[case::form_multipart_command(
RecipeBody::FormMultipart(indexmap! {
"command".into() => "{{ command(['cat', 'data.json']) }}".into(),
}),
Some("multipart/form-data; boundary={BOUNDARY}"),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"command\"\r
\r
{ \"a\": 1, \"b\": 2 }\r
--{BOUNDARY}--\r
",
)]
#[tokio::test]
async fn test_body_stream(
http_engine: HttpEngine,
#[case] body: RecipeBody,
#[case] expected_content_type: Option<&str>,
#[case] expected_body: &'static str,
) {
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/post"))
.respond_with(move |request: &wiremock::Request| {
let mut response = ResponseTemplate::new(StatusCode::OK)
.set_body_bytes(request.body.clone());
if let Some(content_type) =
request.headers.get(header::CONTENT_TYPE)
{
response =
response.append_header(header::CONTENT_TYPE, content_type);
}
response
})
.mount(&server)
.await;
let recipe = Recipe {
method: HttpMethod::Post,
url: "{{ host }}/post".into(),
body: Some(body),
..Recipe::factory(())
};
let context = template_context(recipe, Some(&server.uri()));
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
ticket.request.body().map(reqwest::Body::as_bytes),
Some(None)
);
assert_eq!(ticket.record.body, RequestBody::Stream);
let (expected_content_type, expected_body) = MULTIPART_BOUNDARY
.with_borrow(|boundary| {
(
expected_content_type
.map(|s| s.replace("{BOUNDARY}", boundary)),
expected_body.replace("{BOUNDARY}", boundary),
)
});
let exchange = ticket.send(None).await.unwrap();
assert_eq!(exchange.response.status, StatusCode::OK);
let actual_content_type =
exchange.response.headers.get(header::CONTENT_TYPE).map(
|content_type| {
content_type.to_str().expect("Invalid Content-Type header")
},
);
assert_eq!(
actual_content_type,
expected_content_type.as_deref(),
"Incorrect Content-Type header"
);
let body = exchange.response.body.text().expect("Invalid UTF-8 body");
assert_eq!(body, expected_body, "Incorrect body");
}
#[rstest]
#[tokio::test]
async fn test_override_url(http_engine: HttpEngine) {
let recipe = Recipe::factory(());
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
url: Some("http://custom-host/users/{{ username }}".into()),
..Default::default()
},
);
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(ticket.record.url.as_str(), "http://custom-host/users/user");
}
#[rstest]
#[case::basic(
Some(Authentication::Basic {
username: "username".into(),
password: None,
}),
Authentication::Basic {
username: "{{ username }}".into(),
password: Some("{{ password }}".into()),
},
"Basic dXNlcjpodW50ZXIy",
)]
#[case::bearer(
Some(Authentication::Bearer { token: "token".into() }),
Authentication::Bearer { token: "{{ password }}".into() },
"Bearer hunter2",
)]
#[case::add_auth(
None,
Authentication::Bearer { token: "{{ password }}".into() },
"Bearer hunter2",
)]
#[case::basic_to_bearer(
Some(Authentication::Basic {
username: "{{ username }}".into(),
password: Some("{{ password }}".into()),
}),
Authentication::Bearer { token: "{{ password }}".into() },
"Bearer hunter2",
)]
#[tokio::test]
async fn test_override_authentication(
http_engine: HttpEngine,
#[case] recipe_auth: Option<Authentication>,
#[case] override_auth: Authentication,
#[case] expected_header: &str,
) {
let recipe = Recipe {
authentication: recipe_auth,
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
authentication: Some(override_auth),
..Default::default()
},
);
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
ticket
.record
.headers
.get("Authorization")
.map(|v| v.to_str().unwrap()),
Some(expected_header)
);
}
#[rstest]
#[tokio::test]
async fn test_override_headers(http_engine: HttpEngine) {
let recipe = Recipe {
body: Some(RecipeBody::json(json!("test")).unwrap()),
headers: indexmap! {
"Accept".into() => "application/json".into(),
"Big-Guy".into() => "style1".into(),
"content-type".into() => "text/plain".into(),
},
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
headers: [
("Big-Guy".to_owned(), "style2".into()),
("content-type".to_owned(), BuildFieldOverride::Omit),
("extra".to_owned(), "extra".into()),
]
.into_iter()
.collect(),
..Default::default()
},
);
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
ticket.record.headers,
header_map([
("accept", "application/json"),
("Big-Guy", "style2"),
("content-type", "application/json"),
("extra", "extra"),
])
);
}
#[rstest]
#[tokio::test]
async fn test_override_query(http_engine: HttpEngine) {
let recipe = Recipe {
url: "http://localhost/url".into(),
query: indexmap! {
"mode".into() => "regular".into(),
"fast".into() => [
"true", "false", ].into(),
},
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
query_parameters: [
(("mode".to_owned(), 0), "{{ mode }}".into()),
(("fast".to_owned(), 1), BuildFieldOverride::Omit),
(("extra".to_owned(), 0), "extra".into()),
]
.into_iter()
.collect(),
..Default::default()
},
);
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
ticket.record.url.as_str(),
"http://localhost/url?mode=sudo&fast=true&extra=extra"
);
}
#[rstest]
#[case::raw(
Some("{{ username }}".into()), "{{ password }}".into(), Ok("hunter2"),
)]
#[case::json(
Some(RecipeBody::json(json!({"username": "{{ username }}"})).unwrap()),
json!({"username": "my name is {{ username }}"}).into(),
Ok(r#"{"username":"my name is user"}"#),
)]
#[case::none_to_raw(None, "{{ password }}".into(), Ok("hunter2"))]
#[case::raw_to_json(
Some("{{ username }}".into()),
json!({"username": "my name is {{ username }}"}).into(),
Ok(r#"{"username":"my name is user"}"#),
)]
#[case::error_form(
Some(RecipeBody::FormUrlencoded(IndexMap::default())),
"".into(),
Err("Cannot override form body; override individual form fields instead")
)]
#[tokio::test]
async fn test_override_body(
http_engine: HttpEngine,
#[case] recipe_body: Option<RecipeBody>,
#[case] body_override: BodyOverride,
#[case] expected: Result<&str, &str>,
) {
let recipe = Recipe {
body: recipe_body,
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
body: Some(body_override),
..Default::default()
},
);
let result = http_engine.build(seed, &context).await.map(|ticket| {
let body = ticket
.request
.body()
.and_then(|body| body.as_bytes())
.expect("Request body should be defined");
String::from_utf8(body.into()).unwrap()
});
assert_result(result, expected);
}
#[rstest]
#[tokio::test]
async fn test_override_body_form(http_engine: HttpEngine) {
let recipe = Recipe {
body: Some(RecipeBody::FormUrlencoded(indexmap! {
"user_id".into() => "{{ user_id }}".into(),
"token".into() => "{{ token }}".into(),
"preference".into() => "large".into(),
})),
..Recipe::factory(())
};
let recipe_id = recipe.id.clone();
let context = template_context(recipe, None);
let seed = seed(
&context,
BuildOptions {
form_fields: [
("token".to_owned(), BuildFieldOverride::Omit),
("preference".to_owned(), "small".into()),
("extra".to_owned(), "extra".into()),
]
.into_iter()
.collect(),
..Default::default()
},
);
let ticket = http_engine.build(seed, &context).await.unwrap();
assert_eq!(
*ticket.record,
RequestRecord {
id: ticket.record.id,
profile_id: context.selected_profile.clone(),
recipe_id,
method: HttpMethod::Get,
http_version: HttpVersion::Http11,
url: "http://localhost/url".parse().unwrap(),
headers: header_map([(
"content-type",
"application/x-www-form-urlencoded"
)]),
body: b"user_id=1&preference=small&extra=extra".as_slice().into(),
}
);
}
#[rstest]
#[case::url_body(
// Dedupe happens within a single template AND across templates
"{{ host }}/{{ prompt }}/{{ prompt }}",
"{{ prompt }}".into(),
"first",
)]
#[case::url_multipart_body(
"{{ host }}/{{ stream_prompt }}/{{ stream_prompt }}",
// The body should *not* be streamed because is cached from the URL. This
// works by rendering the body last
RecipeBody::FormMultipart(indexmap!{
"file".into() => "{{ stream_prompt }}".into(),
}),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"file\"\r
\r
first\r
--{BOUNDARY}--\r
",
)]
#[case::multipart_body_multiple(
"{{ host }}/first/first",
// Field is used twice in the same body. The stream is *not* cloned, meaning
// the prompt runs twice. This is a bug but requires a lot of machinery to
// fix and in practice should be very rare. Why would you need to stream the
// same source twice within the same form?
RecipeBody::FormMultipart(indexmap!{
"f1".into() => "{{ stream_prompt }}".into(),
"f2".into() => "{{ stream_prompt }}".into(),
}),
"--{BOUNDARY}\r
Content-Disposition: form-data; name=\"f1\"; filename=\"first.txt\"\r
Content-Type: text/plain\r
\r
first\r
--{BOUNDARY}\r
Content-Disposition: form-data; name=\"f2\"; filename=\"second.txt\"\r
Content-Type: text/plain\r
\r
second\r
--{BOUNDARY}--\r
",
)]
#[tokio::test]
async fn test_profile_duplicate(
http_engine: HttpEngine,
#[case] url: Template,
#[case] body: RecipeBody,
#[case] expected_body: &str,
) {
let server = MockServer::start().await;
let host = server.uri();
Mock::given(matchers::method("POST"))
.and(matchers::path("/first/first"))
.respond_with(|request: &wiremock::Request| {
ResponseTemplate::new(StatusCode::OK)
.set_body_bytes(request.body.clone())
})
.mount(&server)
.await;
let recipe = Recipe {
method: HttpMethod::Post,
url,
body: Some(body),
..Recipe::factory(())
};
let context = template_context(recipe, Some(&host));
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
let expected_body = MULTIPART_BOUNDARY
.with_borrow(|boundary| expected_body.replace("{BOUNDARY}", boundary));
let expected_url: Url = format!("{host}/first/first").parse().unwrap();
let exchange = ticket.send(None).await.unwrap();
assert_eq!(exchange.response.status, StatusCode::OK);
assert_eq!(exchange.request.url, expected_url);
assert_eq!(
std::str::from_utf8(exchange.response.body.bytes()).ok(),
Some(expected_body.as_str())
);
}
#[rstest]
#[tokio::test]
async fn test_profile_duplicate_error(http_engine: HttpEngine) {
let recipe = Recipe {
method: HttpMethod::Post,
url: "{{ host }}/{{ error }}".into(),
body: Some("{{ error }}".into()),
..Recipe::factory(())
};
let recipe_id = recipe.id.clone();
let context = template_context(recipe, None);
let seed = RequestSeed::new(recipe_id, BuildOptions::default());
assert_err(
http_engine.build(seed, &context).await,
"fake_fn(): Unknown function",
);
}
#[rstest]
#[tokio::test]
async fn test_send_request(http_engine: HttpEngine) {
let server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/get"))
.respond_with(
ResponseTemplate::new(StatusCode::OK).set_body_string("hello!"),
)
.mount(&server)
.await;
let recipe = Recipe {
url: "{{ host }}/get".into(),
..Recipe::factory(())
};
let database = CollectionDatabase::factory(());
let context = template_context(recipe, Some(&server.uri()));
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
let exchange = ticket.send(Some(database.clone())).await.unwrap();
let date_header = exchange
.response
.headers
.get("date")
.unwrap()
.to_str()
.unwrap();
assert_eq!(
*exchange.response,
ResponseRecord {
id: exchange.id,
status: StatusCode::OK,
headers: header_map([
("content-type", "text/plain"),
("content-length", "6"),
("date", date_header),
]),
body: ResponseBody::new(b"hello!".as_slice().into())
}
);
assert_eq!(database.get_request(exchange.id).unwrap(), Some(exchange));
}
#[rstest]
#[tokio::test]
async fn test_render_headers_strip() {
let recipe = Recipe {
headers: indexmap! {
"Accept".into() => "application/json".into(),
"Host".into() => "\n{{ host }}\n".into(),
},
..Recipe::factory(())
};
let context = template_context(Recipe::factory(()), None);
let rendered = recipe
.render_headers(&BuildOptions::default(), &context)
.await
.unwrap();
assert_eq!(
rendered,
header_map([
("Accept", "application/json"),
("Host", "http://localhost"),
])
);
}
#[rstest]
#[case::empty(&[], &[])]
#[case::start(&[0, 0, 1, 1], &[1, 1])]
#[case::end(&[1, 1, 0, 0], &[1, 1])]
#[case::both(&[0, 1, 0, 1, 0, 0], &[1, 0, 1])]
fn test_trim_bytes(#[case] bytes: &[u8], #[case] expected: &[u8]) {
let mut bytes = bytes.to_owned();
trim_bytes(&mut bytes, |b| b == 0);
assert_eq!(&bytes, expected);
}
#[rstest]
#[tokio::test]
async fn test_build_curl(http_engine: HttpEngine) {
let recipe = Recipe {
method: HttpMethod::Get,
query: indexmap! {
"mode".into() => "{{ mode }}".into(),
"fast".into() => ["true", "false"].into(),
},
headers: indexmap! {
"Accept".into() => "application/json".into(),
"Content-Type".into() => "application/json".into(),
},
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let command = http_engine.build_curl(seed, &context).await.unwrap();
let expected_command = r"curl -XGET --url 'http://localhost/url?mode=sudo&fast=true&fast=false' \
--header 'accept: application/json' \
--header 'content-type: application/json'";
assert_eq!(command, expected_command);
}
#[rstest]
#[tokio::test]
async fn test_build_curl_complex_json(http_engine: HttpEngine) {
let recipe = Recipe {
method: HttpMethod::Post,
url: "{{ host }}/json".into(),
headers: indexmap! {
"Authorization".into() => "Bearer {{ token }}".into(),
},
body: Some(
RecipeBody::json(json!({
"fishes": [
{"species": "Salmon", "name": "Sam"},
{"species": "Blue Tang", "name": "Dory"},
{"species": "Clownfish", "name": "Nemo"},
],
"count": 207,
}))
.unwrap(),
),
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let command = http_engine.build_curl(seed, &context).await.unwrap();
let expected_command = r#"curl -XPOST --url 'http://localhost/json' \
--header 'authorization: Bearer tokenzzz' \
--json '{
"fishes": [
{
"species": "Salmon",
"name": "Sam"
},
{
"species": "Blue Tang",
"name": "Dory"
},
{
"species": "Clownfish",
"name": "Nemo"
}
],
"count": 207
}'"#;
assert_eq!(command, expected_command);
}
#[rstest]
#[case::basic(
Authentication::Basic {
username: "{{ username }}".into(),
password: Some("{{ password }}".into()),
},
"--user 'user:hunter2'",
)]
#[case::basic_no_password(
Authentication::Basic {
username: "{{ username }}".into(),
password: None,
},
"--user 'user:'",
)]
#[case::bearer(
Authentication::Bearer { token: "{{ token }}".into() },
"--header 'authorization: Bearer tokenzzz'",
)]
#[tokio::test]
async fn test_build_curl_authentication(
http_engine: HttpEngine,
#[case] authentication: Authentication,
#[case] expected_arguments: &str,
) {
let recipe = Recipe {
authentication: Some(authentication),
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let command = http_engine.build_curl(seed, &context).await.unwrap();
let expected_command = format!(
"curl -XGET --url 'http://localhost/url' \\
{expected_arguments}",
);
assert_eq!(command, expected_command);
}
#[rstest]
#[case::text(RecipeBody::Raw("hello!".into()), "--data 'hello!'")]
#[case::stream(
RecipeBody::Stream("{{ file('data.json') }}".into()),
"--data '@{ROOT}/data.json'",
)]
#[case::json(
RecipeBody::json(json!({"group_id": "{{ group_id }}"})).unwrap(),
"--json '{\n \"group_id\": \"3\"\n}'"
)]
#[case::form_urlencoded(
RecipeBody::FormUrlencoded(indexmap! {
"user_id".into() => "{{ user_id }}".into(),
"token".into() => "{{ token }}".into()
}),
"--data-urlencode 'user_id=1' \\\n --data-urlencode 'token=tokenzzz'"
)]
#[case::form_multipart(
// This doesn't support binary content because we can't pass it via cmd
RecipeBody::FormMultipart(indexmap! {
"user_id".into() => "{{ user_id }}".into(),
"token".into() => "{{ token }}".into()
}),
"-F 'user_id=1' \\\n -F 'token=tokenzzz'"
)]
#[case::form_multipart_file(
RecipeBody::FormMultipart(indexmap! {
"file".into() => "{{ file('data.json') }}".into(),
}),
"-F 'file=@{ROOT}/data.json'"
)]
#[case::form_multipart_command(
RecipeBody::FormMultipart(indexmap! {
"command".into() => "{{ command(['cat', 'data.json']) }}".into(),
}),
r#"-F 'command={ "a": 1, "b": 2 }'"#
)]
#[tokio::test]
async fn test_build_curl_body(
http_engine: HttpEngine,
#[case] body: RecipeBody,
#[case] expected_arguments: &str,
) {
let recipe = Recipe {
body: Some(body),
..Recipe::factory(())
};
let context = template_context(recipe, None);
let seed = seed(&context, BuildOptions::default());
let command = http_engine.build_curl(seed, &context).await.unwrap();
let expected_arguments = expected_arguments
.replace('/', path::MAIN_SEPARATOR_STR)
.replace("{ROOT}", &context.root_dir.to_string_lossy());
let expected_command = format!(
"curl -XGET --url 'http://localhost/url' \\
{expected_arguments}"
);
assert_eq!(command, expected_command);
}
#[rstest]
#[case::enabled(true, StatusCode::OK)]
#[case::disabled(false, StatusCode::MOVED_PERMANENTLY)]
#[tokio::test]
async fn test_follow_redirects(
#[case] follow_redirects: bool,
#[case] expected_status: StatusCode,
) {
let server = MockServer::start().await;
let host = server.uri();
Mock::given(matchers::method("GET"))
.and(matchers::path("/get"))
.respond_with(ResponseTemplate::new(StatusCode::OK))
.mount(&server)
.await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/redirect"))
.respond_with(
ResponseTemplate::new(StatusCode::MOVED_PERMANENTLY)
.insert_header("Location", format!("{host}/get")),
)
.mount(&server)
.await;
let http_engine = HttpEngine::new(&HttpEngineConfig {
follow_redirects,
..Default::default()
});
let recipe = Recipe {
url: "{{ host }}/redirect".into(),
..Recipe::factory(())
};
let context = template_context(recipe, Some(&host));
let seed = seed(&context, BuildOptions::default());
let ticket = http_engine.build(seed, &context).await.unwrap();
let exchange = ticket.send(None).await.unwrap();
assert_eq!(exchange.response.status, expected_status);
}