Skip to content

Commit 563ec96

Browse files
Add configurable tester cookie route (#795)
1 parent a730bb1 commit 563ec96

10 files changed

Lines changed: 343 additions & 0 deletions

File tree

crates/trusted-server-adapter-fastly/src/app.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
//! | POST | `/admin/keys/deactivate` (legacy alias) | [`handle_deactivate_key`] |
2424
//! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] |
2525
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
26+
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
2627
//! | OPTIONS | `/_ts/api/v1/identify` | [`cors_preflight_identify`] |
2728
//! | POST | `/auction` | [`handle_auction`] |
2829
//! | GET | `/first-party/proxy` | [`handle_first_party_proxy`] |
@@ -119,6 +120,7 @@ use trusted_server_core::request_signing::{
119120
};
120121
use trusted_server_core::settings::{ProxyAssetRoute, Settings};
121122
use trusted_server_core::settings_data::get_settings;
123+
use trusted_server_core::tester_cookie::handle_set_tester;
122124

123125
use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
124126
use crate::platform::{
@@ -549,6 +551,7 @@ async fn run_named_route(
549551
)
550552
}
551553
}
554+
NamedRouteHandler::SetTester => handle_set_tester(&state.settings),
552555
NamedRouteHandler::Auction => {
553556
// The auction reads consent data, so the consent KV store must be
554557
// available — fail closed with 503 when it is configured but
@@ -905,6 +908,7 @@ enum NamedRouteHandler {
905908
DeactivateKey,
906909
BatchSync,
907910
Identify,
911+
SetTester,
908912
Auction,
909913
FirstPartyProxy,
910914
FirstPartyClick,
@@ -962,6 +966,11 @@ const NAMED_ROUTES: &[NamedRoute] = &[
962966
primary_methods: &[Method::GET, Method::OPTIONS],
963967
handler: NamedRouteHandler::Identify,
964968
},
969+
NamedRoute {
970+
path: "/_ts/set-tester",
971+
primary_methods: &[Method::GET],
972+
handler: NamedRouteHandler::SetTester,
973+
},
965974
NamedRoute {
966975
path: "/auction",
967976
primary_methods: &[Method::POST],
@@ -1479,6 +1488,47 @@ mod tests {
14791488
);
14801489
}
14811490

1491+
#[test]
1492+
fn dispatch_set_tester_is_disabled_by_default() {
1493+
let router = test_router();
1494+
let response = block_on(router.oneshot(empty_request(Method::GET, "/_ts/set-tester")));
1495+
1496+
assert_eq!(
1497+
response.status(),
1498+
StatusCode::NOT_FOUND,
1499+
"disabled tester-cookie route should return 404"
1500+
);
1501+
assert!(
1502+
response.headers().get(header::SET_COOKIE).is_none(),
1503+
"disabled tester-cookie route should not set a cookie"
1504+
);
1505+
}
1506+
1507+
#[test]
1508+
fn dispatch_set_tester_sets_cookie_on_configured_domain() {
1509+
let mut settings = test_settings();
1510+
settings.tester_cookie.enabled = true;
1511+
let state = app_state_for_settings(settings);
1512+
let router = TrustedServerApp::routes_for_state(&state);
1513+
let response = block_on(router.oneshot(empty_request(Method::GET, "/_ts/set-tester")));
1514+
1515+
assert_eq!(
1516+
response.status(),
1517+
StatusCode::NO_CONTENT,
1518+
"enabled tester-cookie route should return no content"
1519+
);
1520+
let set_cookie = response
1521+
.headers()
1522+
.get(header::SET_COOKIE)
1523+
.expect("should set tester cookie")
1524+
.to_str()
1525+
.expect("should render set-cookie as utf-8");
1526+
assert_eq!(
1527+
set_cookie, "ts-tester=true; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax",
1528+
"tester cookie should use publisher.cookie_domain"
1529+
);
1530+
}
1531+
14821532
#[test]
14831533
fn dispatch_fallback_attaches_ec_finalize_state() {
14841534
// The publisher fallback must thread EC finalize state to the entry

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ use trusted_server_core::request_signing::{
5151
};
5252
use trusted_server_core::settings::Settings;
5353
use trusted_server_core::settings_data::get_settings;
54+
use trusted_server_core::tester_cookie::handle_set_tester;
5455

5556
mod app;
5657
mod backend;
@@ -864,6 +865,7 @@ async fn route_request(
864865
let outcome = cors_preflight_identify(settings, &req);
865866
(outcome, false)
866867
}
868+
(Method::GET, "/_ts/set-tester") => (handle_set_tester(settings), false),
867869

868870
// Unified auction endpoint.
869871
(Method::POST, "/auction") => {

crates/trusted-server-adapter-fastly/src/route_tests.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,63 @@ fn routes_use_request_local_consent() {
10241024
// request-local, and EC lifecycle state uses the EC identity store only.
10251025
}
10261026

1027+
#[test]
1028+
fn set_tester_route_is_disabled_by_default() {
1029+
let settings = create_test_settings();
1030+
let (orchestrator, integration_registry) = build_route_stack(&settings);
1031+
let req = Request::get("https://test.com/_ts/set-tester");
1032+
let services = test_runtime_services(&req);
1033+
1034+
let response = route_buffered_response(
1035+
&settings,
1036+
&orchestrator,
1037+
&integration_registry,
1038+
&services,
1039+
req,
1040+
"should route disabled tester-cookie request",
1041+
);
1042+
1043+
assert_eq!(
1044+
response.get_status(),
1045+
StatusCode::NOT_FOUND,
1046+
"disabled tester-cookie route should return 404"
1047+
);
1048+
assert_eq!(
1049+
response.get_header_str(header::SET_COOKIE),
1050+
None,
1051+
"disabled tester-cookie route should not set a cookie"
1052+
);
1053+
}
1054+
1055+
#[test]
1056+
fn set_tester_route_sets_cookie_on_configured_domain_when_enabled() {
1057+
let mut settings = create_test_settings();
1058+
settings.tester_cookie.enabled = true;
1059+
let (orchestrator, integration_registry) = build_route_stack(&settings);
1060+
let req = Request::get("https://test.com/_ts/set-tester");
1061+
let services = test_runtime_services(&req);
1062+
1063+
let response = route_buffered_response(
1064+
&settings,
1065+
&orchestrator,
1066+
&integration_registry,
1067+
&services,
1068+
req,
1069+
"should route enabled tester-cookie request",
1070+
);
1071+
1072+
assert_eq!(
1073+
response.get_status(),
1074+
StatusCode::NO_CONTENT,
1075+
"enabled tester-cookie route should return no content"
1076+
);
1077+
assert_eq!(
1078+
response.get_header_str(header::SET_COOKIE),
1079+
Some("ts-tester=true; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax"),
1080+
"tester cookie should use publisher.cookie_domain"
1081+
);
1082+
}
1083+
10271084
#[test]
10281085
fn malformed_auction_json_returns_bad_request() {
10291086
let settings = create_auction_test_settings(r#"["prebid"]"#);

crates/trusted-server-core/src/constants.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use http::header::HeaderName;
22

33
pub const COOKIE_TS_EC: &str = "ts-ec";
44
pub const COOKIE_TS_EIDS: &str = "ts-eids";
5+
pub const COOKIE_TS_TESTER: &str = "ts-tester";
56
pub const COOKIE_SHAREDID: &str = "sharedId";
67

78
pub const HEADER_X_PUB_USER_ID: HeaderName = HeaderName::from_static("x-pub-user-id");

crates/trusted-server-core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
//! - [`streaming_replacer`]: Streaming URL replacement for large responses
1818
//! - [`ec`]: Edge Cookie (EC) identity subsystem — ID generation, consent gating, lifecycle
1919
//! - [`test_support`]: Testing utilities and mocks
20+
//! - [`tester_cookie`]: Optional tester-cookie endpoint helpers
2021
2122
#![cfg_attr(
2223
test,
@@ -62,6 +63,7 @@ pub mod storage;
6263
pub mod streaming_processor;
6364
pub mod streaming_replacer;
6465
pub mod test_support;
66+
pub mod tester_cookie;
6567
pub mod tsjs;
6668

6769
#[cfg(test)]

crates/trusted-server-core/src/settings.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1699,11 +1699,21 @@ pub struct DebugConfig {
16991699
pub ja4_endpoint_enabled: bool,
17001700
}
17011701

1702+
/// Tester-cookie endpoint configuration.
1703+
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
1704+
pub struct TesterCookieConfig {
1705+
/// Enable `GET /_ts/set-tester`, which sets `ts-tester=true`.
1706+
#[serde(default)]
1707+
pub enabled: bool,
1708+
}
1709+
17021710
#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)]
17031711
pub struct Settings {
17041712
#[validate(nested)]
17051713
pub publisher: Publisher,
17061714
#[serde(default)]
1715+
pub tester_cookie: TesterCookieConfig,
1716+
#[serde(default)]
17071717
#[validate(nested)]
17081718
pub ec: Ec,
17091719
#[serde(default)]
@@ -2361,6 +2371,10 @@ mod tests {
23612371
);
23622372
assert_eq!(settings.publisher.domain, "test-publisher.com");
23632373
assert_eq!(settings.publisher.cookie_domain, ".test-publisher.com");
2374+
assert!(
2375+
!settings.tester_cookie.enabled,
2376+
"tester-cookie route should default to disabled"
2377+
);
23642378
assert_eq!(
23652379
settings.publisher.ec_cookie_domain(),
23662380
".test-publisher.com",
@@ -2379,6 +2393,25 @@ mod tests {
23792393
settings.validate().expect("Failed to validate settings");
23802394
}
23812395

2396+
#[test]
2397+
fn tester_cookie_enabled_parses_from_toml() {
2398+
let toml_str = format!(
2399+
r#"{}
2400+
2401+
[tester_cookie]
2402+
enabled = true
2403+
"#,
2404+
crate_test_settings_str()
2405+
);
2406+
2407+
let settings = Settings::from_toml(&toml_str).expect("should parse tester-cookie config");
2408+
2409+
assert!(
2410+
settings.tester_cookie.enabled,
2411+
"tester-cookie config should enable the route"
2412+
);
2413+
}
2414+
23822415
#[test]
23832416
fn validate_rejects_trailing_slash_in_origin_url() {
23842417
let toml_str = crate_test_settings_str().replace(
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//! Tester-cookie endpoint helpers.
2+
//!
3+
//! The tester route is intentionally disabled unless configured. When enabled,
4+
//! it sets a first-party `ts-tester=true` cookie scoped to the configured
5+
//! publisher cookie domain.
6+
7+
use edgezero_core::body::Body as EdgeBody;
8+
use error_stack::{Report, ResultExt};
9+
use http::{header, HeaderValue, Response, StatusCode};
10+
11+
use crate::constants::COOKIE_TS_TESTER;
12+
use crate::error::TrustedServerError;
13+
use crate::settings::Settings;
14+
15+
/// Formats the tester cookie `Set-Cookie` header value.
16+
fn format_tester_cookie(domain: &str) -> String {
17+
format!(
18+
"{}=true; Domain={}; Path=/; Secure; SameSite=Lax",
19+
COOKIE_TS_TESTER, domain,
20+
)
21+
}
22+
23+
/// Handles `GET /_ts/set-tester`.
24+
///
25+
/// Returns `404 Not Found` while `[tester_cookie].enabled` is false. When the
26+
/// feature is enabled, returns `204 No Content` with `Set-Cookie: ts-tester=true`
27+
/// scoped to `publisher.cookie_domain`.
28+
///
29+
/// # Errors
30+
///
31+
/// Returns [`TrustedServerError::InvalidHeaderValue`] if the configured cookie
32+
/// domain cannot be rendered as an HTTP header value.
33+
pub fn handle_set_tester(
34+
settings: &Settings,
35+
) -> Result<Response<EdgeBody>, Report<TrustedServerError>> {
36+
if !settings.tester_cookie.enabled {
37+
let mut response = Response::new(EdgeBody::empty());
38+
*response.status_mut() = StatusCode::NOT_FOUND;
39+
return Ok(response);
40+
}
41+
42+
let set_cookie =
43+
HeaderValue::from_str(&format_tester_cookie(&settings.publisher.cookie_domain))
44+
.change_context(TrustedServerError::InvalidHeaderValue {
45+
message: "tester cookie contains invalid header value".to_string(),
46+
})?;
47+
48+
let mut response = Response::new(EdgeBody::empty());
49+
*response.status_mut() = StatusCode::NO_CONTENT;
50+
response.headers_mut().insert(
51+
header::CACHE_CONTROL,
52+
HeaderValue::from_static("no-store, private"),
53+
);
54+
response
55+
.headers_mut()
56+
.insert(header::SET_COOKIE, set_cookie);
57+
Ok(response)
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
use crate::test_support::tests::create_test_settings;
64+
65+
#[test]
66+
fn tester_cookie_uses_configured_cookie_domain() {
67+
let mut settings = create_test_settings();
68+
settings.tester_cookie.enabled = true;
69+
settings.publisher.cookie_domain = ".tester.example".to_string();
70+
71+
let response = handle_set_tester(&settings).expect("should build tester response");
72+
73+
assert_eq!(
74+
response.status(),
75+
StatusCode::NO_CONTENT,
76+
"enabled tester route should return no content"
77+
);
78+
assert_eq!(
79+
response
80+
.headers()
81+
.get(header::CACHE_CONTROL)
82+
.and_then(|value| value.to_str().ok()),
83+
Some("no-store, private"),
84+
"tester route should not be cacheable"
85+
);
86+
let set_cookie = response
87+
.headers()
88+
.get(header::SET_COOKIE)
89+
.expect("should set tester cookie")
90+
.to_str()
91+
.expect("should render set-cookie as utf-8");
92+
assert_eq!(
93+
set_cookie, "ts-tester=true; Domain=.tester.example; Path=/; Secure; SameSite=Lax",
94+
"tester cookie should use publisher.cookie_domain"
95+
);
96+
}
97+
98+
#[test]
99+
fn tester_cookie_route_is_disabled_by_default() {
100+
let settings = create_test_settings();
101+
102+
let response = handle_set_tester(&settings).expect("should build disabled tester response");
103+
104+
assert_eq!(
105+
response.status(),
106+
StatusCode::NOT_FOUND,
107+
"disabled tester route should return not found"
108+
);
109+
assert!(
110+
response.headers().get(header::SET_COOKIE).is_none(),
111+
"disabled tester route should not set a cookie"
112+
);
113+
}
114+
}

0 commit comments

Comments
 (0)