diff --git a/.github/actions/setup-integration-test-env/action.yml b/.github/actions/setup-integration-test-env/action.yml index e61d129b..e6572158 100644 --- a/.github/actions/setup-integration-test-env/action.yml +++ b/.github/actions/setup-integration-test-env/action.yml @@ -71,7 +71,7 @@ runs: env: TRUSTED_SERVER__PUBLISHER__ORIGIN_URL: http://127.0.0.1:${{ inputs.origin-port }} TRUSTED_SERVER__PUBLISHER__PROXY_SECRET: integration-test-proxy-secret - TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY: integration-test-secret-key + TRUSTED_SERVER__EC__PASSPHRASE: integration-test-ec-secret TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK: "false" run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/crates/integration-tests/Cargo.lock b/crates/integration-tests/Cargo.lock index 9b98a638..06b8a551 100644 --- a/crates/integration-tests/Cargo.lock +++ b/crates/integration-tests/Cargo.lock @@ -349,6 +349,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -498,6 +527,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -1225,6 +1263,7 @@ dependencies = [ "scraper", "serde_json", "testcontainers", + "urlencoding", ] [[package]] @@ -1334,6 +1373,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1813,6 +1858,22 @@ dependencies = [ "prost", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quote" version = "1.0.45" @@ -1953,6 +2014,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-channel", "futures-core", @@ -2848,6 +2911,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 24d0cdcc..9bfd696f 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -11,10 +11,11 @@ harness = true [dev-dependencies] testcontainers = { version = "0.25", features = ["blocking"] } -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12", features = ["blocking", "cookies", "json"] } scraper = "0.21" log = "0.4.29" serde_json = "1.0.149" error-stack = "0.6" derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" +urlencoding = "2.1" diff --git a/crates/integration-tests/fixtures/configs/viceroy-template.toml b/crates/integration-tests/fixtures/configs/viceroy-template.toml index 68e8bd15..ddc3c580 100644 --- a/crates/integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/integration-tests/fixtures/configs/viceroy-template.toml @@ -21,6 +21,18 @@ key = "placeholder" data = "placeholder" + [[local_server.kv_stores.consent_store]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.ec_identity_store]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.ec_partner_store]] + key = "placeholder" + data = "placeholder" + # These are generated test-only key pairs, not production credentials. # The Ed25519 private key (data) and its matching public key (x in jwks_store below) # exist solely for signing and verifying tokens in the integration test environment. diff --git a/crates/integration-tests/tests/common/ec.rs b/crates/integration-tests/tests/common/ec.rs new file mode 100644 index 00000000..b918faff --- /dev/null +++ b/crates/integration-tests/tests/common/ec.rs @@ -0,0 +1,447 @@ +//! EC integration test helpers. +//! +//! Provides a cookie-aware HTTP client and request builders for the EC +//! identity lifecycle endpoints: partner registration, pixel sync, +//! identify, and batch sync. +//! +//! Also provides a minimal origin server that satisfies organic route +//! proxying so the trusted-server can generate and set EC cookies. + +use crate::common::runtime::{TestError, TestResult}; +use error_stack::{Report, ResultExt}; +use reqwest::blocking::{Client, Response}; +use serde_json::Value; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::thread; +use std::thread::JoinHandle; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Cookie-aware HTTP client +// --------------------------------------------------------------------------- + +/// HTTP client that manually tracks the `ts-ec` cookie value. +/// +/// Reqwest's built-in cookie jar respects domain matching, but the EC +/// cookie is set with `Domain=.test-publisher.com` while tests run +/// against `127.0.0.1`. This client extracts and replays the `ts-ec` +/// cookie manually via the `Cookie` header. +pub struct EcTestClient { + client: Client, + pub base_url: String, + /// The active `ts-ec` cookie value, updated after each response. + ec_cookie: std::cell::RefCell>, +} + +impl EcTestClient { + /// Creates a new client. Redirects are disabled so tests can inspect + /// 302 responses from `/sync`. + pub fn new(base_url: &str) -> Self { + let client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("should build reqwest client"); + + Self { + client, + base_url: base_url.to_owned(), + ec_cookie: std::cell::RefCell::new(None), + } + } + + /// Updates the tracked EC cookie from a response's `Set-Cookie` headers. + fn track_ec_cookie(&self, resp: &Response) { + for value in resp.headers().get_all("set-cookie") { + if let Ok(cookie_str) = value.to_str() { + if cookie_str.starts_with("ts-ec=") { + if cookie_str.contains("Max-Age=0") { + // Cookie deletion + *self.ec_cookie.borrow_mut() = None; + } else if let Some(val) = cookie_str + .split(';') + .next() + .and_then(|s| s.strip_prefix("ts-ec=")) + { + if !val.is_empty() { + *self.ec_cookie.borrow_mut() = Some(val.to_owned()); + } + } + } + } + } + } + + /// Builds a request with the tracked EC cookie attached. + fn attach_ec_cookie( + &self, + builder: reqwest::blocking::RequestBuilder, + ) -> reqwest::blocking::RequestBuilder { + if let Some(ref ec) = *self.ec_cookie.borrow() { + builder.header("cookie", format!("ts-ec={ec}")) + } else { + builder + } + } + + /// `GET {base_url}{path}` with tracked EC cookie. + pub fn get(&self, path: &str) -> TestResult { + let builder = self.client.get(format!("{}{path}", self.base_url)); + let resp = self + .attach_ec_cookie(builder) + .send() + .change_context(TestError::HttpRequest) + .attach(format!("GET {path}"))?; + self.track_ec_cookie(&resp); + Ok(resp) + } + + /// `GET {base_url}{path}` with extra headers. + pub fn get_with_headers(&self, path: &str, headers: &[(&str, &str)]) -> TestResult { + let mut builder = self.client.get(format!("{}{path}", self.base_url)); + for (key, value) in headers { + builder = builder.header(*key, *value); + } + let resp = self + .attach_ec_cookie(builder) + .send() + .change_context(TestError::HttpRequest) + .attach(format!("GET {path}"))?; + self.track_ec_cookie(&resp); + Ok(resp) + } + + /// `POST {base_url}{path}` with JSON body. + pub fn post_json(&self, path: &str, body: &Value) -> TestResult { + let builder = self + .client + .post(format!("{}{path}", self.base_url)) + .json(body); + let resp = self + .attach_ec_cookie(builder) + .send() + .change_context(TestError::HttpRequest) + .attach(format!("POST {path}"))?; + self.track_ec_cookie(&resp); + Ok(resp) + } + + /// `POST {base_url}{path}` with JSON body and basic auth. + pub fn post_json_with_basic_auth( + &self, + path: &str, + body: &Value, + username: &str, + password: &str, + ) -> TestResult { + let builder = self + .client + .post(format!("{}{path}", self.base_url)) + .basic_auth(username, Some(password)) + .json(body); + let resp = self + .attach_ec_cookie(builder) + .send() + .change_context(TestError::HttpRequest) + .attach(format!("POST {path} (basic auth)"))?; + self.track_ec_cookie(&resp); + Ok(resp) + } + + /// `POST {base_url}{path}` with JSON body and bearer token auth. + pub fn post_json_with_bearer( + &self, + path: &str, + body: &Value, + token: &str, + ) -> TestResult { + let builder = self + .client + .post(format!("{}{path}", self.base_url)) + .bearer_auth(token) + .json(body); + let resp = self + .attach_ec_cookie(builder) + .send() + .change_context(TestError::HttpRequest) + .attach(format!("POST {path} (bearer auth)"))?; + self.track_ec_cookie(&resp); + Ok(resp) + } + + /// Returns the currently tracked EC cookie value, if any. + #[allow(dead_code)] + pub fn ec_cookie_value(&self) -> Option { + self.ec_cookie.borrow().clone() + } +} + +// --------------------------------------------------------------------------- +// Partner registration +// --------------------------------------------------------------------------- + +/// Admin credentials matching `trusted-server.toml` `[[handlers]]` for `/admin`. +const ADMIN_USER: &str = "admin"; +const ADMIN_PASS: &str = "changeme"; + +/// Registers a test partner via `POST /admin/partners/register`. +pub fn register_test_partner( + client: &EcTestClient, + partner_id: &str, + api_key: &str, + return_domain: &str, +) -> TestResult<()> { + let body = serde_json::json!({ + "id": partner_id, + "name": format!("Test Partner {partner_id}"), + "api_key": api_key, + "allowed_return_domains": [return_domain], + "source_domain": format!("{partner_id}.example.com"), + "bidstream_enabled": true, + }); + + let resp = client.post_json_with_basic_auth( + "/admin/partners/register", + &body, + ADMIN_USER, + ADMIN_PASS, + )?; + + let status = resp.status().as_u16(); + if !resp.status().is_success() { + let body_text = resp.text().unwrap_or_default(); + return Err(Report::new(TestError::PartnerRegistrationFailed) + .attach(format!("Expected 2xx, got {status}; body: {body_text}"))); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Pixel sync +// --------------------------------------------------------------------------- + +/// Calls `GET /sync` with the required query parameters. +/// +/// Returns the raw response (typically a 302 redirect). +pub fn pixel_sync( + client: &EcTestClient, + partner: &str, + uid: &str, + return_url: &str, +) -> TestResult { + let path = format!( + "/sync?partner={partner}&uid={uid}&return={}", + urlencoding::encode(return_url) + ); + client.get(&path) +} + +// --------------------------------------------------------------------------- +// Identify +// --------------------------------------------------------------------------- + +/// Calls `GET /identify` and returns the raw response. +pub fn identify(client: &EcTestClient) -> TestResult { + client.get("/identify") +} + +// --------------------------------------------------------------------------- +// Batch sync +// --------------------------------------------------------------------------- + +/// Calls `POST /api/v1/sync` with bearer auth and the given mappings. +pub fn batch_sync( + client: &EcTestClient, + api_key: &str, + mappings: &[BatchMapping], +) -> TestResult { + let body = serde_json::json!({ "mappings": mappings_to_json(mappings) }); + client.post_json_with_bearer("/api/v1/sync", &body, api_key) +} + +/// Calls `POST /api/v1/sync` without any auth header. +pub fn batch_sync_no_auth( + client: &EcTestClient, + mappings: &[BatchMapping], +) -> TestResult { + let body = serde_json::json!({ "mappings": mappings_to_json(mappings) }); + client.post_json("/api/v1/sync", &body) +} + +/// Single mapping in a batch sync request. +pub struct BatchMapping { + pub ssc_hash: String, + pub partner_uid: String, + pub timestamp: u64, +} + +fn mappings_to_json(mappings: &[BatchMapping]) -> Vec { + mappings + .iter() + .map(|m| { + serde_json::json!({ + "ssc_hash": m.ssc_hash, + "partner_uid": m.partner_uid, + "timestamp": m.timestamp, + }) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Assertion helpers +// --------------------------------------------------------------------------- + +/// Asserts the response has a specific HTTP status code. +pub fn assert_status(resp: &Response, expected: u16) -> TestResult<()> { + let actual = resp.status().as_u16(); + if actual != expected { + return Err(Report::new(TestError::UnexpectedStatusCode { + expected, + actual, + })); + } + Ok(()) +} + +/// Asserts the response status and returns the parsed JSON body. +pub fn assert_json_response(resp: Response, expected_status: u16) -> TestResult { + let actual = resp.status().as_u16(); + if actual != expected_status { + let body_text = resp.text().unwrap_or_default(); + return Err(Report::new(TestError::UnexpectedStatusCode { + expected: expected_status, + actual, + }) + .attach(format!("body: {body_text}"))); + } + + let body = resp + .text() + .change_context(TestError::ResponseParse) + .attach("failed to read response body")?; + + serde_json::from_str(&body) + .change_context(TestError::ResponseParse) + .attach(format!("invalid JSON: {body}")) +} + +/// Extracts the `ts-ec` cookie value from a `Set-Cookie` response header. +/// +/// Returns `None` if no `ts-ec` cookie was set. +pub fn extract_ec_cookie_from_response(resp: &Response) -> Option { + for value in resp.headers().get_all("set-cookie") { + let cookie_str = value.to_str().ok()?; + if cookie_str.starts_with("ts-ec=") { + let value = cookie_str + .split(';') + .next()? + .strip_prefix("ts-ec=")? + .to_owned(); + if !value.is_empty() { + return Some(value); + } + } + } + None +} + +/// Checks whether the response expires (deletes) the `ts-ec` cookie. +pub fn is_ec_cookie_expired(resp: &Response) -> bool { + for value in resp.headers().get_all("set-cookie") { + if let Ok(cookie_str) = value.to_str() { + if cookie_str.starts_with("ts-ec=") && cookie_str.contains("Max-Age=0") { + return true; + } + } + } + false +} + +/// Extracts the stable 64-char hex prefix from an EC ID (`{64hex}.{6alnum}`). +pub fn ec_hash(ec_id: &str) -> &str { + match ec_id.find('.') { + Some(pos) => &ec_id[..pos], + None => ec_id, + } +} + +// --------------------------------------------------------------------------- +// Minimal origin server +// --------------------------------------------------------------------------- + +/// A minimal HTTP origin server that returns `200 OK` with a simple HTML body +/// for any request. Required for organic route proxying — without a running +/// origin, the trusted-server returns an error and never sets the EC cookie. +/// +/// Runs on the given port in a background thread. Dropped when the handle +/// goes out of scope via explicit shutdown + thread join. +pub struct MinimalOrigin { + shutdown_tx: mpsc::Sender<()>, + handle: Option>, +} + +impl MinimalOrigin { + /// Starts a minimal origin server on `127.0.0.1:{port}`. + /// + /// # Panics + /// + /// Panics if the port is already in use. + pub fn start(port: u16) -> Self { + let listener = + TcpListener::bind(format!("127.0.0.1:{port}")).expect("should bind origin port"); + listener + .set_nonblocking(true) + .expect("should set listener nonblocking"); + let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>(); + + let handle = thread::spawn(move || { + loop { + if shutdown_rx.try_recv().is_ok() { + break; + } + + match listener.accept() { + Ok((mut stream, _addr)) => { + // Read one chunk to consume the request line/headers. + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + + let body = "

