diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml index 73bb37dd71c..b0c82ff52a0 100644 --- a/extra/bundle/pom.xml +++ b/extra/bundle/pom.xml @@ -60,6 +60,11 @@ optable-targeting ${project.version} + + org.prebid.server.hooks.modules + id5-user-id + ${project.version} + org.prebid.server.hooks.modules wurfl-devicedetection diff --git a/extra/modules/id5-user-id/README.md b/extra/modules/id5-user-id/README.md new file mode 100644 index 00000000000..0e1f70dc917 --- /dev/null +++ b/extra/modules/id5-user-id/README.md @@ -0,0 +1,449 @@ +# ID5 User ID Module + +This module integrates ID5's universal identifier service into Prebid Server Java, enabling publishers to fetch and inject ID5 user IDs into bid requests sent to bidders. + +## Quick Navigation + +- **[Production Setup](#production-setup)** - For publishers and PBS operators +- **[Module Development](#module-development)** - For developers working on the module + +--- + +# Production Setup + +## Overview + +The ID5 User ID module fetches identity signals from ID5's API and automatically injects them into OpenRTB bid requests as Extended Identifiers (EIDs). This enhances user matching for participating bidders while respecting user privacy preferences. + +## Features + +- Fetches ID5 universal identifiers and automatically injects them into bid requests as Extended Identifiers (EIDs) +- Only adds ID5 when not already present in the publisher's bid request - preserves existing ID5 identifiers +- Privacy-compliant: respects GDPR, CCPA, COPPA, and GPP signals +- Flexible control: filter by account, country, bidder, or use sampling to gradually roll out + +## How It Works + +The module uses two hooks in the Prebid Server request lifecycle: + +1. **Fetch Hook** (`ProcessedAuctionRequestHook`): + - Triggered early in the auction request processing + - **First checks if ID5 EID already exists** - if present, skips fetching entirely + - Initiates an asynchronous call to ID5's API only when ID5 is not present + - Stores the Future result in module context for later use + - Applies fetch filters (account, country, sampling) + +2. **Inject Hook** (`BidderRequestHook`): + - Triggered before each bidder request + - **Checks again if ID5 EID is already present** - if so, skips injection + - Awaits the ID5 fetch result (with timeout awareness) + - Injects ID5 EIDs into `user.eids` field only when not already present + - Applies inject filters (bidder selection) + - Sets the `inserter` field to the EID if configured + +``` +┌─────────────────┐ +│ Auction Request │ +└────────┬────────┘ + │ + v +┌─────────────────────────┐ +│ ID5 Fetch Hook │ +│ Check: ID5 exists? │ +└────────┬────────────────┘ + │ + ├─ YES ──> Skip (no fetch needed) + │ + └─ NO ───> Async call to ID5 API + stores Future in context + │ + v +┌─────────────────────┐ +│ Bidder Requests │ +└────────┬────────────┘ + │ + v +┌─────────────────────────┐ +│ ID5 Inject Hook │ +│ Check: ID5 exists? │ +└────────┬────────────────┘ + │ + ├─ YES ──> Skip (preserve existing) + │ + └─ NO ───> Await Future, inject EIDs +``` +## What Data is Sent to ID5 + +The module sends the following information to ID5's API: + +**From Bid Request:** +- App bundle (`app.bundle`) +- Site domain (`site.domain`) +- Site referrer (`site.ref`) +- Device IFA/MAID (`device.ifa`) +- Device User Agent (`device.ua`) +- Device IP address (`device.ip`) +- ATT status (`device.ext.atts`) + +**Privacy Signals:** +- GDPR consent string +- GDPR applies flag +- US Privacy (CCPA) string +- COPPA flag +- GPP string and SID + +**Module Metadata:** +- Partner ID +- Timestamp +- PBS version +- Origin identifier +- Provider string + +## What data is added to the bidder request? + +The module's `ID5 Inject Hook` adds [EID](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3227---object-eid-)s to the OpenRTB `user.eids` array. +The EID objects come from a response to the fetch request triggered by `ID5 Fetch Hook` called at earlier stage of the action. +The EID before insertion can be enriched with `inserter` field which is configurable by server host. + +Example EID added: + +```json +{ + "user": { + "eids": [ + { + "source": "id5-sync.com", + "uids": [ + { + "id": "ID5*YsvxY...", + "atype": 1, + "ext": { + "linkType": 2, + "pba": "jWwv+..." + } + } + ], + "inserter": "pbs-company.com" // this can be configured + } + ] + } +} +``` + +## Privacy & Compliance + +The module respects privacy signals: + +- **GDPR**: Passes consent string and GDPR applies flag to ID5 +- **CCPA**: Passes US Privacy string to ID5 +- **COPPA**: Passes COPPA flag to ID5 +- **GPP**: Passes GPP string and applicable sections to ID5 + +ID5's API will respect these signals when generating identifiers. Ensure your privacy policy covers the use of ID5's service and data sharing with ID5. + + +## Configuration + +### Required Properties + +| Property | Type | Description | +|----------|------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | boolean | Must be `true` to activate the module | +| `providerName` | string | Provider identifier string sent to ID5 API. Identifies who is hosting/operating the Prebid Server instance (e.g., "my-company-pbs", "my-company-com"). | +| `fetchEndpoint` | string | ID5 API endpoint URL (`https://api.id5-sync.com/gs/v2`) | +| `partner` | long | ID5 Partner ID (minimum value: 1). Required only when using the default constant provider. Not needed if you provide a custom `Id5PartnerIdProvider` bean (see Custom Partner ID Configuration below) | + +### Custom Partner ID Configuration + +By default, the module uses a constant Partner ID from the `hooks.id5-user-id.partner` configuration property. This partner id is used for each id5id fetch request. + +In some configurations may be needed to pass different partner depending on channel, publisher where the auction request comes from, or anything else. +For such cases implement the `Id5PartnerIdProvider` interface and register it as a Spring bean. The module will automatically use your custom implementation. + +**Important:** If the provider returns an empty value, the ID5 fetch will be skipped for that request. + +### Optional Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `inserterName` | string | null | The canonical domain name of the entity that caused this EID to be added (e.g., "pbs-company.com", "ssp.example.com"). Should be the operational domain of the system running this module. See [OpenRTB EID specification](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3227---object-eid-) for details. | +| `fetchSamplingRate` | double | 1.0 | Percentage of requests to sample (0.0-1.0) | +| `bidderFilter` | ValuesFilter | null | Filter bidders that receive IDs | +| `accountFilter` | ValuesFilter | null | Filter accounts that trigger fetches | +| `countryFilter` | ValuesFilter | null | Filter countries that trigger fetches | + +### ValuesFilter Structure + +Each filter supports include/exclude semantics: + +```yaml +: + exclude: false # false = allowlist, true = blocklist + values: # list of values to include/exclude + - value1 + - value2 +``` + +**Allowlist mode** (`exclude: false`): +- Only requests matching the listed values will proceed +- Empty or null values list = allow all + +**Blocklist mode** (`exclude: true`): +- Requests matching the listed values will be rejected +- Empty or null values list = allow all + +### Filter Behavior + +Filters are evaluated in sequence. If any filter rejects the request, no ID5 fetch occurs: + +1. **Sampling Filter**: Random sampling based on `fetchSamplingRate` +2. **Account Filter**: Checks account ID against `accountFilter` +3. **Country Filter**: Checks country code against `countryFilter` +4. **Bidder Filter**: (Inject only) Checks bidder name against `bidderFilter` + +## Integration + +### Add module dependency +The module dependency must be added to your server application. +```xml + + org.prebid.server.hooks.modules + id5-user-id + ${PREBID_SERVER_VERSION} + +``` + +The module is included in the `extra/bundle` by default `extra/bundle/pom.xml` + +### Configure module + +To run a module you must +- enable module in config and add required properties +- configure an execution plan that registers the module's hooks. See [Configure an execution plan with module's hooks](#configure-an-execution-plan-with-modules-hooks) for details. + +#### Enabling and configuring module +##### Basic (minimal) Setup +Enable for all accounts and bidders: +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + fetch-endpoint: "https://api.id5-sync.com/gs/v2" # Required: ID5 API endpoint + partner: 173 +``` + +##### Gradual Rollout with Sampling +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + fetch-endpoint: "https://api.id5-sync.com/gs/v2" # Required: ID5 API endpoint + partner: 173 + fetch-sampling-rate: 0.1 # 10% of requests +``` + +##### With multiple filters +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + fetch-endpoint: "https://api.id5-sync.com/gs/v2" # Required: ID5 API endpoint + partner: 173 + inserter-name: "pbs-company.com" # Canonical domain of the entity that added this EID + fetch-sampling-rate: 0.8 + account-filter: # for auctions from any account except "test-account" + exclude: true + values: [test-account] + country-filter: # only actions from listed countries + exclude: false + values: [US, GB, DE, FR] + bidder-filter: # id will be added to only listed bidder's requests + exclude: false + values: [rubicon, appnexus, pubmatic] +``` + +#### Configure an execution plan with module's hooks +Default or for a specific account. + +```yaml +accounts: + - id: "1001" + status: active + hooks: + execution-plan: + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-fetch-hook" } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-inject-hook" } + ] + } + ] + } + } + } + } + } +``` +## Troubleshooting + +### No EIDs appearing in bid requests + +**Check:** +1. Module dependency is added to your server application's class path +2. Module is enabled: `enabled: true` in configuration +3. Required properties are set (`partner` property or custom `PartnerIdProvider` spring bean, `provider-name` property) +4. Execution plan configured with module's hooks is configured +5. Enable DEBUG logging and verify fetch/inject occurred +6. Verify filters aren't excluding the request + +## Performance Considerations + +- ID5 fetch is asynchronous and non-blocking +- Fetch result is cached in module context for all bidders +- HTTP client uses connection pooling +- Timeout is respected from the auction context +- Failed fetches don't block the auction (returns empty) + +## Support + +For issues specific to: +- **Module implementation**: Contact ID5 support at support@id5.io +- **ID5 service/API**: Contact ID5 support at support@id5.io +- **Partner ID registration**: Contact your ID5 account manager + +--- + +# Module Development + +This section is for developers working on the ID5 User ID module itself. + +## Debugging + +Enable debug mode to see detailed hook execution messages: + +```yaml +logging: + level: + org.prebid.server.hooks.modules.id5: DEBUG +``` + +Debug logs will show when IDs are fetched, injected, or skipped (due to filters, existing IDs, or timeouts). + +## Local End-to-End Testing + +The repository includes complete sample configurations for local testing with WireMock mocks. This allows you to test the full ID5 module flow without connecting to the real ID5 API. + +### Prerequisites + +- Java 17+ +- Maven 3.8+ +- Docker Desktop (for WireMock) + +### Quick Start + +1. **Start WireMock**: + ```bash + cd sample/wiremock + docker compose -f docker-compose.wiremock.yml up -d + ``` + +2. **Build and run PBS with ID5 module**: + From the project root directory: + ```bash + cd extra + mvn clean package -pl bundle -am -DskipTests + cd .. + java -jar extra/bundle/target/prebid-server-bundle.jar --spring.config.additional-location=sample/configs/prebid-config-with-id5.yaml + ``` + +3. **Send test request**: + ```bash + curl -X POST http://localhost:8080/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d @sample/requests/localdev-test-request.http + ``` + +4. **Verify**: Check logs for `id5-user-id-fetch: id5id fetched` and `id5-user-id-inject: updated user with id5 eids` + +### Configuration Files Reference + +| File | Purpose | +|------|---------| +| `sample/configs/prebid-config-with-id5.yaml` | Main PBS config with ID5 module enabled | +| `sample/configs/sample-app-settings-id5.yaml` | Account settings with hooks execution plan | +| `sample/wiremock/mappings/id5-fetch.json` | WireMock mapping for ID5 API | +| `sample/wiremock/__files/id5-fetch-response.json` | Mock ID5 API response | +| `sample/requests/localdev-test-request.http` | Sample auction request | + +### Testing Different Scenarios + +Test various behaviors by modifying the configuration or WireMock mappings: +- **Control test**: Change account ID to one without hooks configured - verify no EIDs added +- **Timeout behavior**: Add `fixedDelayMilliseconds` to WireMock response +- **Error handling**: Change WireMock to return HTTP 503 +- **Filter testing**: Add bidder/account/country filters to configuration + +## Unit Tests + +Run the comprehensive unit test suite: + +```bash +# From the module directory +cd extra/modules/id5-user-id + +# Run all unit tests (excludes *IT.java integration tests) +mvn test + +# Run specific test class +mvn test -Dtest=Id5IdFetchHookTest + +# Run with debug logging +mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug +``` + +## Integration Tests + +Integration tests for the ID5 module are part of the main Prebid Server functional test suite, located in: + +``` +src/test/groovy/org/prebid/server/functional/tests/module/id5userid/ +``` + +To run them: + +```bash +mvn verify -DskipModuleFunctionalTests=false -DskipFunctionalTests=true -DskipUnitTests=true \ + -Dit.test="Id5UserIdModuleSpec" -DdockerfileName=Dockerfile-modules +``` + +For more details on the functional test framework, see [Functional Tests documentation](../../../docs/developers/functional-tests.md). + +--- + +## Version History + +- **v1.0**: Initial implementation + - Fetch and inject hooks + - Configurable filtering (account, country, bidder, sampling) + - Privacy signal support (GDPR, CCPA, COPPA, GPP) + +## License + +This module is part of Prebid Server Java and follows the same license terms. diff --git a/extra/modules/id5-user-id/pom.xml b/extra/modules/id5-user-id/pom.xml new file mode 100644 index 00000000000..d390b0da4b5 --- /dev/null +++ b/extra/modules/id5-user-id/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + org.prebid.server.hooks.modules + all-modules + 3.41.0-SNAPSHOT + + + id5-user-id + + id5-user-id + ID5 User ID + + + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java new file mode 100644 index 00000000000..97cb223aa8a --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java @@ -0,0 +1,108 @@ +package org.prebid.server.hooks.modules.id5.userid.config; + +import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdFetchHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdInjectHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.AccountFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.CountryFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SamplingFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SelectedBidderFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.ConstantId5PartnerId; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.util.List; +import java.util.Random; + +@Configuration +@EnableConfigurationProperties(Id5IdModuleProperties.class) +@ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "enabled", havingValue = "true") +public class Id5UserIdModuleConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(Id5UserIdModuleConfiguration.class); + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "fetch-sampling-rate") + SamplingFetchFilter fetchSampler(Id5IdModuleProperties properties) { + logger.debug("id5-user-id-fetch-sampling-rate enabled with rate {}", properties.getFetchSamplingRate()); + return new SamplingFetchFilter(new Random(), properties.getFetchSamplingRate()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "bidder-filter.values") + SelectedBidderFilter selectedBidderFilter(Id5IdModuleProperties properties) { + logger.debug("id5-user-id-bidder-filter enabled, {}", properties.getBidderFilter()); + return new SelectedBidderFilter(properties.getBidderFilter()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "account-filter.values") + AccountFetchFilter accountFetchFilter(Id5IdModuleProperties properties) { + logger.debug("id5-user-id-account-filter enabled, {}", properties.getAccountFilter()); + return new AccountFetchFilter(properties.getAccountFilter()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "country-filter.values") + CountryFetchFilter countryFetchFilter(Id5IdModuleProperties properties) { + logger.debug("id5-user-id-country-filter enabled, {}", properties.getCountryFilter()); + return new CountryFetchFilter(properties.getCountryFilter()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "partner") + Id5PartnerIdProvider constantId5PartnerIdProvider(Id5IdModuleProperties properties) { + return new ConstantId5PartnerId(properties.getPartner()); + } + + @Bean + HttpFetchClient fetchClient(Id5IdModuleProperties properties, + VersionInfo versionInfo, + HttpClient httpClient, + Clock clock, + UserFpdActivityMask userFpdActivityMask) { + + logger.debug("id5-user-id-fetch hook enabled, endpoint: {}", properties.getFetchEndpoint()); + return new HttpFetchClient( + properties.getFetchEndpoint(), + httpClient, + clock, + versionInfo, + properties, + userFpdActivityMask); + } + + @Bean + Id5IdFetchHook id5UserIdFetchHook(HttpFetchClient fetchClient, + List filters, + Id5PartnerIdProvider id5PartnerIdProvider) { + + return new Id5IdFetchHook(fetchClient, filters, id5PartnerIdProvider); + } + + @Bean + Id5IdInjectHook id5UserIdInjectHook(Id5IdModuleProperties properties, + List injectFilters) { + + logger.debug("id5-user-id-inject hook enabled"); + return new Id5IdInjectHook(properties.getInserterName(), injectFilters); + } + + @Bean + Id5IdModule id5UserIdModule(Id5IdFetchHook fetchHook, Id5IdInjectHook injectHook) { + return new Id5IdModule(List.of(fetchHook, injectHook)); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java new file mode 100644 index 00000000000..a951f2f2c5c --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java @@ -0,0 +1,23 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.User; + +import java.util.Collections; +import java.util.Optional; + +public class BidRequestUtils { + + private BidRequestUtils() { } + + public static final String ID5_ID_SOURCE = "id5-sync.com"; + + public static boolean isId5IdPresent(BidRequest bidRequest) { + return Optional.ofNullable(bidRequest.getUser()) + .map(User::getEids) + .orElse(Collections.emptyList()) + .stream() + .anyMatch(eid -> ID5_ID_SOURCE.equals(eid.getSource())); + } + +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java new file mode 100644 index 00000000000..1707a935209 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java @@ -0,0 +1,91 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import io.vertx.core.Future; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.FetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.hooks.v1.auction.ProcessedAuctionRequestHook; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class Id5IdFetchHook implements ProcessedAuctionRequestHook { + + private static final Logger logger = LoggerFactory.getLogger(Id5IdFetchHook.class); + + public static final String CODE = "id5-user-id-fetch-hook"; + + private final FetchClient fetchClient; + private final List filters; + private final Id5PartnerIdProvider partnerIdProvider; + + public Id5IdFetchHook(FetchClient fetchClient, + List filters, + Id5PartnerIdProvider partnerIdProvider) { + + this.fetchClient = Objects.requireNonNull(fetchClient); + this.filters = Objects.requireNonNull(filters); + this.partnerIdProvider = Objects.requireNonNull(partnerIdProvider); + } + + @Override + public Future> call(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + + if (BidRequestUtils.isId5IdPresent(payload.bidRequest())) { + return noInvocation("id5id already present in bidRequest"); + } + final FilterResult filterResult = shouldInvoke(payload, invocationContext); + if (!filterResult.isAccepted()) { + return noInvocation(filterResult.reason()); + } + final Optional maybePartnerId = partnerIdProvider.getPartnerId(invocationContext.auctionContext()); + if (maybePartnerId.isEmpty()) { + return noInvocation("partner id not configured"); + } + final Future id5IdFuture = fetchClient.fetch(maybePartnerId.get(), payload, invocationContext); + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext(new Id5IdModuleContext(id5IdFuture)) + .debugMessages(Collections.singletonList("id5-user-id-fetch: id5id fetched")) + .build()); + } + + @Override + public String code() { + return CODE; + } + + private FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + for (FetchActionFilter filter : filters) { + final FilterResult result = filter.shouldInvoke(payload, invocationContext); + if (!result.isAccepted()) { + return result; + } + } + return FilterResult.accepted(); + } + + private static Future> noInvocation(String msg) { + logger.debug("id5-user-id-fetch: skipped, {}", msg); + return Future.succeededFuture(InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_invocation) + .debugMessages(Collections.singletonList("id5-user-id-fetch: " + msg)) + .build()); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java new file mode 100644 index 00000000000..041c69502a9 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java @@ -0,0 +1,137 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestHook; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.util.ListUtil; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class Id5IdInjectHook implements BidderRequestHook { + + private static final Logger logger = LoggerFactory.getLogger(Id5IdInjectHook.class); + + public static final String CODE = "id5-user-id-inject-hook"; + + private final String inserter; + private final List filters; + + public Id5IdInjectHook(String inserter, List filters) { + this.inserter = inserter; + this.filters = Objects.requireNonNull(filters); + } + + @Override + public Future> call(BidderRequestPayload payload, + BidderInvocationContext invocationContext) { + + if (BidRequestUtils.isId5IdPresent(payload.bidRequest())) { + return noInvocation("id5id already present in bidRequest", invocationContext); + } + + // evaluate inject filters + final FilterResult filterResult = shouldInvoke(payload, invocationContext); + if (!filterResult.isAccepted()) { + return noInvocation(filterResult.reason(), invocationContext); + } + + final long remainingMs = invocationContext.timeout().remaining(); + if (remainingMs <= 0) { + return noInvocation("no time left to resolve id5Id", invocationContext); + } + + final String bidder = invocationContext.bidder(); + logger.debug("id5-user-id-inject: remaining time: {}ms for bidder {}", remainingMs, bidder); + final Future userIdFuture = Id5IdModuleContext.from(invocationContext).getId5UserIdFuture(); + return userIdFuture.map(id5UserId -> { + logger.debug("id5-user-id-inject: resolved userId for bidder {}", bidder); + if (id5UserId == null || CollectionUtils.isEmpty(id5UserId.toEIDs())) { + return resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .debugMessages(List.of("id5-user-id-inject: no ids to inject")) + .build(); + } + final User originalUser = payload.bidRequest().getUser(); + final List eIDs = id5UserId.toEIDs().stream() + .map(eid -> eid.toBuilder().inserter(inserter).build()) + .toList(); + + final User updatedUser = Optional.ofNullable(originalUser) + .map(user -> user.toBuilder().eids(mergeEids(user, eIDs))) + .orElseGet(() -> User.builder().eids(eIDs)) + .build(); + final BidRequest updatedBidRequest = payload.bidRequest().toBuilder() + .user(updatedUser) + .build(); + logger.debug("id5-user-id-inject: user updated with {} eid(s)", eIDs.size()); + return resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.update) + .payloadUpdate(initial -> BidderRequestPayloadImpl.of(updatedBidRequest)) + .debugMessages(List.of( + "id5-user-id-inject: updated user with id5 eids")) + .build(); + }); + } + + @Override + public String code() { + return CODE; + } + + private FilterResult shouldInvoke(BidderRequestPayload payload, + BidderInvocationContext invocationContext) { + + for (InjectActionFilter filter : filters) { + final FilterResult result = filter.shouldInvoke(payload, invocationContext); + if (!result.isAccepted()) { + return result; + } + } + return FilterResult.accepted(); + } + + private static InvocationResultImpl.InvocationResultImplBuilder resultBuilder( + BidderInvocationContext bidderInvocationContext) { + + return InvocationResultImpl.builder() + // propagate moduleContext for another bidder requests hook calls + .moduleContext(bidderInvocationContext.moduleContext()); + } + + private static Future> noInvocation( + String reason, BidderInvocationContext invocationContext) { + + logger.debug("id5-user-id-inject: skipped, {}", reason); + return Future.succeededFuture(resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.no_invocation) + .debugMessages(Collections.singletonList("id5-user-id-inject: " + reason)) + .build()); + } + + private static List mergeEids(User user, List newEids) { + return CollectionUtils.isEmpty(user.getEids()) + ? newEids + : ListUtil.union(user.getEids(), newEids); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java new file mode 100644 index 00000000000..809c475ae8a --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java @@ -0,0 +1,28 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; + +import java.util.Collection; + +public class Id5IdModule implements Module { + + public static final String CODE = "id5-user-id"; + + private final Collection> hooks; + + public Id5IdModule(Collection> hooks) { + this.hooks = hooks; + } + + @Override + public String code() { + return CODE; + } + + @Override + public Collection> hooks() { + return hooks; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java new file mode 100644 index 00000000000..3c49fa65112 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java @@ -0,0 +1,28 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import io.vertx.core.Future; +import lombok.Getter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; + +import javax.validation.constraints.NotNull; + +@Getter +public class Id5IdModuleContext { + + private static final Id5IdModuleContext EMPTY = new Id5IdModuleContext(Future.succeededFuture()); + private final Future id5UserIdFuture; + + public Id5IdModuleContext(Future id5UserIdFuture) { + this.id5UserIdFuture = id5UserIdFuture; + } + + @NotNull + static Id5IdModuleContext from(AuctionInvocationContext invocationContext) { + final Object moduleContext = invocationContext.moduleContext(); + if (moduleContext instanceof Id5IdModuleContext) { + return (Id5IdModuleContext) moduleContext; + } + return EMPTY; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java new file mode 100644 index 00000000000..fe2785d7732 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java @@ -0,0 +1,42 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.DecimalMax; + +@Data +@NoArgsConstructor +@ConfigurationProperties(prefix = "hooks." + Id5IdModule.CODE) +@Validated +public class Id5IdModuleProperties { + + @Min(1) + private Long partner; + + @NotBlank + private String providerName; + + private String inserterName; + + @NotBlank + @Pattern(regexp = "https?://.+", message = "must be a valid http(s) URL") + private String fetchEndpoint; + + @PositiveOrZero + @DecimalMax(value = "1.0") + private double fetchSamplingRate; + + private ValuesFilter bidderFilter; + + private ValuesFilter accountFilter; + + private ValuesFilter countryFilter; +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java new file mode 100644 index 00000000000..5d956861237 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java @@ -0,0 +1,27 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import lombok.Data; + +import java.util.Set; + +@Data +public class ValuesFilter { + + private boolean exclude = false; + private Set values; + + /** + * Determines whether a value is allowed based on include/exclude semantics. + * If the set of values is null or empty, no filtering is applied (always allowed). + * Null value is not allowed + */ + public boolean isValueAllowed(T value) { + if (values == null || values.isEmpty()) { + return true; + } + if (value == null) { + return false; + } + return exclude != values.contains(value); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java new file mode 100644 index 00000000000..b4230195d19 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java @@ -0,0 +1,11 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.fetch; + +import io.vertx.core.Future; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +public interface FetchClient { + + Future fetch(long partnerId, AuctionRequestPayload payload, AuctionInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java new file mode 100644 index 00000000000..f713f6d4941 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java @@ -0,0 +1,234 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.fetch; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Site; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.activity.Activity; +import org.prebid.server.activity.ComponentType; +import org.prebid.server.activity.infrastructure.ActivityInfrastructure; +import org.prebid.server.activity.infrastructure.payload.ActivityInvocationPayload; +import org.prebid.server.activity.infrastructure.payload.impl.ActivityInvocationPayloadImpl; +import org.prebid.server.activity.infrastructure.payload.impl.BidRequestActivityInvocationPayload; +import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.PrebidServerMetadata; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.PrebidServerMetadata.PrebidServerMetadataBuilder; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.Publisher; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchResponse; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.privacy.ccpa.Ccpa; +import org.prebid.server.privacy.model.Privacy; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.time.Clock; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class HttpFetchClient implements FetchClient { + + private static final Logger logger = LoggerFactory.getLogger(HttpFetchClient.class); + + private static final ObjectMapper MAPPER = JsonMapper.builder() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) + .enable(StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION) + .build() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .registerModule(new JavaTimeModule()); + + private final String fetchUrl; + private final HttpClient httpClient; + private final Clock clock; + private final VersionInfo versionInfo; + private final Id5IdModuleProperties id5IdModuleProperties; + private final UserFpdActivityMask userFpdActivityMask; + + public HttpFetchClient(String endpoint, + HttpClient httpClient, + Clock clock, + VersionInfo versionInfo, + Id5IdModuleProperties id5IdModuleProperties, + UserFpdActivityMask userFpdActivityMask) { + + this.fetchUrl = Objects.requireNonNull(endpoint); + this.httpClient = Objects.requireNonNull(httpClient); + this.clock = Objects.requireNonNull(clock); + this.versionInfo = Objects.requireNonNull(versionInfo); + this.id5IdModuleProperties = Objects.requireNonNull(id5IdModuleProperties); + this.userFpdActivityMask = Objects.requireNonNull(userFpdActivityMask); + } + + @Override + public Future fetch(long partnerId, + AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + + final FetchRequest fetchRequest = createFetchRequest(partnerId, payload, invocationContext); + final String body; + try { + body = MAPPER.writeValueAsString(fetchRequest); + } catch (JsonProcessingException e) { + return Future.failedFuture(e); + } + final MultiMap headers = HttpUtil.headers(); + final String url = "%s/%s.json".formatted(fetchUrl, partnerId); + final long timeoutMs = invocationContext.timeout().remaining(); + logger.debug("id5-user-id: fetching id5Id from endpoint {} with timeout {}. Headers {}, body {}", + url, timeoutMs, headers, body); + + return httpClient + .post(url, headers, body, timeoutMs) + .map(this::parseResponse) + .recover(this::handleError); + } + + private FetchRequest createFetchRequest(long partnerId, + AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + + final PrebidServerMetadataBuilder providerMetadataBuilder = providerMetadataBuilder(); + final BidRequest bidRequest = maskBidRequest(payload.bidRequest(), invocationContext, providerMetadataBuilder); + final Privacy privacy = invocationContext.auctionContext().getPrivacyContext().getPrivacy(); + final Optional maybeDevice = Optional.ofNullable(bidRequest.getDevice()); + final FetchRequest.FetchRequestBuilder fetchRequestBuilder = FetchRequest.builder() + .trace(invocationContext.debugEnabled()) + .partnerId(partnerId) + .origin("pbs-java") + .version(versionInfo.getVersion()) + .timestamp(clock.instant().toString()) + .provider(id5IdModuleProperties.getProviderName()) + .providerMetadata(createProviderMetadata(bidRequest, providerMetadataBuilder)) + .bundle(Optional.ofNullable(bidRequest.getApp()).map(App::getBundle).orElse(null)) + .domain(Optional.ofNullable(bidRequest.getSite()).map(Site::getDomain).orElse(null)) + .maid(maybeDevice.map(Device::getIfa).orElse(null)) + .userAgent(maybeDevice.map(Device::getUa).orElse(null)) + .ref(Optional.ofNullable(bidRequest.getSite()).map(Site::getRef).orElse(null)) + .ipv4(maybeDevice.map(Device::getIp).orElse(null)) + .ipv6(maybeDevice.map(Device::getIpv6).orElse(null)) + .att(maybeDevice + .map(Device::getExt) + .map(ExtDevice::getAtts) + .map(String::valueOf) + .orElse(null)) + .coppa(Optional.ofNullable(privacy.getCoppa()).map(String::valueOf).orElse(null)) + .usPrivacy(Optional.ofNullable(privacy.getCcpa()).map(Ccpa::getUsPrivacy).orElse(null)) + .gppString(privacy.getGpp()) + .gppSid(toStringOrNull(privacy.getGppSid())) + .gdpr(privacy.getGdpr()) + .gdprConsent(privacy.getConsentString()); + + return fetchRequestBuilder.build(); + } + + private BidRequest maskBidRequest(BidRequest bidRequest, AuctionInvocationContext invocationContext, + PrebidServerMetadataBuilder providerMetadataBuilder) { + + final ActivityInvocationPayload activityInvocationPayload = BidRequestActivityInvocationPayload.of( + ActivityInvocationPayloadImpl.of( + ComponentType.RTD_MODULE, + Id5IdModule.CODE), + bidRequest); + final ActivityInfrastructure activityInfrastructure = + invocationContext.auctionContext().getActivityInfrastructure(); + + final boolean disallowTransmitUfpd = !activityInfrastructure.isAllowed( + Activity.TRANSMIT_UFPD, activityInvocationPayload); + providerMetadataBuilder.transmitUfpdDisallowed(disallowTransmitUfpd); + + final boolean disallowTransmitGeo = !activityInfrastructure.isAllowed( + Activity.TRANSMIT_GEO, activityInvocationPayload); + providerMetadataBuilder.transmitGeoDisallowed(disallowTransmitGeo); + + final Device maskedDevice = userFpdActivityMask.maskDevice( + bidRequest.getDevice(), disallowTransmitUfpd, disallowTransmitGeo); + + return bidRequest.toBuilder() + .device(maskedDevice) + .build(); + } + + private PrebidServerMetadataBuilder providerMetadataBuilder() { + return PrebidServerMetadata.builder().id5ModuleConfig(this.id5IdModuleProperties); + } + + private PrebidServerMetadata createProviderMetadata(BidRequest bidRequest, PrebidServerMetadataBuilder builder) { + Optional.ofNullable(bidRequest.getApp()).map(App::getPublisher) + .or(() -> Optional.ofNullable(bidRequest.getSite()).map(Site::getPublisher)) + .ifPresent(ortbPublisher -> builder.publisher(Publisher.builder() + .id(ortbPublisher.getId()) + .name(ortbPublisher.getName()) + .domain(ortbPublisher.getDomain()) + .build())); + + final Optional maybePrebidExt = Optional.ofNullable(bidRequest.getExt()) + .map(ExtRequest::getPrebid); + + maybePrebidExt + .map(ExtRequestPrebid::getChannel) + .ifPresent(channel -> builder + .channel(channel.getName()) + .channelVersion(channel.getVersion()) + ); + + maybePrebidExt + .map(ExtRequestPrebid::getData) + .map(ExtRequestPrebidData::getBidders) + .ifPresent(builder::bidders); + + return builder.build(); + } + + private Future handleError(Throwable exception) { + logger.error("id5-user-id: failed to fetch id5Id from endpoint {}", fetchUrl, exception); + return Future.succeededFuture(Id5UserId.empty()); + } + + private Id5UserId parseResponse(HttpClientResponse response) { + final String body = response.getBody(); + final int statusCode = response.getStatusCode(); + if (response.getStatusCode() == 200) { + logger.debug("id5-user-id: fetched id5Id succeeded, body {}", body); + try { + return MAPPER.readValue(body, FetchResponse.class); + } catch (JsonProcessingException e) { + logger.error("id5-user-id: failed to parse response body {}", body, e); + return Id5UserId.empty(); + } + } else { + logger.error("id5-user-id: fetched id5Id failed, status {}, body {}", statusCode, body); + return Id5UserId.empty(); + } + } + + private static String toStringOrNull(List gppSid) { + return CollectionUtils.isNotEmpty(gppSid) + ? gppSid.stream().map(String::valueOf).collect(Collectors.joining(",")) + : null; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java new file mode 100644 index 00000000000..69f813fcb62 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java @@ -0,0 +1,32 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.settings.model.Account; + +import java.util.Objects; + +/** + * Filters fetch invocation by account id using {@link ValuesFilter} configuration. + */ +public class AccountFetchFilter implements FetchActionFilter { + + private final ValuesFilter accountFilter; + + public AccountFetchFilter(ValuesFilter accountFilter) { + this.accountFilter = Objects.requireNonNull(accountFilter); + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + final Account account = invocationContext.auctionContext().getAccount(); + final String accountId = account != null ? account.getId() : null; + if (accountId == null || accountId.isBlank()) { + return FilterResult.rejected("missing account id"); + } + return accountFilter.isValueAllowed(accountId) + ? FilterResult.accepted() + : FilterResult.rejected("account " + accountId + " rejected by config"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java new file mode 100644 index 00000000000..56265cb957d --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java @@ -0,0 +1,50 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Geo; +import org.prebid.server.geolocation.model.GeoInfo; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Objects; +import java.util.Optional; + +/** + * Filters fetch invocation by country code using {@link ValuesFilter} configuration. + * Country resolution order: + * 1) AuctionContext.geoInfo.country (resolved by PBS geo module) + * 2) bidRequest.device.geo.country + */ +public class CountryFetchFilter implements FetchActionFilter { + + private final ValuesFilter countryFilter; + + public CountryFetchFilter(ValuesFilter countryFilter) { + this.countryFilter = Objects.requireNonNull(countryFilter); + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + final String country = resolveCountry(payload.bidRequest(), invocationContext); + if (country == null || country.isBlank()) { + return FilterResult.rejected("missing country"); + } + return countryFilter.isValueAllowed(country) + ? FilterResult.accepted() + : FilterResult.rejected("country " + country + " rejected by config"); + } + + private static String resolveCountry(BidRequest bidRequest, AuctionInvocationContext invocationContext) { + final GeoInfo geoInfo = invocationContext.auctionContext().getGeoInfo(); + if (geoInfo != null && geoInfo.getCountry() != null && !geoInfo.getCountry().isBlank()) { + return geoInfo.getCountry(); + } + return Optional.ofNullable(bidRequest) + .map(BidRequest::getDevice) + .map(Device::getGeo) + .map(Geo::getCountry) + .orElse(null); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java new file mode 100644 index 00000000000..739b15a4fed --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +public interface FetchActionFilter { + + FilterResult shouldInvoke(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java new file mode 100644 index 00000000000..19cb663a453 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +public record FilterResult(boolean isAccepted, String reason) { + private static final FilterResult ACCEPTED = new FilterResult(true, ""); + + public static FilterResult rejected(String reason) { + return new FilterResult(false, reason); + } + + public static FilterResult accepted() { + return ACCEPTED; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java new file mode 100644 index 00000000000..d81551852d5 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +public interface InjectActionFilter { + + FilterResult shouldInvoke(BidderRequestPayload payload, + BidderInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java new file mode 100644 index 00000000000..fa897d0c367 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java @@ -0,0 +1,25 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Objects; +import java.util.Random; + +public class SamplingFetchFilter implements FetchActionFilter { + + private final double sampleRate; + private final Random random; + + public SamplingFetchFilter(Random random, double sampleRate) { + this.sampleRate = sampleRate; + this.random = Objects.requireNonNull(random); + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + return random.nextDouble() <= sampleRate + ? FilterResult.accepted() + : FilterResult.rejected("rejected by sampling"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java new file mode 100644 index 00000000000..7b88f9cc019 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java @@ -0,0 +1,24 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +import java.util.Objects; + +public class SelectedBidderFilter implements InjectActionFilter { + + private final ValuesFilter biddersFilter; + + public SelectedBidderFilter(ValuesFilter biddersFilter) { + this.biddersFilter = Objects.requireNonNull(biddersFilter); + } + + @Override + public FilterResult shouldInvoke(BidderRequestPayload payload, BidderInvocationContext invocationContext) { + final String bidder = invocationContext.bidder(); + return biddersFilter.isValueAllowed(bidder) + ? FilterResult.accepted() + : FilterResult.rejected("bidder " + bidder + " rejected by config"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java new file mode 100644 index 00000000000..07a610c602c --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java @@ -0,0 +1,19 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import org.prebid.server.auction.model.AuctionContext; + +import java.util.Optional; + +public class ConstantId5PartnerId implements Id5PartnerIdProvider { + + private final long partnerId; + + public ConstantId5PartnerId(long partnerId) { + this.partnerId = partnerId; + } + + @Override + public Optional getPartnerId(AuctionContext ignore) { + return Optional.of(partnerId); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java new file mode 100644 index 00000000000..962374d2d1f --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java @@ -0,0 +1,87 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; + +import java.util.List; + +@Builder +@Value +public class FetchRequest { + + @JsonProperty("partner") + long partnerId; + + @JsonProperty("ts") + String timestamp; + + @JsonAlias("ip") + String ipv4; + + String ipv6; + + @JsonProperty("ua") + String userAgent; + + String maid; + + String domain; + + String ref; + + String bundle; + + String att; + + String provider; + + PrebidServerMetadata providerMetadata; + + String origin; + + String version; + + @JsonProperty("us_privacy") + String usPrivacy; + + String gdpr; + + @JsonProperty("gdpr_consent") + String gdprConsent; + + @JsonProperty("gpp_string") + String gppString; + + @JsonProperty("gpp_sid") + String gppSid; + + String coppa; + + @JsonProperty("_trace") + boolean trace; + + @Builder + @Value + public static class PrebidServerMetadata { + + String channel; + String channelVersion; + Id5IdModuleProperties id5ModuleConfig; + Publisher publisher; + List bidders; + boolean transmitUfpdDisallowed; + boolean transmitGeoDisallowed; + } + + @Builder + @Value + public static class Publisher { + + String id; + String name; + String domain; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java new file mode 100644 index 00000000000..e5b6b6ca966 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java @@ -0,0 +1,20 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.iab.openrtb.request.Eid; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record FetchResponse(Map ids) implements Id5UserId { + + public record UserId(Eid eid) { + } + + public List toEIDs() { + return Optional.ofNullable(ids) + .map(userIds -> userIds.values().stream().map(UserId::eid).toList()) + .orElse(Collections.emptyList()); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java new file mode 100644 index 00000000000..adefde62023 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java @@ -0,0 +1,30 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import org.prebid.server.auction.model.AuctionContext; + +import javax.validation.constraints.NotNull; +import java.util.Optional; + +/** + * Provider for ID5 Partner ID that can return different values based on the auction context. + *

