From c5e50f9881144e9a6495b5b9ab37e8e91a687411 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 13 May 2026 13:35:49 -0500 Subject: [PATCH 1/3] Add auction endpoint request coverage --- .../src/route_tests.rs | 204 +++++++++++++++++- .../src/auction/endpoints.rs | 2 +- 2 files changed, 203 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f..fce1a4ea 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -3,9 +3,11 @@ use std::sync::Arc; use edgezero_core::key_value_store::NoopKvStore; use error_stack::Report; -use fastly::http::StatusCode; +use fastly::http::{header, StatusCode}; use fastly::Request; -use trusted_server_core::auction::build_orchestrator; +use serde_json::json; +use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH}; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -162,6 +164,46 @@ fn create_test_settings() -> Settings { settings } +fn create_auction_test_settings_without_consent_store(providers: &str) -> Settings { + let config = format!( + r#" + [[handlers]] + path = "^/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [edge_cookie] + secret_key = "test-secret-key" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [auction] + enabled = true + providers = {providers} + timeout_ms = 2000 + "#, + ); + + Settings::from_toml(&config).expect("should parse adapter auction route test settings") +} + +fn build_route_stack(settings: &Settings) -> (AuctionOrchestrator, IntegrationRegistry) { + let orchestrator = build_orchestrator(settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(settings).expect("should create integration registry"); + + (orchestrator, integration_registry) +} + fn test_runtime_services(req: &Request) -> RuntimeServices { RuntimeServices::builder() .config_store(Arc::new(StubJwksConfigStore)) @@ -178,6 +220,45 @@ fn test_runtime_services(req: &Request) -> RuntimeServices { .build() } +fn route_auction(settings: &Settings, body: impl Into>) -> fastly::Response { + let (orchestrator, integration_registry) = build_route_stack(settings); + let req = Request::post("https://test.com/auction") + .with_header(header::CONTENT_TYPE, "application/json") + .with_body(body.into()); + let services = test_runtime_services(&req); + + futures::executor::block_on(route_request( + settings, + &orchestrator, + &integration_registry, + &services, + req, + )) + .expect("should route auction request") +} + +fn valid_banner_ad_unit_body() -> Vec { + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { + "banner": { + "sizes": [[300, 250]] + } + }, + "bids": [ + { + "bidder": "missing-provider", + "params": {} + } + ] + } + ] + })) + .expect("should serialize valid auction route test body") +} + #[test] fn configured_missing_consent_store_only_breaks_consent_routes() { let settings = create_test_settings(); @@ -249,3 +330,122 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { "should scope consent store failures to the consent-dependent routes" ); } + +#[test] +fn malformed_auction_json_returns_bad_request() { + let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); + + let mut response = route_auction(&settings, "{not-json"); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject malformed JSON as a client request error" + ); + assert!( + response.take_body_str().contains("Bad request"), + "should return a client-facing bad request message" + ); +} + +#[test] +fn invalid_auction_banner_size_returns_bad_request() { + let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); + let body = serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { + "banner": { + "sizes": [[300]] + } + } + } + ] + })) + .expect("should serialize invalid auction route test body"); + + let response = route_auction(&settings, body); + + assert_eq!( + response.get_status(), + StatusCode::BAD_REQUEST, + "should reject semantically invalid banner sizes as a client request error" + ); +} + +#[test] +fn valid_auction_request_with_no_providers_returns_bad_gateway() { + let settings = create_auction_test_settings_without_consent_store("[]"); + + let response = route_auction(&settings, valid_banner_ad_unit_body()); + + assert_eq!( + response.get_status(), + StatusCode::BAD_GATEWAY, + "should surface no-provider orchestration failures as gateway errors" + ); +} + +#[test] +fn valid_auction_request_with_unregistered_provider_returns_success_empty_openrtb_response() { + let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); + + let mut response = route_auction(&settings, valid_banner_ad_unit_body()); + + assert_eq!( + response.get_status(), + StatusCode::OK, + "should produce a successful empty OpenRTB response when configured providers are skipped" + ); + assert_eq!( + response.get_header_str(header::CONTENT_TYPE), + Some("application/json"), + "should return JSON for successful auction responses" + ); + assert!( + response.get_header_str(HEADER_X_TS_EC).is_some(), + "should include the auction EC identifier header" + ); + assert!( + response.get_header_str(HEADER_X_TS_EC_FRESH).is_some(), + "should include the fresh EC identifier header" + ); + + let body: serde_json::Value = serde_json::from_str(&response.take_body_str()) + .expect("should parse successful auction response JSON"); + assert!( + body.get("id").and_then(serde_json::Value::as_str).is_some(), + "should include an OpenRTB response id" + ); + assert!( + body.get("seatbid") + .and_then(serde_json::Value::as_array) + .is_none_or(Vec::is_empty), + "should not include bid entries when there are no bids" + ); + assert!( + body.pointer("/ext/orchestrator/strategy") + .and_then(serde_json::Value::as_str) + .is_some(), + "should include orchestrator strategy metadata" + ); + assert_eq!( + body.pointer("/ext/orchestrator/providers") + .and_then(serde_json::Value::as_u64), + Some(0), + "should report no provider responses" + ); + assert_eq!( + body.pointer("/ext/orchestrator/total_bids") + .and_then(serde_json::Value::as_u64), + Some(0), + "should report no bids" + ); + assert!( + body.pointer("/ext/orchestrator/time_ms") + .and_then(serde_json::Value::as_u64) + .is_some(), + "should include orchestration timing metadata" + ); +} diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 0430f08b..a34b2d5c 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -37,7 +37,7 @@ pub async fn handle_auction( ) -> Result> { // Parse request body let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context( - TrustedServerError::Auction { + TrustedServerError::BadRequest { message: "Failed to parse auction request body".to_string(), }, )?; From 680579cfcf85ee231507b62488bceefee2f35e46 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 13 May 2026 14:06:54 -0500 Subject: [PATCH 2/3] Fail auction when no providers launch --- .../src/route_tests.rs | 59 ++----------------- .../src/auction/orchestrator.rs | 6 ++ 2 files changed, 10 insertions(+), 55 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index fce1a4ea..2be7bc98 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -7,7 +7,6 @@ use fastly::http::{header, StatusCode}; use fastly::Request; use serde_json::json; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; -use trusted_server_core::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH}; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -388,64 +387,14 @@ fn valid_auction_request_with_no_providers_returns_bad_gateway() { } #[test] -fn valid_auction_request_with_unregistered_provider_returns_success_empty_openrtb_response() { +fn valid_auction_request_with_unregistered_provider_returns_bad_gateway() { let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); - let mut response = route_auction(&settings, valid_banner_ad_unit_body()); + let response = route_auction(&settings, valid_banner_ad_unit_body()); assert_eq!( response.get_status(), - StatusCode::OK, - "should produce a successful empty OpenRTB response when configured providers are skipped" - ); - assert_eq!( - response.get_header_str(header::CONTENT_TYPE), - Some("application/json"), - "should return JSON for successful auction responses" - ); - assert!( - response.get_header_str(HEADER_X_TS_EC).is_some(), - "should include the auction EC identifier header" - ); - assert!( - response.get_header_str(HEADER_X_TS_EC_FRESH).is_some(), - "should include the fresh EC identifier header" - ); - - let body: serde_json::Value = serde_json::from_str(&response.take_body_str()) - .expect("should parse successful auction response JSON"); - assert!( - body.get("id").and_then(serde_json::Value::as_str).is_some(), - "should include an OpenRTB response id" - ); - assert!( - body.get("seatbid") - .and_then(serde_json::Value::as_array) - .is_none_or(Vec::is_empty), - "should not include bid entries when there are no bids" - ); - assert!( - body.pointer("/ext/orchestrator/strategy") - .and_then(serde_json::Value::as_str) - .is_some(), - "should include orchestrator strategy metadata" - ); - assert_eq!( - body.pointer("/ext/orchestrator/providers") - .and_then(serde_json::Value::as_u64), - Some(0), - "should report no provider responses" - ); - assert_eq!( - body.pointer("/ext/orchestrator/total_bids") - .and_then(serde_json::Value::as_u64), - Some(0), - "should report no bids" - ); - assert!( - body.pointer("/ext/orchestrator/time_ms") - .and_then(serde_json::Value::as_u64) - .is_some(), - "should include orchestration timing metadata" + StatusCode::BAD_GATEWAY, + "should fail when configured providers cannot be launched" ); } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9cbcd2b9..e43c928f 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -362,6 +362,12 @@ impl AuctionOrchestrator { } } + if pending_requests.is_empty() { + return Err(Report::new(TrustedServerError::Auction { + message: "No provider requests launched".to_string(), + })); + } + let deadline = Duration::from_millis(u64::from(context.timeout_ms)); log::info!( "Launched {} concurrent requests, waiting for responses (timeout: {}ms)...", From fef75fc69739806f5744684823f5039ac94ef554 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 28 May 2026 11:54:42 -0500 Subject: [PATCH 3/3] Validate configured auction providers at startup --- .../src/route_tests.rs | 70 +++++------- crates/trusted-server-core/src/auction/mod.rs | 79 +++++++++++++ .../src/auction/orchestrator.rs | 105 +++++++++++++++++- 3 files changed, 209 insertions(+), 45 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 2be7bc98..6a3e847d 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -118,9 +118,8 @@ impl PlatformGeo for NoopGeo { } } -fn create_test_settings() -> Settings { - let settings = Settings::from_toml( - r#" +fn base_route_settings_toml() -> &'static str { + r#" [[handlers]] path = "^/admin" username = "admin" @@ -139,21 +138,35 @@ fn create_test_settings() -> Settings { enabled = false config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" + "# +} - [consent] - consent_store = "missing-consent-store" - +fn prebid_integration_toml() -> &'static str { + r#" [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" + "# +} + +fn create_test_settings() -> Settings { + let base = base_route_settings_toml(); + let prebid = prebid_integration_toml(); + let config = format!( + r#"{base} + + [consent] + consent_store = "missing-consent-store" + +{prebid} [auction] enabled = true providers = ["prebid"] timeout_ms = 2000 "#, - ) - .expect("should parse adapter route test settings"); + ); + let settings = Settings::from_toml(&config).expect("should parse adapter route test settings"); assert_eq!( JWKS_CONFIG_STORE_NAME, "jwks_store", @@ -164,26 +177,12 @@ fn create_test_settings() -> Settings { } fn create_auction_test_settings_without_consent_store(providers: &str) -> Settings { + let base = base_route_settings_toml(); + let prebid = prebid_integration_toml(); let config = format!( - r#" - [[handlers]] - path = "^/admin" - username = "admin" - password = "admin-pass" + r#"{base} - [publisher] - domain = "test-publisher.com" - cookie_domain = ".test-publisher.com" - origin_url = "https://origin.test-publisher.com" - proxy_secret = "unit-test-proxy-secret" - - [edge_cookie] - secret_key = "test-secret-key" - - [request_signing] - enabled = false - config_store_id = "test-config-store-id" - secret_store_id = "test-secret-store-id" +{prebid} [auction] enabled = true @@ -332,7 +331,7 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { #[test] fn malformed_auction_json_returns_bad_request() { - let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); + let settings = create_auction_test_settings_without_consent_store(r#"["prebid"]"#); let mut response = route_auction(&settings, "{not-json"); @@ -349,7 +348,7 @@ fn malformed_auction_json_returns_bad_request() { #[test] fn invalid_auction_banner_size_returns_bad_request() { - let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); + let settings = create_auction_test_settings_without_consent_store(r#"["prebid"]"#); let body = serde_json::to_vec(&json!({ "adUnits": [ { @@ -374,7 +373,7 @@ fn invalid_auction_banner_size_returns_bad_request() { } #[test] -fn valid_auction_request_with_no_providers_returns_bad_gateway() { +fn auction_request_with_empty_provider_list_returns_bad_gateway() { let settings = create_auction_test_settings_without_consent_store("[]"); let response = route_auction(&settings, valid_banner_ad_unit_body()); @@ -385,16 +384,3 @@ fn valid_auction_request_with_no_providers_returns_bad_gateway() { "should surface no-provider orchestration failures as gateway errors" ); } - -#[test] -fn valid_auction_request_with_unregistered_provider_returns_bad_gateway() { - let settings = create_auction_test_settings_without_consent_store(r#"["missing-provider"]"#); - - let response = route_auction(&settings, valid_banner_ad_unit_body()); - - assert_eq!( - response.get_status(), - StatusCode::BAD_GATEWAY, - "should fail when configured providers cannot be launched" - ); -} diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index acf6d520..8de90000 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -73,6 +73,8 @@ pub fn build_orchestrator( } } + orchestrator.validate_configured_provider_names()?; + log::info!( "Auction orchestrator built with {} providers", orchestrator.provider_count() @@ -80,3 +82,80 @@ pub fn build_orchestrator( Ok(orchestrator) } + +#[cfg(test)] +mod tests { + use crate::settings::Settings; + use crate::test_support::tests::crate_test_settings_str; + + use super::build_orchestrator; + + fn settings_with_auction_config(auction_config: &str) -> Settings { + let settings_str = format!("{}\n{auction_config}", crate_test_settings_str()); + Settings::from_toml(&settings_str) + .expect("should parse auction provider validation test settings") + } + + fn assert_orchestrator_error_contains(settings: &Settings, expected: &str) { + let err = match build_orchestrator(settings) { + Ok(_) => panic!("build_orchestrator should reject invalid auction providers"), + Err(err) => err, + }; + assert!( + err.to_string().contains(expected), + "should include expected validation message: {expected}" + ); + } + + #[test] + fn configured_unregistered_provider_fails_startup() { + let settings = settings_with_auction_config( + r#" + [auction] + enabled = true + providers = ["missing-provider"] + timeout_ms = 2000 + "#, + ); + + assert_orchestrator_error_contains( + &settings, + "Auction provider `missing-provider` is configured but not registered", + ); + } + + #[test] + fn mixed_registered_and_unregistered_providers_fail_startup() { + let settings = settings_with_auction_config( + r#" + [auction] + enabled = true + providers = ["prebid", "missing-provider"] + timeout_ms = 2000 + "#, + ); + + assert_orchestrator_error_contains( + &settings, + "Auction provider `missing-provider` is configured but not registered", + ); + } + + #[test] + fn configured_unregistered_mediator_fails_startup() { + let settings = settings_with_auction_config( + r#" + [auction] + enabled = true + providers = ["prebid"] + mediator = "missing-mediator" + timeout_ms = 2000 + "#, + ); + + assert_orchestrator_error_contains( + &settings, + "Auction provider `missing-mediator` is configured but not registered", + ); + } +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index e43c928f..1313ab60 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -52,6 +52,32 @@ impl AuctionOrchestrator { self.providers.len() } + /// Validate that every configured provider name has a registered provider. + pub(crate) fn validate_configured_provider_names( + &self, + ) -> Result<(), Report> { + if !self.config.enabled { + return Ok(()); + } + + for provider_name in self + .config + .providers + .iter() + .chain(self.config.mediator.iter()) + { + if !self.providers.contains_key(provider_name) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Auction provider `{provider_name}` is configured but not registered" + ), + })); + } + } + + Ok(()) + } + /// Execute an auction using the auto-detected strategy. /// /// Strategy is determined by mediator configuration: @@ -364,7 +390,10 @@ impl AuctionOrchestrator { if pending_requests.is_empty() { return Err(Report::new(TrustedServerError::Auction { - message: "No provider requests launched".to_string(), + message: format!( + "All {} configured provider(s) skipped or failed to launch", + provider_names.len() + ), })); } @@ -639,10 +668,13 @@ impl OrchestrationResult { #[cfg(test)] mod tests { use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionRequest, Bid, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType, + PublisherInfo, UserInfo, }; + use crate::error::TrustedServerError; // All-None ClientInfo used across tests that don't need real IP/TLS data. // Defined as a const so &EMPTY_CLIENT_INFO has 'static lifetime, avoiding @@ -654,8 +686,11 @@ mod tests { }; use crate::platform::test_support::noop_services; use crate::test_support::tests::crate_test_settings_str; - use fastly::Request; + use error_stack::Report; + use fastly::http::request::PendingRequest; + use fastly::{Request, Response}; use std::collections::{HashMap, HashSet}; + use std::sync::Arc; use super::AuctionOrchestrator; @@ -706,6 +741,42 @@ mod tests { crate::settings::Settings::from_toml(&settings_str).expect("should parse test settings") } + struct LaunchFailingProvider; + + impl AuctionProvider for LaunchFailingProvider { + fn provider_name(&self) -> &'static str { + "launch-failing" + } + + fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + Err(Report::new(TrustedServerError::Auction { + message: "launch failed in test provider".to_string(), + })) + } + + fn parse_response( + &self, + _response: Response, + _response_time_ms: u64, + ) -> Result> { + Err(Report::new(TrustedServerError::Auction { + message: "launch-failing provider should not parse responses".to_string(), + })) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some("launch-failing-backend".to_string()) + } + } + #[test] fn filters_winning_bids_below_floor() { let orchestrator = AuctionOrchestrator::new(AuctionConfig::default()); @@ -804,6 +875,34 @@ mod tests { assert!(format!("{}", err).contains("No providers configured")); } + #[tokio::test] + async fn provider_launch_failures_error_when_no_requests_launch() { + let config = AuctionConfig { + enabled: true, + providers: vec!["launch-failing".to_string()], + timeout_ms: 2000, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(LaunchFailingProvider)); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = Request::get("https://test.com/test"); + let context = create_test_auction_context(&settings, &req, &EMPTY_CLIENT_INFO, 2000); + + let result = orchestrator + .run_auction(&request, &context, &noop_services()) + .await; + + let err = result.expect_err("should fail when every provider launch fails"); + assert!( + err.to_string() + .contains("All 1 configured provider(s) skipped or failed to launch"), + "should explain that no configured provider request launched" + ); + } + #[test] fn test_orchestrator_is_enabled() { let config = AuctionConfig {