use hive_router_config::{
laboratory::{LaboratoryCollectionConfig, LaboratoryConfig, LaboratoryOperationConfig},
primitives::http_header::HttpHeaderName,
};
use serde::Serialize;
use std::collections::{BTreeMap, HashSet};
const PROPS_PLACEHOLDER: &str = "__LABORATORY_PROPS__";
const GLOBAL_HEADERS_PLACEHOLDER: &str = "__LABORATORY_GLOBAL_HEADERS__";
const SEEDED_COLLECTION_CREATED_AT: &str = "1970-01-01T00:00:00.000Z";
#[derive(Debug, Default, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LaboratorySeed {
#[serde(skip_serializing_if = "Vec::is_empty")]
operations: Vec<SeedOperation>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tabs: Vec<SeedTab>,
#[serde(skip_serializing_if = "Option::is_none")]
active_tab_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
collections: Vec<SeedCollection>,
}
#[derive(Debug, Serialize, PartialEq)]
struct SeedOperation {
id: String,
name: String,
query: String,
#[serde(skip_serializing_if = "Option::is_none")]
variables: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
extensions: Option<String>,
}
#[derive(Debug, Serialize, PartialEq)]
struct SeedTab {
id: String,
#[serde(rename = "type")]
tab_type: &'static str,
data: SeedTabData,
}
#[derive(Debug, Serialize, PartialEq)]
struct SeedTabData {
id: String,
name: String,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SeedCollection {
id: String,
name: String,
created_at: &'static str,
operations: Vec<SeedCollectionOperation>,
}
#[derive(Debug, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct SeedCollectionOperation {
id: String,
name: String,
query: String,
created_at: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
variables: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
extensions: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum LaboratoryConfigError {
#[error("laboratory.operations contains more than one operation named '{0}'")]
DuplicateOperationName(String),
#[error("laboratory.collections contains more than one collection named '{0}'")]
DuplicateCollectionName(String),
#[error(
"laboratory collection '{collection}' contains more than one operation named '{operation}'"
)]
DuplicateCollectionOperationName {
collection: String,
operation: String,
},
#[error("{location}.name must not be empty")]
EmptyName { location: String },
#[error("{location} ('{name}') must contain at least one operation")]
EmptyCollection { location: String, name: String },
}
fn operation_seed_id(name: &str) -> String {
format!("router-seed:{name}")
}
fn tab_seed_id(name: &str) -> String {
format!("router-seed-tab:{name}")
}
fn collection_seed_id(name: &str) -> String {
format!("router-seed-collection:{name}")
}
fn collection_operation_seed_id(collection: &str, operation: &str) -> String {
format!(
"router-seed-op:{}:{}",
encode_id_segment(collection),
encode_id_segment(operation)
)
}
fn encode_id_segment(segment: &str) -> String {
segment.replace('%', "%25").replace(':', "%3A")
}
fn serialize_map<V: Serialize>(map: &Option<BTreeMap<String, V>>) -> Option<String> {
map.as_ref()
.filter(|map| !map.is_empty())
.map(|map| sonic_rs::to_string(map).expect("a map is always serializable"))
}
fn serialize_headers_map<V: Serialize>(
map: &Option<BTreeMap<HttpHeaderName, V>>,
) -> Option<String> {
map.as_ref()
.filter(|map| !map.is_empty())
.map(|map| sonic_rs::to_string(map).expect("a map is always serializable"))
}
fn build_operation(
index: usize,
operation: &LaboratoryOperationConfig,
) -> Result<(SeedOperation, SeedTab), LaboratoryConfigError> {
validate_operation_fields(&format!("laboratory.operations[{index}]"), operation)?;
let id = operation_seed_id(&operation.name);
let tab = SeedTab {
id: tab_seed_id(&operation.name),
tab_type: "operation",
data: SeedTabData {
id: id.clone(),
name: operation.name.clone(),
},
};
let seed_operation = SeedOperation {
id,
name: operation.name.clone(),
query: operation.query.clone(),
variables: serialize_map(&operation.variables),
headers: serialize_headers_map(&operation.headers),
extensions: serialize_map(&operation.extensions),
};
Ok((seed_operation, tab))
}
fn validate_operation_fields(
location: &str,
operation: &LaboratoryOperationConfig,
) -> Result<(), LaboratoryConfigError> {
if operation.name.trim().is_empty() {
return Err(LaboratoryConfigError::EmptyName {
location: location.to_string(),
});
}
Ok(())
}
fn build_collection(
index: usize,
collection: &LaboratoryCollectionConfig,
) -> Result<SeedCollection, LaboratoryConfigError> {
if collection.name.trim().is_empty() {
return Err(LaboratoryConfigError::EmptyName {
location: format!("laboratory.collections[{index}]"),
});
}
if collection.operations.is_empty() {
return Err(LaboratoryConfigError::EmptyCollection {
location: format!("laboratory.collections[{index}]"),
name: collection.name.clone(),
});
}
let mut seen_names = HashSet::with_capacity(collection.operations.len());
let mut operations = Vec::with_capacity(collection.operations.len());
for (op_index, operation) in collection.operations.iter().enumerate() {
let location = format!("laboratory.collections[{index}].operations[{op_index}]");
validate_operation_fields(&location, operation)?;
if !seen_names.insert(operation.name.as_str()) {
return Err(LaboratoryConfigError::DuplicateCollectionOperationName {
collection: collection.name.clone(),
operation: operation.name.clone(),
});
}
operations.push(SeedCollectionOperation {
id: collection_operation_seed_id(&collection.name, &operation.name),
name: operation.name.clone(),
query: operation.query.clone(),
created_at: SEEDED_COLLECTION_CREATED_AT,
variables: serialize_map(&operation.variables),
headers: serialize_headers_map(&operation.headers),
extensions: serialize_map(&operation.extensions),
});
}
Ok(SeedCollection {
id: collection_seed_id(&collection.name),
name: collection.name.clone(),
created_at: SEEDED_COLLECTION_CREATED_AT,
operations,
})
}
pub fn build_laboratory_seed(
config: &LaboratoryConfig,
) -> Result<LaboratorySeed, LaboratoryConfigError> {
let mut seen_names = HashSet::with_capacity(config.operations.len());
let mut operations = Vec::with_capacity(config.operations.len());
let mut tabs = Vec::with_capacity(config.operations.len());
for (index, operation) in config.operations.iter().enumerate() {
let (seed_operation, tab) = build_operation(index, operation)?;
if !seen_names.insert(operation.name.as_str()) {
return Err(LaboratoryConfigError::DuplicateOperationName(
operation.name.clone(),
));
}
operations.push(seed_operation);
tabs.push(tab);
}
let active_tab_id = tabs.first().map(|tab| tab.id.clone());
let mut seen_collection_names = HashSet::with_capacity(config.collections.len());
let mut collections = Vec::with_capacity(config.collections.len());
for (index, collection) in config.collections.iter().enumerate() {
let seed_collection = build_collection(index, collection)?;
if !seen_collection_names.insert(collection.name.as_str()) {
return Err(LaboratoryConfigError::DuplicateCollectionName(
collection.name.clone(),
));
}
collections.push(seed_collection);
}
Ok(LaboratorySeed {
operations,
tabs,
active_tab_id,
collections,
})
}
fn escape_for_js_string_literal(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'<' => escaped.push_str("\\u003c"),
'\u{2028}' => escaped.push_str("\\u2028"),
'\u{2029}' => escaped.push_str("\\u2029"),
_ => escaped.push(character),
}
}
escaped
}
pub fn render_laboratory_html(
template: &str,
config: &LaboratoryConfig,
) -> Result<String, LaboratoryConfigError> {
let seed = build_laboratory_seed(config)?;
let seed_json = sonic_rs::to_string(&seed).expect("laboratory seed is always serializable");
let global_headers_json = sonic_rs::to_string(&config.global_headers)
.expect("laboratory global headers are always serializable");
Ok(template
.replace(PROPS_PLACEHOLDER, &escape_for_js_string_literal(&seed_json))
.replace(
GLOBAL_HEADERS_PLACEHOLDER,
&escape_for_js_string_literal(&global_headers_json),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn operation(name: &str) -> LaboratoryOperationConfig {
LaboratoryOperationConfig {
name: name.to_string(),
query: format!("query {name} {{ hello }}"),
variables: None,
headers: None,
extensions: None,
}
}
fn config_with_operations(operations: Vec<LaboratoryOperationConfig>) -> LaboratoryConfig {
LaboratoryConfig {
operations,
..Default::default()
}
}
fn collection(
name: &str,
operations: Vec<LaboratoryOperationConfig>,
) -> LaboratoryCollectionConfig {
LaboratoryCollectionConfig {
name: name.to_string(),
operations,
}
}
fn config_with_collections(collections: Vec<LaboratoryCollectionConfig>) -> LaboratoryConfig {
LaboratoryConfig {
collections,
..Default::default()
}
}
#[cfg(test)]
fn config_with_global_headers(headers: &[(&str, &str)]) -> LaboratoryConfig {
LaboratoryConfig {
global_headers: headers
.iter()
.map(|(k, v)| (HttpHeaderName::from(*k), v.to_string()))
.collect(),
..Default::default()
}
}
fn extract_injected_json(html: &str) -> String {
let marker = "JSON.parse(\"";
let start = html.find(marker).expect("template should contain the call") + marker.len();
let end = html.rfind("\");").expect("the call should be terminated");
assert!(start <= end, "the injected literal is malformed: {html}");
let literal = &html[start..end];
let mut unescaped = String::with_capacity(literal.len());
let mut chars = literal.chars();
while let Some(character) = chars.next() {
if character != '\\' {
unescaped.push(character);
continue;
}
match chars.next().expect("dangling escape") {
'n' => unescaped.push('\n'),
'r' => unescaped.push('\r'),
't' => unescaped.push('\t'),
'"' => unescaped.push('"'),
'\\' => unescaped.push('\\'),
'u' => {
let code: String = (&mut chars).take(4).collect();
let code = u32::from_str_radix(&code, 16).expect("invalid unicode escape");
unescaped.push(char::from_u32(code).expect("invalid code point"));
}
other => panic!("unexpected escape: \\{other}"),
}
}
unescaped
}
fn injected_operation_query(html: &str, index: usize) -> String {
let seed: serde_json::Value =
serde_json::from_str(&extract_injected_json(html)).expect("should be valid JSON");
seed["operations"][index]["query"]
.as_str()
.expect("query should be present")
.to_string()
}
const TEMPLATE: &str = r#"<script>JSON.parse("__LABORATORY_PROPS__");</script>"#;
const GLOBAL_HEADERS_TEMPLATE: &str =
r#"<script>JSON.parse("__LABORATORY_GLOBAL_HEADERS__");</script>"#;
#[test]
fn injects_configured_global_headers() {
let html = render_laboratory_html(
GLOBAL_HEADERS_TEMPLATE,
&config_with_global_headers(&[("X-Env", "staging")]),
)
.unwrap();
assert!(
!html.contains(GLOBAL_HEADERS_PLACEHOLDER),
"the global-headers placeholder must be replaced"
);
let parsed: serde_json::Value =
serde_json::from_str(&extract_injected_json(&html)).expect("should be valid JSON");
assert_eq!(parsed["x-env"], "staging");
}
#[test]
fn empty_global_headers_still_substitute_to_an_object() {
let html =
render_laboratory_html(GLOBAL_HEADERS_TEMPLATE, &LaboratoryConfig::default()).unwrap();
assert!(
!html.contains(GLOBAL_HEADERS_PLACEHOLDER),
"the placeholder must always be replaced"
);
assert_eq!(extract_injected_json(&html), "{}");
}
#[test]
fn a_global_header_value_cannot_break_out_of_the_script_element() {
let html = render_laboratory_html(
GLOBAL_HEADERS_TEMPLATE,
&config_with_global_headers(&[("X-Evil", "</script><script>alert(1)</script>")]),
)
.unwrap();
assert!(
!html.to_lowercase().contains("</script><script>"),
"the global header value escaped the string literal: {html}"
);
assert_eq!(
html.to_lowercase().matches("</script>").count(),
1,
"unexpected number of closing script tags: {html}"
);
}
#[cfg(not(feature = "graphiql"))]
#[test]
fn the_global_headers_wrapper_is_installed_before_the_bundle() {
let page = crate::LABORATORY_HTML;
let wrapper = page
.find("window.fetch = function")
.expect("the fetch wrapper should be present");
let placeholder = page
.find(GLOBAL_HEADERS_PLACEHOLDER)
.expect("the global-headers placeholder should be present");
let bundle = page
.find("HiveLaboratory")
.expect("the laboratory bundle should be present");
assert!(
placeholder < bundle && wrapper < bundle,
"the global-headers wrapper must be installed before the bundle"
);
assert!(
page.contains("globalThis.fetch"),
"the bundle no longer captures globalThis.fetch — the wrapper may not be used"
);
}
#[test]
fn the_generated_page_uses_the_agreed_storage_keys() {
let page = crate::LABORATORY_HTML;
assert!(
page.contains(r#"var STORAGE_NAMESPACE = "hive-laboratory";"#),
"the laboratory storage namespace changed"
);
for key in ["operations", "tabs", "activeTabId", "collections"] {
assert!(
page.contains(&format!("readStored(\"{key}\")")),
"the page no longer reads the '{key}' laboratory storage key"
);
}
assert!(
page.contains(r#"var SEEDED_TABS_KEY = "hive-router:seeded-tab-ids";"#),
"the seeded-tab bookkeeping key changed"
);
assert!(
page.contains(PROPS_PLACEHOLDER),
"the generated page no longer contains the seed placeholder"
);
}
#[test]
fn every_seed_field_is_read_by_the_generated_page() {
let config = LaboratoryConfig {
operations: vec![operation("GetHello")],
collections: vec![collection("Onboarding", vec![operation("ListUsers")])],
..Default::default()
};
let seed = build_laboratory_seed(&config).expect("should build");
let json = sonic_rs::to_string(&seed).expect("should serialize");
let fields: std::collections::HashMap<String, sonic_rs::Value> =
sonic_rs::from_str(&json).expect("should be an object");
assert_eq!(
fields.len(),
4,
"every seed field must be populated for this test to be meaningful"
);
for field in fields.keys() {
assert!(
crate::LABORATORY_HTML.contains(&format!("seed.{field}")),
"the generated page never reads 'seed.{field}'"
);
}
}
#[test]
fn injects_an_empty_seed_when_there_is_nothing_to_seed() {
let html = render_laboratory_html(TEMPLATE, &LaboratoryConfig::default()).unwrap();
assert!(
!html.contains(PROPS_PLACEHOLDER),
"the placeholder must always be replaced"
);
assert_eq!(extract_injected_json(&html), "{}");
}
#[test]
fn seeds_an_operation_with_a_matching_tab() {
let seed = build_laboratory_seed(&config_with_operations(vec![operation("GetHello")]))
.expect("should build");
assert_eq!(seed.operations.len(), 1);
assert_eq!(seed.tabs.len(), 1);
let operation_id = &seed.operations[0].id;
let tab = &seed.tabs[0];
assert_eq!(
&tab.data.id, operation_id,
"the tab must point at the seeded operation"
);
assert_eq!(tab.tab_type, "operation");
assert_eq!(tab.data.name, "GetHello");
assert_eq!(
seed.active_tab_id.as_ref(),
Some(&tab.id),
"the first seeded tab is the active one"
);
}
#[test]
fn seed_ids_are_stable_across_renders() {
let config = config_with_operations(vec![operation("Get Hello")]);
let first = build_laboratory_seed(&config).unwrap();
let second = build_laboratory_seed(&config).unwrap();
assert_eq!(first.operations[0].id, second.operations[0].id);
assert_eq!(first.operations[0].id, "router-seed:Get Hello");
assert_eq!(first.tabs[0].id, "router-seed-tab:Get Hello");
}
#[test]
fn names_that_differ_only_in_punctuation_or_script_get_distinct_ids() {
for names in [
["Get-Hello", "get_hello"],
["获取用户", "查询数据"],
["a b", "a-b"],
] {
let seed = build_laboratory_seed(&config_with_operations(
names.iter().map(|name| operation(name)).collect(),
))
.unwrap_or_else(|error| panic!("{names:?} should be distinct, got: {error}"));
assert_ne!(seed.operations[0].id, seed.operations[1].id);
assert_ne!(seed.tabs[0].id, seed.tabs[1].id);
}
}
#[test]
fn operation_and_tab_ids_cannot_collide_when_a_name_contains_a_colon() {
let seed = build_laboratory_seed(&config_with_operations(vec![
operation("a"),
operation("tab:a"),
]))
.expect("should build");
let ids: HashSet<&str> = seed
.operations
.iter()
.map(|operation| operation.id.as_str())
.chain(seed.tabs.iter().map(|tab| tab.id.as_str()))
.collect();
assert_eq!(ids.len(), 4, "every seeded id must be distinct: {ids:?}");
}
#[test]
fn reports_a_blank_name_as_blank_rather_than_as_a_duplicate() {
let error = build_laboratory_seed(&config_with_operations(vec![
operation(" "),
operation(" "),
]))
.expect_err("a blank name should be rejected");
assert!(
matches!(
&error,
LaboratoryConfigError::EmptyName { location } if location == "laboratory.operations[0]"
),
"unexpected error: {error}"
);
}
#[test]
fn rejects_duplicate_operation_names() {
let error = build_laboratory_seed(&config_with_operations(vec![
operation("GetHello"),
operation("GetHello"),
]))
.expect_err("duplicates should be rejected");
assert!(
matches!(error, LaboratoryConfigError::DuplicateOperationName(name) if name == "GetHello"),
"unexpected error"
);
}
#[test]
fn seeds_a_collection_with_namespaced_operation_ids() {
let seed = build_laboratory_seed(&config_with_collections(vec![collection(
"Onboarding",
vec![operation("GetHello"), operation("ListUsers")],
)]))
.expect("should build");
assert_eq!(seed.collections.len(), 1);
let coll = &seed.collections[0];
assert_eq!(coll.id, "router-seed-collection:Onboarding");
assert_eq!(coll.name, "Onboarding");
assert_eq!(coll.created_at, SEEDED_COLLECTION_CREATED_AT);
assert_eq!(coll.operations.len(), 2);
assert_eq!(coll.operations[0].id, "router-seed-op:Onboarding:GetHello");
assert_eq!(coll.operations[0].created_at, SEEDED_COLLECTION_CREATED_AT);
assert!(seed.operations.is_empty());
assert!(seed.tabs.is_empty());
}
#[test]
fn the_same_operation_name_in_two_collections_gets_distinct_ids() {
let seed = build_laboratory_seed(&config_with_collections(vec![
collection("A", vec![operation("GetHello")]),
collection("B", vec![operation("GetHello")]),
]))
.expect("should build");
assert_ne!(seed.collections[0].id, seed.collections[1].id);
assert_ne!(
seed.collections[0].operations[0].id,
seed.collections[1].operations[0].id
);
}
#[test]
fn rejects_duplicate_collection_names() {
let error = build_laboratory_seed(&config_with_collections(vec![
collection("Onboarding", vec![operation("A")]),
collection("Onboarding", vec![operation("B")]),
]))
.expect_err("duplicate collection names should be rejected");
assert!(
matches!(&error, LaboratoryConfigError::DuplicateCollectionName(name) if name == "Onboarding"),
"unexpected error: {error}"
);
}
#[test]
fn rejects_duplicate_operation_names_within_a_collection() {
let error = build_laboratory_seed(&config_with_collections(vec![collection(
"Onboarding",
vec![operation("GetHello"), operation("GetHello")],
)]))
.expect_err("duplicate operation names within a collection should be rejected");
assert!(
matches!(
&error,
LaboratoryConfigError::DuplicateCollectionOperationName { collection, operation }
if collection == "Onboarding" && operation == "GetHello"
),
"unexpected error: {error}"
);
}
#[test]
fn reports_a_blank_collection_name_with_its_location() {
let error = build_laboratory_seed(&config_with_collections(vec![collection(
" ",
vec![operation("A")],
)]))
.expect_err("a blank collection name should be rejected");
assert!(
matches!(&error, LaboratoryConfigError::EmptyName { location } if location == "laboratory.collections[0]"),
"unexpected error: {error}"
);
}
#[test]
fn rejects_an_empty_collection() {
let error = build_laboratory_seed(&config_with_collections(vec![collection(
"Onboarding",
vec![],
)]))
.expect_err("a collection with no operations should be rejected");
assert!(
matches!(
&error,
LaboratoryConfigError::EmptyCollection { location, name }
if location == "laboratory.collections[0]" && name == "Onboarding"
),
"unexpected error: {error}"
);
}
#[test]
fn collection_created_at_serializes_as_camel_case() {
let json = sonic_rs::to_string(
&build_laboratory_seed(&config_with_collections(vec![collection(
"Onboarding",
vec![operation("GetHello")],
)]))
.expect("should build"),
)
.expect("should serialize");
let expected = format!("\"createdAt\":\"{SEEDED_COLLECTION_CREATED_AT}\"");
assert!(
json.contains(&expected),
"the collection and its operation must serialize createdAt as camelCase: {json}"
);
assert_eq!(json.matches(&expected).count(), 2, "{json}");
assert!(
!json.contains("created_at"),
"createdAt must not serialize as snake_case: {json}"
);
}
#[test]
fn collection_operation_ids_cannot_collide_when_names_contain_a_colon() {
let seed = build_laboratory_seed(&config_with_collections(vec![
collection("a:b", vec![operation("c")]),
collection("a", vec![operation("b:c")]),
]))
.expect("should build");
assert_ne!(
seed.collections[0].operations[0].id, seed.collections[1].operations[0].id,
"colon-containing names produced a colliding operation id"
);
}
#[test]
fn id_encoding_stays_injective_for_percent_and_colon() {
let seed = build_laboratory_seed(&config_with_collections(vec![collection(
"C",
vec![operation("%3A"), operation(":")],
)]))
.expect("should build");
assert_ne!(
seed.collections[0].operations[0].id, seed.collections[0].operations[1].id,
"'%3A' and ':' produced a colliding operation id (encode order regressed)"
);
}
#[test]
fn reports_a_blank_collection_operation_name_with_its_location() {
let error = build_laboratory_seed(&config_with_collections(vec![collection(
"Onboarding",
vec![operation(" ")],
)]))
.expect_err("a blank collection operation name should be rejected");
assert!(
matches!(&error, LaboratoryConfigError::EmptyName { location } if location == "laboratory.collections[0].operations[0]"),
"unexpected error: {error}"
);
}
#[test]
fn a_config_with_only_collections_is_injected() {
let html = render_laboratory_html(
TEMPLATE,
&config_with_collections(vec![collection("Onboarding", vec![operation("GetHello")])]),
)
.expect("should render");
assert!(
!html.contains(PROPS_PLACEHOLDER),
"the placeholder must be replaced when only collections are configured"
);
assert!(
html.contains("router-seed-collection:Onboarding"),
"the seeded collection must reach the page"
);
}
#[test]
fn nested_variables_serialize_to_a_json_string() {
let mut operation = operation("GetHello");
operation.variables = Some(BTreeMap::from([
(
"filter".to_string(),
serde_json::json!({ "status": "active", "tags": ["a", "b"] }),
),
("limit".to_string(), serde_json::json!(10)),
]));
let seed =
build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");
let variables = seed.operations[0]
.variables
.as_deref()
.expect("variables should be present");
let parsed: serde_json::Value =
serde_json::from_str(variables).expect("should be valid JSON");
assert_eq!(parsed["limit"], 10);
assert_eq!(parsed["filter"]["tags"][1], "b");
}
#[test]
fn an_empty_variables_object_is_treated_as_unset() {
let mut operation = operation("GetHello");
operation.variables = Some(BTreeMap::new());
operation.extensions = Some(BTreeMap::new());
let seed =
build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");
assert!(seed.operations[0].variables.is_none());
assert!(seed.operations[0].extensions.is_none());
}
#[test]
fn a_headers_map_serializes_to_a_json_string() {
let mut operation = operation("GetHello");
operation.headers = Some(BTreeMap::from([
(HttpHeaderName::from("X-Team"), "payments".to_string()),
(HttpHeaderName::from("X-Env"), "staging".to_string()),
]));
let seed =
build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");
assert_eq!(
seed.operations[0].headers.as_deref(),
Some(r#"{"x-env":"staging","x-team":"payments"}"#)
);
}
#[test]
fn an_empty_headers_map_is_treated_as_no_headers() {
let mut operation = operation("GetHello");
operation.headers = Some(BTreeMap::new());
let seed =
build_laboratory_seed(&config_with_operations(vec![operation])).expect("should build");
assert!(seed.operations[0].headers.is_none());
}
#[test]
fn a_configured_value_cannot_break_out_of_the_script_element() {
let mut op = operation("Evil");
op.query =
"query { field } // </script><script>alert('xss')</script> <!-- ${'</SCRIPT'} -->"
.to_string();
let html = render_laboratory_html(TEMPLATE, &config_with_operations(vec![op])).unwrap();
assert!(
!html.to_lowercase().contains("</script><script>"),
"the injected value escaped the string literal: {html}"
);
assert!(
!html.contains("<!--"),
"the injected value emitted an HTML comment: {html}"
);
assert_eq!(
html.to_lowercase().matches("</script>").count(),
1,
"unexpected number of closing script tags: {html}"
);
assert!(injected_operation_query(&html, 0).contains("</script><script>"));
}
#[test]
fn a_collection_operation_query_cannot_break_out_of_the_element() {
let mut op = operation("Evil");
op.query = "query { field } // </script><script>alert(1)</script>".to_string();
let html = render_laboratory_html(
TEMPLATE,
&config_with_collections(vec![collection("Onboarding", vec![op])]),
)
.unwrap();
assert!(
!html.to_lowercase().contains("</script><script>"),
"the collection query escaped the string literal: {html}"
);
assert_eq!(
html.to_lowercase().matches("</script>").count(),
1,
"unexpected number of closing script tags: {html}"
);
}
#[test]
fn line_separators_survive_the_round_trip() {
let mut op = operation("Sep");
op.query = "query { field } # \u{2028}\u{2029}".to_string();
let html = render_laboratory_html(TEMPLATE, &config_with_operations(vec![op])).unwrap();
assert_eq!(
injected_operation_query(&html, 0),
"query { field } # \u{2028}\u{2029}",
"line separators must survive"
);
}
}