diff --git a/crates/common/src/integrations/gpt.rs b/crates/common/src/integrations/gpt.rs index 3e18ac9e..bdc062b4 100644 --- a/crates/common/src/integrations/gpt.rs +++ b/crates/common/src/integrations/gpt.rs @@ -36,19 +36,20 @@ use std::sync::Arc; use async_trait::async_trait; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; +use fastly::http::header; use fastly::{Request, Response}; use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::backend::BackendConfig; +use crate::constants::{HEADER_ACCEPT_ENCODING, HEADER_X_FORWARDED_FOR}; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, }; +use crate::proxy::{proxy_request, ProxyRequestConfig}; use crate::settings::{IntegrationConfig, Settings}; const GPT_INTEGRATION_ID: &str = "gpt"; @@ -123,6 +124,56 @@ impl GptIntegration { )) } + fn build_proxy_config<'a>(target_url: &'a str, req: &Request) -> ProxyRequestConfig<'a> { + let mut config = ProxyRequestConfig::new(target_url).with_streaming(); + config.forward_synthetic_id = false; + config = config.with_header( + header::USER_AGENT, + fastly::http::HeaderValue::from_static("TrustedServer/1.0"), + ); + config = config.with_header( + HEADER_ACCEPT_ENCODING, + req.get_header(HEADER_ACCEPT_ENCODING) + .cloned() + .unwrap_or_else(|| fastly::http::HeaderValue::from_static("")), + ); + config = config.with_header(header::REFERER, fastly::http::HeaderValue::from_static("")); + config.with_header( + HEADER_X_FORWARDED_FOR, + fastly::http::HeaderValue::from_static(""), + ) + } + + fn finalize_gpt_asset_response(&self, mut response: Response) -> Response { + response.set_header("X-GPT-Proxy", "true"); + + if response.get_status().is_success() { + response.set_header( + header::CACHE_CONTROL, + format!("public, max-age={}", self.config.cache_ttl_seconds), + ); + } + + response + } + + async fn proxy_gpt_asset( + &self, + settings: &Settings, + req: Request, + target_url: &str, + context: &str, + ) -> Result> { + let config = Self::build_proxy_config(target_url, &req); + let response = proxy_request(settings, req, config) + .await + .change_context(Self::error(context))?; + + // Preserve upstream non-2xx statuses so GPT failures remain visible to + // callers. Only successful responses receive a cache directive. + Ok(self.finalize_gpt_asset_response(response)) + } + /// Check if a URL points at Google's GPT bootstrap script (`gpt.js`). /// /// Only matches the canonical host: @@ -158,55 +209,18 @@ impl GptIntegration { /// by the GPT script guard shim. async fn handle_script_serving( &self, - _settings: &Settings, + settings: &Settings, req: Request, ) -> Result> { let script_url = &self.config.script_url; log::info!("Fetching GPT script from: {}", script_url); - - let mut gpt_req = Request::new(Method::GET, script_url); - Self::copy_accept_headers(&req, &mut gpt_req); - - let backend_name = BackendConfig::from_url(script_url, true).change_context( - Self::error("Failed to determine backend for GPT script fetch"), - )?; - - let mut gpt_response = gpt_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch GPT script from {}", - script_url - )))?; - - if !gpt_response.get_status().is_success() { - log::error!( - "GPT script fetch failed with status: {}", - gpt_response.get_status() - ); - return Err(Report::new(Self::error(format!( - "GPT script returned error status: {}", - gpt_response.get_status() - )))); - } - - let body = gpt_response.take_body_bytes(); - log::info!("Successfully fetched GPT script: {} bytes", body.len()); - - let mut response = Response::from_status(StatusCode::OK) - .with_header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) - .with_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ) - .with_header("X-GPT-Proxy", "true") - .with_body(body); - - Self::copy_content_encoding_headers(&gpt_response, &mut response); - - Ok(response) + self.proxy_gpt_asset( + settings, + req, + script_url, + &format!("Failed to fetch GPT script from {script_url}"), + ) + .await } /// Proxy a secondary GPT script (anything under `/pagead/*` or `/tag/*`). @@ -217,7 +231,7 @@ impl GptIntegration { /// cascade loads. async fn handle_pagead_proxy( &self, - _settings: &Settings, + settings: &Settings, req: Request, ) -> Result> { let original_path = req.get_path(); @@ -227,101 +241,13 @@ impl GptIntegration { .ok_or_else(|| Self::error(format!("Invalid GPT pagead path: {}", original_path)))?; log::info!("GPT proxy: forwarding to {}", target_url); - - let mut upstream_req = Request::new(Method::GET, &target_url); - Self::copy_accept_headers(&req, &mut upstream_req); - - let backend_name = BackendConfig::from_url(&format!("https://{SECUREPUBADS_HOST}"), true) - .change_context(Self::error( - "Failed to determine backend for GPT pagead proxy", - ))?; - - let mut upstream_response = - upstream_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch GPT resource from {}", - target_url - )))?; - - if !upstream_response.get_status().is_success() { - log::error!( - "GPT pagead proxy: upstream returned status {}", - upstream_response.get_status() - ); - return Err(Report::new(Self::error(format!( - "GPT pagead resource returned error status: {}", - upstream_response.get_status() - )))); - } - - let content_type = upstream_response - .get_header_str(header::CONTENT_TYPE) - .unwrap_or("") - .to_string(); - - let body = upstream_response.take_body_bytes(); - log::info!( - "GPT pagead proxy: fetched {} bytes ({})", - body.len(), - content_type - ); - - let mut response = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, &content_type) - .with_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ) - .with_header("X-GPT-Proxy", "true") - .with_body(body); - - Self::copy_content_encoding_headers(&upstream_response, &mut response); - - Ok(response) - } - - /// Copy safe content-negotiation headers to the upstream request. - fn copy_accept_headers(from: &Request, to: &mut Request) { - to.set_header(header::USER_AGENT, "TrustedServer/1.0"); - - for name in [ - header::ACCEPT, - header::ACCEPT_LANGUAGE, - header::ACCEPT_ENCODING, - ] { - if let Some(value) = from.get_header(&name) { - to.set_header(name, value); - } - } - } - - fn copy_content_encoding_headers(from: &Response, to: &mut Response) { - let Some(content_encoding) = from.get_header(header::CONTENT_ENCODING).cloned() else { - return; - }; - - to.set_header(header::CONTENT_ENCODING, content_encoding); - - let vary = Self::vary_with_accept_encoding(from.get_header_str(header::VARY)); - to.set_header(header::VARY, vary); - } - - fn vary_with_accept_encoding(upstream_vary: Option<&str>) -> String { - match upstream_vary.map(str::trim) { - Some("*") => "*".to_string(), - Some(vary) if !vary.is_empty() => { - if vary - .split(',') - .any(|header_name| header_name.trim().eq_ignore_ascii_case("accept-encoding")) - { - vary.to_string() - } else { - format!("{vary}, Accept-Encoding") - } - } - _ => "Accept-Encoding".to_string(), - } + self.proxy_gpt_asset( + settings, + req, + &target_url, + &format!("Failed to fetch GPT resource from {target_url}"), + ) + .await } } @@ -426,10 +352,10 @@ impl IntegrationHeadInjector for GptIntegration { } fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { - // Set the enable flag and explicitly call the activation function - // registered by the GPT shim module. The unified bundle's " .to_string(), @@ -460,6 +386,7 @@ mod tests { use super::*; use crate::integrations::IntegrationDocumentState; use crate::test_support::tests::create_test_settings; + use fastly::http::Method; fn test_config() -> GptConfig { GptConfig { @@ -615,97 +542,152 @@ mod tests { ); } - // -- Request header forwarding -- + // -- GPT proxy configuration -- #[test] - fn copy_accept_headers_forwards_all_negotiation_headers() { - let mut inbound = Request::new(Method::GET, "https://publisher.example/page"); - inbound.set_header(header::ACCEPT, "application/javascript"); - inbound.set_header(header::ACCEPT_ENCODING, "br, gzip"); - inbound.set_header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9"); - - let mut upstream = Request::new( + fn build_proxy_config_uses_streaming_without_synthetic_forwarding() { + let req = Request::new( Method::GET, + "https://edge.example.com/integrations/gpt/script", + ); + let config = GptIntegration::build_proxy_config( "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, + ); + + assert!( + config.stream_passthrough, + "should stream GPT assets verbatim without rewrite processing" + ); + assert!( + !config.forward_synthetic_id, + "should not append synthetic_id to GPT asset requests" + ); + } + + #[test] + fn build_proxy_config_overrides_privacy_sensitive_headers() { + let mut req = Request::new( + Method::GET, + "https://edge.example.com/integrations/gpt/script", ); + req.set_header(HEADER_ACCEPT_ENCODING, "gzip"); - GptIntegration::copy_accept_headers(&inbound, &mut upstream); + let config = GptIntegration::build_proxy_config( + "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, + ); + + let user_agent = config + .headers + .iter() + .find(|(name, _)| name == header::USER_AGENT) + .and_then(|(_, value)| value.to_str().ok()); + let referer = config + .headers + .iter() + .find(|(name, _)| name == header::REFERER) + .and_then(|(_, value)| value.to_str().ok()); + let x_forwarded_for = config + .headers + .iter() + .find(|(name, _)| name == HEADER_X_FORWARDED_FOR) + .and_then(|(_, value)| value.to_str().ok()); + let accept_encoding = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT_ENCODING) + .and_then(|(_, value)| value.to_str().ok()); assert_eq!( - upstream.get_header_str(header::ACCEPT), - Some("application/javascript"), - "should forward Accept header for content negotiation" + user_agent, + Some("TrustedServer/1.0"), + "should use a stable user agent for GPT upstream requests" ); assert_eq!( - upstream.get_header_str(header::ACCEPT_ENCODING), - Some("br, gzip"), - "should forward Accept-Encoding from the client" + referer, + Some(""), + "should clear Referer before proxying GPT assets" ); assert_eq!( - upstream.get_header_str(header::ACCEPT_LANGUAGE), - Some("en-US,en;q=0.9"), - "should forward Accept-Language header for locale negotiation" + x_forwarded_for, + Some(""), + "should strip X-Forwarded-For before proxying GPT assets" ); assert_eq!( - upstream.get_header_str(header::USER_AGENT), - Some("TrustedServer/1.0"), - "should set a stable user agent for GPT upstream requests" + accept_encoding, + Some("gzip"), + "should preserve the caller Accept-Encoding for streamed GPT assets" ); } - // -- Response header forwarding -- - #[test] - fn copy_content_encoding_headers_sets_encoding_and_vary() { - let upstream = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_ENCODING, "br") - .with_header(header::VARY, "Accept-Language"); - let mut downstream = Response::from_status(StatusCode::OK); + fn build_proxy_config_clears_accept_encoding_when_client_omits_it() { + let req = Request::new( + Method::GET, + "https://edge.example.com/integrations/gpt/script", + ); + let config = GptIntegration::build_proxy_config( + "https://securepubads.g.doubleclick.net/tag/js/gpt.js", + &req, + ); - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); + let accept_encoding = config + .headers + .iter() + .find(|(name, _)| name == HEADER_ACCEPT_ENCODING) + .and_then(|(_, value)| value.to_str().ok()); assert_eq!( - downstream.get_header_str(header::CONTENT_ENCODING), - Some("br"), - "should forward Content-Encoding when upstream response is encoded" - ); - assert_eq!( - downstream.get_header_str(header::VARY), - Some("Accept-Language, Accept-Encoding"), - "should include Accept-Encoding in Vary when forwarding encoded responses" + accept_encoding, + Some(""), + "should avoid advertising encodings the client did not request" ); } #[test] - fn copy_content_encoding_headers_preserves_existing_accept_encoding_vary() { - let upstream = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_ENCODING, "gzip") - .with_header(header::VARY, "Origin, Accept-Encoding"); - let mut downstream = Response::from_status(StatusCode::OK); - - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); + fn finalize_gpt_asset_response_adds_proxy_headers_only_for_successes() { + let integration = GptIntegration::new(test_config()); + let response = Response::from_status(fastly::http::StatusCode::OK); + let response = integration.finalize_gpt_asset_response(response); assert_eq!( - downstream.get_header_str(header::VARY), - Some("Origin, Accept-Encoding"), - "should preserve existing Vary value when Accept-Encoding is already present" + response.get_status(), + fastly::http::StatusCode::OK, + "should preserve successful upstream statuses" + ); + assert_eq!( + response.get_header_str("X-GPT-Proxy"), + Some("true"), + "should tag proxied GPT responses" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("public, max-age=3600"), + "should add cache headers for successful GPT asset responses" ); } #[test] - fn copy_content_encoding_headers_skips_unencoded_responses() { - let upstream = Response::from_status(StatusCode::OK).with_header(header::VARY, "Origin"); - let mut downstream = Response::from_status(StatusCode::OK); - - GptIntegration::copy_content_encoding_headers(&upstream, &mut downstream); + fn finalize_gpt_asset_response_preserves_non_success_statuses_without_cache_headers() { + let integration = GptIntegration::new(test_config()); + let response = Response::from_status(fastly::http::StatusCode::SERVICE_UNAVAILABLE); + let response = integration.finalize_gpt_asset_response(response); - assert!( - downstream.get_header(header::CONTENT_ENCODING).is_none(), - "should not set Content-Encoding when upstream response is unencoded" + assert_eq!( + response.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "should preserve upstream non-success statuses for callers" ); - assert!( - downstream.get_header(header::VARY).is_none(), - "should not add Vary when Content-Encoding is absent" + assert_eq!( + response.get_header_str("X-GPT-Proxy"), + Some("true"), + "should still identify non-success GPT responses as proxied" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + None, + "should not cache upstream non-success GPT responses" ); } @@ -795,12 +777,12 @@ mod tests { #[test] fn build_upstream_url_strips_prefix_and_preserves_path() { let url = GptIntegration::build_upstream_url( - "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js", + "/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js", None, ); assert_eq!( url.as_deref(), - Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js"), + Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js"), "should strip the integration prefix and build the upstream URL" ); } @@ -808,12 +790,12 @@ mod tests { #[test] fn build_upstream_url_preserves_query_string() { let url = GptIntegration::build_upstream_url( - "/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js", + "/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js", Some("cb=123&foo=bar"), ); assert_eq!( url.as_deref(), - Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?cb=123&foo=bar"), + Some("https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js?cb=123&foo=bar"), "should preserve the query string in the upstream URL" ); } @@ -848,53 +830,6 @@ mod tests { ); } - // -- Vary header edge cases -- - - #[test] - fn vary_with_accept_encoding_wildcard() { - let result = GptIntegration::vary_with_accept_encoding(Some("*")); - assert_eq!( - result, "*", - "should preserve Vary: * wildcard without appending Accept-Encoding" - ); - } - - #[test] - fn vary_with_accept_encoding_case_insensitive() { - let result = GptIntegration::vary_with_accept_encoding(Some("Origin, ACCEPT-ENCODING")); - assert_eq!( - result, "Origin, ACCEPT-ENCODING", - "should detect Accept-Encoding case-insensitively" - ); - } - - #[test] - fn vary_with_accept_encoding_adds_when_missing() { - let result = GptIntegration::vary_with_accept_encoding(Some("Origin")); - assert_eq!( - result, "Origin, Accept-Encoding", - "should append Accept-Encoding when not present" - ); - } - - #[test] - fn vary_with_accept_encoding_empty_upstream() { - let result = GptIntegration::vary_with_accept_encoding(None); - assert_eq!( - result, "Accept-Encoding", - "should use Accept-Encoding as default when upstream has no Vary" - ); - } - - #[test] - fn vary_with_accept_encoding_empty_string() { - let result = GptIntegration::vary_with_accept_encoding(Some("")); - assert_eq!( - result, "Accept-Encoding", - "should treat empty string the same as absent Vary" - ); - } - // -- Head injector -- #[test] diff --git a/crates/js/lib/src/integrations/datadome/script_guard.ts b/crates/js/lib/src/integrations/datadome/script_guard.ts index 9da50925..26065712 100644 --- a/crates/js/lib/src/integrations/datadome/script_guard.ts +++ b/crates/js/lib/src/integrations/datadome/script_guard.ts @@ -9,8 +9,9 @@ import { createScriptGuard } from '../../shared/script_guard'; * scripts inserted via appendChild, insertBefore, or any other dynamic DOM * manipulation. * - * Built on the shared script_guard factory with custom URL rewriting to preserve - * the original path from the DataDome URL (e.g., /tags.js, /js/check). + * Built on the shared script_guard factory, which registers with the shared + * DOM insertion dispatcher and preserves the original DataDome path + * (e.g., /tags.js, /js/check). */ /** Regex to match js.datadome.co as a domain in URLs */ @@ -63,16 +64,17 @@ function rewriteDataDomeUrl(originalUrl: string): string { } const guard = createScriptGuard({ - name: 'DataDome', + displayName: 'DataDome', + id: 'datadome', isTargetUrl: isDataDomeSdkUrl, rewriteUrl: rewriteDataDomeUrl, }); /** * Install the DataDome guard to intercept dynamic script loading. - * Patches Element.prototype.appendChild and insertBefore to catch - * ANY dynamically inserted DataDome SDK script elements and rewrite their URLs - * before insertion. Works across all frameworks and vanilla JavaScript. + * Registers a handler with the shared DOM insertion dispatcher so dynamically + * inserted DataDome SDK script elements are rewritten before insertion. + * Works across all frameworks and vanilla JavaScript. */ export const installDataDomeGuard = guard.install; diff --git a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts index 70b99cea..990f2fdf 100644 --- a/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts +++ b/crates/js/lib/src/integrations/google_tag_manager/script_guard.ts @@ -1,3 +1,4 @@ +import { log } from '../../core/log'; import { createBeaconGuard } from '../../shared/beacon_guard'; import { createScriptGuard } from '../../shared/script_guard'; @@ -82,12 +83,15 @@ function extractGtmPath(url: string): string { return parsed.pathname + parsed.search; } catch (error) { // Fallback: extract path after the domain using regex - console.warn('[GTM Guard] URL parsing failed for:', url, 'Error:', error); + log.warn('[GTM Guard] URL parsing failed; falling back to regex extraction', { + error, + url, + }); const match = url.match( /(?:www\.(?:googletagmanager|google-analytics)\.com|analytics\.google\.com)(\/[^'"\s]*)/i ); if (!match || !match[1]) { - console.warn('[GTM Guard] Fallback regex failed, using default path /gtm.js'); + log.warn('[GTM Guard] Fallback regex failed; using default path /gtm.js', { url }); return '/gtm.js'; } return match[1]; @@ -102,7 +106,8 @@ function rewriteGtmUrl(originalUrl: string): string { } const guard = createScriptGuard({ - name: 'GTM', + displayName: 'GTM', + id: 'google_tag_manager', isTargetUrl: isGtmUrl, rewriteUrl: rewriteGtmUrl, }); diff --git a/crates/js/lib/src/integrations/gpt/index.ts b/crates/js/lib/src/integrations/gpt/index.ts index 3303929c..3b33fb59 100644 --- a/crates/js/lib/src/integrations/gpt/index.ts +++ b/crates/js/lib/src/integrations/gpt/index.ts @@ -165,10 +165,16 @@ export function installGptShim(): boolean { // script can call it explicitly. The server emits: // -// Because that inline ' + '' ); expect(nativeWriteSpy).toHaveBeenCalledTimes(1); const [writtenHtml] = nativeWriteSpy.mock.calls[0] ?? []; expect(writtenHtml).toContain(window.location.host); expect(writtenHtml).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar' ); }); @@ -116,10 +114,10 @@ describe('GPT script guard', () => { const script = document.createElement('script'); script.src = - 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js'; + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js'; expect(script.getAttribute('src')).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js' ); } finally { descriptorSpy.mockRestore(); @@ -147,12 +145,12 @@ describe('GPT script guard', () => { const script = document.createElement('script'); script.src = - 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar'; + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar'; container.appendChild(script); expect(script.src).toContain(window.location.host); expect(script.src).toContain( - '/integrations/gpt/pagead/managed/js/gpt/current/pubads_impl.js?foo=bar' + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js?foo=bar' ); }); }); diff --git a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts index e5ee45db..fc61861f 100644 --- a/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts +++ b/crates/js/lib/test/integrations/lockr/nextjs_guard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { installNextJsGuard, isGuardInstalled, @@ -10,20 +10,16 @@ describe('Lockr SDK Script Interception Guard', () => { let originalInsertBefore: typeof Element.prototype.insertBefore; beforeEach(() => { - // Store original methods + // Reset guard state before each test. + resetGuardState(); + + // Store original methods after reset so assertions see the true baseline. originalAppendChild = Element.prototype.appendChild; originalInsertBefore = Element.prototype.insertBefore; - - // Reset guard state before each test - resetGuardState(); }); afterEach(() => { - // Restore original methods - Element.prototype.appendChild = originalAppendChild; - Element.prototype.insertBefore = originalInsertBefore; - - // Reset guard state after each test + // Reset guard state after each test. resetGuardState(); }); @@ -58,6 +54,15 @@ describe('Lockr SDK Script Interception Guard', () => { expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); }); + + it('should restore the original prototype methods on reset', () => { + installNextJsGuard(); + + resetGuardState(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); }); describe('appendChild interception', () => { diff --git a/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts new file mode 100644 index 00000000..375ddec1 --- /dev/null +++ b/crates/js/lib/test/shared/dom_insertion_dispatcher.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + installDataDomeGuard, + resetGuardState as resetDataDomeGuardState, +} from '../../src/integrations/datadome/script_guard'; +import { + installGptGuard, + resetGuardState as resetGptGuardState, +} from '../../src/integrations/gpt/script_guard'; +import { + installGtmGuard, + resetGuardState as resetGtmGuardState, +} from '../../src/integrations/google_tag_manager/script_guard'; +import { + installNextJsGuard, + resetGuardState as resetLockrGuardState, +} from '../../src/integrations/lockr/nextjs_guard'; +import { + installPermutiveGuard, + resetGuardState as resetPermutiveGuardState, +} from '../../src/integrations/permutive/script_guard'; +import { + registerDomInsertionHandler, + resetDomInsertionDispatcherForTests, +} from '../../src/shared/dom_insertion_dispatcher'; +import { createScriptGuard } from '../../src/shared/script_guard'; + +function resetAllScriptGuards(): void { + resetDataDomeGuardState(); + resetGptGuardState(); + resetGtmGuardState(); + resetLockrGuardState(); + resetPermutiveGuardState(); +} + +describe('DOM insertion dispatcher', () => { + const dispatcherKey = Symbol.for('trusted-server.domInsertionDispatcher'); + let originalAppendChild: typeof Element.prototype.appendChild; + let originalInsertBefore: typeof Element.prototype.insertBefore; + + beforeEach(() => { + resetAllScriptGuards(); + resetDomInsertionDispatcherForTests(); + originalAppendChild = Element.prototype.appendChild; + originalInsertBefore = Element.prototype.insertBefore; + }); + + afterEach(() => { + resetAllScriptGuards(); + Element.prototype.appendChild = originalAppendChild; + Element.prototype.insertBefore = originalInsertBefore; + resetDomInsertionDispatcherForTests(); + }); + + it('installs a single shared prototype patch across integrations', () => { + installNextJsGuard(); + + const sharedAppendChild = Element.prototype.appendChild; + const sharedInsertBefore = Element.prototype.insertBefore; + + expect(sharedAppendChild).not.toBe(originalAppendChild); + expect(sharedInsertBefore).not.toBe(originalInsertBefore); + + installPermutiveGuard(); + installDataDomeGuard(); + installGtmGuard(); + installGptGuard(); + + expect(Element.prototype.appendChild).toBe(sharedAppendChild); + expect(Element.prototype.insertBefore).toBe(sharedInsertBefore); + + const container = document.createElement('div'); + + const lockrScript = document.createElement('script'); + lockrScript.src = 'https://aim.loc.kr/identity-lockr-v1.0.js'; + container.appendChild(lockrScript); + expect(lockrScript.src).toContain('/integrations/lockr/sdk'); + + const permutiveScript = document.createElement('script'); + permutiveScript.src = 'https://cdn.permutive.com/abc123-web.js'; + container.appendChild(permutiveScript); + expect(permutiveScript.src).toContain('/integrations/permutive/sdk'); + + const dataDomeScript = document.createElement('script'); + dataDomeScript.src = 'https://js.datadome.co/tags.js'; + container.appendChild(dataDomeScript); + expect(dataDomeScript.src).toContain('/integrations/datadome/tags.js'); + + const gtmScript = document.createElement('script'); + gtmScript.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-TEST'; + container.appendChild(gtmScript); + expect(gtmScript.src).toContain('/integrations/google_tag_manager/gtm.js?id=GTM-TEST'); + + const gptLink = document.createElement('link'); + gptLink.setAttribute('rel', 'preload'); + gptLink.setAttribute('as', 'script'); + gptLink.href = + 'https://securepubads.g.doubleclick.net/pagead/managed/js/gpt/m202603020101/pubads_impl.js'; + container.appendChild(gptLink); + expect(gptLink.href).toContain( + '/integrations/gpt/pagead/managed/js/gpt/m202603020101/pubads_impl.js' + ); + }); + + it('prefers lower-priority handlers when multiple handlers match', () => { + const calls: string[] = []; + + const unregisterSlower = registerDomInsertionHandler({ + handle: () => { + calls.push('slower'); + return true; + }, + id: 'zeta', + priority: 100, + }); + + const unregisterFaster = registerDomInsertionHandler({ + handle: () => { + calls.push('faster'); + return true; + }, + id: 'alpha', + priority: 50, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/priority.js'; + container.appendChild(script); + + expect(calls).toEqual(['faster']); + + unregisterSlower(); + unregisterFaster(); + }); + + it('falls back to integration ID ordering when priorities match', () => { + const calls: string[] = []; + + const unregisterBeta = registerDomInsertionHandler({ + handle: () => { + calls.push('beta'); + return true; + }, + id: 'beta', + priority: 100, + }); + + const unregisterAlpha = registerDomInsertionHandler({ + handle: () => { + calls.push('alpha'); + return true; + }, + id: 'alpha', + priority: 100, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/tie-breaker.js'; + container.appendChild(script); + + expect(calls).toEqual(['alpha']); + + unregisterBeta(); + unregisterAlpha(); + }); + + it('keeps the shared wrapper installed until the last guard resets', () => { + const firstGuard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + const secondGuard = createScriptGuard({ + displayName: 'Beta', + id: 'beta', + isTargetUrl: (url) => url.includes('beta.js'), + proxyPath: '/integrations/beta/sdk', + }); + + firstGuard.install(); + const sharedAppendChild = Element.prototype.appendChild; + const sharedInsertBefore = Element.prototype.insertBefore; + + secondGuard.install(); + firstGuard.reset(); + + expect(Element.prototype.appendChild).toBe(sharedAppendChild); + expect(Element.prototype.insertBefore).toBe(sharedInsertBefore); + + secondGuard.reset(); + + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + + it('does not clobber external prototype patches when the last handler resets', () => { + const guard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + + guard.install(); + + const externalAppendChild = vi.fn(function (this: Element, node: T): T { + return originalAppendChild.call(this, node) as T; + }); + + Element.prototype.appendChild = externalAppendChild as typeof Element.prototype.appendChild; + + guard.reset(); + + expect(Element.prototype.appendChild).toBe( + externalAppendChild as typeof Element.prototype.appendChild + ); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + }); + + it('skips handlers for text nodes and unrelated elements', () => { + const handle = vi.fn(() => true); + const unregister = registerDomInsertionHandler({ + handle, + id: 'alpha', + priority: 100, + }); + + const container = document.createElement('div'); + const textNode = document.createTextNode('dispatcher fast path'); + const image = document.createElement('img'); + image.src = 'https://example.com/image.png'; + + container.appendChild(textNode); + container.appendChild(image); + + expect(handle).not.toHaveBeenCalled(); + expect(container.textContent).toContain('dispatcher fast path'); + expect(container.querySelector('img')).toBe(image); + + unregister(); + }); + + it('continues dispatching when one handler throws', () => { + const calls: string[] = []; + + const unregisterThrowing = registerDomInsertionHandler({ + handle: () => { + calls.push('throwing'); + throw new Error('boom'); + }, + id: 'alpha', + priority: 50, + }); + const unregisterSecond = registerDomInsertionHandler({ + handle: () => { + calls.push('second'); + return true; + }, + id: 'beta', + priority: 100, + }); + + const container = document.createElement('div'); + const script = document.createElement('script'); + script.src = 'https://example.com/recover.js'; + + expect(() => container.appendChild(script)).not.toThrow(); + expect(calls).toEqual(['throwing', 'second']); + expect(container.querySelector('script')).toBe(script); + + unregisterThrowing(); + unregisterSecond(); + }); + + it('replaces stale global dispatcher state when the version changes', () => { + const globalObject = globalThis as Record; + globalObject[dispatcherKey] = { + handlers: new Map(), + nextSequence: 0, + orderedHandlers: [], + version: 0, + }; + + const unregister = registerDomInsertionHandler({ + handle: () => true, + id: 'alpha', + priority: 100, + }); + + const state = globalObject[dispatcherKey] as { + handlers: Map; + version: number; + }; + + expect(state.version).toBe(1); + expect(state.handlers.size).toBe(1); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + + unregister(); + }); + + it('leaves no prototype residue across repeated install and reset cycles', () => { + const guard = createScriptGuard({ + displayName: 'Alpha', + id: 'alpha', + isTargetUrl: (url) => url.includes('alpha.js'), + proxyPath: '/integrations/alpha/sdk', + }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + guard.install(); + expect(Element.prototype.appendChild).not.toBe(originalAppendChild); + expect(Element.prototype.insertBefore).not.toBe(originalInsertBefore); + + guard.reset(); + expect(Element.prototype.appendChild).toBe(originalAppendChild); + expect(Element.prototype.insertBefore).toBe(originalInsertBefore); + } + }); +});