diff --git a/modules/builder.go b/modules/builder.go index eef0bd5992d..85a38fd0228 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -4,6 +4,7 @@ import ( fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v3/modules/fiftyonedegrees/devicedetection" prebidOrtb2blocking "github.com/prebid/prebid-server/v3/modules/prebid/ortb2blocking" prebidRulesengine "github.com/prebid/prebid-server/v3/modules/prebid/rulesengine" + scope3Rtd "github.com/prebid/prebid-server/v3/modules/scope3/rtd" ) // builders returns mapping between module name and its builder @@ -17,5 +18,8 @@ func builders() ModuleBuilders { "ortb2blocking": prebidOrtb2blocking.Builder, "rulesengine": prebidRulesengine.Builder, }, + "scope3": { + "rtd": scope3Rtd.Builder, + }, } } diff --git a/modules/scope3/rtd/README.md b/modules/scope3/rtd/README.md new file mode 100644 index 00000000000..734874b0bc7 --- /dev/null +++ b/modules/scope3/rtd/README.md @@ -0,0 +1,349 @@ +# Scope3 RTD Module + +This module integrates Scope3's Real-Time Data API to provide audience segments for targeting. + +## Maintainer +- Email: bokelley@scope3.com +- Company: Scope3 + +## Configuration + +### YAML Configuration +```yaml +hooks: + enabled: true + modules: + scope3: + rtd: + enabled: true + auth_key: ${SCOPE3_API_KEY} # Set SCOPE3_API_KEY environment variable + endpoint: https://rtdp.scope3.com/amazonaps/rtii + timeout_ms: 1000 + cache_ttl_seconds: 60 # Cache segments for 60 seconds (default) + add_to_targeting: false # Set to true to add segments as individual targeting keys for GAM + masking: # Optional privacy masking configuration + enabled: true # Enable field masking before sending to Scope3 + geo: + preserve_metro: true # Preserve DMA code (default: true) + preserve_zip: true # Preserve postal code (default: true) + preserve_city: false # Preserve city name (default: false) + lat_long_precision: 2 # Lat/long decimal places: 0-4 (default: 2) + user: + preserve_eids: # EID sources to preserve (default list below) + - "liveramp.com" # RampID + - "uidapi.com" # UID2 + - "id5-sync.com" # ID5 + device: + preserve_mobile_ids: false # Keep mobile advertising IDs (default: false) + + host_execution_plan: + endpoints: + /openrtb2/auction: + stages: + entrypoint: + groups: + - timeout: 5 + hook_sequence: + - module_code: "scope3.rtd" + hook_impl_code: "HandleEntrypointHook" + raw_auction_request: + groups: + - timeout: 2000 + hook_sequence: + - module_code: "scope3.rtd" + hook_impl_code: "HandleRawAuctionHook" + auction_response: + groups: + - timeout: 5 + hook_sequence: + - module_code: "scope3.rtd" + hook_impl_code: "HandleAuctionResponseHook" +``` + +### JSON Configuration +```json +{ + "hooks": { + "modules": { + "scope3": { + "rtd": { + "enabled": true, + "endpoint": "https://rtdp.scope3.com/amazonaps/rtii", + "auth_key": "your-scope3-auth-key", + "timeout_ms": 1000, + "cache_ttl_seconds": 60, + "add_to_targeting": false, + "masking": { + "enabled": true, + "geo": { + "preserve_metro": true, + "preserve_zip": true, + "preserve_city": false, + "lat_long_precision": 2 + }, + "user": { + "preserve_eids": ["liveramp.com", "uidapi.com", "id5-sync.com"] + }, + "device": { + "preserve_mobile_ids": false + } + } + } + } + } + } +} +``` + +## Environment Variables +- `SCOPE3_API_KEY`: Your Scope3 API key for authentication + +## Privacy Protection + +This module includes comprehensive privacy masking to protect user data while preserving targeting capabilities. When masking is enabled, sensitive user information is removed or anonymized before being sent to the Scope3 API. + +### Field Masking Categories + +#### 🟢 ALWAYS SENT (Never Masked) +These fields are always passed through without modification as they are not considered sensitive: + +**Device Information** +- `device.devicetype` - Device type (mobile, desktop, CTV, etc.) +- `device.os` - Operating system +- `device.osv` - OS version +- `device.make` - Device manufacturer +- `device.model` - Device model +- `device.ua` - User agent string +- `device.language` - Language preference +- `device.connectiontype` - Connection type (wifi, cellular, etc.) +- `device.js` - JavaScript support +- `device.h/w` - Screen dimensions +- `device.ppi` - Screen PPI +- `device.pxratio` - Pixel ratio + +**Geographic Information (Coarse)** +- `geo.country` - Country code (e.g., "US") +- `geo.region` - State/region code (e.g., "CA") + +**Context Information** +- `site.*` - All site fields (domain, page, ref, etc.) +- `app.*` - All app fields +- `imp.*` - All impression data (ad sizes, positions, etc.) + +#### 🔴 NEVER SENT (Always Masked) +These fields are always removed for privacy protection: + +**Personal Identifiers** +- `device.ip` - IPv4 address +- `device.ipv6` - IPv6 address +- `user.id` - Publisher's first-party user ID +- `user.buyeruid` - Exchange-specific user ID +- `user.yob` - Year of birth +- `user.gender` - Gender +- `user.data` - First-party data segments +- `user.keywords` - User interest keywords + +**High-Precision Location** +- `geo.accuracy` - GPS accuracy radius + +#### 🔧 CONFIGURABLE (With Defaults) +These fields can be configured to be preserved or masked: + +**Geographic Information (Fine-Grained)** + +| Field | Default | Options | Privacy Impact | +|-------|---------|---------|----------------| +| `geo.metro` | **Preserved** | preserve/remove | DMA code for regional targeting | +| `geo.zip` | **Preserved** | preserve/remove | Postal code for local targeting | +| `geo.city` | **Removed** | preserve/remove | City name | +| `geo.lat/lon` | **Truncated to 2 decimals** | 0-4 decimals or remove | See precision guide below | + +**User Identifiers** + +| Field | Default | Options | Notes | +|-------|---------|---------|-------| +| `user.eids` | **Filter to allowlist** | List of EID sources | Default: ["liveramp.com", "uidapi.com", "id5-sync.com"] | + +**Device Identifiers** + +| Field | Default | Options | Notes | +|-------|---------|---------|-------| +| `device.ifa` | **Removed** | preserve/remove | Mobile advertising ID | +| `device.dpidmd5` | **Removed** | preserve/remove | Hashed device ID | +| `device.dpidsha1` | **Removed** | preserve/remove | Hashed device ID | +| Other device IDs | **Removed** | preserve/remove | Various hashed identifiers | + +### Geographic Precision Guide + +When preserving latitude/longitude coordinates, the precision level has significant privacy implications: + +| Decimals | Accuracy | Privacy Level | Use Case | +|----------|----------|---------------|----------| +| 0 | Removed | Maximum | No location data | +| 1 | ~11 km | Country/State | Regional campaigns | +| 2 | ~1.1 km | **Neighborhood (Default)** | Store radius matching | +| 3 | ~111 m | City block | Dense urban targeting | +| 4 | ~11 m | Building | Maximum allowed | + +⚠️ **WARNING**: More than 4 decimal places can identify individuals and is not permitted by this module. + +### Configuration Examples + +#### Maximum Privacy (Strict Mode) +```yaml +masking: + enabled: true + geo: + preserve_metro: false + preserve_zip: false + preserve_city: false + lat_long_precision: 0 + user: + preserve_eids: [] + device: + preserve_mobile_ids: false +``` + +#### Balanced Privacy (Default) +```yaml +masking: + enabled: true + geo: + preserve_metro: true + preserve_zip: true + preserve_city: false + lat_long_precision: 2 + user: + preserve_eids: ["liveramp.com", "uidapi.com", "id5-sync.com"] + device: + preserve_mobile_ids: false +``` + +#### Retail/Commerce Optimization +```yaml +masking: + enabled: true + geo: + preserve_metro: true + preserve_zip: true + preserve_city: true + lat_long_precision: 3 # Higher precision for store matching + user: + preserve_eids: ["liveramp.com", "retail-partner.com"] + device: + preserve_mobile_ids: true # For in-store attribution +``` + +### Privacy Compliance + +**GDPR Compliance** +- Removes direct identifiers by default +- Configurable to meet legitimate interest requirements +- Supports privacy-by-design principles + +**CCPA Compliance** +- Removes sale-related identifiers +- Supports consumer privacy rights +- Configurable per jurisdiction requirements + +**Industry Standards** +- Follows IAB OpenRTB privacy guidelines +- Compatible with TCF 2.0 consent frameworks +- Supports Privacy Sandbox initiatives + +## Features +- **Privacy-First Design**: Comprehensive field masking to protect user data while preserving targeting +- **Flexible Masking**: Configurable privacy controls for different use cases and compliance requirements +- **Real-Time Segments**: Fetches audience segments from Scope3 API with intelligent caching +- **Thread-Safe Caching**: Handles high-frequency requests with concurrent access protection +- **Identity Integration**: Supports multiple identity providers (LiveRamp, UID2, ID5, etc.) +- **Geographic Privacy**: Truncates location data to configurable precision levels +- **Graceful Degradation**: Continues auction processing even when API errors occur +- **Performance Optimized**: HTTP/2, connection pooling, and compression for high-traffic scenarios + +## Performance & Caching +This module implements intelligent caching and HTTP optimizations to handle high-frequency API requests: + +- **Cache Key**: Generated from user identifiers, site domain, and page URL +- **Cache Duration**: Configurable via `cache_ttl_seconds` (default: 60 seconds) +- **Thread Safety**: Uses read-write mutexes for concurrent access +- **Memory Efficiency**: Stores only segment arrays, not full API responses +- **Frequency Cap Compatibility**: Short 60-second default ensures frequency-capped segments are refreshed quickly +- **HTTP Optimization**: Custom transport with connection pooling, HTTP/2, and compression for better performance + +## User Identity Integration +This module automatically detects and forwards available user identifiers from the bid request including: + +- LiveRamp identifiers (when available from publisher implementations or identity providers) +- Publisher first-party user IDs +- Device identifiers +- Encrypted identity envelopes + + +### Supported Identifier Types +1. **LiveRamp Identifiers** (when available): + - `user.ext.eids[]` array with `source: "liveramp.com"` + - `user.ext.rampid` field (alternative location) + +2. **Encrypted Identity Envelopes**: + - `user.ext.liveramp_idl` - ATS envelope location + - `user.ext.ats_envelope` - Alternative envelope location + - `user.ext.rampId_envelope` - Additional envelope location + - `ext.liveramp_idl` - Request-level envelope + +3. **Standard Identifiers**: + - `user.id` - Publisher user ID + - `device.ifa` - Device identifier + - Other standard OpenRTB identifiers + +### How It Works +The module forwards the complete bid request with all available user identifiers to the Scope3 API. Scope3's system can then utilize whatever identifiers are available for audience segmentation, whether they are resolved identifiers or encrypted envelopes that Scope3 may be able to process. + +**Note**: The effectiveness of different identifier types depends on Scope3's integration capabilities and partnerships. + +## Data Output & Integration + +### Auction Response Data +The module adds audience segments to the auction response, giving publishers full control over how to use them: + +1. **Publisher Flexibility**: Segments are always returned in `ext.scope3.segments` for the publisher to decide where to send +2. **Google Ad Manager (GAM)**: Individual targeting keys are added when `add_to_targeting: true` (e.g., `gmp_eligible=true`) +3. **Other Ad Servers**: Publisher can forward segments to any ad server or system +4. **Analytics**: Segment data is available for reporting and analysis + +### Response Format Options +The module provides segments in two formats: + +**Always available:** +```json +{ + "ext": { + "scope3": { + "segments": ["gmp_eligible", "gmp_plus_eligible"] + } + } +} +``` + +**When `add_to_targeting: true`:** +```json +{ + "ext": { + "prebid": { + "targeting": { + "gmp_eligible": "true", + "gmp_plus_eligible": "true" + } + }, + "scope3": { + "segments": ["gmp_eligible", "gmp_plus_eligible"] + } + } +} +``` + +This approach gives publishers maximum flexibility to: +- Send segments to GAM via targeting keys +- Forward to other ad servers or systems +- Use for analytics and reporting +- Control which segments go where based on their business logic diff --git a/modules/scope3/rtd/async_request.go b/modules/scope3/rtd/async_request.go new file mode 100644 index 00000000000..38c4663286d --- /dev/null +++ b/modules/scope3/rtd/async_request.go @@ -0,0 +1,53 @@ +package scope3 + +import ( + "context" + "fmt" + "net/http" + + "github.com/prebid/openrtb/v20/openrtb2" +) + +type ( + AsyncRequest struct { + *Module + + // For managing the lifecycle of the request + // Context is used to pass to the request. This should be the context of the original request + Context context.Context + // Cancel can be called to cancel the request + Cancel context.CancelFunc + // DoneChannel will be closed when the request is done. When nil, no request was made + Done chan struct{} + + // Response + Segments []string + Err error + } +) + +// NewAsyncRequest creates a new AsyncRequest object +// The request's context is used to create a cancellable context for the async request and, which spans multiple hooks +func (m *Module) NewAsyncRequest(req *http.Request) *AsyncRequest { + ret := &AsyncRequest{ + Module: m, + } + ret.Context, ret.Cancel = context.WithCancel(req.Context()) + return ret +} + +// fetchScope3SegmentsAsync starts a goroutine to fetch Scope3 segments and immediately returns +// The Done channel will be closed when the request is done +// If the Done channel is nil, no request was made +func (ar *AsyncRequest) fetchScope3SegmentsAsync(request *openrtb2.BidRequest) { + ar.Done = make(chan struct{}) + go func() { + defer func() { + if r := recover(); r != nil { + ar.Err = fmt.Errorf("panic in async request: %v", r) + } + close(ar.Done) + }() + ar.Segments, ar.Err = ar.Module.fetchScope3Segments(ar.Context, request) + }() +} diff --git a/modules/scope3/rtd/masking.go b/modules/scope3/rtd/masking.go new file mode 100644 index 00000000000..d32689dacf2 --- /dev/null +++ b/modules/scope3/rtd/masking.go @@ -0,0 +1,200 @@ +package scope3 + +import ( + "math" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/util/iterutil" + "github.com/prebid/prebid-server/v3/util/jsonutil" +) + +// maskBidRequest creates a deep copy of the bid request with sensitive fields masked +// according to the masking configuration. Returns nil if masking fails to prevent +// accidental exposure of sensitive data. +func (m *Module) maskBidRequest(original *openrtb2.BidRequest) *openrtb2.BidRequest { + if !m.cfg.Masking.Enabled { + return original + } + + // Create a deep copy by marshaling and unmarshaling + data, err := jsonutil.Marshal(original) + if err != nil { + // Never return unmasked data - this prevents potential data leakage + // The calling function should handle nil gracefully + return nil + } + + var masked openrtb2.BidRequest + if err := jsonutil.Unmarshal(data, &masked); err != nil { + // Never return unmasked data - this prevents potential data leakage + // The calling function should handle nil gracefully + return nil + } + + // Apply masking to different sections + m.maskUser(&masked) + m.maskDevice(&masked) + m.maskGeo(&masked) + + return &masked +} + +// maskUser removes or filters user data according to privacy settings +func (m *Module) maskUser(req *openrtb2.BidRequest) { + if req.User == nil { + return + } + + // Always remove publisher's first-party user ID for privacy + req.User.ID = "" + req.User.BuyerUID = "" + + // Always remove potentially sensitive demographic data + req.User.Yob = 0 + req.User.Gender = "" + + // Remove user data segments (first-party data) + req.User.Data = nil + req.User.Keywords = "" + + // Filter user.eids to only preserve allowed identity providers + req.User.EIDs = m.filterEids(req.User.EIDs) +} + +// filterEids filters the user.eids array to only include allowed identity providers +func (m *Module) filterEids(eids []openrtb2.EID) []openrtb2.EID { + if len(m.cfg.Masking.User.PreserveEids) == 0 { + return []openrtb2.EID{} + } + + // Create allowlist map for fast lookup + allowed := make(map[string]bool) + for _, source := range m.cfg.Masking.User.PreserveEids { + allowed[source] = true + } + + // Filter eids to only include allowed sources + var filtered []openrtb2.EID + for eid := range iterutil.SlicePointerValues(eids) { + if allowed[eid.Source] { + // Intentionally copy the EID struct to create a new filtered slice + // that's independent of the original data + filtered = append(filtered, *eid) + } + } + + return filtered +} + +// maskDevice removes sensitive device information while preserving targeting-safe data +func (m *Module) maskDevice(req *openrtb2.BidRequest) { + if req.Device == nil { + return + } + + // Always remove IP addresses for privacy + req.Device.IP = "" + req.Device.IPv6 = "" + + // Remove mobile advertising IDs unless explicitly preserved + if !m.cfg.Masking.Device.PreserveMobileIds { + req.Device.IFA = "" + req.Device.DPIDMD5 = "" + req.Device.DPIDSHA1 = "" + req.Device.DIDMD5 = "" + req.Device.DIDSHA1 = "" + req.Device.MACMD5 = "" + req.Device.MACSHA1 = "" + } + + // Note: We preserve device characteristics like devicetype, os, browser, etc. + // as these are not considered personally identifiable and are useful for targeting +} + +// maskGeo removes or truncates geographic data according to privacy settings +func (m *Module) maskGeo(req *openrtb2.BidRequest) { + // Mask device geo + if req.Device != nil && req.Device.Geo != nil { + m.maskGeoObject(req.Device.Geo) + } + + // Mask user geo (if different from device geo) + if req.User != nil && req.User.Geo != nil { + m.maskGeoObject(req.User.Geo) + } +} + +// maskGeoObject applies geographic masking rules to a geo object +func (m *Module) maskGeoObject(geo *openrtb2.Geo) { + // Always preserve country and region (state) as these are not considered PII + // geo.Country and geo.Region are preserved + + // Handle optional geographic fields based on configuration + if !m.cfg.Masking.Geo.PreserveMetro { + geo.Metro = "" + } + if !m.cfg.Masking.Geo.PreserveZip { + geo.ZIP = "" + } + if !m.cfg.Masking.Geo.PreserveCity { + geo.City = "" + } + + // Handle lat/long based on precision setting + if m.cfg.Masking.Geo.LatLongPrecision == 0 { + // Remove completely + geo.Lat = nil + geo.Lon = nil + } else if geo.Lat != nil && geo.Lon != nil { + // Truncate to specified precision + truncatedLat := m.truncateCoordinate(*geo.Lat, m.cfg.Masking.Geo.LatLongPrecision) + truncatedLon := m.truncateCoordinate(*geo.Lon, m.cfg.Masking.Geo.LatLongPrecision) + geo.Lat = &truncatedLat + geo.Lon = &truncatedLon + } + + // Always remove high-precision location data + geo.Accuracy = 0 // GPS accuracy radius could reveal precision +} + +// truncateCoordinate truncates a coordinate to the specified number of decimal places +func (m *Module) truncateCoordinate(coord float64, precision int) float64 { + if precision <= 0 || precision > 4 { + return 0 + } + + multiplier := math.Pow(10, float64(precision)) + // Use math.Trunc instead of math.Floor to handle negative numbers correctly + return math.Trunc(coord*multiplier) / multiplier +} + +// getMaskingSummary returns a summary of what fields would be masked for analytics/debugging +func (m *Module) getMaskingSummary() map[string]interface{} { + if !m.cfg.Masking.Enabled { + return map[string]interface{}{"enabled": false} + } + + return map[string]interface{}{ + "enabled": true, + "geo": map[string]interface{}{ + "preserve_metro": m.cfg.Masking.Geo.PreserveMetro, + "preserve_zip": m.cfg.Masking.Geo.PreserveZip, + "preserve_city": m.cfg.Masking.Geo.PreserveCity, + "lat_long_precision": m.cfg.Masking.Geo.LatLongPrecision, + }, + "user": map[string]interface{}{ + "preserve_eids": m.cfg.Masking.User.PreserveEids, + }, + "device": map[string]interface{}{ + "preserve_mobile_ids": m.cfg.Masking.Device.PreserveMobileIds, + }, + "always_removed": []string{ + "device.ip", "device.ipv6", "user.id", "user.buyeruid", + "user.yob", "user.gender", "user.data", "user.keywords", "geo.accuracy", + }, + "never_removed": []string{ + "geo.country", "geo.region", "device.devicetype", "device.os", + "device.browser", "device.make", "device.model", "site.*", "app.*", "imp.*", + }, + } +} diff --git a/modules/scope3/rtd/module.go b/modules/scope3/rtd/module.go new file mode 100644 index 00000000000..ce1bd48fd19 --- /dev/null +++ b/modules/scope3/rtd/module.go @@ -0,0 +1,484 @@ +// Package scope3 implements a Prebid Server module for Scope3 RTD +package scope3 + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash" + "maps" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/coocood/freecache" + "github.com/golang/glog" + jsoniter "github.com/json-iterator/go" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/hooks/hookanalytics" + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/prebid/prebid-server/v3/util/iterutil" + "github.com/prebid/prebid-server/v3/util/jsonutil" +) + +// Builder is the entry point for the module +// This is called by Prebid Server to initialize the module +func Builder(config json.RawMessage, deps moduledeps.ModuleDeps) (interface{}, error) { + var cfg Config + if err := jsonutil.Unmarshal(config, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + if cfg.Endpoint == "" { + cfg.Endpoint = "https://rtdp.scope3.com/prebid/rtii" + } + if cfg.Timeout == 0 { + cfg.Timeout = 1000 // 1000ms default + } + if cfg.CacheTTL == 0 { + cfg.CacheTTL = 60 // 60 seconds default + } + + // Set masking defaults and validate configuration + if cfg.Masking.Enabled { + // Validate and set geo precision (max 4 decimal places for privacy) + if cfg.Masking.Geo.LatLongPrecision == 0 { + cfg.Masking.Geo.LatLongPrecision = 2 // 2 decimal places default (~1.1km precision) + } else if cfg.Masking.Geo.LatLongPrecision > 4 { + return nil, errors.New("lat_long_precision cannot exceed 4 decimal places for privacy protection") + } else if cfg.Masking.Geo.LatLongPrecision < 0 { + return nil, errors.New("lat_long_precision cannot be negative") + } + + // Set default EID allowlist if empty + if len(cfg.Masking.User.PreserveEids) == 0 { + // Default to preserving common identity providers + cfg.Masking.User.PreserveEids = []string{"liveramp.com", "uidapi.com", "id5-sync.com"} + } + + // Set default preserve values for geo fields + if !cfg.Masking.Geo.PreserveMetro && !cfg.Masking.Geo.PreserveZip && !cfg.Masking.Geo.PreserveCity { + cfg.Masking.Geo.PreserveMetro = true + cfg.Masking.Geo.PreserveZip = true + } + } + + return &Module{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: time.Duration(cfg.Timeout) * time.Millisecond, + Transport: deps.HTTPClient.Transport, + }, + cache: freecache.NewCache(10 * 1024 * 1024), + sha256Pool: &sync.Pool{ + New: func() any { + return sha256.New() + }, + }, + }, nil +} + +const ( + // keys for miCtx + asyncRequestKey = "scope3.AsyncRequest" +) + +var ( + // Declare hooks + _ hookstage.Entrypoint = (*Module)(nil) + _ hookstage.RawAuctionRequest = (*Module)(nil) + _ hookstage.AuctionResponse = (*Module)(nil) +) + +// Config holds module configuration +type Config struct { + Endpoint string `json:"endpoint"` + AuthKey string `json:"auth_key"` + Timeout int `json:"timeout_ms"` + CacheTTL int `json:"cache_ttl_seconds"` // Cache segments for this many seconds + AddToTargeting bool `json:"add_to_targeting"` // Add segments as individual targeting keys + Masking MaskingConfig `json:"masking"` // Privacy masking configuration +} + +// MaskingConfig controls what user data is masked before sending to Scope3 +type MaskingConfig struct { + Enabled bool `json:"enabled"` + Geo GeoMaskingConfig `json:"geo"` + User UserMaskingConfig `json:"user"` + Device DeviceMaskingConfig `json:"device"` +} + +// GeoMaskingConfig controls geographic data masking +type GeoMaskingConfig struct { + PreserveMetro bool `json:"preserve_metro"` // DMA code (default: true) + PreserveZip bool `json:"preserve_zip"` // Postal code (default: true) + PreserveCity bool `json:"preserve_city"` // City name (default: false) + LatLongPrecision int `json:"lat_long_precision"` // Decimal places for lat/long (0-4, default: 2) +} + +// UserMaskingConfig controls user data masking +type UserMaskingConfig struct { + PreserveEids []string `json:"preserve_eids"` // List of EID sources to preserve +} + +// DeviceMaskingConfig controls device data masking +type DeviceMaskingConfig struct { + PreserveMobileIds bool `json:"preserve_mobile_ids"` // Keep mobile advertising IDs (default: false) +} + +type userExt struct { + Eids []openrtb2.EID `json:"eids"` + RampID string `json:"rampid"` + LiverampIDL string `json:"liveramp_idl"` + ATSEnvelope string `json:"ats_envelope"` + RampIDEnvelope string `json:"rampId_envelope"` +} + +// Response types for Scope3 API +type Scope3Response struct { + Data []Scope3Data `json:"data"` +} + +type Scope3Data struct { + Destination string `json:"destination"` + Imp []Scope3ImpData `json:"imp"` +} + +type Scope3ImpData struct { + ID string `json:"id"` + Ext *Scope3Ext `json:"ext,omitempty"` +} + +type Scope3Ext struct { + Scope3 *Scope3ExtData `json:"scope3"` +} + +type Scope3ExtData struct { + Segments []Scope3Segment `json:"segments"` +} + +type Scope3Segment struct { + ID string `json:"id"` +} + +// Module implements the Scope3 RTD module +type Module struct { + cfg Config + httpClient *http.Client + cache *freecache.Cache + // sha256Pool provides a pool of reusable SHA-256 hash instances for performance + sha256Pool *sync.Pool +} + +// HandleEntrypointHook initializes the module context with a sync.Map for storing segments +func (m *Module) HandleEntrypointHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.EntrypointPayload, +) (hookstage.HookResult[hookstage.EntrypointPayload], error) { + // Initialize module context with sync.Map for thread-safe segment storage + return hookstage.HookResult[hookstage.EntrypointPayload]{ + ModuleContext: hookstage.ModuleContext{ + asyncRequestKey: m.NewAsyncRequest(payload.Request), + }, + }, nil +} + +// HandleRawAuctionHook is called early in the auction to fetch Scope3 data +func (m *Module) HandleRawAuctionHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.RawAuctionRequestPayload, +) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) { + var ret hookstage.HookResult[hookstage.RawAuctionRequestPayload] + analyticsNamePrefix := "HandleRawAuctionHook." + + asyncRequest, ok := miCtx.ModuleContext[asyncRequestKey].(*AsyncRequest) + if !ok { + // Log error but don't fail the auction + ret.AnalyticsTags = hookanalytics.Analytics{ + Activities: []hookanalytics.Activity{{ + Name: analyticsNamePrefix + asyncRequestKey, + Status: hookanalytics.ActivityStatusError, + Results: []hookanalytics.Result{{ + Status: hookanalytics.ResultStatusError, + Values: map[string]interface{}{"error": "failed to get async request from module context"}, + }}, + }}, + } + return ret, nil + } + + // Parse OpenRTB request here rather than HandleProcessedAuctionHook to get a copy to avoid parallel mutation issues + var bidRequest openrtb2.BidRequest + if err := jsonutil.Unmarshal(payload, &bidRequest); err != nil { + // Log error but don't fail the auction + ret.AnalyticsTags = hookanalytics.Analytics{ + Activities: []hookanalytics.Activity{{ + Name: analyticsNamePrefix + "bidRequest.unmarshal", + Status: hookanalytics.ActivityStatusError, + Results: []hookanalytics.Result{{ + Status: hookanalytics.ResultStatusError, + Values: map[string]interface{}{"error": err.Error()}, + }}, + }}, + } + return ret, nil + } + + // Start async request to Scope3 + asyncRequest.fetchScope3SegmentsAsync(&bidRequest) + + return ret, nil +} + +// HandleAuctionResponseHook adds targeting data to the auction response +func (m *Module) HandleAuctionResponseHook( + ctx context.Context, + miCtx hookstage.ModuleInvocationContext, + payload hookstage.AuctionResponsePayload, +) (hookstage.HookResult[hookstage.AuctionResponsePayload], error) { + analyticsNamePrefix := "HandleAuctionResponseHook." + var ret hookstage.HookResult[hookstage.AuctionResponsePayload] + asyncRequest, ok := miCtx.ModuleContext[asyncRequestKey].(*AsyncRequest) + if !ok { + // Log error but don't fail the auction + ret.AnalyticsTags = hookanalytics.Analytics{ + Activities: []hookanalytics.Activity{{ + Name: analyticsNamePrefix + asyncRequestKey, + Status: hookanalytics.ActivityStatusError, + Results: []hookanalytics.Result{{ + Status: hookanalytics.ResultStatusError, + Values: map[string]interface{}{"error": "failed to get async request from module context"}, + }}, + }}, + } + return ret, nil + } + // Ensure we cancel the request context always to free resources + defer asyncRequest.Cancel() + + // Check if a request was made + if asyncRequest.Done == nil { + return ret, nil + } + + // Wait for the async request to complete + select { + case <-asyncRequest.Done: + // Continue with processing + case <-ctx.Done(): + return ret, nil // Context cancelled, exit gracefully + } + + // Get results + segments, err := asyncRequest.Segments, asyncRequest.Err + if err != nil { + // Log error but don't fail the auction + ret.AnalyticsTags = hookanalytics.Analytics{ + Activities: []hookanalytics.Activity{{ + Name: analyticsNamePrefix + "scope3_fetch", + Status: hookanalytics.ActivityStatusError, + Results: []hookanalytics.Result{{ + Status: hookanalytics.ResultStatusError, + Values: map[string]interface{}{"error": err.Error()}, + }}, + }}, + } + return ret, nil + } + + if len(segments) == 0 { + return ret, nil + } + + // Add segments to the auction response + ret.ChangeSet.AddMutation( + func(payload hookstage.AuctionResponsePayload) (hookstage.AuctionResponsePayload, error) { + // Add Scope3 segments to the response ext so publisher can use them + if payload.BidResponse.Ext == nil { + payload.BidResponse.Ext = json.RawMessage("{}") + } + + var extMap map[string]interface{} + if err = jsonutil.Unmarshal(payload.BidResponse.Ext, &extMap); err != nil { + extMap = make(map[string]interface{}) + } + + // Add segments as individual targeting keys for GAM integration + if m.cfg.AddToTargeting { + prebidMap, ok := extMap["prebid"].(map[string]interface{}) + if !ok { + prebidMap = make(map[string]interface{}) + extMap["prebid"] = prebidMap + } + targetingMap, ok := prebidMap["targeting"].(map[string]interface{}) + if !ok { + targetingMap = make(map[string]interface{}) + prebidMap["targeting"] = targetingMap + } + // Add each segment as individual targeting key + for _, segment := range segments { + targetingMap[segment] = "true" + } + } + + // Always add to a dedicated scope3 section for publisher flexibility + extMap["scope3"] = map[string]interface{}{ + "segments": segments, + } + + extResp, err := jsonutil.Marshal(extMap) + if err == nil { + payload.BidResponse.Ext = extResp + } + + return payload, nil + }, + hookstage.MutationUpdate, + "ext", + ) + + return ret, nil +} + +// fetchScope3Segments calls the Scope3 API and extracts segments +func (m *Module) fetchScope3Segments(ctx context.Context, bidRequest *openrtb2.BidRequest) ([]string, error) { + // Create cache key based on relevant user identifiers and site context + cacheKey := []byte(m.createCacheKey(bidRequest)) + + // Check cache first + if segments, err := m.cache.Get(cacheKey); err == nil { + return strings.Split(string(segments), ","), nil + } + + // Apply privacy masking before sending to Scope3 + requestToSend := bidRequest + if m.cfg.Masking.Enabled { + maskedRequest := m.maskBidRequest(bidRequest) + if maskedRequest == nil { + // Masking failed - don't send request to prevent data leakage + return nil, errors.New("failed to mask bid request for privacy protection") + } + requestToSend = maskedRequest + } + + // Marshal the (potentially masked) bid request + requestBody, err := jsonutil.Marshal(requestToSend) + if err != nil { + return nil, err + } + + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, "POST", m.cfg.Endpoint, bytes.NewReader(requestBody)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-scope3-auth", m.cfg.AuthKey) + + // Make the request + resp, err := m.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("scope3 returned status %d", resp.StatusCode) + } + + // Parse response + var scope3Resp Scope3Response + if err = jsoniter.ConfigCompatibleWithStandardLibrary.NewDecoder(resp.Body).Decode(&scope3Resp); err != nil { + return nil, err + } + + // Extract unique segments (exclude destination) + segmentMap := make(map[string]bool) + for data := range iterutil.SlicePointerValues(scope3Resp.Data) { + // Extract actual segments from impression-level data + for imp := range iterutil.SlicePointerValues(data.Imp) { + if imp.Ext != nil && imp.Ext.Scope3 != nil { + for segment := range iterutil.SlicePointerValues(imp.Ext.Scope3.Segments) { + segmentMap[segment.ID] = true + } + } + } + } + + // Convert to slice + segments := slices.AppendSeq(make([]string, 0, len(segmentMap)), maps.Keys(segmentMap)) + + // Cache the result + err = m.cache.Set(cacheKey, []byte(strings.Join(segments, ",")), m.cfg.CacheTTL) + if err != nil { + glog.Infof("could not set segments in cache: %v", err) + } + + return segments, nil +} + +// createCacheKey generates a cache key based on non-sensitive context and identifiers +// Note: Uses only privacy-safe identifiers to prevent correlation attacks +func (m *Module) createCacheKey(bidRequest *openrtb2.BidRequest) string { + hasher := m.sha256Pool.Get().(hash.Hash) + hasher.Reset() + defer m.sha256Pool.Put(hasher) + + // Include site/app information (not sensitive) + if bidRequest.Site != nil { + hasher.Write([]byte("site:" + bidRequest.Site.Domain)) + if bidRequest.Site.Page != "" { + hasher.Write([]byte("page:" + bidRequest.Site.Page)) + } + } + if bidRequest.App != nil { + hasher.Write([]byte("app:" + bidRequest.App.Bundle)) + } + + // Include user identifiers for per-user caching + hasPrivacySafeID := false + if bidRequest.User != nil && bidRequest.User.Ext != nil { + var userExtension userExt + if err := jsonutil.Unmarshal(bidRequest.User.Ext, &userExtension); err == nil { + // Include LiveRamp identifiers (these are privacy-safe for caching) + for eid := range iterutil.SlicePointerValues(userExtension.Eids) { + if eid.Source == "liveramp.com" && len(eid.UIDs) > 0 { + hasher.Write([]byte("eid:rampid:" + eid.UIDs[0].ID)) + hasPrivacySafeID = true + } + } + + // Include other privacy-safe identifier types + if userExtension.RampID != "" { + hasher.Write([]byte("eid:rampid:" + userExtension.RampID)) + hasPrivacySafeID = true + } + if userExtension.LiverampIDL != "" { + hasher.Write([]byte("eid:ats:" + userExtension.LiverampIDL)) + hasPrivacySafeID = true + } + } + } + + // If no privacy-safe identifiers are available, use hashed user.id for per-user caching + if !hasPrivacySafeID && bidRequest.User != nil && bidRequest.User.ID != "" { + userHasher := m.sha256Pool.Get().(hash.Hash) + userHasher.Reset() + defer m.sha256Pool.Put(userHasher) + + userHasher.Write([]byte("user_id:" + bidRequest.User.ID)) + hasher.Write([]byte("hashed_user_id:" + hex.EncodeToString(userHasher.Sum(nil)))) + } + + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/modules/scope3/rtd/module_test.go b/modules/scope3/rtd/module_test.go new file mode 100644 index 00000000000..47638a01bc6 --- /dev/null +++ b/modules/scope3/rtd/module_test.go @@ -0,0 +1,1427 @@ +package scope3 + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/coocood/freecache" + "github.com/prebid/openrtb/v20/adcom1" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/prebid/prebid-server/v3/util/jsonutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func getTestModuleDeps(t *testing.T) moduledeps.ModuleDeps { + t.Helper() + return moduledeps.ModuleDeps{ + HTTPClient: http.DefaultClient, + } +} + +func getTestEntrypointPayload(t *testing.T) hookstage.EntrypointPayload { + body := []byte(`{}`) + return hookstage.EntrypointPayload{ + Request: httptest.NewRequest(http.MethodPost, "/openrtb2/auction", bytes.NewBuffer(body)), + Body: body, + } +} + +func TestBuilder(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "endpoint": "https://rtdp.scope3.com/amazonaps/rtii", + "auth_key": "test-key", + "timeout_ms": 1000, + "cache_ttl_seconds": 60, + "add_to_targeting": false + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.NoError(t, err) + assert.NotNil(t, module) + assert.IsType(t, &Module{}, module) + + m := module.(*Module) + assert.Equal(t, "https://rtdp.scope3.com/amazonaps/rtii", m.cfg.Endpoint) + assert.Equal(t, "test-key", m.cfg.AuthKey) + assert.Equal(t, 1000, m.cfg.Timeout) + assert.Equal(t, 60, m.cfg.CacheTTL) + assert.Equal(t, false, m.cfg.AddToTargeting) + assert.NotNil(t, m.cache) +} + +func TestBuilderInvalidConfig(t *testing.T) { + config := json.RawMessage(`invalid json`) + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + + module, err := Builder(config, deps) + + assert.Error(t, err) + assert.Nil(t, module) +} + +func TestHandleEntrypointHook(t *testing.T) { + module := &Module{} + ctx := context.Background() + miCtx := hookstage.ModuleInvocationContext{} + payload := getTestEntrypointPayload(t) + + result, err := module.HandleEntrypointHook(ctx, miCtx, payload) + + assert.NoError(t, err) + assert.NotNil(t, result.ModuleContext[asyncRequestKey]) +} + +func TestHandleAuctionResponseHook_NoSegments(t *testing.T) { + module := &Module{} + ctx := context.Background() + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: hookstage.ModuleContext{ + "segments": &sync.Map{}, + }, + } + payload := hookstage.AuctionResponsePayload{} + + result, err := module.HandleAuctionResponseHook(ctx, miCtx, payload) + + assert.NoError(t, err) + assert.Empty(t, result.ChangeSet) +} + +func TestBuilderDefaults(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "auth_key": "test-key" + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.NoError(t, err) + m := module.(*Module) + assert.Equal(t, "https://rtdp.scope3.com/prebid/rtii", m.cfg.Endpoint) + assert.Equal(t, 1000, m.cfg.Timeout) + assert.Equal(t, 60, m.cfg.CacheTTL) + assert.Equal(t, false, m.cfg.AddToTargeting) +} + +func TestScope3APIIntegration(t *testing.T) { + // Create mock Scope3 API server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and headers + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Equal(t, "test-auth-key", r.Header.Get("x-scope3-auth")) + + // Return mock Scope3 response with segments + response := `{ + "data": [ + { + "destination": "triplelift.com", + "imp": [ + { + "id": "test-imp-1", + "ext": { + "scope3": { + "segments": [ + {"id": "gmp_eligible"}, + {"id": "gmp_plus_eligible"} + ] + } + } + } + ] + } + ] + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with mock server endpoint + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "cache_ttl_seconds": 60, + "add_to_targeting": false + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Create test bid request + width := int64(300) + height := int64(250) + bidRequest := &openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test-page", + }, + User: &openrtb2.User{ + ID: "test-user", + Ext: json.RawMessage(`{ + "eids": [ + { + "source": "liveramp.com", + "uids": [{"id": "test-ramp-id"}] + } + ] + }`), + }, + } + + // Test fetchScope3Segments + ctx := context.Background() + segments, err := module.fetchScope3Segments(ctx, bidRequest) + require.NoError(t, err) + assert.Len(t, segments, 2) + assert.Contains(t, segments, "gmp_eligible") + assert.Contains(t, segments, "gmp_plus_eligible") + assert.NotContains(t, segments, "triplelift.com") // Should not include destination +} + +func TestScope3APIIntegrationWithTargeting(t *testing.T) { + // Create mock server that returns segments + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "data": [ + { + "destination": "triplelift.com", + "imp": [ + { + "id": "test-imp-1", + "ext": { + "scope3": { + "segments": [ + {"id": "test_segment_1"}, + {"id": "test_segment_2"} + ] + } + } + } + ] + } + ] + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with targeting enabled + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "add_to_targeting": true + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Test full hook workflow + ctx := context.Background() + + // Test entrypoint hook + entrypointResult, err := module.HandleEntrypointHook(ctx, hookstage.ModuleInvocationContext{}, getTestEntrypointPayload(t)) + require.NoError(t, err) + assert.NotNil(t, entrypointResult.ModuleContext[asyncRequestKey]) + + // Create test request payload + width := int64(300) + height := int64(250) + bidRequest := openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + } + requestPayload, _ := json.Marshal(bidRequest) + + // Test raw auction hook + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: entrypointResult.ModuleContext, + } + _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + require.NoError(t, err) + + // Test auction response hook + responsePayload := hookstage.AuctionResponsePayload{ + BidResponse: &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{}`), + }, + } + + responseResult, err := module.HandleAuctionResponseHook(ctx, miCtx, responsePayload) + require.NoError(t, err) + + // Verify the response was modified + assert.True(t, len(responseResult.ChangeSet.Mutations()) > 0) + + // Apply the mutations and check the result + modifiedPayload := responsePayload + for _, mutation := range responseResult.ChangeSet.Mutations() { + var err error + modifiedPayload, err = mutation.Apply(modifiedPayload) + require.NoError(t, err) + } + + // Parse the modified response + var extMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.Ext, &extMap) + require.NoError(t, err) + + // Verify scope3 section exists + scope3Data, exists := extMap["scope3"].(map[string]interface{}) + require.True(t, exists) + segments, exists := scope3Data["segments"].([]interface{}) + require.True(t, exists) + assert.Len(t, segments, 2) + + // Verify targeting section exists (add_to_targeting: true) + prebidData, exists := extMap["prebid"].(map[string]interface{}) + require.True(t, exists) + targetingData, exists := prebidData["targeting"].(map[string]interface{}) + require.True(t, exists) + + // Check individual targeting keys + assert.Equal(t, "true", targetingData["test_segment_1"]) + assert.Equal(t, "true", targetingData["test_segment_2"]) +} + +func TestScope3APIIntegrationWithExistingPrebidTargeting(t *testing.T) { + // Create mock server that returns segments + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "data": [ + { + "destination": "triplelift.com", + "imp": [ + { + "id": "test-imp-1", + "ext": { + "scope3": { + "segments": [ + {"id": "test_segment_1"}, + {"id": "test_segment_2"} + ] + } + } + } + ] + } + ] + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with targeting enabled + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "add_to_targeting": true + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Test full hook workflow + ctx := context.Background() + + // Test entrypoint hook + entrypointResult, err := module.HandleEntrypointHook(ctx, hookstage.ModuleInvocationContext{}, getTestEntrypointPayload(t)) + require.NoError(t, err) + assert.NotNil(t, entrypointResult.ModuleContext[asyncRequestKey]) + + // Create test request payload + width := int64(300) + height := int64(250) + bidRequest := openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + } + requestPayload, _ := json.Marshal(bidRequest) + + // Test raw auction hook + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: entrypointResult.ModuleContext, + } + _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + require.NoError(t, err) + + // Test auction response hook + responsePayload := hookstage.AuctionResponsePayload{ + BidResponse: &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{"prebid":{"targeting":{"segment_existing":"true"}}}`), + }, + } + + responseResult, err := module.HandleAuctionResponseHook(ctx, miCtx, responsePayload) + require.NoError(t, err) + + // Verify the response was modified + assert.True(t, len(responseResult.ChangeSet.Mutations()) > 0) + + // Apply the mutations and check the result + modifiedPayload := responsePayload + for _, mutation := range responseResult.ChangeSet.Mutations() { + var err error + modifiedPayload, err = mutation.Apply(modifiedPayload) + require.NoError(t, err) + } + + // Parse the modified response + var extMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.Ext, &extMap) + require.NoError(t, err) + + // Verify scope3 section exists + scope3Data, exists := extMap["scope3"].(map[string]interface{}) + require.True(t, exists) + segments, exists := scope3Data["segments"].([]interface{}) + require.True(t, exists) + assert.Len(t, segments, 2) + + // Verify targeting section exists (add_to_targeting: true) + prebidData, exists := extMap["prebid"].(map[string]interface{}) + require.True(t, exists) + targetingData, exists := prebidData["targeting"].(map[string]interface{}) + require.True(t, exists) + + // Check individual targeting keys + assert.Equal(t, "true", targetingData["segment_existing"]) + assert.Equal(t, "true", targetingData["test_segment_1"]) + assert.Equal(t, "true", targetingData["test_segment_2"]) +} + +func TestScope3APIIntegrationWithExistingPrebidNoTargeting(t *testing.T) { + // Create mock server that returns segments + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "data": [ + { + "destination": "triplelift.com", + "imp": [ + { + "id": "test-imp-1", + "ext": { + "scope3": { + "segments": [ + {"id": "test_segment_1"}, + {"id": "test_segment_2"} + ] + } + } + } + ] + } + ] + }` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + // Create module with targeting enabled + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000, + "add_to_targeting": true + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Test full hook workflow + ctx := context.Background() + + // Test entrypoint hook + entrypointResult, err := module.HandleEntrypointHook(ctx, hookstage.ModuleInvocationContext{}, getTestEntrypointPayload(t)) + require.NoError(t, err) + assert.NotNil(t, entrypointResult.ModuleContext[asyncRequestKey]) + + // Create test request payload + width := int64(300) + height := int64(250) + bidRequest := openrtb2.BidRequest{ + ID: "test-auction", + Imp: []openrtb2.Imp{{ + ID: "test-imp-1", + Banner: &openrtb2.Banner{W: &width, H: &height}, + }}, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + } + requestPayload, _ := json.Marshal(bidRequest) + + // Test raw auction hook + miCtx := hookstage.ModuleInvocationContext{ + ModuleContext: entrypointResult.ModuleContext, + } + _, err = module.HandleRawAuctionHook(ctx, miCtx, requestPayload) + require.NoError(t, err) + + // Test auction response hook + responsePayload := hookstage.AuctionResponsePayload{ + BidResponse: &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{"prebid":{"something_else":"true"}}`), + }, + } + + responseResult, err := module.HandleAuctionResponseHook(ctx, miCtx, responsePayload) + require.NoError(t, err) + + // Verify the response was modified + assert.True(t, len(responseResult.ChangeSet.Mutations()) > 0) + + // Apply the mutations and check the result + modifiedPayload := responsePayload + for _, mutation := range responseResult.ChangeSet.Mutations() { + var err error + modifiedPayload, err = mutation.Apply(modifiedPayload) + require.NoError(t, err) + } + + // Parse the modified response + var extMap map[string]interface{} + err = json.Unmarshal(modifiedPayload.BidResponse.Ext, &extMap) + require.NoError(t, err) + + // Verify scope3 section exists + scope3Data, exists := extMap["scope3"].(map[string]interface{}) + require.True(t, exists) + segments, exists := scope3Data["segments"].([]interface{}) + require.True(t, exists) + assert.Len(t, segments, 2) + + // Verify targeting section exists (add_to_targeting: true) + prebidData, exists := extMap["prebid"].(map[string]interface{}) + require.True(t, exists) + targetingData, exists := prebidData["targeting"].(map[string]interface{}) + require.True(t, exists) + + // Check individual targeting keys + assert.Equal(t, "true", targetingData["test_segment_1"]) + assert.Equal(t, "true", targetingData["test_segment_2"]) +} + +func TestScope3APIError(t *testing.T) { + // Create mock server that returns an error + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Internal Server Error")) + })) + defer mockServer.Close() + + // Create module with mock server + config := json.RawMessage(`{ + "endpoint": "` + mockServer.URL + `", + "auth_key": "test-auth-key", + "timeout_ms": 1000 + }`) + + moduleInterface, err := Builder(config, getTestModuleDeps(t)) + require.NoError(t, err) + module := moduleInterface.(*Module) + + // Test that API errors are handled gracefully + bidRequest := &openrtb2.BidRequest{ + ID: "test-auction", + Site: &openrtb2.Site{Domain: "example.com"}, + } + + ctx := context.Background() + segments, err := module.fetchScope3Segments(ctx, bidRequest) + assert.Error(t, err) + assert.Empty(t, segments) + assert.Contains(t, err.Error(), "scope3 returned status 500") +} + +// MASKING TESTS + +func TestBuilderWithMasking(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "endpoint": "https://rtdp.scope3.com/amazonaps/rtii", + "auth_key": "test-key", + "timeout_ms": 1000, + "cache_ttl_seconds": 60, + "add_to_targeting": false, + "masking": { + "enabled": true, + "geo": { + "preserve_metro": true, + "preserve_zip": false, + "preserve_city": true, + "lat_long_precision": 3 + }, + "user": { + "preserve_eids": ["liveramp.com", "custom.com"] + }, + "device": { + "preserve_mobile_ids": true + } + } + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.NoError(t, err) + assert.NotNil(t, module) + + m := module.(*Module) + assert.Equal(t, true, m.cfg.Masking.Enabled) + assert.Equal(t, true, m.cfg.Masking.Geo.PreserveMetro) + assert.Equal(t, false, m.cfg.Masking.Geo.PreserveZip) + assert.Equal(t, true, m.cfg.Masking.Geo.PreserveCity) + assert.Equal(t, 3, m.cfg.Masking.Geo.LatLongPrecision) + assert.Equal(t, []string{"liveramp.com", "custom.com"}, m.cfg.Masking.User.PreserveEids) + assert.Equal(t, true, m.cfg.Masking.Device.PreserveMobileIds) +} + +func TestBuilderMaskingDefaults(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "auth_key": "test-key", + "masking": { + "enabled": true + } + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.NoError(t, err) + m := module.(*Module) + + // Check defaults are applied + assert.Equal(t, true, m.cfg.Masking.Enabled) + assert.Equal(t, 2, m.cfg.Masking.Geo.LatLongPrecision) // Default precision + assert.Equal(t, true, m.cfg.Masking.Geo.PreserveMetro) // Default preserve + assert.Equal(t, true, m.cfg.Masking.Geo.PreserveZip) // Default preserve + assert.Equal(t, []string{"liveramp.com", "uidapi.com", "id5-sync.com"}, m.cfg.Masking.User.PreserveEids) + assert.Equal(t, false, m.cfg.Masking.Device.PreserveMobileIds) // Default false +} + +func TestMaskBidRequest_Disabled(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{Enabled: false}, + }, + } + + lat := 37.774929 + lon := -122.419416 + original := &openrtb2.BidRequest{ + User: &openrtb2.User{ + ID: "publisher-user-123", + }, + Device: &openrtb2.Device{ + IP: "192.168.1.1", + IFA: "12345-67890", + Geo: &openrtb2.Geo{ + Lat: &lat, + Lon: &lon, + }, + }, + } + + result := module.maskBidRequest(original) + + // Should return exact same request when disabled + assert.Equal(t, original, result) +} + +func TestMaskUser(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + User: UserMaskingConfig{ + PreserveEids: []string{"liveramp.com", "uidapi.com"}, + }, + }, + }, + } + + original := &openrtb2.BidRequest{ + User: &openrtb2.User{ + ID: "publisher-user-123", + BuyerUID: "exchange-user-456", + Yob: 1990, + Gender: "M", + Keywords: "sports,automotive", + Data: []openrtb2.Data{{ + ID: "segment1", + Name: "sports_fans", + }}, + EIDs: []openrtb2.EID{ + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp123"}}}, + {Source: "blocked.com", UIDs: []openrtb2.UID{{ID: "blocked456"}}}, + {Source: "uidapi.com", UIDs: []openrtb2.UID{{ID: "uid789"}}}, + }, + }, + } + + module.maskUser(original) + + // Check that sensitive fields are removed + assert.Equal(t, "", original.User.ID) + assert.Equal(t, "", original.User.BuyerUID) + assert.Equal(t, int64(0), original.User.Yob) + assert.Equal(t, "", original.User.Gender) + assert.Equal(t, "", original.User.Keywords) + assert.Nil(t, original.User.Data) + + // Check that eids are filtered correctly + assert.Len(t, original.User.EIDs, 2) + assert.Equal(t, "liveramp.com", original.User.EIDs[0].Source) + assert.Equal(t, "uidapi.com", original.User.EIDs[1].Source) +} + +func TestFilterEids(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + User: UserMaskingConfig{ + PreserveEids: []string{"liveramp.com", "id5-sync.com"}, + }, + }, + }, + } + + eids := []openrtb2.EID{ + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp123"}}}, + {Source: "blocked.com", UIDs: []openrtb2.UID{{ID: "blocked456"}}}, + {Source: "id5-sync.com", UIDs: []openrtb2.UID{{ID: "id5789"}}}, + {Source: "another-blocked.com", UIDs: []openrtb2.UID{{ID: "blocked999"}}}, + } + + result := module.filterEids(eids) + + assert.Len(t, result, 2) + assert.Equal(t, "liveramp.com", result[0].Source) + assert.Equal(t, "id5-sync.com", result[1].Source) +} + +func TestFilterEids_EmptyAllowlist(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + User: UserMaskingConfig{ + PreserveEids: []string{}, + }, + }, + }, + } + + eids := []openrtb2.EID{ + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp123"}}}, + {Source: "blocked.com", UIDs: []openrtb2.UID{{ID: "blocked456"}}}, + } + + result := module.filterEids(eids) + assert.Len(t, result, 0) +} + +func TestMaskDevice(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Device: DeviceMaskingConfig{ + PreserveMobileIds: false, + }, + }, + }, + } + + original := &openrtb2.BidRequest{ + Device: &openrtb2.Device{ + IP: "192.168.1.1", + IPv6: "2001:db8::1", + IFA: "12345-67890", + DPIDMD5: "abc123", + DPIDSHA1: "def456", + DIDMD5: "ghi789", + DIDSHA1: "jkl012", + MACMD5: "mno345", + MACSHA1: "pqr678", + DeviceType: 1, + OS: "iOS", + Make: "Apple", + Model: "iPhone", + }, + } + + module.maskDevice(original) + + // Check sensitive fields are removed + assert.Equal(t, "", original.Device.IP) + assert.Equal(t, "", original.Device.IPv6) + assert.Equal(t, "", original.Device.IFA) + assert.Equal(t, "", original.Device.DPIDMD5) + assert.Equal(t, "", original.Device.DPIDSHA1) + assert.Equal(t, "", original.Device.DIDMD5) + assert.Equal(t, "", original.Device.DIDSHA1) + assert.Equal(t, "", original.Device.MACMD5) + assert.Equal(t, "", original.Device.MACSHA1) + + // Check targeting-safe fields are preserved + assert.Equal(t, adcom1.DeviceType(1), original.Device.DeviceType) + assert.Equal(t, "iOS", original.Device.OS) + assert.Equal(t, "Apple", original.Device.Make) + assert.Equal(t, "iPhone", original.Device.Model) +} + +func TestMaskDevice_PreserveMobileIds(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Device: DeviceMaskingConfig{ + PreserveMobileIds: true, + }, + }, + }, + } + + original := &openrtb2.BidRequest{ + Device: &openrtb2.Device{ + IP: "192.168.1.1", + IFA: "12345-67890", + DPIDMD5: "abc123", + DPIDSHA1: "def456", + }, + } + + module.maskDevice(original) + + // IP should still be removed + assert.Equal(t, "", original.Device.IP) + + // But mobile IDs should be preserved + assert.Equal(t, "12345-67890", original.Device.IFA) + assert.Equal(t, "abc123", original.Device.DPIDMD5) + assert.Equal(t, "def456", original.Device.DPIDSHA1) +} + +func TestMaskGeoObject(t *testing.T) { + lat := 37.774929 + lon := -122.419416 + accuracy := int64(10) + + tests := []struct { + name string + config GeoMaskingConfig + expectedLat *float64 + expectedLon *float64 + expectedMetro string + expectedZip string + expectedCity string + expectedAccuracy int64 + }{ + { + name: "precision_0_removes_coordinates", + config: GeoMaskingConfig{ + LatLongPrecision: 0, + PreserveMetro: true, + PreserveZip: true, + PreserveCity: true, + }, + expectedLat: nil, + expectedLon: nil, + expectedMetro: "807", // preserved + expectedZip: "94102", // preserved + expectedCity: "San Francisco", // preserved + expectedAccuracy: 0, // always removed + }, + { + name: "precision_2_truncates_coordinates", + config: GeoMaskingConfig{ + LatLongPrecision: 2, + PreserveMetro: false, + PreserveZip: false, + PreserveCity: false, + }, + expectedLat: &[]float64{37.77}[0], // truncated to 2 decimals + expectedLon: &[]float64{-122.41}[0], // truncated to 2 decimals + expectedMetro: "", // not preserved + expectedZip: "", // not preserved + expectedCity: "", // not preserved + expectedAccuracy: 0, // always removed + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Geo: tt.config, + }, + }, + } + + geo := &openrtb2.Geo{ + Lat: &lat, + Lon: &lon, + Country: "USA", // always preserved + Region: "CA", // always preserved + Metro: "807", + ZIP: "94102", + City: "San Francisco", + Accuracy: accuracy, + } + + module.maskGeoObject(geo) + + // Check coordinates + if tt.expectedLat == nil { + assert.Nil(t, geo.Lat) + } else { + require.NotNil(t, geo.Lat) + assert.InDelta(t, *tt.expectedLat, *geo.Lat, 0.001) + } + + if tt.expectedLon == nil { + assert.Nil(t, geo.Lon) + } else { + require.NotNil(t, geo.Lon) + assert.InDelta(t, *tt.expectedLon, *geo.Lon, 0.001) + } + + // Check other fields + assert.Equal(t, "USA", geo.Country) // always preserved + assert.Equal(t, "CA", geo.Region) // always preserved + assert.Equal(t, tt.expectedMetro, geo.Metro) + assert.Equal(t, tt.expectedZip, geo.ZIP) + assert.Equal(t, tt.expectedCity, geo.City) + assert.Equal(t, int64(0), geo.Accuracy) // always removed + }) + } +} + +func TestTruncateCoordinate(t *testing.T) { + module := &Module{} + + tests := []struct { + coord float64 + precision int + expected float64 + }{ + {37.774929, 0, 0}, + {37.774929, 1, 37.7}, + {37.774929, 2, 37.77}, + {37.774929, 3, 37.774}, + {37.774929, 4, 37.7749}, + {-122.419416, 2, -122.41}, + {-122.419416, 3, -122.419}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("coord_%.6f_precision_%d", tt.coord, tt.precision), func(t *testing.T) { + result := module.truncateCoordinate(tt.coord, tt.precision) + assert.InDelta(t, tt.expected, result, 0.0001) + }) + } +} + +func TestMaskBidRequest_FullIntegration(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Geo: GeoMaskingConfig{ + PreserveMetro: true, + PreserveZip: false, + PreserveCity: false, + LatLongPrecision: 2, + }, + User: UserMaskingConfig{ + PreserveEids: []string{"liveramp.com"}, + }, + Device: DeviceMaskingConfig{ + PreserveMobileIds: false, + }, + }, + }, + } + + lat := 37.774929 + lon := -122.419416 + original := &openrtb2.BidRequest{ + ID: "test-request", + User: &openrtb2.User{ + ID: "publisher-user-123", + BuyerUID: "exchange-user-456", + Gender: "M", + EIDs: []openrtb2.EID{ + {Source: "liveramp.com", UIDs: []openrtb2.UID{{ID: "ramp123"}}}, + {Source: "blocked.com", UIDs: []openrtb2.UID{{ID: "blocked456"}}}, + }, + }, + Device: &openrtb2.Device{ + IP: "192.168.1.1", + IFA: "12345-67890", + DeviceType: 1, + OS: "iOS", + Geo: &openrtb2.Geo{ + Lat: &lat, + Lon: &lon, + Country: "USA", + Region: "CA", + Metro: "807", + ZIP: "94102", + City: "San Francisco", + }, + }, + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + } + + result := module.maskBidRequest(original) + + // Should be different object (deep copy) + assert.NotEqual(t, original, result) + + // Check user masking + assert.Equal(t, "", result.User.ID) + assert.Equal(t, "", result.User.BuyerUID) + assert.Equal(t, "", result.User.Gender) + assert.Len(t, result.User.EIDs, 1) + assert.Equal(t, "liveramp.com", result.User.EIDs[0].Source) + + // Check device masking + assert.Equal(t, "", result.Device.IP) + assert.Equal(t, "", result.Device.IFA) + assert.Equal(t, adcom1.DeviceType(1), result.Device.DeviceType) // preserved + assert.Equal(t, "iOS", result.Device.OS) // preserved + + // Check geo masking + assert.Equal(t, "USA", result.Device.Geo.Country) // preserved + assert.Equal(t, "CA", result.Device.Geo.Region) // preserved + assert.Equal(t, "807", result.Device.Geo.Metro) // preserved + assert.Equal(t, "", result.Device.Geo.ZIP) // not preserved + assert.Equal(t, "", result.Device.Geo.City) // not preserved + assert.InDelta(t, 37.77, *result.Device.Geo.Lat, 0.001) // truncated + assert.InDelta(t, -122.41, *result.Device.Geo.Lon, 0.001) // truncated + + // Check site is preserved completely + assert.Equal(t, "example.com", result.Site.Domain) + assert.Equal(t, "https://example.com/test", result.Site.Page) + + // Original should be unchanged + assert.Equal(t, "publisher-user-123", original.User.ID) + assert.Equal(t, "192.168.1.1", original.Device.IP) + assert.InDelta(t, 37.774929, *original.Device.Geo.Lat, 0.000001) +} + +func TestGetMaskingSummary(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Geo: GeoMaskingConfig{ + PreserveMetro: true, + PreserveZip: false, + LatLongPrecision: 3, + }, + User: UserMaskingConfig{ + PreserveEids: []string{"liveramp.com", "uidapi.com"}, + }, + Device: DeviceMaskingConfig{ + PreserveMobileIds: true, + }, + }, + }, + } + + summary := module.getMaskingSummary() + + assert.Equal(t, true, summary["enabled"]) + + geoConfig, ok := summary["geo"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, geoConfig["preserve_metro"]) + assert.Equal(t, false, geoConfig["preserve_zip"]) + assert.Equal(t, 3, geoConfig["lat_long_precision"]) + + userConfig, ok := summary["user"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, []string{"liveramp.com", "uidapi.com"}, userConfig["preserve_eids"]) + + deviceConfig, ok := summary["device"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, true, deviceConfig["preserve_mobile_ids"]) +} + +func TestGetMaskingSummary_Disabled(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{Enabled: false}, + }, + } + + summary := module.getMaskingSummary() + assert.Equal(t, map[string]interface{}{"enabled": false}, summary) +} + +func TestMaskGeo_UserGeoOnly(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{ + Enabled: true, + Geo: GeoMaskingConfig{ + PreserveMetro: false, + PreserveZip: false, + LatLongPrecision: 2, + }, + }, + }, + } + + lat := 40.7128 + lon := -74.0060 + original := &openrtb2.BidRequest{ + User: &openrtb2.User{ + Geo: &openrtb2.Geo{ + Lat: &lat, + Lon: &lon, + Country: "USA", + Region: "NY", + Metro: "501", + ZIP: "10001", + }, + }, + // No device geo + Device: &openrtb2.Device{ + OS: "iOS", + }, + } + + module.maskGeo(original) + + // Check user geo was masked + assert.Equal(t, "USA", original.User.Geo.Country) // preserved + assert.Equal(t, "NY", original.User.Geo.Region) // preserved + assert.Equal(t, "", original.User.Geo.Metro) // removed + assert.Equal(t, "", original.User.Geo.ZIP) // removed + assert.InDelta(t, 40.71, *original.User.Geo.Lat, 0.001) // truncated + assert.InDelta(t, -74.00, *original.User.Geo.Lon, 0.001) // truncated +} + +func TestTruncateCoordinate_EdgeCases(t *testing.T) { + module := &Module{} + + // Test precision out of range + assert.Equal(t, float64(0), module.truncateCoordinate(37.774929, -1)) + assert.Equal(t, float64(0), module.truncateCoordinate(37.774929, 5)) + assert.Equal(t, float64(0), module.truncateCoordinate(37.774929, 0)) + + // Test zero coordinate + assert.Equal(t, float64(0), module.truncateCoordinate(0.0, 2)) +} + +func TestMaskDevice_NoDevice(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{Enabled: true}, + }, + } + + original := &openrtb2.BidRequest{ + ID: "test", + User: &openrtb2.User{ID: "user-123"}, + // No device + } + + module.maskDevice(original) + + // Should not crash when device is nil + assert.Nil(t, original.Device) +} + +func TestMaskUser_NoUser(t *testing.T) { + module := &Module{ + cfg: Config{ + Masking: MaskingConfig{Enabled: true}, + }, + } + + original := &openrtb2.BidRequest{ + ID: "test", + // No user + Device: &openrtb2.Device{IP: "192.168.1.1"}, + } + + module.maskUser(original) + + // Should not crash when user is nil + assert.Nil(t, original.User) +} + +func TestBuilderConfigValidation_GeoPrecisionTooHigh(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "auth_key": "test-key", + "masking": { + "enabled": true, + "geo": { + "lat_long_precision": 5 + } + } + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot exceed 4 decimal places") + assert.Nil(t, module) +} + +func TestBuilderConfigValidation_GeoPrecisionNegative(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "auth_key": "test-key", + "masking": { + "enabled": true, + "geo": { + "lat_long_precision": -1 + } + } + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be negative") + assert.Nil(t, module) +} + +func TestBuilderConfigValidation_GeoPrecisionValid(t *testing.T) { + config := json.RawMessage(`{ + "enabled": true, + "auth_key": "test-key", + "masking": { + "enabled": true, + "geo": { + "lat_long_precision": 4 + } + } + }`) + + deps := moduledeps.ModuleDeps{HTTPClient: http.DefaultClient} + module, err := Builder(config, deps) + + assert.NoError(t, err) + assert.NotNil(t, module) + + m := module.(*Module) + assert.Equal(t, 4, m.cfg.Masking.Geo.LatLongPrecision) +} + +func TestCreateCacheKey_HashedUserID(t *testing.T) { + module := &Module{ + sha256Pool: &sync.Pool{ + New: func() any { + return sha256.New() + }, + }, + } + + // Create request with user ID (no privacy-safe identifiers) + bidRequest := &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + User: &openrtb2.User{ + ID: "sensitive-user-id-123", // This should be hashed in cache key + }, + } + + key1 := module.createCacheKey(bidRequest) + + // Create another request with different user ID + bidRequest2 := &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + User: &openrtb2.User{ + ID: "different-user-id-456", // Different hash should produce different key + }, + } + + key2 := module.createCacheKey(bidRequest2) + + // Keys should be different since user IDs are different (per-user caching) + assert.NotEqual(t, key1, key2, "Cache keys should be different for different user IDs to enable per-user caching") + + // Create request with same user ID to verify consistency + bidRequest3 := &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + User: &openrtb2.User{ + ID: "sensitive-user-id-123", // Same as first request + }, + } + + key3 := module.createCacheKey(bidRequest3) + + // Should match first key for same user + assert.Equal(t, key1, key3, "Cache keys should be consistent for same user ID") + + // Verify key is SHA-256 length (64 hex characters) + assert.Len(t, key1, 64, "Cache key should be SHA-256 hash (64 characters)") +} + +func TestCreateCacheKey_PrivacySafeIdentifiersPriority(t *testing.T) { + module := &Module{ + sha256Pool: &sync.Pool{ + New: func() any { + return sha256.New() + }, + }, + } + + // Create request with both user.id and privacy-safe identifiers + userExtBytes, _ := jsonutil.Marshal(userExt{ + RampID: "ramp_abc123", + }) + + bidRequest := &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + User: &openrtb2.User{ + ID: "user-id-should-not-be-used", + Ext: userExtBytes, + }, + } + + key1 := module.createCacheKey(bidRequest) + + // Create another request with same privacy-safe ID but different user.id + bidRequest2 := &openrtb2.BidRequest{ + Site: &openrtb2.Site{ + Domain: "example.com", + Page: "https://example.com/test", + }, + User: &openrtb2.User{ + ID: "completely-different-user-id", + Ext: userExtBytes, // Same RampID + }, + } + + key2 := module.createCacheKey(bidRequest2) + + // Keys should be the same since privacy-safe identifiers take priority + assert.Equal(t, key1, key2, "Cache keys should be same when privacy-safe identifiers match, regardless of user.id") +} + +func TestFetchScope3Segments_MaskingFailure(t *testing.T) { + // Create a test server that simulates API response + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{"data": []}` + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(response)) + })) + defer mockServer.Close() + + module := &Module{ + cfg: Config{ + Endpoint: mockServer.URL, + AuthKey: "test-key", + Timeout: 1000, + Masking: MaskingConfig{Enabled: true}, + }, + httpClient: &http.Client{ + Timeout: 1 * time.Second, + }, + cache: freecache.NewCache(10), + sha256Pool: &sync.Pool{ + New: func() any { + return sha256.New() + }, + }, + } + + // Create a request that should work normally + bidRequest := &openrtb2.BidRequest{ + ID: "test-request", + User: &openrtb2.User{ + ID: "user-123", + }, + } + + ctx := context.Background() + + // This should succeed now that we have proper HTTP client setup + // The masking should work correctly and not cause errors + segments, err := module.fetchScope3Segments(ctx, bidRequest) + + // Should succeed with proper setup + assert.NoError(t, err) + assert.NotNil(t, segments) +}