Test Origin

"; + let response = format!( + "HTTP/1.1 200 OK\r\n\ + Content-Type: text/html\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + + Self { + shutdown_tx, + handle: Some(handle), + } + } +} + +impl Drop for MinimalOrigin { + fn drop(&mut self) { + let _ = self.shutdown_tx.send(()); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/crates/integration-tests/tests/common/mod.rs b/crates/integration-tests/tests/common/mod.rs index 61fb404e..9dba15c4 100644 --- a/crates/integration-tests/tests/common/mod.rs +++ b/crates/integration-tests/tests/common/mod.rs @@ -1,2 +1,3 @@ pub mod assertions; +pub mod ec; pub mod runtime; diff --git a/crates/integration-tests/tests/common/runtime.rs b/crates/integration-tests/tests/common/runtime.rs index bf8b795a..95bc6c14 100644 --- a/crates/integration-tests/tests/common/runtime.rs +++ b/crates/integration-tests/tests/common/runtime.rs @@ -45,6 +45,19 @@ pub enum TestError { #[display("Origin URL not rewritten in HTML attributes")] AttributeNotRewritten, + // EC lifecycle errors + #[display("EC cookie was not set on the response")] + EcCookieNotSet, + + #[display("Expected HTTP status {expected}, got {actual}")] + UnexpectedStatusCode { expected: u16, actual: u16 }, + + #[display("Partner registration failed")] + PartnerRegistrationFailed, + + #[display("JSON field assertion failed: {field}")] + JsonFieldMismatch { field: String }, + // Resource errors #[display("No available port found")] NoPortAvailable, diff --git a/crates/integration-tests/tests/frameworks/scenarios.rs b/crates/integration-tests/tests/frameworks/scenarios.rs index 09268a4e..9990e2d9 100644 --- a/crates/integration-tests/tests/frameworks/scenarios.rs +++ b/crates/integration-tests/tests/frameworks/scenarios.rs @@ -1,5 +1,10 @@ use crate::common::assertions; -use crate::common::runtime::{TestError, TestResult, origin_port}; +use crate::common::ec::{ + assert_json_response, assert_status, batch_sync, batch_sync_no_auth, ec_hash, + extract_ec_cookie_from_response, identify, is_ec_cookie_expired, pixel_sync, + register_test_partner, BatchMapping, EcTestClient, +}; +use crate::common::runtime::{origin_port, TestError, TestResult}; use error_stack::Report; use error_stack::ResultExt as _; @@ -422,3 +427,345 @@ impl CustomScenario { } } } + +// --------------------------------------------------------------------------- +// EC identity lifecycle scenarios +// --------------------------------------------------------------------------- + +/// EC identity lifecycle scenarios that test KV-backed stateful behavior. +/// +/// These run against the Viceroy runtime directly without a frontend +/// framework container — they exercise EC-specific endpoints (`/sync`, +/// `/identify`, `/api/v1/sync`, `/admin/partners/register`). +#[derive(Debug, Clone)] +pub enum EcScenario { + /// Full flow: organic request generates EC → pixel sync writes partner + /// UID → identify returns UID. + FullLifecycle, + + /// Consent withdrawal: GPC header triggers EC cookie deletion. + ConsentWithdrawal, + + /// Identify without EC cookie returns 204. + IdentifyWithoutEc, + + /// Identify with consent denied returns 403. + IdentifyConsentDenied, + + /// Two pixel syncs with different partners → identify returns both UIDs. + ConcurrentPartnerSyncs, + + /// Batch sync happy path: authenticated request writes UID. + BatchSyncHappyPath, + + /// Batch sync auth rejection: no auth → 401, wrong auth → 401. + BatchSyncAuthRejection, +} + +impl EcScenario { + /// All EC scenarios in order. + pub fn all() -> Vec { + vec![ + Self::FullLifecycle, + Self::ConsentWithdrawal, + Self::IdentifyWithoutEc, + Self::IdentifyConsentDenied, + Self::ConcurrentPartnerSyncs, + Self::BatchSyncHappyPath, + Self::BatchSyncAuthRejection, + ] + } + + /// Execute this EC scenario against a running Viceroy instance. + /// + /// Each scenario creates its own `EcTestClient` to isolate cookie state. + /// + /// # Errors + /// + /// Returns [`TestError`] on assertion failures. + pub fn run(&self, base_url: &str) -> TestResult<()> { + match self { + Self::FullLifecycle => ec_full_lifecycle(base_url), + Self::ConsentWithdrawal => ec_consent_withdrawal(base_url), + Self::IdentifyWithoutEc => ec_identify_without_ec(base_url), + Self::IdentifyConsentDenied => ec_identify_consent_denied(base_url), + Self::ConcurrentPartnerSyncs => ec_concurrent_partner_syncs(base_url), + Self::BatchSyncHappyPath => ec_batch_sync_happy_path(base_url), + Self::BatchSyncAuthRejection => ec_batch_sync_auth_rejection(base_url), + } + } +} + +/// Full lifecycle: page load → EC → pixel sync → identify with UID. +fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + // 1. Organic request generates EC cookie + let resp = client.get("/")?; + let ec_id = extract_ec_cookie_from_response(&resp).ok_or_else(|| { + Report::new(TestError::EcCookieNotSet).attach("organic GET / should set ts-ec cookie") + })?; + log::info!("EC full lifecycle: generated EC ID = {ec_id}"); + + // 2. Register a test partner + register_test_partner(&client, "inttest", "inttest-api-key-1", "sync.example.com") + .attach("EC full lifecycle: partner registration")?; + + // 3. Pixel sync writes partner UID + let return_url = "https://sync.example.com/done?ok=1"; + let resp = pixel_sync(&client, "inttest", "user-uid-42", return_url)?; + + let status = resp.status().as_u16(); + if status != 302 { + let body = resp.text().unwrap_or_default(); + return Err(Report::new(TestError::UnexpectedStatusCode { + expected: 302, + actual: status, + }) + .attach(format!("pixel sync should redirect; body: {body}"))); + } + + // 4. Identify should return the synced UID + let json = assert_json_response(identify(&client)?, 200) + .attach("EC full lifecycle: identify after pixel sync")?; + + let uids = json + .get("uids") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + Report::new(TestError::JsonFieldMismatch { + field: "uids".to_owned(), + }) + .attach(format!("identify body: {json}")) + })?; + + let uid_value = uids.get("inttest").and_then(|v| v.as_str()); + if uid_value != Some("user-uid-42") { + return Err(Report::new(TestError::JsonFieldMismatch { + field: "uids.inttest".to_owned(), + }) + .attach(format!( + "expected uid 'user-uid-42', got {:?}; body: {json}", + uid_value + ))); + } + + log::info!("EC full lifecycle: PASSED"); + Ok(()) +} + +/// Consent withdrawal: GPC header clears EC cookie. +fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + // 1. Generate EC (no consent headers → non-regulated → EC allowed) + let resp = client.get("/")?; + let ec_id = extract_ec_cookie_from_response(&resp).ok_or_else(|| { + Report::new(TestError::EcCookieNotSet).attach("should set ts-ec on first organic request") + })?; + log::info!("EC consent withdrawal: generated EC = {ec_id}"); + + // 2. Second request with GPC=1 should revoke consent and expire the EC + // cookie. This endpoint was selected because step #1 proved EC was + // allowed for this client in the active runtime config. + let resp = client.get_with_headers("/", &[("sec-gpc", "1")])?; + + if !is_ec_cookie_expired(&resp) { + return Err(Report::new(TestError::JsonFieldMismatch { + field: "set-cookie(ts-ec expired)".to_owned(), + }) + .attach("consent withdrawal should expire ts-ec cookie")); + } + + // 3. With cookie revoked and no GPC header on identify, server should + // report no EC present. + let resp = identify(&client)?; + assert_status(&resp, 204).attach("identify should return 204 after cookie revocation")?; + + // 4. With GPC still asserted, identify should reflect consent denial. + let resp = client.get_with_headers("/identify", &[("sec-gpc", "1")])?; + assert_status(&resp, 403) + .attach("identify with GPC should return 403 after consent withdrawal")?; + + log::info!("EC consent withdrawal: PASSED"); + Ok(()) +} + +/// Identify without EC cookie returns 204 No Content. +fn ec_identify_without_ec(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + let resp = identify(&client)?; + assert_status(&resp, 204).attach("identify without EC cookie should return 204")?; + + log::info!("EC identify without EC: PASSED"); + Ok(()) +} + +/// Identify with consent denied returns 403. +fn ec_identify_consent_denied(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + // Generate EC first (non-regulated → allowed) + let resp = client.get("/")?; + let _ec_id = extract_ec_cookie_from_response(&resp).ok_or_else(|| { + Report::new(TestError::EcCookieNotSet) + .attach("should set ts-ec on organic request for consent-denied test") + })?; + + // Identify with GPC=1 — if jurisdiction is non-regulated + GPC, + // consent may still be denied depending on US-state detection. + // Without geo, jurisdiction is Unknown → fail-closed → 403. + let resp = client.get_with_headers("/identify", &[("sec-gpc", "1")])?; + + let status = resp.status().as_u16(); + // Under Unknown jurisdiction (no geo in Viceroy), EC is denied + // so the response may be 403 or 204 depending on whether the EC + // context reads the cookie before consent check. + if status != 403 && status != 204 { + return Err(Report::new(TestError::UnexpectedStatusCode { + expected: 403, + actual: status, + }) + .attach("identify with consent denied should return 403 or 204")); + } + + log::info!("EC identify consent denied: PASSED (status={status})"); + Ok(()) +} + +/// Two pixel syncs with different partners → identify returns both UIDs. +fn ec_concurrent_partner_syncs(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + // Generate EC + let resp = client.get("/")?; + let ec_id = extract_ec_cookie_from_response(&resp).ok_or_else(|| { + Report::new(TestError::EcCookieNotSet).attach("concurrent syncs: need EC cookie") + })?; + log::info!("EC concurrent syncs: EC = {ec_id}"); + + // Register two partners + register_test_partner(&client, "sspa", "key-sspa", "sync.example.com") + .attach("register partner sspa")?; + register_test_partner(&client, "sspb", "key-sspb", "sync.example.com") + .attach("register partner sspb")?; + + // Pixel sync both + let return_url = "https://sync.example.com/done"; + let resp = pixel_sync(&client, "sspa", "uid-a", return_url)?; + assert_status(&resp, 302).attach("pixel sync sspa should redirect")?; + + let resp = pixel_sync(&client, "sspb", "uid-b", return_url)?; + assert_status(&resp, 302).attach("pixel sync sspb should redirect")?; + + // Identify should contain both + let json = + assert_json_response(identify(&client)?, 200).attach("identify after dual pixel sync")?; + + let uids = json + .get("uids") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + Report::new(TestError::JsonFieldMismatch { + field: "uids".to_owned(), + }) + .attach(format!("body: {json}")) + })?; + + for (partner, expected_uid) in [("sspa", "uid-a"), ("sspb", "uid-b")] { + let actual = uids.get(partner).and_then(|v| v.as_str()); + if actual != Some(expected_uid) { + return Err(Report::new(TestError::JsonFieldMismatch { + field: format!("uids.{partner}"), + }) + .attach(format!( + "expected '{expected_uid}', got {:?}; body: {json}", + actual + ))); + } + } + + log::info!("EC concurrent partner syncs: PASSED"); + Ok(()) +} + +/// Batch sync happy path: authenticated request writes UID, verify via identify. +fn ec_batch_sync_happy_path(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + // Generate EC to get a valid hash + let resp = client.get("/")?; + let ec_id = extract_ec_cookie_from_response(&resp).ok_or_else(|| { + Report::new(TestError::EcCookieNotSet).attach("batch sync: need EC cookie") + })?; + let hash = ec_hash(&ec_id).to_owned(); + log::info!("EC batch sync happy path: hash = {hash}"); + + // Register partner with known API key + register_test_partner(&client, "batchssp", "batch-api-key-1", "sync.example.com") + .attach("register batch sync partner")?; + + // Batch sync writes a UID for this hash + let mappings = vec![BatchMapping { + ssc_hash: hash.clone(), + partner_uid: "batch-uid-99".to_owned(), + timestamp: 1_700_000_000, + }]; + let resp = batch_sync(&client, "batch-api-key-1", &mappings)?; + let json = assert_json_response(resp, 200).attach("batch sync should return 200")?; + + let accepted = json.get("accepted").and_then(|v| v.as_u64()); + if accepted != Some(1) { + return Err(Report::new(TestError::JsonFieldMismatch { + field: "accepted".to_owned(), + }) + .attach(format!( + "expected accepted=1, got {:?}; body: {json}", + accepted + ))); + } + + // Verify via identify + let json = assert_json_response(identify(&client)?, 200).attach("identify after batch sync")?; + + let uid = json + .get("uids") + .and_then(|v| v.get("batchssp")) + .and_then(|v| v.as_str()); + + if uid != Some("batch-uid-99") { + return Err(Report::new(TestError::JsonFieldMismatch { + field: "uids.batchssp".to_owned(), + }) + .attach(format!( + "expected 'batch-uid-99', got {:?}; body: {json}", + uid + ))); + } + + log::info!("EC batch sync happy path: PASSED"); + Ok(()) +} + +/// Batch sync auth rejection: no auth → 401, wrong auth → 401. +fn ec_batch_sync_auth_rejection(base_url: &str) -> TestResult<()> { + let client = EcTestClient::new(base_url); + + let dummy_mappings = vec![BatchMapping { + ssc_hash: "a".repeat(64), + partner_uid: "uid-1".to_owned(), + timestamp: 1_700_000_000, + }]; + + // No auth header + let resp = batch_sync_no_auth(&client, &dummy_mappings)?; + assert_status(&resp, 401).attach("batch sync without auth should return 401")?; + + // Wrong bearer token + let resp = batch_sync(&client, "completely-wrong-key", &dummy_mappings)?; + assert_status(&resp, 401).attach("batch sync with wrong auth should return 401")?; + + log::info!("EC batch sync auth rejection: PASSED"); + Ok(()) +} diff --git a/crates/integration-tests/tests/integration.rs b/crates/integration-tests/tests/integration.rs index e52d0944..84d6d1ad 100644 --- a/crates/integration-tests/tests/integration.rs +++ b/crates/integration-tests/tests/integration.rs @@ -2,9 +2,10 @@ mod common; mod environments; mod frameworks; -use common::runtime::{TestError, origin_port, wasm_binary_path}; +use common::runtime::{RuntimeEnvironment, TestError, origin_port, wasm_binary_path}; use environments::{RUNTIME_ENVIRONMENTS, ReadyCheckOptions, wait_for_http_ready}; use error_stack::ResultExt as _; +use frameworks::scenarios::EcScenario; use frameworks::{FRAMEWORKS, FrontendFramework}; use std::time::Duration; use testcontainers::runners::SyncRunner as _; @@ -134,3 +135,39 @@ fn test_nextjs_fastly() { let framework = frameworks::nextjs::NextJs; test_combination(&runtime, &framework).expect("should pass Next.js on Fastly"); } + +// --------------------------------------------------------------------------- +// EC identity lifecycle tests (no frontend framework container needed) +// --------------------------------------------------------------------------- + +/// Runs all EC lifecycle scenarios against a standalone Viceroy instance. +/// +/// Unlike framework tests, these use a minimal TCP origin server instead +/// of a Docker container — organic routes need *something* to proxy to +/// so the trusted-server can generate and set EC cookies. +#[test] +#[ignore = "requires Viceroy and pre-built WASM binary"] +fn test_ec_lifecycle_fastly() { + init_logger(); + let port = origin_port(); + + // Start a minimal origin server so organic route proxying succeeds. + let _origin = common::ec::MinimalOrigin::start(port); + log::info!("EC lifecycle tests: minimal origin running on port {port}"); + + let runtime = environments::fastly::FastlyViceroy; + let wasm_path = wasm_binary_path(); + + let process = runtime + .spawn(&wasm_path) + .expect("should spawn Viceroy for EC tests"); + + log::info!("EC lifecycle tests: Viceroy running at {}", process.base_url); + + for scenario in EcScenario::all() { + log::info!(" Running EC scenario: {scenario:?}"); + scenario + .run(&process.base_url) + .unwrap_or_else(|e| panic!("EC scenario {scenario:?} failed: {e:?}")); + } +} diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 888adb13..fb1289d3 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -32,7 +32,7 @@ echo "==> Validating shared integration-test dependency versions..." echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY="integration-test-secret-key" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 diff --git a/scripts/integration-tests.sh b/scripts/integration-tests.sh index 3b9ec974..318b9323 100755 --- a/scripts/integration-tests.sh +++ b/scripts/integration-tests.sh @@ -53,7 +53,7 @@ fi echo "==> Building WASM binary (origin=http://127.0.0.1:$ORIGIN_PORT)..." TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$ORIGIN_PORT" \ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET="integration-test-proxy-secret" \ -TRUSTED_SERVER__EDGE_COOKIE__SECRET_KEY="integration-test-secret-key" \ +TRUSTED_SERVER__EC__PASSPHRASE="integration-test-ec-secret" \ TRUSTED_SERVER__PROXY__CERTIFICATE_CHECK=false \ cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1