From 7b61e1e31c23fa2c6950c16f637a2a567412f50e Mon Sep 17 00:00:00 2001 From: Hansheng Zhao Date: Wed, 23 Sep 2026 17:00:31 -0500 Subject: [PATCH 1/3] AC-179 - Add MARSClient::delete_user with optional OHTTP and a short timeout Adds a DELETE /v1/delete_user request builder to the MARS client so the ads client can send the context id deletion itself instead of relying on the context_id component's direct request. The request goes through MARSTransport with a 5 s timeout on the relay request and, when asked, the ads-client OHTTP channel; gateway key retrieval keeps viaduct's own timeout. When OHTTP is asked for and the channel is not configured the request fails with OhttpChannelNotConfigured rather than being sent directly. Without OHTTP the helper sends a direct request; the OHTTP-only gate lives in the ads client's rotation path. MARSTransport gains fire_with_timeout, and client_for now takes the ClientSettings to use instead of always using the default. --- components/ads-client/src/mars.rs | 55 ++++++++++++++++++- components/ads-client/src/mars/delete_user.rs | 12 ++++ components/ads-client/src/mars/error.rs | 22 ++++++++ components/ads-client/src/mars/transport.rs | 38 ++++++++++--- 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 components/ads-client/src/mars/delete_user.rs diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index 03dc3872f2..42a07d1c5c 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -5,6 +5,7 @@ pub mod ad_request; pub mod ad_response; +mod delete_user; pub mod environment; pub mod error; mod preflight; @@ -18,8 +19,10 @@ use sql_support::open_database; use self::{ ad_request::{AdPlacementRequest, AdRequest, AdRequestFlags, AdRequestRelevance}, ad_response::{AdResponse, AdResponseValue}, + delete_user::DeleteUserRequest, error::{ - CallbackRequestError, FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError, + CallbackRequestError, DeleteUserError, FetchAdsError, RecordClickError, + RecordImpressionError, ReportAdError, }, preflight::PreflightRequest, transport::MARSTransport, @@ -37,6 +40,10 @@ use std::collections::HashMap; use url::Url; use viaduct::{Headers, Request}; +/// Timeout for the best-effort deletion request, which runs inside an ad request. +/// Over OHTTP it bounds the relay request; the gateway key fetch keeps its own. +const DELETE_USER_TIMEOUT_MS: u32 = 5_000; + pub struct MARSClient { environment: Environment, transport: MARSTransport, @@ -54,6 +61,15 @@ impl MARSClient { self.transport.clear_cache() } + /// Sends `DELETE /delete_user` for `context_id`, over OHTTP when `ohttp` is set. + pub fn delete_user(&self, context_id: &str, ohttp: bool) -> Result<(), DeleteUserError> { + let request = Request::delete(self.environment.clone().into_url("delete_user")) + .json(&DeleteUserRequest { context_id }); + self.transport + .fire_with_timeout(request, ohttp, DELETE_USER_TIMEOUT_MS) + .map_err(Into::into) + } + #[allow(clippy::too_many_arguments)] pub fn fetch_ads( &self, @@ -416,4 +432,41 @@ mod tests { assert!(result.is_ok()); m.assert(); } + + #[test] + fn test_delete_user_sends_json_delete() { + viaduct_dev::init_backend_dev(); + let m = mock("DELETE", "/delete_user") + .match_header("content-type", "application/json") + .match_body(mockito::Matcher::Json( + serde_json::json!({ "context_id": TEST_CONTEXT_ID }), + )) + .with_status(200) + .expect(1) + .create(); + + let client = make_test_client(None); + let result = client.delete_user(TEST_CONTEXT_ID, false); + + assert!(result.is_ok(), "unexpected error: {result:?}"); + m.assert(); + } + + #[test] + fn test_delete_user_without_ohttp_channel_fails_closed() { + viaduct_dev::init_backend_dev(); + // No channel configured: fail closed, never fall back to a direct DELETE. + let m = mock("DELETE", "/delete_user").expect(0).create(); + + let client = make_test_client(None); + let result = client.delete_user(TEST_CONTEXT_ID, true); + + assert!(matches!( + result, + Err(DeleteUserError::Request( + viaduct::ViaductError::OhttpChannelNotConfigured(ref channel) + )) if channel == "ads-client" + )); + m.assert(); + } } diff --git a/components/ads-client/src/mars/delete_user.rs b/components/ads-client/src/mars/delete_user.rs new file mode 100644 index 0000000000..ef80136a86 --- /dev/null +++ b/components/ads-client/src/mars/delete_user.rs @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use serde::Serialize; + +/// Request body for the MARS `/v1/delete_user` endpoint. +#[derive(Debug, Serialize)] +pub struct DeleteUserRequest<'a> { + pub context_id: &'a str, +} diff --git a/components/ads-client/src/mars/error.rs b/components/ads-client/src/mars/error.rs index 5cd9cf13df..be223f3f72 100644 --- a/components/ads-client/src/mars/error.rs +++ b/components/ads-client/src/mars/error.rs @@ -30,6 +30,18 @@ pub enum CallbackRequestError { Request(#[from] viaduct::ViaductError), } +#[derive(Debug, thiserror::Error)] +pub enum DeleteUserError { + #[error("Context id deletion request failed, MARS responded with: {0}")] + HTTPError(#[from] HTTPError), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Error sending request: {0}")] + Request(#[from] viaduct::ViaductError), +} + #[derive(Debug, thiserror::Error)] pub enum FetchAdsError { #[error("Error building ad request: {0}")] @@ -132,6 +144,16 @@ impl From for FetchAdsError { } } +impl From for DeleteUserError { + fn from(err: TransportError) -> Self { + match err { + TransportError::Http(e) => Self::HTTPError(e), + TransportError::Json(e) => Self::Json(e), + TransportError::Request(e) => Self::Request(e), + } + } +} + impl From for CallbackRequestError { fn from(err: TransportError) -> Self { match err { diff --git a/components/ads-client/src/mars/transport.rs b/components/ads-client/src/mars/transport.rs index 153beb1476..b68fc824b3 100644 --- a/components/ads-client/src/mars/transport.rs +++ b/components/ads-client/src/mars/transport.rs @@ -33,10 +33,20 @@ impl MARSTransport { } pub fn fire(&self, request: Request, ohttp: bool) -> Result<(), TransportError> { - let client = Self::client_for(ohttp)?; - let response = client.send_sync(request)?; - HTTPError::check(&response)?; - Ok(()) + self.fire_with_settings(request, ohttp, ClientSettings::default()) + } + + pub fn fire_with_timeout( + &self, + request: Request, + ohttp: bool, + timeout_ms: u32, + ) -> Result<(), TransportError> { + let settings = ClientSettings { + timeout: timeout_ms, + ..ClientSettings::default() + }; + self.fire_with_settings(request, ohttp, settings) } #[allow(dead_code)] @@ -56,7 +66,7 @@ impl MARSTransport { policy: &CachePolicy, ohttp: bool, ) -> Result { - let client = Self::client_for(ohttp)?; + let client = Self::client_for(ohttp, ClientSettings::default())?; if let Some(cache) = self.http_cache.as_ref() { let (response, outcomes) = cache.send_with_policy(&client, request, policy)?; for outcome in &outcomes { @@ -77,11 +87,23 @@ impl MARSTransport { } } - fn client_for(ohttp: bool) -> Result { + fn client_for(ohttp: bool, settings: ClientSettings) -> Result { if ohttp { - Client::with_ohttp_channel(OHTTP_CHANNEL_ID, ClientSettings::default()) + Client::with_ohttp_channel(OHTTP_CHANNEL_ID, settings) } else { - Ok(Client::new(ClientSettings::default())) + Ok(Client::new(settings)) } } + + fn fire_with_settings( + &self, + request: Request, + ohttp: bool, + settings: ClientSettings, + ) -> Result<(), TransportError> { + let client = Self::client_for(ohttp, settings)?; + let response = client.send_sync(request)?; + HTTPError::check(&response)?; + Ok(()) + } } From 7cdc36c75ef12dbb741b88c38681dfaed6ee9a80 Mon Sep 17 00:00:00 2001 From: Hansheng Zhao Date: Wed, 23 Sep 2026 17:04:29 -0500 Subject: [PATCH 2/3] AC-179 - Send the context id deletion request from the ads client over OHTTP The embedded ContextIDComponent used to send DELETE /v1/delete_user to MARS directly whenever the context id rotated, outside the OHTTP channel the ads client uses for ad requests. The ads client now disables the component's request (running_in_test_automation = true, as Firefox already does for its JS-side component) and implements ContextIdCallback itself with a small queue: rotated ids are collected and flushed right after each ad fetch, with the OHTTP setting of the request_ads call that drains them. When that call did not use OHTTP the deletion is skipped and logged. Failures are logged and not retried. Consumers that do not configure an OHTTP channel for the ads client therefore no longer send the deletion request after this change. The context id still rotates on the same schedule. The usage docs now note that the ohttp request option also applies to this deletion request. This also replaces the cfg!(test) that was passed as running_in_test_automation, a compile-time constant answering a runtime question, which meant release builds always sent the component's request. --- .../ads-client/docs/usage-javascript.md | 4 +- components/ads-client/docs/usage-kotlin.md | 2 +- components/ads-client/docs/usage-swift.md | 2 +- components/ads-client/src/client.rs | 143 ++++++++++++++++-- .../src/client/context_id_deletion.rs | 75 +++++++++ 5 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 components/ads-client/src/client/context_id_deletion.rs diff --git a/components/ads-client/docs/usage-javascript.md b/components/ads-client/docs/usage-javascript.md index d3298ddc1f..4eaef593e0 100644 --- a/components/ads-client/docs/usage-javascript.md +++ b/components/ads-client/docs/usage-javascript.md @@ -247,7 +247,7 @@ Options passed when making a single ad request. * @typedef {Object} MozAdsRequestOptions * @property {MozAdsRequestCachePolicy|null} cachePolicy - Per-request caching policy. * @property {Object.} flags - Request-level flags forwarded as the `flags` object on the wire. An empty object omits it. - * @property {boolean} ohttp - Whether to route this request through OHTTP (default: false). + * @property {boolean} ohttp - Whether to route this request through OHTTP; the context id deletion request that may follow it uses the same setting and is skipped without OHTTP (default: false). * @property {Object.} relevance - Per client calculated relevance scores. An empty object omits it. Defaults to `{}`. */ ``` @@ -256,7 +256,7 @@ Options passed when making a single ad request. | ------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | | `flags` | `Object.` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty object omits it. e.g. `{ contextual_placement: true }`. Defaults to `{}`. | -| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `Object.` | Per client calculated relevance scores. An empty object omits it. Defaults to `{}`. --- diff --git a/components/ads-client/docs/usage-kotlin.md b/components/ads-client/docs/usage-kotlin.md index e6e9afcce8..e9d67e542d 100644 --- a/components/ads-client/docs/usage-kotlin.md +++ b/components/ads-client/docs/usage-kotlin.md @@ -230,7 +230,7 @@ data class MozAdsRequestOptions( | ------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | | `flags` | `Map` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty map omits it. e.g. `mapOf("contextual_placement" to true)`. Defaults to `emptyMap()`. | -| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `Boolean` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `Map` | Per client calculated relevance scores. An empty object omits it. Defaults to `emptyMap()``. | --- diff --git a/components/ads-client/docs/usage-swift.md b/components/ads-client/docs/usage-swift.md index b2cdf78d94..ad22921f2c 100644 --- a/components/ads-client/docs/usage-swift.md +++ b/components/ads-client/docs/usage-swift.md @@ -230,7 +230,7 @@ struct MozAdsRequestOptions { | ------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. | | `flags` | `[String: Bool]` | Request-level flags forwarded verbatim as the `flags` object on the wire. An empty dictionary omits it. e.g. `["contextual_placement": true]`. Defaults to `[:]`. | -| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. | +| `ohttp` | `Bool` | Whether to route this request through OHTTP. The context id deletion request that may follow it uses the same setting and is skipped without OHTTP. Defaults to `false`. | | `relevance` | `[String: Double]` | Per client calculated relevance scores. An empty object omits it.Defaults to `[:]`. | --- diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 8b645c5c97..62bdff9f6d 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -14,7 +14,8 @@ use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError} use crate::mars::{MARSClient, ReportReason}; use crate::telemetry::{self, ClientOperation}; use config::AdsClientConfig; -use context_id::{ContextIDComponent, DefaultContextIdCallback}; +use context_id::ContextIDComponent; +use context_id_deletion::ContextIdDeletionQueue; use error::RequestAdsError; use sql_support::open_database; use std::collections::HashMap; @@ -25,26 +26,34 @@ use url::Url; use uuid::Uuid; pub mod config; +mod context_id_deletion; pub mod error; const DEFAULT_TTL_SECONDS: u64 = 300; const DEFAULT_MAX_CACHE_SIZE_MIB: u64 = 10; const DEFAULT_ROTATION_DAYS: u8 = 3; +/// Passed as `running_in_test_automation` so `ContextIDComponent` never sends +/// its own `DELETE /delete_user`; `AdsClient` sends it instead. Firefox's +/// `ContextId.sys.mjs` does the same. +const DISABLE_CONTEXT_ID_COMPONENT_DELETION: bool = true; + pub struct AdsClient { #[cfg(feature = "stateful")] ads_store: Arc>, client: MARSClient, context_id_component: ContextIDComponent, + context_id_deletion_queue: ContextIdDeletionQueue, } impl AdsClient { pub fn new(client_config: AdsClientConfig) -> Self { + let context_id_deletion_queue = ContextIdDeletionQueue::default(); let context_id_component = ContextIDComponent::new( &Uuid::new_v4().to_string(), 0, - cfg!(test), - Box::new(DefaultContextIdCallback), + DISABLE_CONTEXT_ID_COMPONENT_DELETION, + Box::new(context_id_deletion_queue.clone()), ); let environment = client_config.environment; @@ -92,6 +101,7 @@ impl AdsClient { ads_store: Arc::new(ads_store), client, context_id_component, + context_id_deletion_queue, } } @@ -300,6 +310,22 @@ impl AdsClient { self.client.shutdown_db(); } + /// Best-effort: sends each retired id's deletion over OHTTP, or skips it when + /// the ad request did not use OHTTP. Failures are logged and not retried. + fn flush_context_id_deletions(&self, retired_context_ids: Vec, ohttp: bool) { + for old_context_id in retired_context_ids { + if !ohttp { + error_support::info!( + "Skipping context id deletion request: the ad request did not use OHTTP" + ); + continue; + } + if let Err(e) = self.client.delete_user(&old_context_id, true) { + error_support::warn!("Context id deletion request failed: {e}"); + } + } + } + fn request_ads( &self, placements: Vec, @@ -317,9 +343,12 @@ impl AdsClient { } else { None }; + // Take the retired ids now so they are flushed with this call's OHTTP flag. + // A concurrent call may take them first; nothing is ever sent outside OHTTP. + let retired_context_ids = self.context_id_deletion_queue.take_all(); let cache_policy = options.unwrap_or_default(); - let (mut response, request_hash) = self.client.fetch_ads::( + let result = self.client.fetch_ads::( context_id, flags, placements, @@ -327,7 +356,10 @@ impl AdsClient { ohttp, blocks, relevance, - )?; + ); + // Flush even if the fetch failed: the ids have already left the queue. + self.flush_context_id_deletions(retired_context_ids, ohttp); + let (mut response, request_hash) = result?; response.enrich_callbacks(&request_hash); Ok(response) } @@ -339,7 +371,7 @@ mod tests { #[cfg(feature = "stateful")] use crate::ads_store::builder::AdsStoreBuilder; use crate::{ - mars::Environment, + mars::{error::FetchAdsError, Environment}, test_utils::{ get_example_happy_image_response, get_example_happy_spoc_response, get_example_happy_uatile_response, make_happy_placement_requests, @@ -349,7 +381,17 @@ mod tests { use super::*; fn new_with_mars_client(client: MARSClient) -> AdsClient { - AdsClient { + new_with_mars_client_at(client, 0).0 + } + + /// Builds a client whose context id was created at `creation_timestamp_s` + /// (0 = now) and hands back the deletion queue shared with it. + fn new_with_mars_client_at( + client: MARSClient, + creation_timestamp_s: i64, + ) -> (AdsClient, ContextIdDeletionQueue) { + let context_id_deletion_queue = ContextIdDeletionQueue::default(); + let ads_client = AdsClient { #[cfg(feature = "stateful")] ads_store: Arc::new(Some( AdsStoreBuilder::new("test_store.db") @@ -359,11 +401,90 @@ mod tests { client, context_id_component: ContextIDComponent::new( &Uuid::new_v4().to_string(), - 0, - false, - Box::new(DefaultContextIdCallback), + creation_timestamp_s, + DISABLE_CONTEXT_ID_COMPONENT_DELETION, + Box::new(context_id_deletion_queue.clone()), ), - } + context_id_deletion_queue: context_id_deletion_queue.clone(), + }; + (ads_client, context_id_deletion_queue) + } + + fn thirty_days_ago_s() -> i64 { + (chrono::Utc::now() - chrono::Duration::days(30)).timestamp() + } + + #[test] + fn test_rotation_without_ohttp_skips_delete() { + viaduct_dev::init_backend_dev(); + + let expected_response = get_example_happy_uatile_response(); + let ads_mock = mockito::mock("POST", "/ads") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(serde_json::to_string(&expected_response.data).unwrap()) + .expect(1) + .create(); + let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create(); + + let mars_client = MARSClient::new(Environment::Test, None); + let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s()); + + // A 30-day-old id rotates on first use and queues the old id. + ads_client.get_context_id().unwrap(); + assert!(!queue.is_empty()); + + // Without OHTTP the queue is drained and no deletion is sent. + let result = ads_client.request_tile_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + false, + Default::default(), + AdRequestRelevance::default(), + ); + assert!(result.is_ok()); + ads_mock.assert(); + delete_mock.assert(); + assert!(queue.is_empty()); + } + + #[test] + fn test_rotation_flush_runs_even_when_fetch_fails() { + viaduct_dev::init_backend_dev(); + + // OHTTP without a configured channel: preflight succeeds, the ad fetch fails, + // and the queue is still drained without sending a deletion. + let preflight_mock = mockito::mock("GET", "/ads-preflight") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"geo_location":"US-CA","normalized_ua":"Firefox/140.0"}"#) + .expect(1) + .create(); + let delete_mock = mockito::mock("DELETE", "/delete_user").expect(0).create(); + + let mars_client = MARSClient::new(Environment::Test, None); + let (ads_client, queue) = new_with_mars_client_at(mars_client, thirty_days_ago_s()); + ads_client.get_context_id().unwrap(); + assert!(!queue.is_empty()); + + let result = ads_client.request_tile_ads( + make_happy_placement_requests(), + AdRequestFlags::default(), + None, + true, + Default::default(), + AdRequestRelevance::default(), + ); + assert!(matches!( + result, + Err(RequestAdsError::FetchAds(FetchAdsError::Request( + viaduct::ViaductError::OhttpChannelNotConfigured(_) + ))) + )); + preflight_mock.assert(); + delete_mock.assert(); + assert!(queue.is_empty()); } #[test] diff --git a/components/ads-client/src/client/context_id_deletion.rs b/components/ads-client/src/client/context_id_deletion.rs new file mode 100644 index 0000000000..b5fd0ac668 --- /dev/null +++ b/components/ads-client/src/client/context_id_deletion.rs @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ + +use std::sync::Arc; + +use context_id::ContextIdCallback; +use parking_lot::Mutex; + +/// `ContextIdCallback` that queues retired context ids. The component calls +/// `rotated` under its own lock and without the request's OHTTP flag, so +/// `AdsClient` drains the queue in `request_ads` and sends the deletions itself. +/// +/// Clones share one queue. `persist` is a no-op because the ads client does +/// not persist its context id. +#[derive(Clone, Debug, Default)] +pub struct ContextIdDeletionQueue(Arc>>); + +impl ContextIdDeletionQueue { + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.0.lock().is_empty() + } + + pub fn take_all(&self) -> Vec { + std::mem::take(&mut *self.0.lock()) + } +} + +impl ContextIdCallback for ContextIdDeletionQueue { + fn persist(&self, _context_id: String, _creation_date: i64) {} + + fn rotated(&self, old_context_id: String) { + self.0.lock().push(old_context_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use context_id::ContextIdCallback; + + #[test] + fn test_rotated_ids_are_queued_until_taken() { + let queue = ContextIdDeletionQueue::default(); + assert!(queue.is_empty()); + + queue.rotated("old-1".to_string()); + queue.rotated("old-2".to_string()); + assert!(!queue.is_empty()); + + assert_eq!( + queue.take_all(), + vec!["old-1".to_string(), "old-2".to_string()] + ); + assert!(queue.is_empty()); + assert!(queue.take_all().is_empty()); + } + + #[test] + fn test_persist_is_a_no_op() { + let queue = ContextIdDeletionQueue::default(); + queue.persist("id".to_string(), 0); + assert!(queue.is_empty()); + } + + #[test] + fn test_clones_share_the_queue() { + let queue = ContextIdDeletionQueue::default(); + let callback = queue.clone(); + callback.rotated("old".to_string()); + assert_eq!(queue.take_all(), vec!["old".to_string()]); + } +} From 3be5fbb2e84444378fe7d5346e698a85f1d9e85f Mon Sep 17 00:00:00 2001 From: Hansheng Zhao Date: Wed, 23 Sep 2026 17:05:51 -0500 Subject: [PATCH 3/3] AC-179 - Add changelog entry and a prod OHTTP contract test for delete_user The contract test sends the same DELETE /v1/delete_user the ads client sends after a rotation, over the production relay and gateway, prints the status, and checks that MARS answers 200. Because it writes to production and CI runs this crate with --ignored, it returns without sending unless ADS_CLIENT_PROD_DELETE_USER_CONTRACT is set; it is meant to be run by hand with --nocapture and the observed status pasted into the PR. --- CHANGELOG.md | 1 + .../integration-tests/tests/mars.rs | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ffb2bc2f..1d207dd282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Telemetry is now a process-wide singleton internally; the binding API is unchanged, but the `MozAdsTelemetry` callback now receives metrics from every client in the process. - `request_spoc_or_image_ads` is added to return ads that may ambiguously be spocs or images. - Added support for relevance scores ([AC-205](https://mozilla-hub.atlassian.net/browse/AC-205)) ([AC-206](https://mozilla-hub.atlassian.net/browse/AC-206)) Accepts a HashMap. +- The `DELETE /v1/delete_user` request for a retired context id is now sent by the ads client right after an ad request, over that request's OHTTP channel, instead of as a direct request from the `context_id` component. When the ad request did not use OHTTP the deletion is skipped and logged, so consumers that do not configure the `ads-client` OHTTP channel no longer send it. No binding API change. ([AC-179](https://mozilla-hub.atlassian.net/browse/AC-179)) ### Autofill diff --git a/components/ads-client/integration-tests/tests/mars.rs b/components/ads-client/integration-tests/tests/mars.rs index 77a01a09ba..513d1f0084 100644 --- a/components/ads-client/integration-tests/tests/mars.rs +++ b/components/ads-client/integration-tests/tests/mars.rs @@ -292,3 +292,54 @@ fn test_contract_tile_ohttp_prod() { "OHTTP response should contain mock_tile_1" ); } + +/// Sends the ads client's rotation-time `DELETE /v1/delete_user` through the +/// production OHTTP relay and gateway and expects a 200. Built with viaduct +/// directly because the MARS module is private. CI runs `--ignored`, so it only +/// writes to production when `ADS_CLIENT_PROD_DELETE_USER_CONTRACT` is set. +#[test] +#[ignore = "writes to production MARS: run manually with ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 -- --ignored --nocapture"] +fn test_contract_delete_user_ohttp_prod() { + if std::env::var_os("ADS_CLIENT_PROD_DELETE_USER_CONTRACT").is_none() { + eprintln!( + "skipping: set ADS_CLIENT_PROD_DELETE_USER_CONTRACT=1 to send a DELETE to production" + ); + return; + } + init_backend(); + viaduct::ohttp::configure_ohttp_channel( + "ads-client".to_string(), + viaduct::ohttp::OhttpConfig { + gateway_host: "prod.ohttp-gateway.prod.webservices.mozgcp.net".to_string(), + relay_url: "https://mozilla-ohttp.fastly-edge.com/".to_string(), + }, + ) + .expect("OHTTP channel configuration should succeed"); + + let settings = viaduct::ClientSettings { + timeout: 5_000, + ..viaduct::ClientSettings::default() + }; + let client = viaduct::Client::with_ohttp_channel("ads-client", settings) + .expect("ads-client OHTTP channel should be configured"); + let request = viaduct::Request::delete( + url::Url::parse("https://ads.mozilla.org/v1/delete_user").unwrap(), + ) + .json(&serde_json::json!({ + // Synthetic id never issued to a client, so this write touches no real data. + "context_id": "00000000-0000-4000-8000-0000000000ac" + })); + + let response = client + .send_sync(request) + .expect("delete_user over OHTTP should get an HTTP response"); + eprintln!( + "DELETE /v1/delete_user over OHTTP -> {} {}", + response.status, + response.text() + ); + assert_eq!( + response.status, 200, + "delete_user over OHTTP should reach MARS and succeed" + ); +}