From ef619539659d9126c11d6f0b22892af91c9b1163 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 23:20:24 +0530 Subject: [PATCH 1/3] Suppress fabricated empty Prebid bidder params A configured bidder with no inline params and no matching override expanded to `"bidder": {}`, which PBS rejects. After applying overrides, drop fabricated empty bidders, preserve an explicitly supplied empty object so genuine misconfiguration stays visible, and fall back to the stored-request path when no eligible bidders remain. --- .../src/integrations/prebid.rs | 179 ++++++++++++++++-- 1 file changed, 163 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..68d718a1 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -1424,10 +1424,22 @@ impl PrebidAuctionProvider { // Only pass through keys that are known PBS bidders — skip provider-specific // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); + // Bidders the publisher explicitly supplied — including an + // explicit empty `{}`. A configured bidder that exists only + // because `expand_trusted_server_bidders` fabricated an empty + // params object is NOT explicit and must not ship as + // `"bidder": {}` (which PBS rejects). + let mut explicit_bidders: HashSet = HashSet::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { + if let Some(per_bidder) = + params.get(BIDDER_PARAMS_KEY).and_then(Json::as_object) + { + explicit_bidders.extend(per_bidder.keys().cloned()); + } bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); } else if self.config.bidders.iter().any(|b| b == name) { + explicit_bidders.insert(name.clone()); bidder.insert(name.clone(), params.clone()); } else if name != "aps" { // `aps` is intentionally handled by its own provider. Any @@ -1443,16 +1455,32 @@ impl PrebidAuctionProvider { } } - // When no inline PBS bidder params exist (e.g. creative-opportunity slots - // whose PBS params live in stored requests), tell PBS to resolve bidder - // config from the stored request keyed by this slot ID. + // Apply canonical and compatibility-derived rules in normalized + // order. An override rule can populate a bidder that arrived with + // empty params, promoting a fabricated empty into a valid bidder. + for (name, params) in &mut bidder { + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); + } + + // Drop bidders that are still an empty object after overrides and + // were not explicitly supplied. Shipping `"bidder": {}` makes PBS + // reject the imp; an explicit empty object is preserved so genuine + // publisher misconfiguration stays visible. + bidder.retain(|name, params| { + let is_empty_object = params.as_object().is_some_and(serde_json::Map::is_empty); + !is_empty_object || explicit_bidders.contains(name) + }); + + // When no eligible PBS bidder params remain (e.g. creative-opportunity + // slots whose PBS params live in stored requests, or a slot whose + // configured bidders all resolved to fabricated empties), tell PBS to + // resolve bidder config from the stored request keyed by this slot ID. // - // This cannot fire for the client /auction path: the JS adapter - // injects a `trustedServer` entry into every ad unit, so `bidder` - // is only empty for server-side creative-opportunity slots with - // no inline provider params (or when `config.bidders` is empty, - // where PBS previously received an empty bidder map and returned - // no bids — a stored-request miss is the same no-bid outcome). + // This cannot fire for a client /auction slot that carries real + // inline params: the JS adapter injects a `trustedServer` entry, and + // any bidder with params survives the drop above. It falls back only + // when nothing eligible remains — the same no-bid outcome as before. let storedrequest = if bidder.is_empty() { Some(ImpStoredRequest { id: slot.id.clone(), @@ -1461,12 +1489,6 @@ impl PrebidAuctionProvider { None }; - // Apply canonical and compatibility-derived rules in normalized order. - for (name, params) in &mut bidder { - self.bid_param_override_engine - .apply(BidParamOverrideFacts { bidder: name, zone }, params); - } - Some(Imp { id: Some(slot.id.clone()), banner: Some(Banner { @@ -5950,6 +5972,131 @@ set = { placementId = "explicit_header" } ); } + #[test] + fn to_openrtb_drops_fabricated_empty_bidder_params() { + // config.bidders lists three, but the slot supplies inline params only + // for kargo. Without a matching override, triplelift and criteo would + // expand to empty `{}` objects — invalid bidder entries PBS rejects. + // They must be dropped; the valid kargo bidder must still ship. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift", "criteo"] +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "kn1" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "kn1", + "should keep the valid inline bidder" + ); + assert!( + !params.contains_key("triplelift"), + "should drop a fabricated empty bidder with no inline params or override" + ); + assert!( + !params.contains_key("criteo"), + "should drop a fabricated empty bidder with no inline params or override" + ); + } + + #[test] + fn to_openrtb_preserves_an_explicitly_empty_bidder() { + // A publisher-supplied empty `{}` is a real (if misconfigured) signal and + // must survive so the misconfiguration stays visible — unlike a fabricated + // empty, which is dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({ "kargo": {} }), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"], + json!({}), + "should preserve an explicitly supplied empty bidder object" + ); + } + + #[test] + fn to_openrtb_keeps_a_fabricated_bidder_that_an_override_populates() { + // criteo has no inline params (fabricated empty), but an override rule + // fills it — so it is valid and must ship, not be dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["criteo"] + +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["criteo"]["networkId"], 99999, + "override should populate the fabricated empty bidder, keeping it" + ); + } + + #[test] + fn to_openrtb_falls_back_to_stored_request_when_all_bidders_are_fabricated_empty() { + // config.bidders present, but the slot supplies no inline params and no + // override matches — every configured bidder resolves to a fabricated + // empty and is dropped, leaving PBS to resolve via the stored request. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should drop all fabricated empty bidders" + ); + assert_eq!( + prebid["storedrequest"]["id"], "ad-header-0", + "should fall back to stored request when no eligible bidders remain" + ); + } + #[test] fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { let slot = make_slot( From bfea103d9fd5136423b573aba4eb478370174513 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 16 Jul 2026 12:45:06 +0530 Subject: [PATCH 2/3] Prefer direct bidder params over fabricated-empty in mixed slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slot can carry both a direct bidder entry with valid inline params and a trustedServer entry whose bidderParams omits that bidder. Because slot.bidders is a HashMap, the trustedServer expansion (which fabricates an empty {} for the omitted bidder) could run after the direct entry and overwrite its valid params via extend. The direct entry already marked the bidder explicit, so the empty object survived the retention drop and PBS rejected the impression — the failure this path is meant to eliminate — nondeterministically, depending on iteration order. Replace the extend with an entry().or_insert() loop so trustedServer-expanded bidders only fill in absent entries and direct params win regardless of order. Add a looped regression test exercising a slot with both representations. --- .../src/integrations/prebid.rs | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 68d718a1..3eb7cfb7 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1437,7 +1437,18 @@ impl PrebidAuctionProvider { { explicit_bidders.extend(per_bidder.keys().cloned()); } - bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); + // Fill in trusted-server-expanded bidders without + // clobbering a direct bidder entry. `slot.bidders` is a + // HashMap, so this branch can run after a direct entry; + // `extend` would then overwrite valid direct params with a + // fabricated empty `{}`, which `explicit_bidders` would + // wrongly preserve past the drop below. `or_insert` makes + // direct params win regardless of iteration order. + for (bidder_name, bidder_params) in + expand_trusted_server_bidders(&self.config.bidders, params) + { + bidder.entry(bidder_name).or_insert(bidder_params); + } } else if self.config.bidders.iter().any(|b| b == name) { explicit_bidders.insert(name.clone()); bidder.insert(name.clone(), params.clone()); @@ -6011,6 +6022,55 @@ bidders = ["kargo", "triplelift", "criteo"] ); } + #[test] + fn to_openrtb_prefers_direct_bidder_params_over_fabricated_empty() { + // A slot can carry BOTH a direct bidder entry (valid inline params) and a + // `trustedServer` entry whose `bidderParams` omits that bidder. Since + // `slot.bidders` is a HashMap, the trustedServer expansion — which + // fabricates an empty `{}` for the omitted bidder — could run after the + // direct entry and overwrite its valid params; the direct entry marks the + // bidder explicit, so the empty would survive the drop and PBS would reject + // the imp. Direct params must win regardless of iteration order. + // + // Looped because the HashMap iteration order is randomized per map, so a + // single run could miss the overwrite ordering; the fix must hold every + // time. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift"] +"#, + ); + + for _ in 0..64 { + let slot = make_slot( + "ad-header-0", + HashMap::from([ + ("kargo".to_string(), json!({ "placementId": "direct-1" })), + ( + TRUSTED_SERVER_BIDDER.to_string(), + json!({ BIDDER_PARAMS_KEY: { "triplelift": { "inventoryCode": "tl-1" } } }), + ), + ]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config.clone(), &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "direct-1", + "direct bidder params must win over a fabricated empty from trustedServer expansion" + ); + assert_eq!( + params["triplelift"]["inventoryCode"], "tl-1", + "trustedServer-supplied bidder params must still ship" + ); + } + } + #[test] fn to_openrtb_preserves_an_explicitly_empty_bidder() { // A publisher-supplied empty `{}` is a real (if misconfigured) signal and From 6a75cc499adf43d97e66cf0c1976f20702ad9b15 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Fri, 17 Jul 2026 14:17:18 +0530 Subject: [PATCH 3/3] Drop empty and non-object PBS bidder params instead of shipping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expand_trusted_server_bidders and the direct merge could both put an unusable value into imp.ext.prebid.bidder — an empty {} (fabricated or publisher-supplied) or a non-object like null. PBS rejects such an imp, and parse_response collapses one non-2xx into an auction-wide bid wipeout, so a single misconfigured slot zeroed every slot's bids. - Add is_unusable_bidder_params (empty object or non-object). - Collect the trustedServer expansion and direct entries separately, then merge so a direct entry wins but an unusable value never clobbers real params from the expansion (fixes the asymmetric direct-{} overwrite). - Drop every remaining unusable value after overrides and log::warn each drop; the slot then falls back to its stored request. Explicit empties are no longer preserved — PBS cannot tell them from fabricated ones. - Correct the stored-request comment: the fallback also fires for real inline params naming a bidder absent from config.bidders. - Tests: explicit-empty now drops to stored request; add direct-empty no-clobber, null drop, and unconfigured-bidder fallback. --- .../src/integrations/prebid.rs | 269 ++++++++++++++---- 1 file changed, 206 insertions(+), 63 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 6fa5d134..12c2769e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -927,6 +927,18 @@ impl IntegrationHeadInjector for PrebidIntegration { } } +/// Returns `true` when `params` is not a usable PBS bidder-params object — an +/// empty object `{}` or any non-object value such as `null`. +/// +/// PBS rejects an imp whose `bidder.` carries such a value, and it cannot +/// tell a fabricated empty from an explicitly supplied one — they are identical +/// bytes on the wire. The merge uses this to stop an unusable value from +/// clobbering real params, and the final pass uses it to drop whatever remains. +fn is_unusable_bidder_params(params: &Json) -> bool { + // `None` covers non-object values (e.g. `null`); an empty map covers `{}`. + params.as_object().is_none_or(serde_json::Map::is_empty) +} + fn expand_trusted_server_bidders( configured_bidders: &[String], params: &Json, @@ -1417,40 +1429,25 @@ impl PrebidAuctionProvider { .and_then(|p| p.get(ZONE_KEY)) .and_then(Json::as_str); - // Build the bidder map for PBS. - // The JS adapter sends "trustedServer" as the bidder (our orchestrator - // adapter name). Replace it with the real PBS bidders from config. - // Only pass through keys that are known PBS bidders — skip provider-specific - // keys like "aps" which belong to their own separate auction provider. - let mut bidder: HashMap = HashMap::new(); - // Bidders the publisher explicitly supplied — including an - // explicit empty `{}`. A configured bidder that exists only - // because `expand_trusted_server_bidders` fabricated an empty - // params object is NOT explicit and must not ship as - // `"bidder": {}` (which PBS rejects). - let mut explicit_bidders: HashSet = HashSet::new(); + // Build the bidder map for PBS from two sources: + // 1. the `trustedServer` orchestrator entry, expanded into the + // configured PBS bidders (`expand_trusted_server_bidders`); + // 2. direct entries naming a configured PBS bidder. + // The JS adapter sends "trustedServer" as the bidder (our + // orchestrator adapter name); provider-specific keys like "aps" + // run their own auction and are skipped here. + // + // The two sources are collected separately so the merge below is + // deterministic regardless of `slot.bidders` HashMap iteration + // order: direct entries win, but an unusable direct value (`{}` or + // a non-object) must not clobber real params from the expansion. + let mut expanded: HashMap = HashMap::new(); + let mut direct: Vec<(String, Json)> = Vec::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { - if let Some(per_bidder) = - params.get(BIDDER_PARAMS_KEY).and_then(Json::as_object) - { - explicit_bidders.extend(per_bidder.keys().cloned()); - } - // Fill in trusted-server-expanded bidders without - // clobbering a direct bidder entry. `slot.bidders` is a - // HashMap, so this branch can run after a direct entry; - // `extend` would then overwrite valid direct params with a - // fabricated empty `{}`, which `explicit_bidders` would - // wrongly preserve past the drop below. `or_insert` makes - // direct params win regardless of iteration order. - for (bidder_name, bidder_params) in - expand_trusted_server_bidders(&self.config.bidders, params) - { - bidder.entry(bidder_name).or_insert(bidder_params); - } + expanded.extend(expand_trusted_server_bidders(&self.config.bidders, params)); } else if self.config.bidders.iter().any(|b| b == name) { - explicit_bidders.insert(name.clone()); - bidder.insert(name.clone(), params.clone()); + direct.push((name.clone(), params.clone())); } else if name != "aps" { // `aps` is intentionally handled by its own provider. Any // other unrecognized key is likely a misconfiguration (a @@ -1465,6 +1462,22 @@ impl PrebidAuctionProvider { } } + let mut bidder = expanded; + for (name, params) in direct { + // Direct entries win over the expansion — except when an + // unusable direct value (`{}` or a non-object) would overwrite + // real params the expansion already supplied. PBS cannot tell a + // fabricated empty from an explicit one, so real params must not + // be discarded in favour of either. + let clobbers_real = is_unusable_bidder_params(¶ms) + && bidder + .get(&name) + .is_some_and(|existing| !is_unusable_bidder_params(existing)); + if !clobbers_real { + bidder.insert(name, params); + } + } + // Apply canonical and compatibility-derived rules in normalized // order. An override rule can populate a bidder that arrived with // empty params, promoting a fabricated empty into a valid bidder. @@ -1473,24 +1486,41 @@ impl PrebidAuctionProvider { .apply(BidParamOverrideFacts { bidder: name, zone }, params); } - // Drop bidders that are still an empty object after overrides and - // were not explicitly supplied. Shipping `"bidder": {}` makes PBS - // reject the imp; an explicit empty object is preserved so genuine - // publisher misconfiguration stays visible. + // Drop any bidder whose params are still unusable after overrides — + // an empty `{}` or a non-object like `null`. Both are byte-identical + // to PBS regardless of whether they were fabricated or supplied + // explicitly, and shipping either gets the imp rejected; a single + // non-2xx then collapses into an auction-wide bid wipeout (see + // `parse_response`), so one misconfigured slot must never reach the + // wire. Log each drop at warn so the misconfiguration is visible + // where an operator can act on it — the PBS 400 body is otherwise + // discarded unless trace logging is enabled. bidder.retain(|name, params| { - let is_empty_object = params.as_object().is_some_and(serde_json::Map::is_empty); - !is_empty_object || explicit_bidders.contains(name) + if is_unusable_bidder_params(params) { + log::warn!( + "prebid: dropping bidder '{}' on slot '{}' — empty or non-object params; slot falls back to its stored request", + name, + slot.id + ); + return false; + } + true }); - // When no eligible PBS bidder params remain (e.g. creative-opportunity - // slots whose PBS params live in stored requests, or a slot whose - // configured bidders all resolved to fabricated empties), tell PBS to - // resolve bidder config from the stored request keyed by this slot ID. + // When no eligible PBS bidder params remain, tell PBS to resolve + // bidder config from the stored request keyed by this slot ID. This + // covers creative-opportunity slots whose PBS params live in stored + // requests, and slots whose configured bidders all resolved to + // unusable params and were dropped above. // - // This cannot fire for a client /auction slot that carries real - // inline params: the JS adapter injects a `trustedServer` entry, and - // any bidder with params survives the drop above. It falls back only - // when nothing eligible remains — the same no-bid outcome as before. + // Note the fallback set is wider than "slots with no inline params": + // a slot carrying real inline params for a bidder absent from + // `config.bidders` also lands here — that bidder is never expanded, + // the configured bidders fabricate empties, the drop clears them, and + // the real params are lost. That param loss is pre-existing (see the + // debug log in the build loop); it is called out here because it + // means an operator with `config.bidders` set but no stored request + // provisioned for such a slot gets a no-bid. let storedrequest = if bidder.is_empty() { Some(ImpStoredRequest { id: slot.id.clone(), @@ -6027,16 +6057,13 @@ bidders = ["kargo", "triplelift", "criteo"] #[test] fn to_openrtb_prefers_direct_bidder_params_over_fabricated_empty() { // A slot can carry BOTH a direct bidder entry (valid inline params) and a - // `trustedServer` entry whose `bidderParams` omits that bidder. Since - // `slot.bidders` is a HashMap, the trustedServer expansion — which - // fabricates an empty `{}` for the omitted bidder — could run after the - // direct entry and overwrite its valid params; the direct entry marks the - // bidder explicit, so the empty would survive the drop and PBS would reject - // the imp. Direct params must win regardless of iteration order. + // `trustedServer` entry whose `bidderParams` omits that bidder — the + // expansion fabricates an empty `{}` for the omitted bidder. Direct real + // params must win over that fabricated empty, and the trustedServer-supplied + // params for the other bidder must still ship. // - // Looped because the HashMap iteration order is randomized per map, so a - // single run could miss the overwrite ordering; the fix must hold every - // time. + // Looped because `slot.bidders` HashMap iteration order is randomized per + // map; the merge must be order-independent every time. let config = parse_prebid_toml( r#" [integrations.prebid] @@ -6074,10 +6101,12 @@ bidders = ["kargo", "triplelift"] } #[test] - fn to_openrtb_preserves_an_explicitly_empty_bidder() { - // A publisher-supplied empty `{}` is a real (if misconfigured) signal and - // must survive so the misconfiguration stays visible — unlike a fabricated - // empty, which is dropped. + fn to_openrtb_drops_an_explicitly_empty_bidder() { + // PBS cannot distinguish a publisher-supplied empty `{}` from a fabricated + // one — they are identical bytes on the wire and PBS rejects both. So an + // explicit empty is dropped just like a fabricated one, rather than shipped + // as `"bidder": {"kargo": {}}` (which would fail the whole auction). With no + // eligible bidder left, the slot falls back to its stored request. let config = parse_prebid_toml( r#" [integrations.prebid] @@ -6091,12 +6120,126 @@ bidders = ["kargo"] let request = make_auction_request(vec![slot]); let ortb = call_to_openrtb(config, &request); - let params = bidder_params(&ortb); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + assert!( + prebid.get("bidder").is_none(), + "should drop an explicitly supplied empty bidder object, not ship it" + ); assert_eq!( - params["kargo"], - json!({}), - "should preserve an explicitly supplied empty bidder object" + prebid["storedrequest"]["id"], "ad-header-0", + "should fall back to the stored request once the empty bidder is dropped" + ); + } + + #[test] + fn to_openrtb_direct_empty_does_not_clobber_trusted_server_params() { + // A slot can carry BOTH a direct empty entry and a `trustedServer` entry + // whose `bidderParams` supplies real params for the same bidder. The direct + // empty must NOT overwrite the real params — otherwise the slot ships + // `"bidder": {"kargo": {}}`, the exact invalid payload this path removes. + // Looped because `slot.bidders` HashMap iteration order is randomized. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] +"#, + ); + + for _ in 0..64 { + let slot = make_slot( + "ad-header-0", + HashMap::from([ + ("kargo".to_string(), json!({})), + ( + TRUSTED_SERVER_BIDDER.to_string(), + json!({ BIDDER_PARAMS_KEY: { "kargo": { "placementId": "ts-1" } } }), + ), + ]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config.clone(), &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "ts-1", + "a direct empty must not clobber real trustedServer-supplied params" + ); + } + } + + #[test] + fn to_openrtb_drops_non_object_bidder_params() { + // Non-object params (e.g. `null`, reachable via a POST that omits `params` + // because `BidConfig.params` is `#[serde(default)]`) are as invalid to PBS + // as `{}` and must be dropped, not shipped as `"bidder": {"kargo": null}`. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] +"#, + ); + + let slot = make_slot( + "ad-header-0", + HashMap::from([("kargo".to_string(), Json::Null)]), + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should drop a null (non-object) bidder params value" + ); + assert_eq!( + prebid["storedrequest"]["id"], "ad-header-0", + "should fall back to the stored request once the null bidder is dropped" + ); + } + + #[test] + fn to_openrtb_falls_back_to_stored_request_for_real_params_of_unconfigured_bidder() { + // Real inline params naming a bidder absent from `config.bidders` do NOT + // ship: the bidder is never expanded, the configured bidders fabricate + // empties, and the drop clears them — so the slot falls back to its stored + // request and the real params are lost. Pins the corrected comment that this + // fallback can fire even when a slot carries real inline params. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["appnexus"] +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "kn1" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "real params for an unconfigured bidder are not shipped" + ); + assert_eq!( + prebid["storedrequest"]["id"], "ad-header-0", + "slot falls back to its stored request when the only inline params name an unconfigured bidder" ); }