Skip to content
15 changes: 15 additions & 0 deletions crates/common/src/cookies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> Strin
)
}

/// Sets the synthetic ID cookie on the given response.
///
/// This helper abstracts the logic of creating the cookie string and appending
/// the Set-Cookie header to the response.
pub fn set_synthetic_cookie(
settings: &Settings,
response: &mut fastly::Response,
synthetic_id: &str,
) {
response.append_header(
header::SET_COOKIE,
create_synthetic_cookie(settings, synthetic_id),
);
}

#[cfg(test)]
mod tests {
use crate::test_support::tests::create_test_settings;
Expand Down
195 changes: 194 additions & 1 deletion crates/common/src/integrations/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use fastly::http::Method;
use fastly::{Request, Response};
use matchit::Router;

use crate::constants::HEADER_X_SYNTHETIC_ID;
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)]
Expand Down Expand Up @@ -602,6 +604,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,
Expand All @@ -611,7 +616,19 @@ impl IntegrationRegistry {
req: Request,
) -> Option<Result<Response, Report<TrustedServerError>>> {
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 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());
crate::cookies::set_synthetic_cookie(settings, response, synthetic_id.as_str());
}
}
Some(result)
} else {
None
}
Expand Down Expand Up @@ -1042,4 +1059,180 @@ 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::constants::COOKIE_SYNTHETIC_ID;
use crate::test_support::tests::create_test_settings;
use fastly::http::header;

/// 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<IntegrationEndpoint> {
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<Response, Report<TrustedServerError>> {
// 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<dyn IntegrationProxy>,
"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_always_sets_cookie() {
let settings = create_test_settings();
let routes = vec![(
Method::GET,
"/integrations/test/synthetic",
(
Arc::new(SyntheticIdTestProxy) as Arc<dyn IntegrationProxy>,
"test",
),
)];

let registry = IntegrationRegistry::from_routes(routes);

let mut req = Request::get("https://test.example.com/integrations/test/synthetic");
// Pre-existing cookie
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,
))
.expect("should handle proxy request");

let response = result.expect("proxy handle should succeed");

// 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"
);

// Should ALWAYS set the cookie again (per new requirements)
let set_cookie = response.get_header(header::SET_COOKIE);

assert!(
set_cookie.is_some(),
"Should set Set-Cookie header even if cookie is present"
);

if let Some(cookie) = set_cookie {
let cookie_str = cookie.to_str().unwrap_or("");
assert!(
cookie_str.contains(COOKIE_SYNTHETIC_ID),
"Should contain 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<dyn IntegrationProxy>,
"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"
);
}
}
2 changes: 0 additions & 2 deletions crates/common/src/integrations/testlight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -175,7 +174,6 @@ impl IntegrationProxy for TestlightIntegration {
}
}

response.set_header(HEADER_X_SYNTHETIC_ID, &synthetic_id);
Ok(response)
}
}
Expand Down
28 changes: 4 additions & 24 deletions crates/common/src/publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use fastly::{Body, Request, Response};
use crate::backend::ensure_backend_from_url;
use crate::http_util::{serve_static_with_etag, RequestInfo};

use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID};
use crate::cookies::create_synthetic_cookie;
use crate::constants::{HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID};
use crate::cookies::set_synthetic_cookie;
use crate::error::TrustedServerError;
use crate::integrations::IntegrationRegistry;
use crate::rsc_flight::RscFlightUrlRewriter;
Expand Down Expand Up @@ -198,23 +198,8 @@ pub fn handle_publisher_request(

// Generate synthetic identifiers before the request body is consumed.
let synthetic_id = get_or_generate_synthetic_id(settings, &req)?;
let has_synthetic_cookie = req
.get_header(header::COOKIE)
.and_then(|h| h.to_str().ok())
.map(|cookies| {
cookies.split(';').any(|cookie| {
cookie
.trim_start()
.starts_with(&format!("{}=", COOKIE_SYNTHETIC_ID))
})
})
.unwrap_or(false);

log::debug!(
"Proxy synthetic IDs - trusted: {}, has_cookie: {}",
synthetic_id,
has_synthetic_cookie
);
log::debug!("Proxy synthetic IDs - trusted: {}", synthetic_id,);

let backend_name = ensure_backend_from_url(&settings.publisher.origin_url)?;
let origin_host = settings.publisher.origin_host();
Expand Down Expand Up @@ -308,12 +293,7 @@ pub fn handle_publisher_request(
}

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()),
);
}
set_synthetic_cookie(settings, &mut response, synthetic_id.as_str());

Ok(response)
}
Expand Down