diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 5237921a..afca15ed 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -307,8 +307,38 @@ pub fn convert_to_openrtb_response( ..Default::default() }; + let mut response_json = + serde_json::to_value(&response_body).change_context(TrustedServerError::Auction { + message: "Failed to serialize auction response".to_string(), + })?; + + if let Some(object) = response_json.as_object_mut() { + // The generated OpenRTB types skip empty vectors, but this endpoint's + // response contract uses explicit empty arrays for no-bid and no-adomain + // cases. + if response_body.seatbid.is_empty() { + object.insert("seatbid".to_string(), JsonValue::Array(Vec::new())); + } + + if let Some(seatbids) = object.get_mut("seatbid").and_then(JsonValue::as_array_mut) { + for seatbid in seatbids { + let Some(bids) = seatbid.get_mut("bid").and_then(JsonValue::as_array_mut) else { + continue; + }; + + for bid in bids { + if let Some(bid_object) = bid.as_object_mut() { + bid_object + .entry("adomain".to_string()) + .or_insert_with(|| JsonValue::Array(Vec::new())); + } + } + } + } + } + let body_bytes = - serde_json::to_vec(&response_body).change_context(TrustedServerError::Auction { + serde_json::to_vec(&response_json).change_context(TrustedServerError::Auction { message: "Failed to serialize auction response".to_string(), })?; @@ -318,3 +348,611 @@ pub fn convert_to_openrtb_response( .with_header(HEADER_X_TS_EC_FRESH, &auction_request.user.fresh_id) .with_body(body_bytes)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::{AuctionResponse, Bid, BidStatus}; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + use fastly::http::HeaderValue; + use serde_json::json; + use std::collections::{HashMap, HashSet}; + + fn make_request() -> Request { + let mut req = Request::new("POST", "https://publisher.example.com/auction"); + req.set_header( + header::USER_AGENT, + HeaderValue::from_str("Mozilla/5.0 test").expect("should create user-agent header"), + ); + req + } + + fn make_auction_request() -> AuctionRequest { + AuctionRequest { + id: "auction-1".to_string(), + slots: vec![AdSlot { + id: "div-gpt-top".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "test-publisher.com".to_string(), + page_url: Some("https://test-publisher.com".to_string()), + }, + user: UserInfo { + id: "ec-id".to_string(), + fresh_id: "fresh-id".to_string(), + consent: Some(ConsentContext::default()), + }, + device: None, + site: None, + context: HashMap::new(), + } + } + + fn make_bid(slot_id: &str, bidder: &str, price: Option) -> Bid { + Bid { + slot_id: slot_id.to_string(), + price, + currency: "USD".to_string(), + creative: Some("
Ad
".to_string()), + adomain: Some(vec!["advertiser.example.com".to_string()]), + bidder: bidder.to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + metadata: HashMap::new(), + } + } + + fn make_result(bid: Bid) -> OrchestrationResult { + OrchestrationResult { + provider_responses: vec![AuctionResponse { + provider: "prebid".to_string(), + bids: vec![bid.clone()], + status: BidStatus::Success, + response_time_ms: 42, + metadata: HashMap::new(), + }], + mediator_response: None, + winning_bids: HashMap::from([(bid.slot_id.clone(), bid)]), + total_time_ms: 50, + metadata: HashMap::new(), + } + } + + fn response_json(mut response: Response) -> JsonValue { + serde_json::from_slice(&response.take_body_bytes()).expect("should parse JSON response") + } + + fn make_banner_body(config: Option) -> AdRequest { + AdRequest { + ad_units: vec![AdUnit { + code: "div-gpt-top".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250], vec![728, 90]], + }), + }), + bids: Some(vec![ + BidConfig { + bidder: "appnexus".to_string(), + params: json!({ "placementId": 123 }), + }, + BidConfig { + bidder: "rubicon".to_string(), + params: json!({ "accountId": 456 }), + }, + ]), + }], + config, + } + } + + fn convert_body_to_auction_request( + body: &AdRequest, + settings: &crate::settings::Settings, + ) -> AuctionRequest { + let req = make_request(); + let services = noop_services(); + + convert_tsjs_to_auction_request( + body, + settings, + &services, + &req, + ConsentContext::default(), + "existing-ec-id", + None, + ) + .expect("should convert banner request") + } + + #[test] + fn convert_tsjs_to_auction_request_maps_banner_sizes_to_formats() { + let settings = create_test_settings(); + let body = make_banner_body(None); + let auction_request = convert_body_to_auction_request(&body, &settings); + + assert_eq!(auction_request.slots.len(), 1, "should create one slot"); + let slot = &auction_request.slots[0]; + assert_eq!(slot.id, "div-gpt-top", "should preserve ad unit code"); + assert_eq!( + slot.formats, + vec![ + AdFormat { + width: 300, + height: 250, + media_type: MediaType::Banner, + }, + AdFormat { + width: 728, + height: 90, + media_type: MediaType::Banner, + }, + ], + "should convert banner sizes to formats" + ); + } + + #[test] + fn convert_tsjs_to_auction_request_preserves_bidder_params() { + let settings = create_test_settings(); + let body = make_banner_body(None); + let auction_request = convert_body_to_auction_request(&body, &settings); + let slot = &auction_request.slots[0]; + + assert_eq!( + slot.bidders.get("appnexus"), + Some(&json!({ "placementId": 123 })), + "should preserve bidder params" + ); + assert_eq!( + slot.bidders.get("rubicon"), + Some(&json!({ "accountId": 456 })), + "should preserve all bidder params" + ); + } + + #[test] + fn convert_tsjs_to_auction_request_populates_publisher_user_device_and_site_metadata() { + let settings = create_test_settings(); + let body = make_banner_body(None); + let auction_request = convert_body_to_auction_request(&body, &settings); + + assert_eq!( + auction_request.publisher.domain, settings.publisher.domain, + "should copy publisher domain" + ); + assert_eq!( + auction_request + .site + .as_ref() + .map(|site| site.domain.as_str()), + Some(settings.publisher.domain.as_str()), + "should create site metadata from settings" + ); + assert_eq!( + auction_request.user.id, "existing-ec-id", + "should use caller-provided EC ID" + ); + assert!( + !auction_request.user.fresh_id.is_empty(), + "should generate a fresh EC ID" + ); + assert!( + auction_request.user.consent.is_some(), + "should preserve consent context" + ); + assert_eq!( + auction_request + .device + .as_ref() + .and_then(|device| device.user_agent.as_deref()), + Some("Mozilla/5.0 test"), + "should copy user-agent into device info" + ); + } + + #[test] + fn convert_tsjs_to_auction_request_filters_context_values() { + let mut settings = create_test_settings(); + settings.auction.allowed_context_keys = HashSet::from([ + "segments".to_string(), + "lockr_id".to_string(), + "count".to_string(), + "unsupported".to_string(), + ]); + let body = make_banner_body(Some(json!({ + "segments": ["seg-a", "seg-b"], + "lockr_id": "lockr-123", + "count": 2, + "unsupported": { "nested": true }, + "blocked": "drop-me" + }))); + let auction_request = convert_body_to_auction_request(&body, &settings); + + assert_eq!( + auction_request.context.get("segments"), + Some(&ContextValue::StringList(vec![ + "seg-a".to_string(), + "seg-b".to_string() + ])), + "should keep allowed string-list context values" + ); + assert_eq!( + auction_request.context.get("lockr_id"), + Some(&ContextValue::Text("lockr-123".to_string())), + "should keep allowed text context values" + ); + assert_eq!( + auction_request.context.get("count"), + Some(&ContextValue::Number(2.0)), + "should keep allowed number context values" + ); + assert!( + !auction_request.context.contains_key("unsupported"), + "should drop allowed keys with unsupported value types" + ); + assert!( + !auction_request.context.contains_key("blocked"), + "should drop disallowed context keys" + ); + } + + #[test] + fn convert_tsjs_to_auction_request_rejects_banner_sizes_that_are_not_width_height_pairs() { + let settings = create_test_settings(); + let req = make_request(); + let body = AdRequest { + ad_units: vec![AdUnit { + code: "div-gpt-top".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250, 1]], + }), + }), + bids: None, + }], + config: None, + }; + + let services = noop_services(); + let err = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "existing-ec-id", + None, + ) + .expect_err("should reject malformed banner size"); + + assert!( + format!("{err:?}").contains("Invalid banner size; expected [width, height]"), + "should explain invalid banner size" + ); + } + + #[test] + fn convert_tsjs_to_auction_request_skips_units_without_banner_media() { + let settings = create_test_settings(); + let req = make_request(); + let body = AdRequest { + ad_units: vec![ + AdUnit { + code: "no-media".to_string(), + media_types: None, + bids: None, + }, + AdUnit { + code: "no-banner".to_string(), + media_types: Some(MediaTypes { banner: None }), + bids: None, + }, + ], + config: None, + }; + + let services = noop_services(); + let auction_request = convert_tsjs_to_auction_request( + &body, + &settings, + &services, + &req, + ConsentContext::default(), + "existing-ec-id", + None, + ) + .expect("should skip unsupported media units"); + + assert!( + auction_request.slots.is_empty(), + "should only create slots for banner media" + ); + } + + #[test] + fn convert_to_openrtb_response_serializes_winning_bid_headers_and_orchestrator_ext() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert auction result to OpenRTB response"); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/json"), + "should set JSON content type" + ); + assert_eq!( + response.get_header_str(HEADER_X_TS_EC), + Some("ec-id"), + "should set EC ID header" + ); + assert_eq!( + response.get_header_str(HEADER_X_TS_EC_FRESH), + Some("fresh-id"), + "should set fresh EC ID header" + ); + + let json = response_json(response); + assert_eq!(json["id"], json!("auction-1"), "should preserve auction ID"); + assert_eq!( + json["seatbid"][0]["seat"], + json!("appnexus"), + "should use bidder as seat" + ); + let bid = &json["seatbid"][0]["bid"][0]; + assert_eq!(bid["id"], json!("appnexus-div-gpt-top")); + assert_eq!(bid["impid"], json!("div-gpt-top")); + assert_eq!(bid["price"], json!(2.75)); + assert_eq!(bid["adm"], json!("
Ad
")); + assert_eq!(bid["crid"], json!("appnexus-creative")); + assert_eq!(bid["w"], json!(300)); + assert_eq!(bid["h"], json!(250)); + assert_eq!(bid["adomain"], json!(["advertiser.example.com"])); + assert_eq!( + json["ext"]["orchestrator"]["strategy"], + json!("parallel_only"), + "should use default parallel-only strategy" + ); + assert_eq!(json["ext"]["orchestrator"]["providers"], json!(1)); + assert_eq!(json["ext"]["orchestrator"]["total_bids"], json!(1)); + assert_eq!(json["ext"]["orchestrator"]["time_ms"], json!(50)); + assert_eq!( + json["ext"]["orchestrator"]["provider_details"][0]["name"], + json!("prebid"), + "should include provider summary details" + ); + } + + #[test] + fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = None; + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert bid without creative HTML"); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + let json = response_json(response); + assert_eq!( + json["seatbid"][0]["bid"][0]["adm"], + json!(""), + "should serialize missing creative as empty adm" + ); + } + + #[test] + fn convert_to_openrtb_response_serializes_missing_adomain_as_empty_array() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.adomain = None; + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert bid without advertiser domains"); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + let json = response_json(response); + assert_eq!( + json["seatbid"][0]["bid"][0]["adomain"], + json!([]), + "should serialize missing adomain as an empty array" + ); + } + + #[test] + fn convert_to_openrtb_response_allows_empty_winning_bids() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let result = OrchestrationResult { + provider_responses: vec![], + mediator_response: None, + winning_bids: HashMap::new(), + total_time_ms: 50, + metadata: HashMap::new(), + }; + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert auction result without winning bids"); + + assert_eq!(response.get_status(), StatusCode::OK, "should return OK"); + let json = response_json(response); + assert_eq!( + json["seatbid"], + json!([]), + "should emit empty seatbid array" + ); + assert_eq!( + json["ext"]["orchestrator"]["total_bids"], + json!(0), + "should report zero total bids" + ); + } + + #[test] + fn convert_to_openrtb_response_serializes_multiple_winning_bids() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let top_bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + let mut sidebar_bid = make_bid("div-gpt-sidebar", "rubicon", Some(1.25)); + sidebar_bid.creative = Some("
Sidebar
".to_string()); + let result = OrchestrationResult { + provider_responses: vec![AuctionResponse { + provider: "prebid".to_string(), + bids: vec![top_bid.clone(), sidebar_bid.clone()], + status: BidStatus::Success, + response_time_ms: 42, + metadata: HashMap::new(), + }], + mediator_response: None, + winning_bids: HashMap::from([ + (top_bid.slot_id.clone(), top_bid), + (sidebar_bid.slot_id.clone(), sidebar_bid), + ]), + total_time_ms: 50, + metadata: HashMap::new(), + }; + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert multiple winning bids"); + let json = response_json(response); + let seatbids = json["seatbid"] + .as_array() + .expect("should serialize seatbid array"); + + assert_eq!(seatbids.len(), 2, "should emit one seatbid per winner"); + + let top_seatbid = seatbids + .iter() + .find(|seatbid| seatbid["bid"][0]["impid"].as_str() == Some("div-gpt-top")) + .expect("should include top slot seatbid"); + assert_eq!( + top_seatbid["seat"], + json!("appnexus"), + "should preserve top bidder as seat" + ); + let top_bid = &top_seatbid["bid"][0]; + assert_eq!( + top_bid["id"], + json!("appnexus-div-gpt-top"), + "should preserve top bid ID" + ); + assert_eq!( + top_bid["impid"], + json!("div-gpt-top"), + "should preserve top slot impid" + ); + assert_eq!(top_bid["price"], json!(2.75), "should preserve top price"); + assert_eq!( + top_bid["adm"], + json!("
Ad
"), + "should preserve top creative" + ); + + let sidebar_seatbid = seatbids + .iter() + .find(|seatbid| seatbid["bid"][0]["impid"].as_str() == Some("div-gpt-sidebar")) + .expect("should include sidebar slot seatbid"); + assert_eq!( + sidebar_seatbid["seat"], + json!("rubicon"), + "should preserve sidebar bidder as seat" + ); + let sidebar_bid = &sidebar_seatbid["bid"][0]; + assert_eq!( + sidebar_bid["id"], + json!("rubicon-div-gpt-sidebar"), + "should preserve sidebar bid ID" + ); + assert_eq!( + sidebar_bid["impid"], + json!("div-gpt-sidebar"), + "should preserve sidebar slot impid" + ); + assert_eq!( + sidebar_bid["price"], + json!(1.25), + "should preserve sidebar price" + ); + assert_eq!( + sidebar_bid["adm"], + json!("
Sidebar
"), + "should preserve sidebar creative" + ); + assert_eq!( + json["ext"]["orchestrator"]["total_bids"], + json!(2), + "should count both provider bids" + ); + } + + #[test] + fn convert_to_openrtb_response_uses_parallel_mediation_when_mediator_configured() { + let mut settings = create_test_settings(); + settings.auction.mediator = Some("adserver_mock".to_string()); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", Some(2.75))); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert auction result to OpenRTB response"); + let json = response_json(response); + + assert_eq!( + json["ext"]["orchestrator"]["strategy"], + json!("parallel_mediation"), + "should use mediation strategy when mediator is configured" + ); + } + + #[test] + fn convert_to_openrtb_response_errors_when_winning_bid_has_no_price() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_bid("div-gpt-top", "appnexus", None)); + + let err = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect_err("should reject winning bid without decoded price"); + + assert!( + format!("{err:?}").contains("has no decoded price"), + "should explain missing decoded price" + ); + } + + #[test] + fn convert_to_openrtb_response_omits_out_of_range_dimensions() { + let settings = create_test_settings(); + let auction_request = make_auction_request(); + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.width = u32::MAX; + bid.height = u32::MAX; + let result = make_result(bid); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request) + .expect("should convert bid with out-of-range OpenRTB dimensions"); + let json = response_json(response); + let bid = &json["seatbid"][0]["bid"][0]; + + assert!(bid.get("w").is_none(), "should omit out-of-range width"); + assert!(bid.get("h").is_none(), "should omit out-of-range height"); + } +} diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 2720e88b..11c1b3e1 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -95,8 +95,9 @@ pub trait IntoHttpResponse { /// Get a safe, user-facing error message. /// - /// Client errors (4xx) return a brief description; server/integration errors - /// return a generic message. Full error details are preserved in server logs. + /// Selected client errors return a brief description; forbidden and + /// server/integration errors return a generic message. Full error details + /// are preserved in server logs. fn user_message(&self) -> String; } @@ -122,7 +123,7 @@ impl IntoHttpResponse for TrustedServerError { fn user_message(&self) -> String { match self { - // Client errors (4xx) — safe to surface a brief description + // Selected client errors with safe details to surface. Self::BadRequest { message } => format!("Bad request: {message}"), // Consent strings may contain user data; return category only. Self::GdprConsent { .. } => "GDPR consent error".to_string(), @@ -183,6 +184,26 @@ mod tests { } } + #[test] + fn forbidden_errors_return_generic_user_message() { + let cases = [ + TrustedServerError::Forbidden { + message: "policy detail".into(), + }, + TrustedServerError::AllowlistViolation { + host: "blocked.example.com".into(), + }, + ]; + + for error in &cases { + assert_eq!( + error.user_message(), + "An internal error occurred", + "should not leak forbidden details for {error:?}", + ); + } + } + #[test] fn client_errors_return_safe_descriptions() { let error = TrustedServerError::BadRequest { @@ -200,4 +221,130 @@ mod tests { }; assert_eq!(error.user_message(), "Invalid header value"); } + + #[test] + fn status_code_maps_each_error_variant_to_expected_http_response() { + // Compile-time guard: adding a TrustedServerError variant without + // updating this test will fail to compile. + let _guard: fn(&TrustedServerError) = |error| match error { + TrustedServerError::BadRequest { .. } + | TrustedServerError::Configuration { .. } + | TrustedServerError::Auction { .. } + | TrustedServerError::Gam { .. } + | TrustedServerError::GdprConsent { .. } + | TrustedServerError::InvalidUtf8 { .. } + | TrustedServerError::InvalidHeaderValue { .. } + | TrustedServerError::KvStore { .. } + | TrustedServerError::Prebid { .. } + | TrustedServerError::Integration { .. } + | TrustedServerError::Proxy { .. } + | TrustedServerError::Forbidden { .. } + | TrustedServerError::AllowlistViolation { .. } + | TrustedServerError::Settings { .. } + | TrustedServerError::Ec { .. } => (), + }; + + let cases = [ + ( + TrustedServerError::BadRequest { + message: "bad input".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + TrustedServerError::GdprConsent { + message: "missing consent".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + TrustedServerError::InvalidHeaderValue { + message: "invalid header".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + TrustedServerError::Forbidden { + message: "not allowed".to_string(), + }, + StatusCode::FORBIDDEN, + ), + ( + TrustedServerError::AllowlistViolation { + host: "evil.example".to_string(), + }, + StatusCode::FORBIDDEN, + ), + ( + TrustedServerError::Configuration { + message: "config failed".to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + TrustedServerError::Settings { + message: "settings failed".to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + TrustedServerError::InvalidUtf8 { + message: "invalid utf-8".to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + TrustedServerError::Ec { + message: "ec failed".to_string(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + TrustedServerError::KvStore { + store_name: "store".to_string(), + message: "kv failed".to_string(), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + TrustedServerError::Auction { + message: "auction failed".to_string(), + }, + StatusCode::BAD_GATEWAY, + ), + ( + TrustedServerError::Gam { + message: "gam failed".to_string(), + }, + StatusCode::BAD_GATEWAY, + ), + ( + TrustedServerError::Prebid { + message: "prebid failed".to_string(), + }, + StatusCode::BAD_GATEWAY, + ), + ( + TrustedServerError::Integration { + integration: "test".to_string(), + message: "integration failed".to_string(), + }, + StatusCode::BAD_GATEWAY, + ), + ( + TrustedServerError::Proxy { + message: "proxy failed".to_string(), + }, + StatusCode::BAD_GATEWAY, + ), + ]; + + for (error, expected) in cases { + assert_eq!( + error.status_code(), + expected, + "should map {error:?} to expected HTTP status" + ); + } + } } diff --git a/crates/trusted-server-core/src/host_rewrite.rs b/crates/trusted-server-core/src/host_rewrite.rs index cb15f407..9199e03b 100644 --- a/crates/trusted-server-core/src/host_rewrite.rs +++ b/crates/trusted-server-core/src/host_rewrite.rs @@ -47,3 +47,135 @@ pub(crate) fn rewrite_bare_host_at_boundaries( out.push_str(&text[search..]); Some(out) } + +#[cfg(test)] +mod tests { + use super::*; + + const ORIGIN_HOST: &str = "origin.example.com"; + const REQUEST_HOST: &str = "proxy.example.com"; + + fn assert_rewrite(input: &str, expected: &str) { + assert_eq!( + rewrite_bare_host_at_boundaries(input, ORIGIN_HOST, REQUEST_HOST), + Some(expected.to_string()), + "should rewrite bare host at valid boundaries" + ); + } + + fn assert_no_rewrite(input: &str, message: &str) { + assert_eq!( + rewrite_bare_host_at_boundaries(input, ORIGIN_HOST, REQUEST_HOST), + None, + "{message}" + ); + } + + #[test] + fn returns_none_when_origin_or_request_host_is_empty() { + assert_eq!( + rewrite_bare_host_at_boundaries("origin.example.com", "", REQUEST_HOST), + None, + "should ignore empty origin host" + ); + assert_eq!( + rewrite_bare_host_at_boundaries("origin.example.com", ORIGIN_HOST, ""), + None, + "should ignore empty request host" + ); + } + + #[test] + fn returns_none_when_origin_host_is_absent() { + assert_no_rewrite( + "https://other.example.com/news", + "should return none when origin host is absent", + ); + } + + #[test] + fn rewrites_exact_bare_host() { + assert_rewrite("origin.example.com", "proxy.example.com"); + } + + #[test] + fn rewrites_bare_host_with_path_query_and_fragment() { + assert_rewrite( + "origin.example.com/news?x=1#top", + "proxy.example.com/news?x=1#top", + ); + } + + #[test] + fn rewrites_bare_host_as_path_segment() { + assert_rewrite( + "https://cdn.example.com/assets/origin.example.com/image.png", + "https://cdn.example.com/assets/proxy.example.com/image.png", + ); + } + + #[test] + fn rewrites_multiple_valid_occurrences() { + assert_rewrite( + "origin.example.com/a and origin.example.com/b", + "proxy.example.com/a and proxy.example.com/b", + ); + } + + #[test] + fn rewrites_hosts_surrounded_by_punctuation_and_whitespace() { + assert_rewrite( + r#"{"host":"origin.example.com", "next": (origin.example.com) }"#, + r#"{"host":"proxy.example.com", "next": (proxy.example.com) }"#, + ); + } + + #[test] + fn does_not_rewrite_subdomains_or_embedded_prefixes() { + assert_no_rewrite( + "cdn.origin.example.com", + "should not rewrite host embedded in a subdomain", + ); + assert_no_rewrite( + "notorigin.example.com", + "should not rewrite host embedded in a larger host token", + ); + assert_no_rewrite( + "foo-origin.example.com", + "should not rewrite host preceded by host-character punctuation", + ); + } + + #[test] + fn does_not_rewrite_suffix_domains_or_host_char_continuations() { + assert_no_rewrite( + "origin.example.com.uk", + "should not rewrite host followed by a domain suffix", + ); + assert_no_rewrite( + "origin.example.com-prod", + "should not rewrite host followed by host-character punctuation", + ); + } + + #[test] + fn rewrites_origin_host_with_port_when_origin_includes_port() { + assert_eq!( + rewrite_bare_host_at_boundaries( + "origin.example.com:8443/path", + "origin.example.com:8443", + REQUEST_HOST, + ), + Some("proxy.example.com/path".to_string()), + "should rewrite host and port when origin host includes the port" + ); + } + + #[test] + fn does_not_rewrite_host_with_port_when_origin_omits_port() { + assert_no_rewrite( + "origin.example.com:8443/path", + "should not rewrite host with port when origin omits port", + ); + } +} diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 4921a9bb..d3f2f22c 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -64,3 +64,159 @@ pub fn tsjs_deferred_script_tags(module_ids: &[&str]) -> String { .map(|id| tsjs_deferred_script_tag(id)) .collect::() } + +#[cfg(test)] +mod tests { + use super::*; + + fn hash_query_value(src: &str) -> &str { + src.split_once("?v=") + .map(|(_, hash)| hash) + .expect("should contain cache-busting hash query") + } + + fn assert_sha256_hex_hash(value: &str) { + assert_eq!(value.len(), 64, "should be a SHA-256 hex digest"); + assert!( + value.chars().all(|ch| ch.is_ascii_hexdigit()), + "should contain only ASCII hex digits" + ); + } + + #[test] + fn tsjs_script_src_formats_unified_bundle_url_with_hash() { + let src = tsjs_script_src(&["creative"]); + + assert!( + src.starts_with("/static/tsjs=tsjs-unified.min.js?v="), + "should use unified static bundle path" + ); + assert_sha256_hex_hash(hash_query_value(&src)); + } + + #[test] + fn tsjs_script_src_empty_module_list_matches_core_only_bundle() { + let empty_src = tsjs_script_src(&[]); + + assert!( + empty_src.starts_with("/static/tsjs=tsjs-unified.min.js?v="), + "should use unified static bundle path" + ); + assert_sha256_hex_hash(hash_query_value(&empty_src)); + assert_eq!( + empty_src, + tsjs_script_src(&["core"]), + "should include core exactly once for an empty module list" + ); + } + + #[test] + fn tsjs_script_src_hash_changes_with_module_set() { + let creative_src = tsjs_script_src(&["creative"]); + let creative_prebid_src = tsjs_script_src(&["creative", "prebid"]); + + assert_ne!( + creative_src, creative_prebid_src, + "should include requested modules in cache-busting hash" + ); + } + + #[test] + fn tsjs_script_src_hash_depends_on_module_order() { + assert_ne!( + tsjs_script_src(&["creative", "prebid"]), + tsjs_script_src(&["prebid", "creative"]), + "should include module order in cache-busting hash" + ); + } + + #[test] + fn tsjs_script_src_deduplicates_core_module() { + assert_eq!( + tsjs_script_src(&["core", "prebid"]), + tsjs_script_src(&["prebid"]), + "should not hash core twice when requested explicitly" + ); + } + + #[test] + fn tsjs_script_tag_wraps_source_in_single_trustedserver_tag() { + let module_ids = ["creative"]; + let src = tsjs_script_src(&module_ids); + + assert_eq!( + tsjs_script_tag(&module_ids), + format!(""), + "should generate exactly one trusted server script tag" + ); + } + + #[test] + fn tsjs_unified_helpers_use_all_module_ids() { + let ids = all_module_ids(); + + assert_eq!( + tsjs_unified_script_src(), + tsjs_script_src(&ids), + "should hash all module IDs for the unified script source" + ); + assert_eq!( + tsjs_unified_script_tag(), + tsjs_script_tag(&ids), + "should wrap the all-module unified script source" + ); + } + + #[test] + fn tsjs_deferred_script_src_formats_known_module_url_with_hash() { + let src = tsjs_deferred_script_src("prebid"); + + assert!( + src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), + "should use per-module static bundle path" + ); + assert_sha256_hex_hash(hash_query_value(&src)); + } + + #[test] + fn tsjs_deferred_script_src_uses_empty_hash_for_unknown_module() { + assert_eq!( + tsjs_deferred_script_src("unknown-module"), + "/static/tsjs=tsjs-unknown-module.min.js?v=", + "should document current unknown-module hash behavior" + ); + } + + #[test] + fn tsjs_deferred_script_tag_marks_script_defer() { + let src = tsjs_deferred_script_src("prebid"); + + assert_eq!( + tsjs_deferred_script_tag("prebid"), + format!(""), + "should generate a deferred script tag" + ); + } + + #[test] + fn tsjs_deferred_script_tags_returns_empty_for_empty_input() { + assert_eq!( + tsjs_deferred_script_tags(&[]), + "", + "should not emit tags when no deferred modules exist" + ); + } + + #[test] + fn tsjs_deferred_script_tags_preserves_input_order() { + assert_eq!( + tsjs_deferred_script_tags(&["prebid", "creative"]), + format!( + "{}{}", + tsjs_deferred_script_tag("prebid"), + tsjs_deferred_script_tag("creative") + ), + "should preserve caller-provided deferred module order" + ); + } +}