From 6c01e722781341f33e57230f971455a6e0cbc0a3 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 25 Mar 2026 18:59:59 -0500 Subject: [PATCH] Add EC lifecycle integration test scenarios Implement Story 11 (#544): Viceroy-driven E2E tests covering full EC lifecycle (generation, pixel sync, identify, batch sync, consent withdrawal, auth rejection). Adds EC test helpers with manual cookie tracking, minimal origin server with graceful shutdown, and required KV store fixtures. Fixes integration build env vars. --- .../setup-integration-test-env/action.yml | 2 +- crates/integration-tests/Cargo.lock | 69 +++ crates/integration-tests/Cargo.toml | 3 +- .../fixtures/configs/viceroy-template.toml | 12 + crates/integration-tests/tests/common/ec.rs | 447 ++++++++++++++++++ crates/integration-tests/tests/common/mod.rs | 1 + .../integration-tests/tests/common/runtime.rs | 13 + .../tests/frameworks/scenarios.rs | 349 +++++++++++++- crates/integration-tests/tests/integration.rs | 39 +- scripts/integration-tests-browser.sh | 2 +- scripts/integration-tests.sh | 2 +- 11 files changed, 933 insertions(+), 6 deletions(-) create mode 100644 crates/integration-tests/tests/common/ec.rs 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