From e0340b0c52502ee6f6cdea34ec7d40ffbab3f304 Mon Sep 17 00:00:00 2001 From: antonbabak Date: Fri, 10 Jan 2025 20:08:07 +0100 Subject: [PATCH 1/4] Setuid: Cookie Family Fix --- .../prebid/server/handler/SetuidHandler.java | 82 +++++++++++-------- .../server/handler/SetuidHandlerTest.java | 36 +++++++- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/main/java/org/prebid/server/handler/SetuidHandler.java b/src/main/java/org/prebid/server/handler/SetuidHandler.java index 1f6bed55e5f..64438a27441 100644 --- a/src/main/java/org/prebid/server/handler/SetuidHandler.java +++ b/src/main/java/org/prebid/server/handler/SetuidHandler.java @@ -12,7 +12,9 @@ import io.vertx.ext.web.RoutingContext; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; import org.prebid.server.activity.Activity; import org.prebid.server.activity.ComponentType; import org.prebid.server.activity.infrastructure.ActivityInfrastructure; @@ -27,7 +29,6 @@ import org.prebid.server.auction.privacy.contextfactory.SetuidPrivacyContextFactory; import org.prebid.server.bidder.BidderCatalog; import org.prebid.server.bidder.UsersyncFormat; -import org.prebid.server.bidder.UsersyncMethod; import org.prebid.server.bidder.UsersyncMethodType; import org.prebid.server.bidder.UsersyncUtil; import org.prebid.server.bidder.Usersyncer; @@ -88,7 +89,7 @@ public class SetuidHandler implements ApplicationResource { private final AnalyticsReporterDelegator analyticsDelegator; private final Metrics metrics; private final TimeoutFactory timeoutFactory; - private final Map cookieNameToSyncType; + private final Map> cookieNameToBidderAndSyncType; public SetuidHandler(long defaultTimeout, UidsCookieService uidsCookieService, @@ -112,36 +113,30 @@ public SetuidHandler(long defaultTimeout, this.analyticsDelegator = Objects.requireNonNull(analyticsDelegator); this.metrics = Objects.requireNonNull(metrics); this.timeoutFactory = Objects.requireNonNull(timeoutFactory); - this.cookieNameToSyncType = collectMap(bidderCatalog); + this.cookieNameToBidderAndSyncType = collectMap(bidderCatalog); } - private static Map collectMap(BidderCatalog bidderCatalog) { + private static Map> collectMap(BidderCatalog bidderCatalog) { - final Supplier> usersyncers = () -> bidderCatalog.names() + final Supplier>> usersyncers = () -> bidderCatalog.names() .stream() .filter(bidderCatalog::isActive) - .map(bidderCatalog::usersyncerByName) - .filter(Optional::isPresent) - .map(Optional::get) - .distinct(); + .filter(bidderName -> bidderCatalog.resolveBaseBidder(bidderName).equals(bidderName)) + .map(bidderName -> bidderCatalog.usersyncerByName(bidderName) + .map(usersyncer -> Pair.of(bidderName, usersyncer))) + .flatMap(Optional::stream); - validateUsersyncers(usersyncers.get()); + validateUsersyncers(usersyncers.get().map(Pair::getRight)); return usersyncers.get() - .collect(Collectors.toMap(Usersyncer::getCookieFamilyName, SetuidHandler::preferredUserSyncType)); - } - - @Override - public List endpoints() { - return Collections.singletonList(HttpEndpoint.of(HttpMethod.GET, Endpoint.setuid.value())); + .collect(Collectors.toMap( + pair -> pair.getRight().getCookieFamilyName(), + pair -> Pair.of(pair.getLeft(), preferredUserSyncType(pair.getRight())))); } private static UsersyncMethodType preferredUserSyncType(Usersyncer usersyncer) { - return Stream.of(usersyncer.getIframe(), usersyncer.getRedirect()) - .filter(Objects::nonNull) - .findFirst() - .map(UsersyncMethod::getType) - .get(); // when usersyncer is present, it will contain at least one method + // when usersyncer is present, it will contain at least one method + return ObjectUtils.firstNonNull(usersyncer.getIframe(), usersyncer.getRedirect()).getType(); } private static void validateUsersyncers(Stream usersyncers) { @@ -160,6 +155,11 @@ private static void validateUsersyncers(Stream usersyncers) { } } + @Override + public List endpoints() { + return Collections.singletonList(HttpEndpoint.of(HttpMethod.GET, Endpoint.setuid.value())); + } + @Override public void handle(RoutingContext routingContext) { toSetuidContext(routingContext) @@ -173,6 +173,11 @@ private Future toSetuidContext(RoutingContext routingContext) { final String requestAccount = httpRequest.getParam(ACCOUNT_PARAM); final Timeout timeout = timeoutFactory.create(defaultTimeout); + final UsersyncMethodType syncType = Optional.ofNullable(cookieName) + .map(cookieNameToBidderAndSyncType::get) + .map(Pair::getRight) + .orElse(null); + return accountById(requestAccount, timeout) .compose(account -> setuidPrivacyContextFactory.contextFrom(httpRequest, account, timeout) .map(privacyContext -> SetuidContext.builder() @@ -181,7 +186,7 @@ private Future toSetuidContext(RoutingContext routingContext) { .timeout(timeout) .account(account) .cookieName(cookieName) - .syncType(cookieNameToSyncType.get(cookieName)) + .syncType(syncType) .privacyContext(privacyContext) .build())) @@ -211,11 +216,11 @@ private void handleSetuidContextResult(AsyncResult setuidContextR if (setuidContextResult.succeeded()) { final SetuidContext setuidContext = setuidContextResult.result(); - final String bidderCookieName = setuidContext.getCookieName(); + final String bidderCookieFamily = setuidContext.getCookieName(); final TcfContext tcfContext = setuidContext.getPrivacyContext().getTcfContext(); try { - validateSetuidContext(setuidContext, bidderCookieName); + validateSetuidContext(setuidContext, bidderCookieFamily); } catch (InvalidRequestException | UnauthorizedUidsException | UnavailableForLegalReasonsException e) { handleErrors(e, routingContext, tcfContext); return; @@ -224,28 +229,33 @@ private void handleSetuidContextResult(AsyncResult setuidContextR final AccountPrivacyConfig privacyConfig = setuidContext.getAccount().getPrivacy(); final AccountGdprConfig accountGdprConfig = privacyConfig != null ? privacyConfig.getGdpr() : null; + final String bidderName = cookieNameToBidderAndSyncType.get(bidderCookieFamily).getLeft(); + Future.all( tcfDefinerService.isAllowedForHostVendorId(tcfContext), tcfDefinerService.resultForBidderNames( - Collections.singleton(bidderCookieName), tcfContext, accountGdprConfig)) - .onComplete(hostTcfResponseResult -> respondByTcfResponse(hostTcfResponseResult, setuidContext)); + Collections.singleton(bidderName), tcfContext, accountGdprConfig)) + .onComplete(hostTcfResponseResult -> respondByTcfResponse( + hostTcfResponseResult, + bidderName, + setuidContext)); } else { final Throwable error = setuidContextResult.cause(); handleErrors(error, routingContext, null); } } - private void validateSetuidContext(SetuidContext setuidContext, String bidder) { + private void validateSetuidContext(SetuidContext setuidContext, String bidderCookieFamily) { final String cookieName = setuidContext.getCookieName(); final boolean isCookieNameBlank = StringUtils.isBlank(cookieName); - if (isCookieNameBlank || !cookieNameToSyncType.containsKey(cookieName)) { + if (isCookieNameBlank || !cookieNameToBidderAndSyncType.containsKey(cookieName)) { final String cookieNameError = isCookieNameBlank ? "required" : "invalid"; throw new InvalidRequestException("\"bidder\" query param is " + cookieNameError); } final TcfContext tcfContext = setuidContext.getPrivacyContext().getTcfContext(); if (tcfContext.isInGdprScope() && !tcfContext.isConsentValid()) { - metrics.updateUserSyncTcfInvalidMetric(bidder); + metrics.updateUserSyncTcfInvalidMetric(bidderCookieFamily); throw new InvalidRequestException("Consent string is invalid"); } @@ -256,7 +266,7 @@ private void validateSetuidContext(SetuidContext setuidContext, String bidder) { final ActivityInfrastructure activityInfrastructure = setuidContext.getActivityInfrastructure(); final ActivityInvocationPayload activityInvocationPayload = TcfContextActivityInvocationPayload.of( - ActivityInvocationPayloadImpl.of(ComponentType.BIDDER, bidder), + ActivityInvocationPayloadImpl.of(ComponentType.BIDDER, bidderCookieFamily), tcfContext); if (!activityInfrastructure.isAllowed(Activity.SYNC_USER, activityInvocationPayload)) { @@ -264,8 +274,10 @@ private void validateSetuidContext(SetuidContext setuidContext, String bidder) { } } - private void respondByTcfResponse(AsyncResult hostTcfResponseResult, SetuidContext setuidContext) { - final String bidderCookieName = setuidContext.getCookieName(); + private void respondByTcfResponse(AsyncResult hostTcfResponseResult, + String bidderName, + SetuidContext setuidContext) { + final TcfContext tcfContext = setuidContext.getPrivacyContext().getTcfContext(); final RoutingContext routingContext = setuidContext.getRoutingContext(); @@ -276,7 +288,7 @@ private void respondByTcfResponse(AsyncResult hostTcfResponseRe final Map vendorIdToAction = bidderTcfResponse.getActions(); final PrivacyEnforcementAction action = vendorIdToAction != null - ? vendorIdToAction.get(bidderCookieName) + ? vendorIdToAction.get(bidderName) : null; final boolean notInGdprScope = BooleanUtils.isFalse(bidderTcfResponse.getUserInGdprScope()); @@ -285,7 +297,7 @@ private void respondByTcfResponse(AsyncResult hostTcfResponseRe if (hostVendorTcfResponse.isVendorAllowed() && isBidderVendorAllowed) { respondWithCookie(setuidContext); } else { - metrics.updateUserSyncTcfBlockedMetric(bidderCookieName); + metrics.updateUserSyncTcfBlockedMetric(setuidContext.getCookieName()); final HttpResponseStatus status = new HttpResponseStatus(UNAVAILABLE_FOR_LEGAL_REASONS, "Unavailable for legal reasons"); @@ -300,7 +312,7 @@ private void respondByTcfResponse(AsyncResult hostTcfResponseRe } } else { final Throwable error = hostTcfResponseResult.cause(); - metrics.updateUserSyncTcfBlockedMetric(bidderCookieName); + metrics.updateUserSyncTcfBlockedMetric(setuidContext.getCookieName()); handleErrors(error, routingContext, tcfContext); } } diff --git a/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java b/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java index 8cb03cda9b5..a9525db96d7 100644 --- a/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java +++ b/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java @@ -77,6 +77,7 @@ public class SetuidHandlerTest extends VertxTest { private static final String RUBICON = "rubicon"; private static final String FACEBOOK = "audienceNetwork"; private static final String ADNXS = "adnxs"; + private static final String APPNEXUS = "appnexus"; @Mock(strictness = LENIENT) private UidsCookieService uidsCookieService; @@ -98,6 +99,7 @@ public class SetuidHandlerTest extends VertxTest { private Metrics metrics; private SetuidHandler setuidHandler; + @Mock(strictness = LENIENT) private RoutingContext routingContext; @Mock(strictness = LENIENT) @@ -113,7 +115,7 @@ public class SetuidHandlerTest extends VertxTest { public void setUp() { final Map bidderToGdpr = Map.of( RUBICON, PrivacyEnforcementAction.allowAll(), - ADNXS, PrivacyEnforcementAction.allowAll(), + APPNEXUS, PrivacyEnforcementAction.allowAll(), FACEBOOK, PrivacyEnforcementAction.allowAll()); tcfContext = TcfContext.builder().inGdprScope(false).build(); @@ -140,13 +142,16 @@ public void setUp() { given(uidsCookieService.toCookie(any())).willReturn(Cookie.cookie("test", "test")); - given(bidderCatalog.names()).willReturn(new HashSet<>(asList("rubicon", "audienceNetwork"))); + given(bidderCatalog.names()).willReturn(new HashSet<>(asList(RUBICON, FACEBOOK, APPNEXUS))); given(bidderCatalog.isActive(any())).willReturn(true); + given(bidderCatalog.resolveBaseBidder(any())).willAnswer(invocation -> invocation.getArgument(0)); given(bidderCatalog.usersyncerByName(eq(RUBICON))).willReturn( Optional.of(Usersyncer.of(RUBICON, null, redirectMethod()))); given(bidderCatalog.usersyncerByName(eq(FACEBOOK))).willReturn( Optional.of(Usersyncer.of(FACEBOOK, null, redirectMethod()))); + given(bidderCatalog.usersyncerByName(eq(APPNEXUS))).willReturn( + Optional.of(Usersyncer.of(ADNXS, null, redirectMethod()))); given(activityInfrastructure.isAllowed(any(), any())) .willReturn(true); @@ -459,6 +464,33 @@ public void shouldRespondWithCookieFromRequestParam() throws IOException { assertThat(decodedUids.getUids().get(RUBICON).getUid()).isEqualTo("J5VLCWQP-26-CWFT"); } + @Test + public void shouldRespondWithCookieFromRequestParamWhenBidderAndCookieFamilyAreDifferent() throws IOException { + // given + final UidsCookie uidsCookie = emptyUidsCookie(); + given(uidsCookieService.parseFromRequest(any(RoutingContext.class))) + .willReturn(uidsCookie); + given(uidsCookieService.updateUidsCookie(uidsCookie, ADNXS, "J5VLCWQP-26-CWFT")) + .willReturn(UidsCookieUpdateResult.updated(uidsCookie)); + + // {"tempUIDs":{"adnxs":{"uid":"J5VLCWQP-26-CWFT"}}} + given(uidsCookieService.toCookie(any())).willReturn(Cookie + .cookie("uids", "eyJ0ZW1wVUlEcyI6eyJhZG54cyI6eyJ1aWQiOiJKNVZMQ1dRUC0yNi1DV0ZUIn19fQ==")); + + given(httpRequest.getParam("bidder")).willReturn(ADNXS); + given(httpRequest.getParam("uid")).willReturn("J5VLCWQP-26-CWFT"); + + // when + setuidHandler.handle(routingContext); + + // then + verify(routingContext, never()).addCookie(any(Cookie.class)); + final String encodedUidsCookie = getUidsCookie(); + final Uids decodedUids = decodeUids(encodedUidsCookie); + assertThat(decodedUids.getUids()).hasSize(1); + assertThat(decodedUids.getUids().get(ADNXS).getUid()).isEqualTo("J5VLCWQP-26-CWFT"); + } + @Test public void shouldSendPixelWhenFParamIsEqualToIWhenTypeIsIframe() { // given From 295d988973d58d417e8460e222d1463378eed191 Mon Sep 17 00:00:00 2001 From: osulzhenko <125548596+osulzhenko@users.noreply.github.com> Date: Mon, 13 Jan 2025 10:29:34 +0200 Subject: [PATCH 2/4] Tests: Increase coverage for setUid endpoint (#3676) * Increase coverage for setUid endpoint --- .../server/functional/tests/SetUidSpec.groovy | 79 ++++++++++--- .../tests/privacy/GdprSetUidSpec.groovy | 104 +++++++++++++++++- 2 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/test/groovy/org/prebid/server/functional/tests/SetUidSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/SetUidSpec.groovy index 53f633b710c..1e33542e564 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/SetUidSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/SetUidSpec.groovy @@ -12,10 +12,16 @@ import spock.lang.Shared import java.time.Clock import java.time.ZonedDateTime +import static org.prebid.server.functional.model.bidder.BidderName.ALIAS +import static org.prebid.server.functional.model.bidder.BidderName.ALIAS_CAMEL_CASE import static org.prebid.server.functional.model.bidder.BidderName.APPNEXUS +import static org.prebid.server.functional.model.bidder.BidderName.EMPTY import static org.prebid.server.functional.model.bidder.BidderName.GENERIC +import static org.prebid.server.functional.model.bidder.BidderName.GENERIC_CAMEL_CASE import static org.prebid.server.functional.model.bidder.BidderName.OPENX import static org.prebid.server.functional.model.bidder.BidderName.RUBICON +import static org.prebid.server.functional.model.bidder.BidderName.UNKNOWN +import static org.prebid.server.functional.model.bidder.BidderName.WILDCARD import static org.prebid.server.functional.model.request.setuid.UidWithExpiry.defaultUidWithExpiry import static org.prebid.server.functional.model.response.cookiesync.UserSyncInfo.Type.REDIRECT import static org.prebid.server.functional.testcontainers.Dependencies.networkServiceContainer @@ -37,11 +43,13 @@ class SetUidSpec extends BaseSpec { "adapters.${APPNEXUS.value}.usersync.cookie-family-name" : APPNEXUS.value, "adapters.${GENERIC.value}.usersync.${USER_SYNC_TYPE.value}.url" : USER_SYNC_URL, "adapters.${GENERIC.value}.usersync.${USER_SYNC_TYPE.value}.support-cors": CORS_SUPPORT.toString()] + private static final Map GENERIC_ALIAS_CONFIG = ["adapters.generic.aliases.alias.enabled" : "true", + "adapters.generic.aliases.alias.endpoint": "$networkServiceContainer.rootUri/auction".toString()] private static final String TCF_ERROR_MESSAGE = "The gdpr_consent param prevents cookies from being saved" private static final int UNAVAILABLE_FOR_LEGAL_REASONS_CODE = 451 @Shared - PrebidServerService prebidServerService = pbsServiceFactory.getService(PBS_CONFIG) + PrebidServerService prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + GENERIC_ALIAS_CONFIG) def "PBS should set uids cookie"() { given: "Default SetuidRequest" @@ -77,8 +85,8 @@ class SetUidSpec extends BaseSpec { def "PBS setuid should return requested uids cookie when priority bidder not present in config"() { given: "PBS config" - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": null]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": null] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Setuid request" def request = SetuidRequest.defaultSetuidRequest.tap { @@ -94,13 +102,16 @@ class SetUidSpec extends BaseSpec { then: "Response should contain requested uids" assert response.uidsCookie.tempUIDs[GENERIC] assert response.uidsCookie.tempUIDs[RUBICON] + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS setuid should return prioritized uids bidder when size is full"() { given: "PBS config" def genericBidder = GENERIC - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": genericBidder.value]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": genericBidder.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Setuid request" def request = SetuidRequest.defaultSetuidRequest.tap { @@ -119,12 +130,15 @@ class SetUidSpec extends BaseSpec { then: "Response should contain uids cookies" assert response.uidsCookie.tempUIDs[rubiconBidder] assert response.uidsCookie.tempUIDs[genericBidder] + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS setuid should remove earliest expiration bidder when size is full"() { given: "PBS config" - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": GENERIC.value]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": GENERIC.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Setuid request" def request = SetuidRequest.defaultSetuidRequest.tap { @@ -147,12 +161,15 @@ class SetUidSpec extends BaseSpec { then: "Response should contain uids cookies" assert response.uidsCookie.tempUIDs[APPNEXUS] assert response.uidsCookie.tempUIDs[GENERIC] + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS setuid should ignore requested bidder and log metric when cookie's filled and requested bidder not in prioritize list"() { given: "PBS config" - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": APPNEXUS.value]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": APPNEXUS.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Setuid request" def bidderName = GENERIC @@ -176,14 +193,17 @@ class SetUidSpec extends BaseSpec { and: "Response should contain uids cookies" assert response.uidsCookie.tempUIDs[APPNEXUS] assert response.uidsCookie.tempUIDs[RUBICON] + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS setuid should reject bidder when cookie's filled and requested bidder in pri and rejected by tcf"() { given: "Setuid request" def bidderName = RUBICON - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG - + ["gdpr.host-vendor-id": RUBICON_VENDOR_ID.toString(), - "cookie-sync.pri" : bidderName.value]) + def pbsConfig = PBS_CONFIG + ["gdpr.host-vendor-id": RUBICON_VENDOR_ID.toString(), + "cookie-sync.pri" : bidderName.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) def request = SetuidRequest.defaultSetuidRequest.tap { it.bidder = bidderName @@ -207,12 +227,15 @@ class SetUidSpec extends BaseSpec { and: "usersync.FAMILY.tcf.blocked metric should be updated" def metric = prebidServerService.sendCollectedMetricsRequest() assert metric["usersync.${bidderName.value}.tcf.blocked"] == 1 + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS setuid should remove oldest uid and log metric when cookie's filled and oldest uid's not on the pri"() { given: "PBS config" - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": GENERIC.value]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": GENERIC.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Flush metrics" flushMetrics(prebidServerService) @@ -241,12 +264,15 @@ class SetUidSpec extends BaseSpec { then: "Response should contain uids cookies" assert response.uidsCookie.tempUIDs[APPNEXUS] assert response.uidsCookie.tempUIDs[GENERIC] + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) } def "PBS SetUid should remove oldest bidder from uids cookie in favor of prioritized bidder"() { given: "PBS config" - def prebidServerService = pbsServiceFactory.getService(PBS_CONFIG + - ["cookie-sync.pri": "$OPENX.value, $GENERIC.value" as String]) + def pbsConfig = PBS_CONFIG + ["cookie-sync.pri": "$OPENX.value, $GENERIC.value" as String] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) and: "Set uid request" def request = SetuidRequest.defaultSetuidRequest.tap { @@ -281,5 +307,26 @@ class SetUidSpec extends BaseSpec { and: "usersync.FAMILY.sets metric should be updated" assert metricsRequest["usersync.${OPENX.value}.sets"] == 1 + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) + } + + def "PBS setuid should reject request when requested bidder mismatching with cookie-family-name"() { + given: "Default SetuidRequest" + def request = SetuidRequest.getDefaultSetuidRequest().tap { + it.bidder = bidderName + } + + when: "PBS processes setuid request" + prebidServerService.sendSetUidRequest(request, UidsCookie.defaultUidsCookie) + + then: "Request should fail with error" + def exception = thrown(PrebidServerException) + assert exception.statusCode == 400 + assert exception.responseBody == 'Invalid request format: "bidder" query param is invalid' + + where: + bidderName << [UNKNOWN, WILDCARD, GENERIC_CAMEL_CASE, ALIAS, ALIAS_CAMEL_CASE] } } diff --git a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprSetUidSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprSetUidSpec.groovy index 0fede80a71a..ce8d308c3a2 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprSetUidSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/privacy/GdprSetUidSpec.groovy @@ -17,6 +17,7 @@ import org.prebid.server.util.ResourceUtil import static org.prebid.server.functional.model.AccountStatus.ACTIVE import static org.prebid.server.functional.model.bidder.BidderName.GENERIC +import static org.prebid.server.functional.model.bidder.BidderName.GENER_X import static org.prebid.server.functional.model.config.Purpose.P1 import static org.prebid.server.functional.model.config.PurposeEnforcement.FULL import static org.prebid.server.functional.model.config.PurposeEnforcement.NO @@ -53,7 +54,7 @@ class GdprSetUidSpec extends PrivacyBaseSpec { .build() } - and: "Default uids cookie with rubicon bidder" + and: "Default uids cookie with gener_x bidder" def uidsCookie = UidsCookie.defaultUidsCookie.tap { it.tempUIDs = [(GENERIC): defaultUidWithExpiry] } @@ -75,6 +76,90 @@ class GdprSetUidSpec extends PrivacyBaseSpec { ResourceUtil.readByteArrayFromClassPath("org/prebid/server/functional/tracking-pixel.png") } + def "PBS setuid shouldn't failed with tcf when bidder name and cookie-family-name mismatching"() { + given: "PBS with different cookie-family-name" + def pbsConfig = VENDOR_GENERIC_PBS_CONFIG + + ["adapters.${GENERIC.value}.usersync.cookie-family-name": GENER_X.value] + def prebidServerService = pbsServiceFactory.getService(pbsConfig) + + and: "Setuid request with account" + def setuidRequest = SetuidRequest.defaultSetuidRequest.tap { + it.account = PBSUtils.randomNumber.toString() + it.uid = UUID.randomUUID().toString() + it.bidder = GENER_X + it.gdpr = "1" + it.gdprConsent = new TcfConsent.Builder() + .setPurposesLITransparency(DEVICE_ACCESS) + .setVendorLegitimateInterest([GENERIC_VENDOR_ID]) + .build() + } + + and: "Default uids cookie with gener_x bidder" + def uidsCookie = UidsCookie.defaultUidsCookie.tap { + it.tempUIDs = [(GENER_X): defaultUidWithExpiry] + } + + and: "Save account config with purpose into DB" + def accountConfig = new AccountConfig( + auction: new AccountAuctionConfig(debugAllow: true), + privacy: new AccountPrivacyConfig(gdpr: new AccountGdprConfig(purposes: [(P1): new PurposeConfig(enforcePurpose: NO)], enabled: true))) + def account = new Account(status: ACTIVE, uuid: setuidRequest.account, config: accountConfig) + accountDao.save(account) + + when: "PBS processes setuid request" + def response = prebidServerService.sendSetUidRequest(setuidRequest, uidsCookie) + + then: "Response should contain tempUids cookie and headers" + assert response.headers.size() == 7 + assert response.uidsCookie.tempUIDs[GENER_X].uid == setuidRequest.uid + assert response.responseBody == + ResourceUtil.readByteArrayFromClassPath("org/prebid/server/functional/tracking-pixel.png") + + cleanup: "Stop and remove pbs container" + pbsServiceFactory.removeContainer(pbsConfig) + } + + def "PBS setuid should failed with tcf when dgpr value is invalid"() { + given: "Default setuid request with account" + def setuidRequest = SetuidRequest.defaultSetuidRequest.tap { + it.account = PBSUtils.randomNumber.toString() + it.uid = UUID.randomUUID().toString() + it.bidder = GENERIC + it.gdpr = "1" + it.gdprConsent = new TcfConsent.Builder() + .setPurposesLITransparency(DEVICE_ACCESS) + .setVendorLegitimateInterest([PBSUtils.getRandomNumberWithExclusion(GENERIC_VENDOR_ID, 0, 65534)]) + .build() + } + + and: "Flush metrics" + flushMetrics(prebidServerService) + + and: "Default uids cookie with generic bidder" + def uidsCookie = UidsCookie.defaultUidsCookie.tap { + it.tempUIDs = [(GENERIC): defaultUidWithExpiry] + } + + and: "Save account config with purpose into DB" + def accountConfig = new AccountConfig( + auction: new AccountAuctionConfig(debugAllow: true), + privacy: new AccountPrivacyConfig(gdpr: new AccountGdprConfig(purposes: [(P1): new PurposeConfig(enforcePurpose: NO)], enabled: true))) + def account = new Account(status: ACTIVE, uuid: setuidRequest.account, config: accountConfig) + accountDao.save(account) + + when: "PBS processes setuid request" + prebidServerService.sendSetUidRequest(setuidRequest, uidsCookie) + + then: "Request should fail with error" + def exception = thrown(PrebidServerException) + assert exception.statusCode == UNAVAILABLE_FOR_LEGAL_REASONS_CODE + assert exception.responseBody == TCF_ERROR_MESSAGE + + and: "Metric should be increased usersync.FAMILY.tcf.blocked" + def metric = prebidServerService.sendCollectedMetricsRequest() + assert metric["usersync.${GENERIC.value}.tcf.blocked"] == 1 + } + def "PBS setuid should failed with tcf when purpose access device enforced for account"() { given: "Default setuid request with account" def setuidRequest = SetuidRequest.defaultSetuidRequest.tap { @@ -88,11 +173,14 @@ class GdprSetUidSpec extends PrivacyBaseSpec { .build() } - and: "Default uids cookie with rubicon bidder" + and: "Default uids cookie with generic bidder" def uidsCookie = UidsCookie.defaultUidsCookie.tap { it.tempUIDs = [(GENERIC): defaultUidWithExpiry] } + and: "Flush metrics" + flushMetrics(prebidServerService) + and: "Save account config with purpose into DB" def accountConfig = new AccountConfig( auction: new AccountAuctionConfig(debugAllow: true), @@ -130,11 +218,14 @@ class GdprSetUidSpec extends PrivacyBaseSpec { .build() } - and: "Default uids cookie with rubicon bidder" + and: "Default uids cookie with generic bidder" def uidsCookie = UidsCookie.defaultUidsCookie.tap { it.tempUIDs = [(GENERIC): defaultUidWithExpiry] } + and: "Flush metrics" + flushMetrics(prebidServerService) + and: "Save account config with purpose into DB" def accountConfig = new AccountConfig( auction: new AccountAuctionConfig(debugAllow: true), @@ -176,11 +267,14 @@ class GdprSetUidSpec extends PrivacyBaseSpec { .build() } - and: "Default uids cookie with rubicon bidder" + and: "Default uids cookie with generic bidder" def uidsCookie = UidsCookie.defaultUidsCookie.tap { it.tempUIDs = [(GENERIC): defaultUidWithExpiry] } + and: "Flush metrics" + flushMetrics(prebidServerService) + and: "Save account config with purpose into DB" def accountConfig = new AccountConfig( auction: new AccountAuctionConfig(debugAllow: true), @@ -214,7 +308,7 @@ class GdprSetUidSpec extends PrivacyBaseSpec { .build() } - and: "Default uids cookie with rubicon bidder" + and: "Default uids cookie with generic bidder" def uidsCookie = UidsCookie.defaultUidsCookie.tap { it.tempUIDs = [(GENERIC): defaultUidWithExpiry] } From 59fba6a799351fca9fd10ef544ff3ac0deda92b9 Mon Sep 17 00:00:00 2001 From: Alex Maltsev Date: Mon, 13 Jan 2025 15:43:15 +0200 Subject: [PATCH 3/4] Fixed validation. --- .../server/cookie/CookieSyncService.java | 9 +-- .../prebid/server/handler/SetuidHandler.java | 67 ++++++++++--------- .../org/prebid/server/util/StreamUtil.java | 9 +++ .../server/handler/SetuidHandlerTest.java | 31 ++++++--- 4 files changed, 67 insertions(+), 49 deletions(-) diff --git a/src/main/java/org/prebid/server/cookie/CookieSyncService.java b/src/main/java/org/prebid/server/cookie/CookieSyncService.java index 2d381bfa665..fb15b5478d9 100644 --- a/src/main/java/org/prebid/server/cookie/CookieSyncService.java +++ b/src/main/java/org/prebid/server/cookie/CookieSyncService.java @@ -43,6 +43,7 @@ import org.prebid.server.spring.config.bidder.model.usersync.CookieFamilySource; import org.prebid.server.util.HttpUtil; import org.prebid.server.util.ObjectUtil; +import org.prebid.server.util.StreamUtil; import java.util.ArrayList; import java.util.Collection; @@ -54,7 +55,6 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -385,16 +385,11 @@ private static Set allowedBiddersByPriority(CookieSyncContext cookieSync private List validStatuses(Set biddersToSync, CookieSyncContext cookieSyncContext) { return biddersToSync.stream() - .filter(distinctBy(bidder -> bidderCatalog.cookieFamilyName(bidder).orElseThrow())) + .filter(StreamUtil.distinctBy(bidder -> bidderCatalog.cookieFamilyName(bidder).orElseThrow())) .map(bidder -> validStatus(bidder, cookieSyncContext)) .toList(); } - private static Predicate distinctBy(Function keyExtractor) { - final Set seen = new HashSet<>(); - return value -> seen.add(keyExtractor.apply(value)); - } - private BidderUsersyncStatus validStatus(String bidder, CookieSyncContext cookieSyncContext) { final BiddersContext biddersContext = cookieSyncContext.getBiddersContext(); final RoutingContext routingContext = cookieSyncContext.getRoutingContext(); diff --git a/src/main/java/org/prebid/server/handler/SetuidHandler.java b/src/main/java/org/prebid/server/handler/SetuidHandler.java index 64438a27441..332da0a2e50 100644 --- a/src/main/java/org/prebid/server/handler/SetuidHandler.java +++ b/src/main/java/org/prebid/server/handler/SetuidHandler.java @@ -10,7 +10,6 @@ import io.vertx.core.http.HttpServerRequest; import io.vertx.core.http.HttpServerResponse; import io.vertx.ext.web.RoutingContext; -import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; @@ -55,6 +54,7 @@ import org.prebid.server.settings.model.AccountGdprConfig; import org.prebid.server.settings.model.AccountPrivacyConfig; import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.StreamUtil; import org.prebid.server.vertx.verticles.server.HttpEndpoint; import org.prebid.server.vertx.verticles.server.application.ApplicationResource; @@ -65,9 +65,8 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; +import java.util.function.Predicate; import java.util.stream.Collectors; -import java.util.stream.Stream; public class SetuidHandler implements ApplicationResource { @@ -113,48 +112,54 @@ public SetuidHandler(long defaultTimeout, this.analyticsDelegator = Objects.requireNonNull(analyticsDelegator); this.metrics = Objects.requireNonNull(metrics); this.timeoutFactory = Objects.requireNonNull(timeoutFactory); - this.cookieNameToBidderAndSyncType = collectMap(bidderCatalog); + this.cookieNameToBidderAndSyncType = collectUsersyncers(bidderCatalog); } - private static Map> collectMap(BidderCatalog bidderCatalog) { + private static Map> collectUsersyncers(BidderCatalog bidderCatalog) { + validateUsersyncersDuplicates(bidderCatalog); - final Supplier>> usersyncers = () -> bidderCatalog.names() - .stream() - .filter(bidderCatalog::isActive) - .filter(bidderName -> bidderCatalog.resolveBaseBidder(bidderName).equals(bidderName)) + return bidderCatalog.usersyncReadyBidders().stream() + .filter(bidderName -> !isAliasWithRootCookieFamilyName(bidderCatalog, bidderName)) + .filter(StreamUtil.distinctBy(bidderCatalog::cookieFamilyName)) .map(bidderName -> bidderCatalog.usersyncerByName(bidderName) .map(usersyncer -> Pair.of(bidderName, usersyncer))) - .flatMap(Optional::stream); - - validateUsersyncers(usersyncers.get().map(Pair::getRight)); - - return usersyncers.get() + .flatMap(Optional::stream) .collect(Collectors.toMap( pair -> pair.getRight().getCookieFamilyName(), pair -> Pair.of(pair.getLeft(), preferredUserSyncType(pair.getRight())))); } - private static UsersyncMethodType preferredUserSyncType(Usersyncer usersyncer) { - // when usersyncer is present, it will contain at least one method - return ObjectUtils.firstNonNull(usersyncer.getIframe(), usersyncer.getRedirect()).getType(); - } - - private static void validateUsersyncers(Stream usersyncers) { - final List cookieFamilyNameDuplicates = usersyncers.map(Usersyncer::getCookieFamilyName) - .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) - .entrySet() - .stream() - .filter(name -> name.getValue() > 1) - .map(Map.Entry::getKey) + private static void validateUsersyncersDuplicates(BidderCatalog bidderCatalog) { + final List duplicatedCookieFamilyNames = bidderCatalog.usersyncReadyBidders().stream() + .filter(bidderName -> !isAliasWithRootCookieFamilyName(bidderCatalog, bidderName)) + .map(bidderCatalog::usersyncerByName) + .flatMap(Optional::stream) + .map(Usersyncer::getCookieFamilyName) + .filter(Predicate.not(StreamUtil.distinctBy(Function.identity()))) .distinct() .toList(); - if (CollectionUtils.isNotEmpty(cookieFamilyNameDuplicates)) { + + if (!duplicatedCookieFamilyNames.isEmpty()) { throw new IllegalArgumentException( "Duplicated \"cookie-family-name\" found, values: " - + String.join(", ", cookieFamilyNameDuplicates)); + + String.join(", ", duplicatedCookieFamilyNames)); } } + private static boolean isAliasWithRootCookieFamilyName(BidderCatalog bidderCatalog, String bidder) { + final String bidderCookieFamilyName = bidderCatalog.cookieFamilyName(bidder).orElse(StringUtils.EMPTY); + final String parentCookieFamilyName = + bidderCatalog.cookieFamilyName(bidderCatalog.resolveBaseBidder(bidder)).orElse(null); + + return bidderCatalog.isAlias(bidder) + && parentCookieFamilyName != null + && parentCookieFamilyName.equals(bidderCookieFamilyName); + } + + private static UsersyncMethodType preferredUserSyncType(Usersyncer usersyncer) { + return ObjectUtils.firstNonNull(usersyncer.getIframe(), usersyncer.getRedirect()).getType(); + } + @Override public List endpoints() { return Collections.singletonList(HttpEndpoint.of(HttpMethod.GET, Endpoint.setuid.value())); @@ -232,9 +237,9 @@ private void handleSetuidContextResult(AsyncResult setuidContextR final String bidderName = cookieNameToBidderAndSyncType.get(bidderCookieFamily).getLeft(); Future.all( - tcfDefinerService.isAllowedForHostVendorId(tcfContext), - tcfDefinerService.resultForBidderNames( - Collections.singleton(bidderName), tcfContext, accountGdprConfig)) + tcfDefinerService.isAllowedForHostVendorId(tcfContext), + tcfDefinerService.resultForBidderNames( + Collections.singleton(bidderName), tcfContext, accountGdprConfig)) .onComplete(hostTcfResponseResult -> respondByTcfResponse( hostTcfResponseResult, bidderName, diff --git a/src/main/java/org/prebid/server/util/StreamUtil.java b/src/main/java/org/prebid/server/util/StreamUtil.java index 0a48bede9e7..998e6f669b6 100644 --- a/src/main/java/org/prebid/server/util/StreamUtil.java +++ b/src/main/java/org/prebid/server/util/StreamUtil.java @@ -1,7 +1,11 @@ package org.prebid.server.util; +import java.util.HashSet; import java.util.Iterator; +import java.util.Set; import java.util.Spliterator; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -17,4 +21,9 @@ public static Stream asStream(Spliterator spliterator) { public static Stream asStream(Iterator iterator) { return StreamSupport.stream(IterableUtil.iterable(iterator).spliterator(), false); } + + public static Predicate distinctBy(Function keyExtractor) { + final Set seen = new HashSet<>(); + return value -> seen.add(keyExtractor.apply(value)); + } } diff --git a/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java b/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java index a9525db96d7..583c06e508b 100644 --- a/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java +++ b/src/test/java/org/prebid/server/handler/SetuidHandlerTest.java @@ -52,11 +52,10 @@ import java.time.Instant; import java.time.ZoneId; import java.util.Base64; -import java.util.HashSet; import java.util.Map; import java.util.Optional; +import java.util.Set; -import static java.util.Arrays.asList; import static java.util.Collections.emptyMap; import static java.util.Collections.singleton; import static java.util.Collections.singletonMap; @@ -83,7 +82,7 @@ public class SetuidHandlerTest extends VertxTest { private UidsCookieService uidsCookieService; @Mock(strictness = LENIENT) private ApplicationSettings applicationSettings; - @Mock + @Mock(strictness = LENIENT) private BidderCatalog bidderCatalog; @Mock(strictness = LENIENT) private SetuidPrivacyContextFactory setuidPrivacyContextFactory; @@ -142,16 +141,20 @@ public void setUp() { given(uidsCookieService.toCookie(any())).willReturn(Cookie.cookie("test", "test")); - given(bidderCatalog.names()).willReturn(new HashSet<>(asList(RUBICON, FACEBOOK, APPNEXUS))); - given(bidderCatalog.isActive(any())).willReturn(true); - given(bidderCatalog.resolveBaseBidder(any())).willAnswer(invocation -> invocation.getArgument(0)); + given(bidderCatalog.usersyncReadyBidders()).willReturn(Set.of(RUBICON, FACEBOOK, APPNEXUS)); + given(bidderCatalog.isAlias(any())).willReturn(false); given(bidderCatalog.usersyncerByName(eq(RUBICON))).willReturn( Optional.of(Usersyncer.of(RUBICON, null, redirectMethod()))); + given(bidderCatalog.cookieFamilyName(eq(RUBICON))).willReturn(Optional.of(RUBICON)); + given(bidderCatalog.usersyncerByName(eq(FACEBOOK))).willReturn( Optional.of(Usersyncer.of(FACEBOOK, null, redirectMethod()))); + given(bidderCatalog.cookieFamilyName(eq(FACEBOOK))).willReturn(Optional.of(FACEBOOK)); + given(bidderCatalog.usersyncerByName(eq(APPNEXUS))).willReturn( Optional.of(Usersyncer.of(ADNXS, null, redirectMethod()))); + given(bidderCatalog.cookieFamilyName(eq(APPNEXUS))).willReturn(Optional.of(ADNXS)); given(activityInfrastructure.isAllowed(any(), any())) .willReturn(true); @@ -533,7 +536,7 @@ public void shouldSendEmptyResponseWhenFParamIsEqualToBWhenTypeIsRedirect() { given(httpRequest.getParam("bidder")).willReturn(RUBICON); given(httpRequest.getParam("f")).willReturn("b"); given(httpRequest.getParam("uid")).willReturn("J5VLCWQP-26-CWFT"); - given(bidderCatalog.names()).willReturn(singleton(RUBICON)); + given(bidderCatalog.usersyncReadyBidders()).willReturn(singleton(RUBICON)); given(bidderCatalog.usersyncerByName(any())) .willReturn(Optional.of(Usersyncer.of(RUBICON, null, redirectMethod()))); @@ -619,7 +622,7 @@ public void shouldSendPixelWhenFParamNotDefinedAndTypeIsRedirect() { given(uidsCookieService.toCookie(any())).willReturn(Cookie .cookie("uids", "eyJ0ZW1wVUlEcyI6eyJydWJpY29uIjp7InVpZCI6Iko1VkxDV1FQLTI2LUNXRlQifX19")); given(httpRequest.getParam("bidder")).willReturn(RUBICON); - given(bidderCatalog.names()).willReturn(singleton(RUBICON)); + given(bidderCatalog.usersyncReadyBidders()).willReturn(singleton(RUBICON)); given(bidderCatalog.usersyncerByName(any())) .willReturn(Optional.of(Usersyncer.of(RUBICON, null, redirectMethod()))); given(httpRequest.getParam("uid")).willReturn("J5VLCWQP-26-CWFT"); @@ -809,17 +812,23 @@ public void shouldPassSuccessfulEventToAnalyticsReporter() { } @Test - public void shouldThrowExceptionInCaseOfCookieFamilyNameDuplicates() { + public void shouldThrowExceptionInCaseOfBaseBidderCookieFamilyNameDuplicates() { // given final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); final String firstDuplicateName = "firstBidderWithDuplicate"; final String secondDuplicateName = "secondBidderWithDuplicate"; - given(bidderCatalog.names()) - .willReturn(new HashSet<>(asList(RUBICON, FACEBOOK, firstDuplicateName, secondDuplicateName))); + final String thirdDuplicateName = "thirdDuplicateName"; + + given(bidderCatalog.usersyncReadyBidders()) + .willReturn(Set.of(RUBICON, FACEBOOK, firstDuplicateName, secondDuplicateName, thirdDuplicateName)); + given(bidderCatalog.isAlias(thirdDuplicateName)).willReturn(true); given(bidderCatalog.usersyncerByName(eq(firstDuplicateName))).willReturn( Optional.of(Usersyncer.of(RUBICON, iframeMethod(), redirectMethod()))); given(bidderCatalog.usersyncerByName(eq(secondDuplicateName))).willReturn( Optional.of(Usersyncer.of(FACEBOOK, iframeMethod(), redirectMethod()))); + given(bidderCatalog.usersyncerByName(eq(thirdDuplicateName))).willReturn( + Optional.of(Usersyncer.of(FACEBOOK, iframeMethod(), redirectMethod()))); + final Executable exceptionSource = () -> new SetuidHandler( 2000, uidsCookieService, From be3e4c687fc9aa1fd7ba5f2209743a127f8caf3c Mon Sep 17 00:00:00 2001 From: Alex Maltsev Date: Mon, 13 Jan 2025 15:52:23 +0200 Subject: [PATCH 4/4] Fixed usersyncer resolution. --- src/main/java/org/prebid/server/handler/SetuidHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/prebid/server/handler/SetuidHandler.java b/src/main/java/org/prebid/server/handler/SetuidHandler.java index 332da0a2e50..728285fb0f6 100644 --- a/src/main/java/org/prebid/server/handler/SetuidHandler.java +++ b/src/main/java/org/prebid/server/handler/SetuidHandler.java @@ -59,6 +59,7 @@ import org.prebid.server.vertx.verticles.server.application.ApplicationResource; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; @@ -119,7 +120,7 @@ private static Map> collectUsersyncers( validateUsersyncersDuplicates(bidderCatalog); return bidderCatalog.usersyncReadyBidders().stream() - .filter(bidderName -> !isAliasWithRootCookieFamilyName(bidderCatalog, bidderName)) + .sorted(Comparator.comparing(bidderName -> BooleanUtils.toInteger(bidderCatalog.isAlias(bidderName)))) .filter(StreamUtil.distinctBy(bidderCatalog::cookieFamilyName)) .map(bidderName -> bidderCatalog.usersyncerByName(bidderName) .map(usersyncer -> Pair.of(bidderName, usersyncer)))