diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index 7fe9e77..e3c611b 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -4,12 +4,15 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use error_stack::Report; -use fastly::http::Method; +use fastly::http::{header, Method}; use fastly::{Request, Response}; use matchit::Router; +use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_SYNTHETIC_ID}; +use crate::cookies::{create_synthetic_cookie, handle_request_cookies}; use crate::error::TrustedServerError; use crate::settings::Settings; +use crate::synthetic::get_or_generate_synthetic_id; /// Action returned by attribute rewriters to describe how the runtime should mutate the element. #[derive(Debug, Clone, PartialEq, Eq)] @@ -602,6 +605,9 @@ impl IntegrationRegistry { } /// Dispatch a proxy request when an integration handles the path. + /// + /// This method automatically sets the `x-synthetic-id` header and + /// `synthetic_id` cookie on successful responses. #[must_use] pub async fn handle_proxy( &self, @@ -611,7 +617,29 @@ impl IntegrationRegistry { req: Request, ) -> Option>> { if let Some((proxy, _)) = self.find_route(method, path) { - Some(proxy.handle(settings, req).await) + // Generate synthetic ID before consuming request + let synthetic_id_result = get_or_generate_synthetic_id(settings, &req); + let has_synthetic_cookie = handle_request_cookies(&req) + .ok() + .flatten() + .and_then(|jar| jar.get(COOKIE_SYNTHETIC_ID).map(|_| true)) + .unwrap_or(false); + + let mut result = proxy.handle(settings, req).await; + + // Set synthetic ID header on successful responses + if let Ok(ref mut response) = result { + if let Ok(ref synthetic_id) = synthetic_id_result { + response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str()); + if !has_synthetic_cookie { + response.set_header( + header::SET_COOKIE, + create_synthetic_cookie(settings, synthetic_id.as_str()), + ); + } + } + } + Some(result) } else { None } @@ -1042,4 +1070,208 @@ mod tests { assert!(!registry.has_route(&Method::GET, "/integrations/test/users")); assert!(!registry.has_route(&Method::POST, "/integrations/test/users")); } + + // Tests for synthetic ID header on proxy responses + + use crate::cookies::parse_cookies_to_jar; + use crate::test_support::tests::create_test_settings; + + #[test] + fn cookie_jar_finds_synthetic_id() { + let cookies = "other=value; synthetic_id=abc123; more=stuff"; + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_some(), + "Should detect synthetic_id cookie" + ); + } + + #[test] + fn cookie_jar_handles_missing_cookie() { + let cookies = "other=value; session=xyz"; + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_none(), + "Should not find synthetic_id when missing" + ); + } + + #[test] + fn cookie_jar_handles_empty_cookies() { + let cookies = ""; + let jar = parse_cookies_to_jar(cookies); + assert!( + jar.get(COOKIE_SYNTHETIC_ID).is_none(), + "Should handle empty cookie string" + ); + } + + /// Mock proxy that returns a simple 200 OK response + struct SyntheticIdTestProxy; + + #[async_trait(?Send)] + impl IntegrationProxy for SyntheticIdTestProxy { + fn integration_name(&self) -> &'static str { + "synthetic_id_test" + } + + fn routes(&self) -> Vec { + vec![ + IntegrationEndpoint { + method: Method::GET, + path: "/integrations/test/synthetic".to_string(), + }, + IntegrationEndpoint { + method: Method::POST, + path: "/integrations/test/synthetic".to_string(), + }, + ] + } + + async fn handle( + &self, + _settings: &Settings, + _req: Request, + ) -> Result> { + // Return a simple response without the synthetic ID header. + // The registry's handle_proxy should add it. + Ok(Response::from_status(fastly::http::StatusCode::OK).with_body("test response")) + } + } + + #[test] + fn handle_proxy_sets_synthetic_id_header_on_response() { + let settings = create_test_settings(); + let routes = vec![( + Method::GET, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + // Create a request without a synthetic ID cookie + let req = Request::get("https://test-publisher.com/integrations/test/synthetic"); + + // Call handle_proxy (uses futures executor in test environment) + let result = futures::executor::block_on(registry.handle_proxy( + &Method::GET, + "/integrations/test/synthetic", + &settings, + req, + )); + + // Should have matched and returned a response + assert!(result.is_some(), "Should find route and handle request"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + + // Verify x-synthetic-id header is present + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "Response should have x-synthetic-id header" + ); + + // Verify Set-Cookie header is present (since no cookie was in request) + let set_cookie = response.get_header(header::SET_COOKIE); + assert!( + set_cookie.is_some(), + "Response should have Set-Cookie header for synthetic_id" + ); + + let cookie_value = set_cookie.unwrap().to_str().unwrap(); + assert!( + cookie_value.contains(COOKIE_SYNTHETIC_ID), + "Set-Cookie should contain synthetic_id cookie, got: {}", + cookie_value + ); + } + + #[test] + fn handle_proxy_skips_cookie_when_already_present() { + let settings = create_test_settings(); + let routes = vec![( + Method::GET, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + // Create a request WITH an existing synthetic_id cookie + let mut req = Request::get("https://test-publisher.com/integrations/test/synthetic"); + req.set_header(header::COOKIE, "synthetic_id=existing_id_12345"); + + let result = futures::executor::block_on(registry.handle_proxy( + &Method::GET, + "/integrations/test/synthetic", + &settings, + req, + )); + + assert!(result.is_some(), "Should find route"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + + // Should still have x-synthetic-id header + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "Response should still have x-synthetic-id header" + ); + + // But should NOT set the cookie again (it's already present) + let set_cookie = response.get_header(header::SET_COOKIE); + + // Either no Set-Cookie, or if present, not for synthetic_id + if let Some(cookie) = set_cookie { + let cookie_str = cookie.to_str().unwrap_or(""); + assert!( + !cookie_str.contains(COOKIE_SYNTHETIC_ID), + "Should not set duplicate synthetic_id cookie, got: {}", + cookie_str + ); + } + } + + #[test] + fn handle_proxy_works_with_post_method() { + let settings = create_test_settings(); + let routes = vec![( + Method::POST, + "/integrations/test/synthetic", + ( + Arc::new(SyntheticIdTestProxy) as Arc, + "synthetic_id_test", + ), + )]; + let registry = IntegrationRegistry::from_routes(routes); + + let req = Request::post("https://test-publisher.com/integrations/test/synthetic") + .with_body("test body"); + + let result = futures::executor::block_on(registry.handle_proxy( + &Method::POST, + "/integrations/test/synthetic", + &settings, + req, + )); + + assert!(result.is_some(), "Should find POST route"); + let response = result.unwrap(); + assert!(response.is_ok(), "Handler should succeed"); + + let response = response.unwrap(); + assert!( + response.get_header(HEADER_X_SYNTHETIC_ID).is_some(), + "POST response should have x-synthetic-id header" + ); + } } diff --git a/crates/common/src/integrations/testlight.rs b/crates/common/src/integrations/testlight.rs index b6883ac..8dd3ec7 100644 --- a/crates/common/src/integrations/testlight.rs +++ b/crates/common/src/integrations/testlight.rs @@ -8,7 +8,6 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::constants::HEADER_X_SYNTHETIC_ID; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -175,7 +174,6 @@ impl IntegrationProxy for TestlightIntegration { } } - response.set_header(HEADER_X_SYNTHETIC_ID, &synthetic_id); Ok(response) } } diff --git a/trusted-server.toml b/trusted-server.toml index 2e22c06..daa0214 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -21,11 +21,6 @@ secret_key = "trusted-server" # - "random_uuid" template = "{{ client_ip }}:{{ user_agent }}:{{ accept_language }}:{{ accept_encoding }}" -# Custom headers to be included in every response -# Allows publishers to include tags such as X-Robots-Tag: noindex -# [response_headers] -# X-Custom-Header = "custom header value" - # Request Signing Configuration # Enable signing of OpenRTB requests and other API calls [request_signing]