+ * Implement this interface to provide dynamic Partner IDs based on account ID, channel, or other criteria. + * Register your implementation as a Spring bean with @Component or in a @Configuration class. + *

+ * The default implementation {@link ConstantId5PartnerId} returns a constant value from configuration. + *

+ * If {@link #getPartnerId(AuctionContext)} returns {@link Optional#empty()}, the ID5 fetch will be skipped + * for that request. + */ +public interface Id5PartnerIdProvider { + + /** + * Returns the ID5 Partner ID for the given auction context. + * + * @param auctionContext the auction context containing account, request, and privacy information + * @return Optional containing the Partner ID, or empty to skip the ID5 fetch for this request + */ + @NotNull + Optional getPartnerId(AuctionContext auctionContext); + +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java new file mode 100644 index 00000000000..ed207a56bcb --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java @@ -0,0 +1,17 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.iab.openrtb.request.Eid; + +import java.util.List; + +public interface Id5UserId { + + List toEIDs(); + + Id5UserId EMPTY = List::of; + + static Id5UserId empty() { + return EMPTY; + } + +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java new file mode 100644 index 00000000000..f0d8d72fa13 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java @@ -0,0 +1,147 @@ +package org.prebid.server.hooks.modules.id5.userid.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdFetchHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdInjectHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.AccountFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.CountryFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SamplingFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SelectedBidderFilter; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5UserIdModuleConfigurationTest { + + private ApplicationContextRunner contextRunner() { + final VersionInfo versionInfo = Mockito.mock(VersionInfo.class); + Mockito.when(versionInfo.getVersion()).thenReturn("1.2.3"); + + final HttpClient httpClient = Mockito.mock(HttpClient.class); + final JacksonMapper jacksonMapper = new JacksonMapper(new ObjectMapper()); + final UserFpdActivityMask userFpdActivityMask = Mockito.mock(UserFpdActivityMask.class); + + return new ApplicationContextRunner() + .withBean(VersionInfo.class, () -> versionInfo) + .withBean(HttpClient.class, () -> httpClient) + .withBean(JacksonMapper.class, () -> jacksonMapper) + .withBean(Clock.class, Clock::systemUTC) + .withBean(UserFpdActivityMask.class, () -> userFpdActivityMask) + .withUserConfiguration(Id5UserIdModuleConfiguration.class); + } + + @Test + void shouldNotLoadConfigurationWhenModuleDisabled() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=false", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider") + .run(context -> { + assertThat(context).doesNotHaveBean(Id5IdModule.class); + assertThat(context).doesNotHaveBean(Id5IdFetchHook.class); + assertThat(context).doesNotHaveBean(Id5IdInjectHook.class); + assertThat(context).doesNotHaveBean(SamplingFetchFilter.class); + assertThat(context).doesNotHaveBean(SelectedBidderFilter.class); + assertThat(context).doesNotHaveBean(AccountFetchFilter.class); + assertThat(context).doesNotHaveBean(CountryFetchFilter.class); + }); + } + + @Test + void shouldCreateMainBeansWhenEnabledWithoutFilters() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider") + .run(context -> { + assertThat(context).hasSingleBean(Id5IdModule.class); + assertThat(context).hasSingleBean(Id5IdFetchHook.class); + assertThat(context).hasSingleBean(Id5IdInjectHook.class); + + assertThat(context).doesNotHaveBean(SamplingFetchFilter.class); + assertThat(context).doesNotHaveBean(SelectedBidderFilter.class); + assertThat(context).doesNotHaveBean(AccountFetchFilter.class); + assertThat(context).doesNotHaveBean(CountryFetchFilter.class); + }); + } + + @Test + void shouldCreateSamplingFetchFilterWhenSamplingRatePropertyPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.fetch-sampling-rate=0.5") + .run(context -> assertThat(context).hasSingleBean(SamplingFetchFilter.class)); + } + + @Test + void shouldCreateSelectedBidderFilterWhenBidderFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon") + .run(context -> assertThat(context).hasSingleBean(SelectedBidderFilter.class)); + } + + @Test + void shouldCreateAccountFetchFilterWhenAccountFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.account-filter.values=acc-1,acc-2") + .run(context -> assertThat(context).hasSingleBean(AccountFetchFilter.class)); + } + + @Test + void shouldCreateCountryFetchFilterWhenCountryFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.values=US,PL") + .run(context -> assertThat(context).hasSingleBean(CountryFetchFilter.class)); + } + + @Test + void shouldCreateAllFiltersWhenAllPropertiesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.fetch-endpoint=https://api.id5-sync.com/gs/v2", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.fetch-sampling-rate=1.0", + "hooks.id5-user-id.bidder-filter.values=appnexus", + "hooks.id5-user-id.account-filter.values=acc-1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.values=US") + .run(context -> { + assertThat(context).hasSingleBean(SamplingFetchFilter.class); + assertThat(context).hasSingleBean(SelectedBidderFilter.class); + assertThat(context).hasSingleBean(AccountFetchFilter.class); + assertThat(context).hasSingleBean(CountryFetchFilter.class); + }); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java new file mode 100644 index 00000000000..b59df791a51 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java @@ -0,0 +1,505 @@ +package org.prebid.server.hooks.modules.id5.userid.fetch; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.BidRequest.BidRequestBuilder; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Uid; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.prebid.server.activity.Activity; +import org.prebid.server.activity.ComponentType; +import org.prebid.server.activity.infrastructure.ActivityInfrastructure; +import org.prebid.server.activity.infrastructure.payload.ActivityInvocationPayload; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchResponse; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.privacy.ccpa.Ccpa; +import org.prebid.server.privacy.gdpr.model.TcfContext; +import org.prebid.server.privacy.model.Privacy; +import org.prebid.server.privacy.model.PrivacyContext; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidChannel; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData; +import org.prebid.server.settings.model.Account; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HttpFetchClientTest { + + private static final String URL = "http://example.test/fetch"; + + private JacksonMapper mapper; + private HttpClient httpClient; + private VersionInfo versionInfo; + private Clock fixedClock; + private Id5IdModuleProperties props; + private UserFpdActivityMask userFpdActivityMask; + private ActivityInfrastructure activityInfrastructure; + + @BeforeEach + void setUp() { + mapper = new JacksonMapper(new ObjectMapper()); + httpClient = Mockito.mock(HttpClient.class); + versionInfo = Mockito.mock(VersionInfo.class); + when(versionInfo.getVersion()).thenReturn("1.2.3"); + fixedClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + props = new Id5IdModuleProperties(); + props.setProviderName("pbs"); + activityInfrastructure = Mockito.mock(ActivityInfrastructure.class); + when(activityInfrastructure.isAllowed(any(), any())).thenReturn(true); + userFpdActivityMask = Mockito.mock(UserFpdActivityMask.class); + when(userFpdActivityMask.maskDevice(any(), any(Boolean.class), any(Boolean.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void shouldReturnEmptyOnNon200Response() { + // given + final long partnerId = 123L; + final String expectedUrl = URL + "/" + partnerId + ".json"; + when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.succeededFuture( + HttpClientResponse.of(503, MultiMap.caseInsensitiveMultiMap(), "oops")) + ); + + final HttpFetchClient client = new HttpFetchClient( + URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false); + + // when + final Id5UserId result = client.fetch(partnerId, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).isEmpty(); + } + + @Test + void shouldReturnEmptyOnException() { + // given + final long partnerId = 123L; + final String expectedUrl = URL + "/" + partnerId + ".json"; + when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.failedFuture(new RuntimeException("boom"))); + + final HttpFetchClient client = new HttpFetchClient( + URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false); + + // when + final Id5UserId result = client.fetch(partnerId, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).isEmpty(); + } + + @Test + void shouldParseSuccessfulResponse() { + // given + final Eid eid = Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-xyz").build())) + .build(); + final FetchResponse response = new FetchResponse(java.util.Map.of("id5", new FetchResponse.UserId(eid))); + final String body = mapper.encodeToString(response); + final String expectedUrl123b = URL + "/123.json"; + when(httpClient.post(eq(expectedUrl123b), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.succeededFuture( + HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), body)) + ); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, + fixedClock, versionInfo, props, userFpdActivityMask); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), false); + + // when + final Id5UserId result = client.fetch(123L, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).hasSize(1); + assertThat(result.toEIDs().getFirst().getSource()).isEqualTo("id5-sync.com"); + } + + @Test + void shouldBuildRequestWithExpectedFieldsAndUseTimeout() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class); + final long remainingTime = 100L; + final Timeout timeout = mock(Timeout.class); + when(timeout.remaining()).thenReturn(remainingTime); + when(httpClient.post(anyString(), headersCaptor.capture(), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, + fixedClock, versionInfo, props, userFpdActivityMask); + + final BidRequest bidRequest = BidRequest.builder() + .app(App.builder().bundle("com.example.app").build()) + .site(Site.builder().domain("example.com").ref("https://ref.example").build()) + .device(Device.builder() + .ifa("ifa-123") + .ua("UA/1.0") + .ip("203.0.113.10") + .ipv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + .ext(ExtDevice.of(3, null)) + .build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Privacy privacy = Privacy.builder() + .gdpr("1") + .consentString("CONSENT_STRING") + .ccpa(Ccpa.of("1YNN")) + .coppa(1) + .gpp("GPP_STRING") + .gppSid(List.of(7, 8)) + .build(); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .privacyContext(PrivacyContext.of(privacy, TcfContext.empty())) + .build(); + + final AuctionInvocationContext invocation = auctionInvocationContext( + timeout, + auctionContext, + false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final MultiMap headers = headersCaptor.getValue(); + final String contentType = headers.get("Content-Type"); + assertThat(contentType).isEqualTo("application/json;charset=utf-8"); + + // then: request body + final String captured = bodyCaptor.getValue(); + final Map json = mapper.decodeValue(captured, new TypeReference<>() { + }); + assertThat(((Number) json.get("partner")).longValue()).isEqualTo(999L); + assertThat(json.get("version")).isEqualTo("1.2.3"); + assertThat(json.get("bundle")).isEqualTo("com.example.app"); + assertThat(json.get("domain")).isEqualTo("example.com"); + assertThat(json.get("maid")).isEqualTo("ifa-123"); + assertThat(json.get("ua")).isEqualTo("UA/1.0"); + assertThat(json.get("ref")).isEqualTo("https://ref.example"); + assertThat(json.get("ipv4")).isEqualTo("203.0.113.10"); + assertThat(json.get("ipv6")).isEqualTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + assertThat(json.get("att")).isEqualTo("3"); + assertThat(json.get("gdpr")).isEqualTo("1"); + assertThat(json.get("gdpr_consent")).isEqualTo("CONSENT_STRING"); + assertThat(json.get("us_privacy")).isEqualTo("1YNN"); + assertThat(json.get("coppa")).isEqualTo("1"); + assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING"); + assertThat(json.get("gpp_sid")).isEqualTo("7,8"); + assertThat(json.get("origin")).isEqualTo("pbs-java"); + assertThat(json.get("provider")).isEqualTo("pbs"); + assertThat(json.get("ts")).isEqualTo("2025-01-01T00:00:00Z"); + assertThat(json.get("_trace")).isEqualTo(false); + + verify(httpClient, times(1)).post(eq(URL + "/999.json"), + any(MultiMap.class), anyString(), eq(remainingTime)); + } + + @Test + void shouldSetTraceWhenDebugEnabled() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + final Timeout timeout = mock(Timeout.class); + when(timeout.remaining()).thenReturn(100L); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient( + URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask); + + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, auctionContext("acc"), true); + + // when + client.fetch(999L, + AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result(); + + // then: __trace should be true + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + assertThat(json.get("_trace")).isEqualTo(true); + } + + @Test + void shouldHandleEmptyGppSidList() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, + fixedClock, versionInfo, props, userFpdActivityMask); + + final Privacy privacy = Privacy.builder() + .gpp("GPP_STRING") + .gppSid(List.of()) // Empty list should result in null gpp_sid + .build(); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .privacyContext(PrivacyContext.of(privacy, TcfContext.empty())) + .build(); + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, false); + + // when + client.fetch(999L, + AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result(); + + // then: gpp_sid should be null when list is empty (not empty string "") + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING"); + assertThat(json.get("gpp_sid")).isNull(); + } + + public static Stream publisherSources() { + + return Stream.of( + Arguments.of("site", + (BiConsumer) (rq, p) -> + rq.site(Site.builder() + .publisher(p) + .build())), + Arguments.of("app", + (BiConsumer) (rq1, p1) -> + rq1.app(App.builder() + .publisher(p1) + .build())) + ); + } + + @MethodSource("publisherSources") + @ParameterizedTest(name = "from {0}") + @SuppressWarnings("unchecked") + void shouldIncludePublisher(String ignore, BiConsumer publisherSetter) { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, + fixedClock, versionInfo, props, userFpdActivityMask); + + final BidRequestBuilder bidRequestBuilder = BidRequest.builder(); + publisherSetter.accept(bidRequestBuilder, Publisher.builder() + .id("pub-123") + .domain("pub.domain") + .name("Test Publisher") + .build()); + final BidRequest bidRequest = bidRequestBuilder.build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc-1"), false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + final Map metadata = (Map) json.get("providerMetadata"); + + assertThat(metadata.get("publisher")).isNotNull(); + final Map publisher = (Map) metadata.get("publisher"); + assertThat(publisher.get("id")).isEqualTo("pub-123"); + assertThat(publisher.get("name")).isEqualTo("Test Publisher"); + assertThat(publisher.get("domain")).isEqualTo("pub.domain"); + } + + @Test + @SuppressWarnings("unchecked") + void shouldIncludeAllProviderMetadataFieldsWhenAllPresent() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final Id5IdModuleProperties moduleProps = new Id5IdModuleProperties(); + moduleProps.setProviderName("comprehensive-provider"); + moduleProps.setPartner(789L); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, + fixedClock, versionInfo, moduleProps, userFpdActivityMask); + + final BidRequest bidRequest = BidRequest.builder() + .app(App.builder() + .publisher(Publisher.builder() + .id("pub-789") + .name("Comprehensive Publisher") + .build()) + .build()) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .channel(ExtRequestPrebidChannel.of("mobile-app", "3.5")) + .data(ExtRequestPrebidData.of(List.of("bidder1", "bidder2"), null)) + .build())) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc-1"), false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + final Map metadata = (Map) json.get("providerMetadata"); + + // Verify all fields are present + assertThat(metadata.get("id5ModuleConfig")).isNotNull(); + assertThat(metadata.get("publisher")).isNotNull(); + assertThat(metadata.get("channel")).isEqualTo("mobile-app"); + assertThat(metadata.get("channelVersion")).isEqualTo("3.5"); + assertThat(metadata.get("bidders")).isNotNull(); + + final Map config = (Map) metadata.get("id5ModuleConfig"); + assertThat(config.get("providerName")).isEqualTo("comprehensive-provider"); + assertThat(((Number) config.get("partner")).longValue()).isEqualTo(789L); + + final Map publisher = (Map) metadata.get("publisher"); + assertThat(publisher.get("id")).isEqualTo("pub-789"); + + final List bidders = (List) metadata.get("bidders"); + assertThat(bidders).containsExactly("bidder1", "bidder2"); + } + + @Test + void shouldUseMaskedPersonalDataWhenDisallowed() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final Device originalDevice = Device.builder() + .ip("203.0.113.10") + .ipv6("2001:0db8:85a3::8a2e:0370:7334") + .build(); + + final Device maskedDevice = Device.builder() + .ip("192.0.2.1") + .ipv6("2001:db8::1") + .build(); + + Mockito.reset(userFpdActivityMask); + when(userFpdActivityMask.maskDevice(any(), any(Boolean.class), any(Boolean.class))) + .thenReturn(maskedDevice); + + final HttpFetchClient client = new HttpFetchClient( + URL, httpClient, fixedClock, versionInfo, props, userFpdActivityMask); + + final BidRequest bidRequest = BidRequest.builder().device(originalDevice).build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext("acc"), false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(ActivityInvocationPayload.class); + verify(activityInfrastructure).isAllowed(eq(Activity.TRANSMIT_UFPD), payloadCaptor.capture()); + verify(activityInfrastructure).isAllowed(eq(Activity.TRANSMIT_GEO), payloadCaptor.capture()); + + final ActivityInvocationPayload capturedPayload = payloadCaptor.getValue(); + assertThat(capturedPayload.componentType()).isEqualTo(ComponentType.RTD_MODULE); + assertThat(capturedPayload.componentName()).isEqualTo(Id5IdModule.CODE); + + // verify request uses masked data + final String captured = bodyCaptor.getValue(); + final Map json = mapper.decodeValue(captured, new TypeReference<>() { + }); + assertThat(json.get("ipv4")).isEqualTo(maskedDevice.getIp()); + assertThat(json.get("ipv6")).isEqualTo(maskedDevice.getIpv6()); + } + + private static AuctionContext auctionContext(String accountId) { + return AuctionContext.builder() + .account(Account.builder().id(accountId).build()) + .privacyContext(PrivacyContext.of(Privacy.builder().build(), TcfContext.empty())) + .build(); + } + + private AuctionInvocationContextImpl auctionInvocationContext(Timeout timeout, + AuctionContext auctionContext, + boolean debugEnabled) { + + final AuctionContext contextWithActivity = auctionContext.toBuilder() + .activityInfrastructure(activityInfrastructure) + .build(); + + return AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, + org.prebid.server.model.Endpoint.openrtb2_auction), + contextWithActivity, debugEnabled, null, null); + } + +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java new file mode 100644 index 00000000000..33e619a8dfa --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java @@ -0,0 +1,73 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BidRequestUtilsTest { + + @Test + void shouldReturnFalseWhenUserIsNull() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnFalseWhenEidsIsNull() { + // given + final User user = User.builder().build(); // eids null + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnFalseWhenOnlyOtherSourcesPresent() { + // given + final User user = User.builder() + .eids(List.of( + Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnTrueWhenId5SourcePresentAmongOthers() { + // given + final User user = User.builder() + .eids(List.of( + Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build(), + Eid.builder().source(BidRequestUtils.ID5_ID_SOURCE) + .uids(List.of(Uid.builder().id("id5-1").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isTrue(); + } + + @Test + void shouldReturnFalseWhenEidSourceIsNull() { + // given + final User user = User.builder() + .eids(List.of(Eid.builder().source(null).uids(List.of(Uid.builder().id("x").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java new file mode 100644 index 00000000000..c73d3c57a5d --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java @@ -0,0 +1,196 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.FetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +class Id5IdFetchHookTest { + + @Test + void shouldReturnNoInvocationAndSetModuleContextWithFutureWhenSampled() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final Future future = Future.succeededFuture(Id5UserId.empty()); + when(fetchClient.fetch(anyLong(), any(AuctionRequestPayload.class), any())).thenReturn(future); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted()); + when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.of(123L)); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-1").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final Future> resultFuture = hook.call(payload, invocation); + final InvocationResult result = resultFuture.result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + final Id5IdModuleContext ctx = (Id5IdModuleContext) result.moduleContext(); + assertThat(ctx.getId5UserIdFuture()).isSameAs(future); + } + + @Test + void shouldReturnNoInvocationWhenId5IdAlreadyPresent() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(), partnerIdProvider); + + final User userWithId5 = User.builder() + .eids(List.of(Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-xyz").build())) + .build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + } + + @Test + void shouldReturnNoInvocationWhenSamplerRejects() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.rejected("rejected by sampling")); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-2").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + } + + @Test + void shouldReturnNoInvocationWhenAnyFetchFilterRejectsMultipleFilters() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final FetchActionFilter accept1 = Mockito.mock(FetchActionFilter.class); + final FetchActionFilter reject = Mockito.mock(FetchActionFilter.class); + final FetchActionFilter accept2 = Mockito.mock(FetchActionFilter.class); + + when(accept1.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.accepted()); + when(reject.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.rejected("block-by-second")); + when(accept2.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.accepted()); + + final Id5IdFetchHook hook = new Id5IdFetchHook( + fetchClient, List.of(accept1, reject, accept2), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-3").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + // ensure fetch client was not called after filter rejection + Mockito.verifyNoInteractions(fetchClient); + // reason from rejecting filter should be present in debug messages + assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second")); + } + + @Test + void shouldReturnNoInvocationWhenPartnerIdNotConfigured() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted()); + when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.empty()); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-5").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("partner id not configured")); + Mockito.verifyNoInteractions(fetchClient); + } + + private static AuctionInvocationContextImpl createAuctionContext() { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + return AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null + ); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java new file mode 100644 index 00000000000..809bc36af1b --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java @@ -0,0 +1,314 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +class Id5IdInjectHookTest { + + private BidderInvocationContextImpl bidderCtxWithEmptyIds() { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + // provide module context with empty Ids future to ensure fetch path would continue if not filtered + new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()))); + return BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + } + + @Test + void shouldSkipWhenId5EidAlreadyPresent() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of()); + + final User userWithId5 = User.builder() + .eids(List.of(Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("abc").build())) + .build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldSkipWhenNoTimeLeft() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of()); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1).minus(10_000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldSkipWhenFetcherReturnsEmpty() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of()); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldInjectEidsWhenFetcherReturnsIds() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of()); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().eids(List.of()).build()).build(); + + final Id5UserId id5 = () -> List.of( + Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-123").build())) + .build()); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(id5)); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx) + .toCompletionStage().toCompletableFuture().join(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.update); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + + final BidderRequestPayload updated = result.payloadUpdate().apply(BidderRequestPayloadImpl.of(bidRequest)); + assertThat(updated.bidRequest().getUser().getEids()).hasSize(1); + final Eid eid = updated.bidRequest().getUser().getEids().getFirst(); + assertThat(eid.getSource()).isEqualTo("id5-sync.com"); + assertThat(eid.getInserter()).isEqualTo("inserterX"); + } + + static Stream mergeEidsScenarios() { + final Eid existingEid = Eid.builder() + .source("other-sync.com") + .uids(List.of(Uid.builder().id("other-123").build())) + .build(); + return Stream.of( + Arguments.of("null user", + null, + List.of("id5-sync.com")), + Arguments.of("null user eids", + User.builder().build(), + List.of("id5-sync.com")), + Arguments.of("existing non-id5 eids", + User.builder().eids(List.of(existingEid)).build(), + List.of("other-sync.com", "id5-sync.com")) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("mergeEidsScenarios") + void shouldInjectAndMergeEids(String ignore, User user, List expectedSources) { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook(null, List.of()); + + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + final Id5UserId id5 = () -> List.of( + Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-123").build())) + .build()); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + new Id5IdModuleContext(Future.succeededFuture(id5))); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "bidder"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx) + .toCompletionStage().toCompletableFuture().join(); + + // then + final BidderRequestPayload updated = result.payloadUpdate().apply(BidderRequestPayloadImpl.of(bidRequest)); + assertThat(updated.bidRequest().getUser().getEids()) + .extracting(Eid::getSource) + .containsExactlyInAnyOrderElementsOf(expectedSources); + } + + @Test + void shouldReturnNoActionWhenNoModuleContextPresent() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of()); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null // no Id5IdModuleContext provided + ); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.moduleContext()).isNull(); // nothing to propagate + } + + @Test + void shouldReturnNoInvocationWhenInjectFilterRejectsSingleFilter() { + // given + final InjectActionFilter filter = Mockito.mock(InjectActionFilter.class); + Mockito.when(filter.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("reject-by-filter")); + + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(filter)); + + final BidRequest bidRequest = BidRequest.builder().build(); + final BidderInvocationContext bidderCtx = bidderCtxWithEmptyIds(); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("reject-by-filter")); + assertThat(result.moduleContext()).isNotNull(); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext()); + } + + @Test + void shouldReturnNoInvocationWhenAnyInjectFilterRejectsMultipleFilters() { + // given + final InjectActionFilter accept1 = Mockito.mock(InjectActionFilter.class); + final InjectActionFilter reject = Mockito.mock(InjectActionFilter.class); + final InjectActionFilter accept2 = Mockito.mock(InjectActionFilter.class); + Mockito.when(accept1.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted()); + Mockito.when(reject.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("block-by-second")); + Mockito.when(accept2.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted()); + + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(accept1, reject, accept2)); + + final BidRequest bidRequest = BidRequest.builder().build(); + final BidderInvocationContext bidderCtx = bidderCtxWithEmptyIds(); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second")); + assertThat(result.moduleContext()).isNotNull(); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext()); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java new file mode 100644 index 00000000000..207b57624ea --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java @@ -0,0 +1,63 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5IdModuleContextTest { + + @Test + void shouldReturnProvidedModuleContextWhenPresent() { + // given + final Future future = Future.succeededFuture(); + final Id5IdModuleContext moduleContext = new Id5IdModuleContext(future); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + moduleContext); + + // when + final Id5IdModuleContext result = Id5IdModuleContext.from(invocation); + + // then + assertThat(result).isSameAs(moduleContext); + assertThat(result.getId5UserIdFuture()).isSameAs(future); + } + + @Test + void shouldReturnEmptyModuleContextWhenAbsent() { + // given + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final Id5IdModuleContext result = Id5IdModuleContext.from(invocation); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId5UserIdFuture()).isNotNull(); + assertThat(result.getId5UserIdFuture().succeeded()).isTrue(); + assertThat(result.getId5UserIdFuture().result()).isNull(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java new file mode 100644 index 00000000000..718e809ef3b --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java @@ -0,0 +1,89 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5IdModulePropertiesTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestPropsConfig.class); + + @Configuration + @EnableConfigurationProperties(Id5IdModuleProperties.class) + static class TestPropsConfig { } + + @Test + void shouldBindBidderFilterValuesWithDefaultExcludeFalse() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.fetch-endpoint=http://localhost", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getBidderFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isFalse(); // default + assertThat(filter.getValues()).containsExactlyInAnyOrder("appnexus", "rubicon"); + }); + } + + @Test + void shouldBindAccountFilterWithExcludeTrue() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.fetch-endpoint=http://localhost", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.account-filter.exclude=true", + "hooks.id5-user-id.account-filter.values=acc-1,acc-2") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getAccountFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isTrue(); + assertThat(filter.getValues()).containsExactlyInAnyOrder("acc-1", "acc-2"); + }); + } + + @Test + void shouldBindCountryFilterWhenOnlyExcludeProvidedValuesRemainNull() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.fetch-endpoint=http://localhost", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.exclude=true") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getCountryFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isTrue(); + assertThat(filter.getValues()).isNull(); + }); + } + + @Test + void shouldNotCreateFiltersWhenNotProvided() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.fetch-endpoint=http://localhost", + "hooks.id5-user-id.provider-name=test-provider") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + + assertThat(props.getBidderFilter()).isNull(); + assertThat(props.getAccountFilter()).isNull(); + assertThat(props.getCountryFilter()).isNull(); + }); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java new file mode 100644 index 00000000000..c2fdf5f07bd --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java @@ -0,0 +1,62 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ValuesFilterTest { + + @Test + void shouldAllowAllWhenValuesNull() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setValues(null); + filter.setExclude(false); // default include mode + + // expect + assertThat(filter.isValueAllowed("anything")).isTrue(); + assertThat(filter.isValueAllowed(null)).isTrue(); + } + + @Test + void shouldAllowAllWhenValuesEmpty() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setValues(Set.of()); + filter.setExclude(false); + + // expect + assertThat(filter.isValueAllowed("x")).isTrue(); + assertThat(filter.isValueAllowed("y")).isTrue(); + } + + @Test + void shouldWhitelistAllowOnlyListedWhenExcludeFalse() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setExclude(false); // allowlist semantics + filter.setValues(Set.of("a", "b")); + + // expect + assertThat(filter.isValueAllowed("a")).isTrue(); + assertThat(filter.isValueAllowed("b")).isTrue(); + assertThat(filter.isValueAllowed("c")).isFalse(); + assertThat(filter.isValueAllowed(null)).isFalse(); + } + + @Test + void shouldBlacklistRejectListedWhenExcludeTrue() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setExclude(true); // blocklist semantics + filter.setValues(Set.of("blocked", "forbidden")); + + // expect + assertThat(filter.isValueAllowed("blocked")).isFalse(); + assertThat(filter.isValueAllowed("forbidden")).isFalse(); + assertThat(filter.isValueAllowed("other")).isTrue(); + assertThat(filter.isValueAllowed(null)).isFalse(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java new file mode 100644 index 00000000000..22455bcf7e6 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java @@ -0,0 +1,83 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class AccountFetchFilterTest { + + @Test + void shouldAcceptWhenAccountAllowed() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + when(valuesFilter.isValueAllowed(eq("acc-2"))).thenReturn(true); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc-2").build()).build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenAccountNotAllowed() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + when(valuesFilter.isValueAllowed(eq("acc-3"))).thenReturn(false); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc-3").build()).build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("account acc-3 rejected"); + } + + @Test + void shouldRejectWhenAccountMissing() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("missing account id"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java new file mode 100644 index 00000000000..1f756d2bb3f --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java @@ -0,0 +1,132 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Geo; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.geolocation.model.GeoInfo; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class CountryFetchFilterTest { + + @Test + void shouldUseGeoInfoCountryFirst() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("PL")).thenReturn(true); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .geoInfo(GeoInfo.builder().vendor("test").country("PL").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldFallbackToDeviceGeoCountry() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("US")).thenReturn(true); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenCountryMissing() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder().build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("missing country"); + } + + @Test + void shouldRejectWhenCountryNotAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("US")).thenReturn(false); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("country US rejected"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java new file mode 100644 index 00000000000..e739f81ef3a --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java @@ -0,0 +1,76 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; + +class SamplingFetchFilterTest { + + @Test + void shouldAcceptWhenRandomBelowRate() { + // given + final Random random = new Random() { + @Override + public double nextDouble() { + return 0.1d; + } + }; + final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.25d); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final FilterResult result = filter.shouldInvoke(payload, invocation); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenRandomAboveRate() { + // given + final Random random = new Random() { + @Override + public double nextDouble() { + return 0.9d; + } + }; + final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.5d); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final FilterResult result = filter.shouldInvoke(payload, invocation); + + // then + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("sampling"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java new file mode 100644 index 00000000000..be922e50d33 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java @@ -0,0 +1,67 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.model.Endpoint; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class SelectedBidderFilterTest { + + private BidderInvocationContextImpl bidderCtx(String bidder) { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + return BidderInvocationContextImpl.of(auctionCtx, bidder); + } + + @Test + void shouldAcceptWhenBidderAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed(any())).thenReturn(true); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("rubicon")); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenBidderNotAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed(any())).thenReturn(false); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("pubmatic")); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("bidder pubmatic rejected"); + } + + @Test + void shouldDelegateDecisionToValuesFilter() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("anything")).thenReturn(true); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("anything")); + assertThat(result.isAccepted()).isTrue(); + } +} diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml index ca9e5258a2b..edece3bee8e 100644 --- a/extra/modules/pom.xml +++ b/extra/modules/pom.xml @@ -28,6 +28,7 @@ wurfl-devicedetection live-intent-omni-channel-identity pb-rule-engine + id5-user-id diff --git a/sample/configs/localdev-config-wiremock.yaml b/sample/configs/localdev-config-wiremock.yaml new file mode 100644 index 00000000000..6bef32cc64f --- /dev/null +++ b/sample/configs/localdev-config-wiremock.yaml @@ -0,0 +1,26 @@ +status-response: "ok" +adapters: + generic: + enabled: true + endpoint: http://localhost:8091/generic-exchange +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + filesystem: + settings-filename: sample/configs/sample-app-settings.yaml + stored-requests-dir: sample/stored + stored-imps-dir: sample/stored + profiles-dir: sample/profiles + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 diff --git a/sample/configs/prebid-config-with-id5.yaml b/sample/configs/prebid-config-with-id5.yaml new file mode 100644 index 00000000000..2737fd1362f --- /dev/null +++ b/sample/configs/prebid-config-with-id5.yaml @@ -0,0 +1,45 @@ +status-response: "ok" +adapters: + generic: + enabled: true + endpoint: http://localhost:8091/generic-exchange +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + generate-storedrequest-bidrequest-id: true + filesystem: + settings-filename: sample/configs/sample-app-settings-id5.yaml + stored-requests-dir: sample/stored + stored-imps-dir: sample/stored + profiles-dir: sample/profiles + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 +hooks: + id5-user-id: + enabled: true + partner: 173 + fetchEndpoint: http://localhost:8091/id5-fetch + inserterName: local-pbs.example.com + providerName: local-pbs + +logging: + level: + org.prebid.server.hooks.modules.id5: DEBUG + +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false diff --git a/sample/configs/sample-app-settings-id5.yaml b/sample/configs/sample-app-settings-id5.yaml new file mode 100644 index 00000000000..bfcd72a1698 --- /dev/null +++ b/sample/configs/sample-app-settings-id5.yaml @@ -0,0 +1,33 @@ +accounts: + - id: "1001" + status: active + hooks: + execution-plan: + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-fetch-hook" } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-inject-hook" } + ] + } + ] + } + } + } + } + } diff --git a/sample/wiremock/README.md b/sample/wiremock/README.md new file mode 100644 index 00000000000..e01981ad960 --- /dev/null +++ b/sample/wiremock/README.md @@ -0,0 +1,91 @@ +# Sample WireMock for Prebid Server Java + +This directory contains a minimal WireMock setup you can use for local integration testing with Prebid Server (PBS-Java). + +Structure: +- `mappings/generic-exchange.json` — maps requests to the `/generic-exchange` endpoint. +- `__files/generic-bid.json` — sample OpenRTB BidResponse returned by the mapping. +- `docker-compose.wiremock.yml` — ready-to-use Docker Compose definition for running WireMock in a container. + +Target mock endpoint: `POST /generic-exchange`. + +--- + +## Run with Docker Compose +Requirements: Docker Desktop (or Docker Engine) + Docker Compose. + +1. Go to the sample directory: + ```bash + cd sample/wiremock + ``` +2. Start WireMock in the background: + ```bash + docker compose -f docker-compose.wiremock.yml up -d + ``` + - The container runs image `wiremock/wiremock:3.13.2`. + - WireMock listens inside the container on port `8080` and is mapped on the host as `http://localhost:8090`. + - A volume mounts the current directory as `/home/wiremock` (read-only), so any changes in `mappings`/`__files` are visible without rebuilding the image. + +3. Verify the endpoint works (example call): + ```bash + curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}' + ``` + You should receive the content from `__files/generic-bid.json`. + +4. Tail logs: + ```bash + docker logs -f wiremock-prebid-server + ``` + +5. Stop and remove the container: + ```bash + docker compose -f docker-compose.wiremock.yml down + ``` + +Notes: +- If port `8090` is taken, change the port mapping in `docker-compose.wiremock.yml` (the line `ports: - "8090:8080"`) to something else, e.g., `9090:8080`, and remember to use the new port in your calls. + +--- + +## Run using the IntelliJ WireMock Plugin +Requirements: IntelliJ IDEA with the “WireMock” plugin installed. + +1. Install the plugin: + - File → Settings → Plugins → Marketplace → search for “WireMock” → Install → Restart IDE. + +2. Create a WireMock Run Configuration: + - Run → Edit Configurations… → `+` → select “WireMock”. + - Set the fields: + - Files root (or Root dir): point to the `sample/wiremock` directory in the repo. + - Port: set to `8090` (important: see the port note below). + - Optionally enable `Verbose` logs. + +3. Run the configuration and test: + - Click Run on the new configuration. + - Check the endpoint: + ```bash + curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}' + ``` + +### Important: set the port in the IntelliJ Run Configuration +- Make sure the WireMock configuration uses port `8090` to stay consistent with this sample and with any local PBS config that points to `http://localhost:8090/...`. +- If needed, you can choose a different port (e.g., 9090), but then update the addresses in your testing tools and/or PBS configuration accordingly. + +--- + +## Integration with Prebid Server (locally) +- In your PBS adapter/bidder configuration, set the endpoint to the WireMock address, e.g., `http://localhost:8090/generic-exchange`. +- This sample does not enforce a specific request body — any `POST` to `/generic-exchange` returns the fixed response from `__files/generic-bid.json`. + +--- + +## Customizing mappings +- To change the response payload, edit `__files/generic-bid.json`. +- To refine match conditions (e.g., headers, body patterns), update `mappings/generic-exchange.json` according to WireMock 3.x documentation. + +--- + +## Troubleshooting +- Port in use: change the port in Docker Compose or in the IntelliJ Run Configuration. +- No response/404: ensure `Files root` points to `sample/wiremock` and that files under `mappings` and `__files` are visible. +- Changes not reflected in Docker: remember the volume is mounted as `:ro` (read-only) in the container — make edits on the host; the container reads them live. diff --git a/sample/wiremock/__files/generic-bid-response.json b/sample/wiremock/__files/generic-bid-response.json new file mode 100644 index 00000000000..21aaf45cabd --- /dev/null +++ b/sample/wiremock/__files/generic-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "test-bid-request-id", + "seatbid": [ + { + "bid": [ + { + "id": "bid1", + "impid": "test-imp-id", + "price": 1.23, + "crid": "crid001", + "adm": "adm001", + "w": 300, + "h": 250 + } + ] + } + ] +} diff --git a/sample/wiremock/__files/id5-fetch-response.json b/sample/wiremock/__files/id5-fetch-response.json new file mode 100644 index 00000000000..c0b8c115648 --- /dev/null +++ b/sample/wiremock/__files/id5-fetch-response.json @@ -0,0 +1,31 @@ +{ + "created_at": "2025-11-23T12:52:34+09:00", + "original_uid": "ID5*YsvxY", + "universal_uid": "ID5*YsvxY", + "privacy": { + "jurisdiction": "gdpr", + "id5_consent": true + }, + "signature": "signature", + "ext": { + "linkType": 0, + "pba": "pba-value" + }, + "ids": { + "id5id": { + "eid": { + "source": "id5-sync.com", + "uids": [ + { + "id": "ID5*YsvxY", + "atype": 1, + "ext": { + "linkType": 2, + "pba": "jWwv+" + } + } + ] + } + } + } +} diff --git a/sample/wiremock/docker-compose.wiremock.yml b/sample/wiremock/docker-compose.wiremock.yml new file mode 100644 index 00000000000..2844ba2315e --- /dev/null +++ b/sample/wiremock/docker-compose.wiremock.yml @@ -0,0 +1,10 @@ +services: + wiremock: + image: wiremock/wiremock:3.13.2 + container_name: wiremock-prebid-server + command: ["--port", "8080", "--root-dir", "/home/wiremock", "--verbose"] + ports: + - "8091:8080" + volumes: + - ./:/home/wiremock:ro + restart: unless-stopped diff --git a/sample/wiremock/mappings/generic-exchange.json b/sample/wiremock/mappings/generic-exchange.json new file mode 100644 index 00000000000..1211343e651 --- /dev/null +++ b/sample/wiremock/mappings/generic-exchange.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "urlPath": "/generic-exchange" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "generic-bid-response.json" + } +} diff --git a/sample/wiremock/mappings/id5-fetch.json b/sample/wiremock/mappings/id5-fetch.json new file mode 100644 index 00000000000..b2233df45b7 --- /dev/null +++ b/sample/wiremock/mappings/id5-fetch.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "urlPathPattern": "/id5-fetch/[0-9]+\\.json" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "id5-fetch-response.json" + } +} diff --git a/src/test/groovy/org/prebid/server/functional/model/ModuleName.groovy b/src/test/groovy/org/prebid/server/functional/model/ModuleName.groovy index 19a29ae0058..91851a1ef67 100644 --- a/src/test/groovy/org/prebid/server/functional/model/ModuleName.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/ModuleName.groovy @@ -9,7 +9,8 @@ enum ModuleName { ORTB2_BLOCKING("ortb2-blocking"), PB_REQUEST_CORRECTION('pb-request-correction'), OPTABLE_TARGETING('optable-targeting'), - PB_RULE_ENGINE('pb-rule-engine') + PB_RULE_ENGINE('pb-rule-engine'), + ID5_USER_ID('id5-user-id') @JsonValue final String code diff --git a/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy index c0933a238e7..8e997379509 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/module/ModuleBaseSpec.groovy @@ -1,14 +1,18 @@ package org.prebid.server.functional.tests.module +import org.prebid.server.functional.model.config.EndpointExecutionPlan import org.prebid.server.functional.model.config.Endpoint +import org.prebid.server.functional.model.config.ExecutionGroup import org.prebid.server.functional.model.config.ExecutionPlan +import org.prebid.server.functional.model.config.HookId import org.prebid.server.functional.model.config.Stage +import org.prebid.server.functional.model.config.StageExecutionPlan import org.prebid.server.functional.model.response.auction.AnalyticResult import org.prebid.server.functional.model.response.auction.BidResponse -import org.prebid.server.functional.model.response.auction.InvocationResult import org.prebid.server.functional.tests.BaseSpec import org.prebid.server.functional.util.PBSUtils +import static org.prebid.server.functional.model.ModuleName.ID5_USER_ID import static org.prebid.server.functional.model.ModuleName.OPTABLE_TARGETING import static org.prebid.server.functional.model.ModuleName.ORTB2_BLOCKING import static org.prebid.server.functional.model.ModuleName.PB_RESPONSE_CORRECTION @@ -17,6 +21,7 @@ import static org.prebid.server.functional.model.ModuleName.PB_REQUEST_CORRECTIO import static org.prebid.server.functional.model.ModuleName.PB_RULE_ENGINE import static org.prebid.server.functional.model.config.Endpoint.OPENRTB2_AUCTION import static org.prebid.server.functional.model.config.Stage.ALL_PROCESSED_BID_RESPONSES +import static org.prebid.server.functional.model.config.Stage.BIDDER_REQUEST import static org.prebid.server.functional.model.config.Stage.PROCESSED_AUCTION_REQUEST import static org.prebid.server.functional.testcontainers.Dependencies.getNetworkServiceContainer @@ -88,6 +93,32 @@ class ModuleBaseSpec extends BaseSpec { "hooks.host-execution-plan" : encode(ExecutionPlan.getSingleEndpointExecutionPlan(endpoint, PB_RULE_ENGINE, [stage]))] } + protected static Map getId5UserIdSettings(Map extraConfig = [:], + Endpoint endpoint = OPENRTB2_AUCTION) { + def executionPlan = new ExecutionPlan(endpoints: [ + (endpoint): new EndpointExecutionPlan(stages: [ + (PROCESSED_AUCTION_REQUEST): new StageExecutionPlan(groups: [ + new ExecutionGroup(timeout: 5000, hookSequence: [ + new HookId(moduleCode: ID5_USER_ID.code, hookImplCode: "${ID5_USER_ID.code}-fetch-hook" as String) + ]) + ]), + (BIDDER_REQUEST): new StageExecutionPlan(groups: [ + new ExecutionGroup(timeout: 5000, hookSequence: [ + new HookId(moduleCode: ID5_USER_ID.code, hookImplCode: "${ID5_USER_ID.code}-inject-hook" as String) + ]) + ]) + ]) + ]) + def config = ["hooks.${ID5_USER_ID.code}.enabled" : "true", + "hooks.${ID5_USER_ID.code}.partner" : "173", + "hooks.${ID5_USER_ID.code}.provider-name" : "prebid-server", + "hooks.${ID5_USER_ID.code}.inserter-name" : "prebid-server", + "hooks.${ID5_USER_ID.code}.fetch-endpoint": "$networkServiceContainer.rootUri/id5-fetch".toString(), + "hooks.host-execution-plan" : encode(executionPlan)] + .collectEntries { key, value -> [(key.toString()): value.toString()] } as Map + config + extraConfig + } + protected static List getAnalyticResults(BidResponse response) { response.ext.prebid.modules?.trace?.stages?.first() ?.outcomes?.first()?.groups?.first() diff --git a/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy new file mode 100644 index 00000000000..701ac1e506f --- /dev/null +++ b/src/test/groovy/org/prebid/server/functional/tests/module/id5userid/Id5UserIdModuleSpec.groovy @@ -0,0 +1,198 @@ +package org.prebid.server.functional.tests.module.id5userid + +import org.mockserver.model.HttpRequest +import org.mockserver.model.HttpResponse +import org.mockserver.model.MediaType +import org.prebid.server.functional.model.bidder.AppNexus +import org.prebid.server.functional.model.bidder.Generic +import org.prebid.server.functional.model.request.auction.BidRequest +import org.prebid.server.functional.model.request.auction.Eid +import org.prebid.server.functional.model.request.auction.Uid +import org.prebid.server.functional.model.request.auction.User +import org.prebid.server.functional.service.PrebidServerService +import org.prebid.server.functional.testcontainers.scaffolding.Bidder +import org.prebid.server.functional.testcontainers.scaffolding.NetworkScaffolding +import org.prebid.server.functional.tests.module.ModuleBaseSpec +import org.testcontainers.containers.MockServerContainer + +import static org.prebid.server.functional.model.ModuleName.ID5_USER_ID +import static org.prebid.server.functional.testcontainers.Dependencies.networkServiceContainer + +class Id5UserIdModuleSpec extends ModuleBaseSpec { + + private static final String ID5_SOURCE = "id5-sync.com" + private static final String TEST_ID5_VALUE = "ID5*test-id5-user-id" + private static final String BLOCKED_ACCOUNT = "blocked-account" + + private static final String ALIAS_ENDPOINT = "/alias-auction" + private static final String APPNEXUS_ENDPOINT = "/appnexus-auction" + + private static final Id5FetchService id5FetchService = new Id5FetchService(networkServiceContainer) + private static final Bidder aliasBidder = new Bidder(networkServiceContainer, ALIAS_ENDPOINT) + private static final Bidder appnexusBidder = new Bidder(networkServiceContainer, APPNEXUS_ENDPOINT) + + private static final Map CONFIG = + getId5UserIdSettings([ + "hooks.${ID5_USER_ID.code}.bidder-filter.exclude" : "false", + "hooks.${ID5_USER_ID.code}.bidder-filter.values" : "generic,alias", + "hooks.${ID5_USER_ID.code}.account-filter.exclude": "true", + "hooks.${ID5_USER_ID.code}.account-filter.values" : BLOCKED_ACCOUNT + ]) + [ + "adapters.generic.aliases.alias.enabled" : "true", + "adapters.generic.aliases.alias.endpoint": "${networkServiceContainer.rootUri}${ALIAS_ENDPOINT}".toString(), + "adapters.appnexus.enabled" : "true", + "adapters.appnexus.endpoint" : "${networkServiceContainer.rootUri}${APPNEXUS_ENDPOINT}".toString() + ] + + private static final PrebidServerService pbsService = pbsServiceFactory.getService(CONFIG) + + def setupSpec() { + aliasBidder.setResponse() + appnexusBidder.setResponse() + } + + def setup() { + id5FetchService.reset() + id5FetchService.setFetchResponse(TEST_ID5_VALUE) + } + + def cleanupSpec() { + pbsServiceFactory.removeContainer(CONFIG) + aliasBidder.reset() + appnexusBidder.reset() + id5FetchService.reset() + } + + def "PBS should fetch ID5 and inject EIDs into allowed bidders and skip filtered-out bidders"() { + given: "Bid request with all bidders" + def bidRequest = createBidRequest() + + when: "PBS processes auction request" + pbsService.sendAuctionRequest(bidRequest) + + then: "ID5 fetch endpoint should be called once" + assert id5FetchService.requestCount == 1 + + and: "Allowed bidders should receive request with ID5 EID" + def genericBidderRequest = bidder.getBidderRequest(bidRequest.id) + verifyId5EidPresent(genericBidderRequest.user, TEST_ID5_VALUE) + def aliasBidderRequest = aliasBidder.getBidderRequest(bidRequest.id) + verifyId5EidPresent(aliasBidderRequest.user, TEST_ID5_VALUE) + + and: "Not allowed bidder should not receive ID5 EID" + def appnexusBidderRequest = appnexusBidder.getBidderRequest(bidRequest.id) + verifyId5EidAbsent(appnexusBidderRequest.user) + } + + def "PBS should skip ID5 fetch when ID5 EID already present in request"() { + given: "Bid request with existing ID5 EID" + def existingId5Value = "existing-id5-value" + def bidRequest = createBidRequest().tap { + user = new User(eids: [new Eid(source: ID5_SOURCE, uids: [new Uid(id: existingId5Value, atype: 1)])]) + } + + when: "PBS processes auction request" + pbsService.sendAuctionRequest(bidRequest) + + then: "ID5 fetch endpoint should not be called" + assert id5FetchService.requestCount == 0 + + and: "All bidders should receive request with original ID5 EID" + verifyId5EidPresent(bidder.getBidderRequest(bidRequest.id).user, existingId5Value, false) + verifyId5EidPresent(aliasBidder.getBidderRequest(bidRequest.id).user, existingId5Value, false) + verifyId5EidPresent(appnexusBidder.getBidderRequest(bidRequest.id).user, existingId5Value, false) + } + + def "PBS should skip ID5 fetch for blocked account"() { + given: "Bid request with blocked account" + def bidRequest = createBidRequest().tap { + accountId = BLOCKED_ACCOUNT + } + + when: "PBS processes auction request" + pbsService.sendAuctionRequest(bidRequest) + + then: "ID5 fetch endpoint should not be called" + assert id5FetchService.requestCount == 0 + + and: "All bidders should receive request without ID5 EID" + verifyId5EidAbsent(bidder.getBidderRequest(bidRequest.id).user) + verifyId5EidAbsent(aliasBidder.getBidderRequest(bidRequest.id).user) + verifyId5EidAbsent(appnexusBidder.getBidderRequest(bidRequest.id).user) + } + + private static BidRequest createBidRequest() { + BidRequest.defaultBidRequest.tap { + imp[0].ext.prebid.bidder.alias = new Generic() + imp[0].ext.prebid.bidder.appNexus = AppNexus.default + } + } + + private static void verifyId5EidPresent(User user, String expectedId5Value, boolean expectInserter = true) { + def eids = user?.ext?.eids ?: user?.eids + assert eids, "Expected EIDs to be present in bidder request" + def id5Eid = eids.find { it.source == ID5_SOURCE } + assert id5Eid, "Expected ID5 EID with source '$ID5_SOURCE'" + assert id5Eid.uids[0].id == expectedId5Value + if (expectInserter) { + assert id5Eid.inserter == "prebid-server" + } + } + + private static void verifyId5EidAbsent(User user) { + def eids = user?.ext?.eids ?: user?.eids + if (eids) { + assert !eids.any { it.source == ID5_SOURCE }, "Expected no ID5 EID in bidder request" + } + } + + static class Id5FetchService extends NetworkScaffolding { + + private static final String ID5_FETCH_ENDPOINT = "/id5-fetch" + + Id5FetchService(MockServerContainer mockServerContainer) { + super(mockServerContainer, ID5_FETCH_ENDPOINT) + } + + void setFetchResponse(String id5Value) { + def responseBody = """ + { + "ids": { + "id5": { + "eid": { + "source": "id5-sync.com", + "uids": [{ + "id": "${id5Value}", + "atype": 1 + }] + } + } + } + } + """ + mockServerClient.when(HttpRequest.request().withMethod("POST").withPath("${endpoint}/.*")) + .respond(HttpResponse.response().withStatusCode(200) + .withBody(responseBody, MediaType.APPLICATION_JSON)) + } + + @Override + protected HttpRequest getRequest(String value) { + HttpRequest.request().withMethod("POST").withPath("${endpoint}/${value}") + } + + @Override + protected HttpRequest getRequest() { + HttpRequest.request().withMethod("POST").withPath("${endpoint}/.*") + } + + @Override + void reset() { + super.reset("${endpoint}/.*" as String) + } + + @Override + void setResponse() { + setFetchResponse("ID5*test-id5-user-id") + } + } +}