1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.adservices.debuggablects;
18 
19 import static com.android.adservices.service.FlagsConstants.KEY_FLEDGE_HTTP_CACHE_ENABLE;
20 import static com.android.adservices.service.FlagsConstants.KEY_FLEDGE_ON_DEVICE_AUCTION_SHOULD_USE_UNIFIED_TABLES;
21 import static com.android.adservices.service.FlagsConstants.KEY_FLEDGE_REGISTER_AD_BEACON_ENABLED;
22 
23 import static com.google.common.truth.Truth.assertThat;
24 
25 import static org.junit.Assert.assertThrows;
26 import static org.junit.Assume.assumeTrue;
27 
28 import android.adservices.adid.AdId;
29 import android.adservices.adid.AdIdCompatibleManager;
30 import android.adservices.adselection.AdSelectionConfig;
31 import android.adservices.adselection.AdSelectionFromOutcomesConfig;
32 import android.adservices.adselection.AdSelectionOutcome;
33 import android.adservices.common.AdTechIdentifier;
34 import android.adservices.customaudience.CustomAudience;
35 import android.adservices.customaudience.FetchAndJoinCustomAudienceRequest;
36 import android.adservices.utils.FledgeScenarioTest;
37 import android.adservices.utils.ScenarioDispatcher;
38 import android.adservices.utils.ScenarioDispatcherFactory;
39 import android.adservices.utils.Scenarios;
40 import android.net.Uri;
41 import android.util.Log;
42 
43 import com.android.adservices.common.AdServicesOutcomeReceiverForTests;
44 import com.android.adservices.shared.testing.annotations.SetFlagDisabled;
45 import com.android.adservices.shared.testing.annotations.SetFlagEnabled;
46 import com.android.compatibility.common.util.ShellUtils;
47 
48 import com.google.common.util.concurrent.MoreExecutors;
49 
50 import org.junit.Test;
51 
52 import java.time.Instant;
53 import java.time.temporal.ChronoUnit;
54 import java.util.List;
55 import java.util.Objects;
56 import java.util.concurrent.ExecutionException;
57 import java.util.concurrent.TimeUnit;
58 import java.util.concurrent.TimeoutException;
59 
60 @SetFlagDisabled(KEY_FLEDGE_HTTP_CACHE_ENABLE)
61 public class AdSelectionTest extends FledgeScenarioTest {
62 
63     /**
64      * End-to-end test for ad selection.
65      *
66      * <p>Covers the following Remarketing CUJs:
67      *
68      * <ul>
69      *   <li><b>001</b>: A buyer can provide bidding logic using JS
70      *   <li><b>002</b>: A seller can provide scoring logic using JS
71      *   <li><b>035</b>: A buyer can provide the trusted signals to be used during ad selection
72      * </ul>
73      */
74     @Test
testAdSelection_withBiddingAndScoringLogic_happyPath()75     public void testAdSelection_withBiddingAndScoringLogic_happyPath() throws Exception {
76         ScenarioDispatcher dispatcher =
77                 setupDispatcher(
78                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
79                                 "scenarios/remarketing-cuj-default.json"));
80         AdSelectionConfig adSelectionConfig =
81                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
82 
83         try {
84             joinCustomAudience(SHIRTS_CA);
85             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
86             assertThat(result.hasOutcome()).isTrue();
87         } finally {
88             leaveCustomAudience(SHIRTS_CA);
89         }
90 
91         assertThat(dispatcher.getCalledPaths())
92                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
93     }
94 
95     /**
96      * {@link AdSelectionTest#testAdSelection_withBiddingAndScoringLogic_happyPath} with flag {@link
97      * KEY_FLEDGE_ON_DEVICE_AUCTION_SHOULD_USE_UNIFIED_TABLES} turned on.
98      */
99     @Test
100     @SetFlagEnabled(KEY_FLEDGE_ON_DEVICE_AUCTION_SHOULD_USE_UNIFIED_TABLES)
testAdSelection_withUnifiedTable_withBiddingAndScoringLogic_happyPath()101     public void testAdSelection_withUnifiedTable_withBiddingAndScoringLogic_happyPath()
102             throws Exception {
103         testAdSelection_withAdCostInUrl_happyPath();
104     }
105 
106     /**
107      * Test for ad selection with V3 bidding logic.
108      *
109      * <p>Covers the following Remarketing CUJs:
110      *
111      * <ul>
112      *   <li><b>119</b>: A ad selection can be run with V3 bidding logic without override
113      * </ul>
114      */
115     @Test
testAdSelection_withBiddingLogicV3_happyPath()116     public void testAdSelection_withBiddingLogicV3_happyPath() throws Exception {
117         ScenarioDispatcher dispatcher =
118                 setupDispatcher(
119                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
120                                 "scenarios/remarketing-cuj-119.json"));
121         AdSelectionConfig adSelectionConfig =
122                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
123 
124         try {
125             joinCustomAudience(SHOES_CA);
126             overrideBiddingLogicVersionToV3(true);
127             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
128             assertThat(result.hasOutcome()).isTrue();
129             assertThat(result.getRenderUri()).isNotNull();
130         } finally {
131             overrideBiddingLogicVersionToV3(false);
132             leaveCustomAudience(SHOES_CA);
133         }
134 
135         assertThat(dispatcher.getCalledPaths())
136                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
137     }
138 
139     /**
140      * Test that buyers can specify an adCost in generateBid that is found in the buyer impression
141      * reporting URI (Remarketing CUJ 160).
142      */
143     @Test
testAdSelection_withAdCostInUrl_happyPath()144     public void testAdSelection_withAdCostInUrl_happyPath() throws Exception {
145         ScenarioDispatcher dispatcher =
146                 setupDispatcher(
147                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
148                                 "scenarios/remarketing-cuj-160.json"));
149         AdSelectionConfig adSelectionConfig =
150                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
151         long adSelectionId;
152 
153         try {
154             overrideCpcBillingEnabled(true);
155             joinCustomAudience(SHOES_CA);
156             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
157             adSelectionId = result.getAdSelectionId();
158             assertThat(result.hasOutcome()).isTrue();
159             assertThat(result.getRenderUri()).isNotNull();
160         } finally {
161             overrideCpcBillingEnabled(false);
162             leaveCustomAudience(SHOES_CA);
163         }
164         doReportImpression(adSelectionId, adSelectionConfig);
165 
166         assertThat(dispatcher.getCalledPaths())
167                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
168     }
169 
170     /**
171      * Test that buyers can specify an adCost in generateBid that reported (Remarketing CUJ 161).
172      */
173     @Test
174     @SetFlagEnabled(KEY_FLEDGE_REGISTER_AD_BEACON_ENABLED)
testAdSelection_withAdCostInUrl_adCostIsReported()175     public void testAdSelection_withAdCostInUrl_adCostIsReported() throws Exception {
176         ScenarioDispatcher dispatcher =
177                 setupDispatcher(
178                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
179                                 "scenarios/remarketing-cuj-161.json"));
180         AdSelectionConfig adSelectionConfig =
181                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
182         long adSelectionId;
183 
184         try {
185             overrideCpcBillingEnabled(true);
186             joinCustomAudience(SHOES_CA);
187             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
188             adSelectionId = result.getAdSelectionId();
189             doReportImpression(adSelectionId, adSelectionConfig);
190             doReportEvent(adSelectionId, "click");
191         } finally {
192             overrideCpcBillingEnabled(false);
193             leaveCustomAudience(SHOES_CA);
194         }
195 
196         assertThat(dispatcher.getCalledPaths())
197                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
198     }
199 
200     /**
201      * Test that custom audience can be successfully fetched from a server and joined to participate
202      * in a successful ad selection (Remarketing CUJ 169).
203      */
204     @Test
testAdSelection_withFetchCustomAudience_fetchesAndReturnsSuccessfully()205     public void testAdSelection_withFetchCustomAudience_fetchesAndReturnsSuccessfully()
206             throws Exception {
207         ScenarioDispatcher dispatcher =
208                 setupDispatcher(
209                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
210                                 "scenarios/remarketing-cuj-fetchCA.json"));
211         AdSelectionConfig adSelectionConfig =
212                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
213         String customAudienceName = "hats";
214 
215         try {
216             CustomAudience customAudience = makeCustomAudience(customAudienceName).build();
217             ShellUtils.runShellCommand(
218                     "device_config put adservices fledge_fetch_custom_audience_enabled true");
219             mCustomAudienceClient
220                     .fetchAndJoinCustomAudience(
221                             new FetchAndJoinCustomAudienceRequest.Builder(
222                                             Uri.parse(
223                                                     dispatcher.getBaseAddressWithPrefix().toString()
224                                                             + Scenarios.FETCH_CA_PATH))
225                                     .setActivationTime(customAudience.getActivationTime())
226                                     .setExpirationTime(customAudience.getExpirationTime())
227                                     .setName(customAudience.getName())
228                                     .setUserBiddingSignals(customAudience.getUserBiddingSignals())
229                                     .build())
230                     .get(5, TimeUnit.SECONDS);
231             Log.d(TAG, "Joined Custom Audience: " + customAudienceName);
232             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
233             assertThat(result.hasOutcome()).isTrue();
234             assertThat(result.getRenderUri()).isNotNull();
235         } finally {
236             ShellUtils.runShellCommand(
237                     "device_config put adservices fledge_fetch_custom_audience_enabled false");
238             leaveCustomAudience(customAudienceName);
239         }
240 
241         assertThat(dispatcher.getCalledPaths())
242                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
243     }
244 
245     /** Test that ad selection fails with an expired custom audience. */
246     @Test
testAdSelection_withShortlyExpiringCustomAudience_selectAdsThrowsException()247     public void testAdSelection_withShortlyExpiringCustomAudience_selectAdsThrowsException()
248             throws Exception {
249         ScenarioDispatcher dispatcher =
250                 setupDispatcher(
251                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
252                                 "scenarios/remarketing-cuj-default.json"));
253         AdSelectionConfig config = makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
254         CustomAudience customAudience =
255                 makeCustomAudience(SHOES_CA)
256                         .setExpirationTime(Instant.now().plus(5, ChronoUnit.SECONDS))
257                         .build();
258 
259         joinCustomAudience(customAudience);
260         Log.d(TAG, "Joined custom audience");
261         // Make a call to verify ad selection succeeds before timing out.
262         mAdSelectionClient.selectAds(config).get(TIMEOUT, TimeUnit.SECONDS);
263         Thread.sleep(7000);
264 
265         Exception selectAdsException =
266                 assertThrows(
267                         ExecutionException.class,
268                         () -> mAdSelectionClient.selectAds(config).get(TIMEOUT, TimeUnit.SECONDS));
269         assertThat(selectAdsException.getCause()).isInstanceOf(IllegalStateException.class);
270     }
271 
272     /**
273      * Test that not providing any ad selection Ids to selectAds with ad selection outcomes should
274      * result in failure (Remarketing CUJ 071).
275      */
276     @Test
testAdSelectionOutcomes_withNoAdSelectionId_throwsException()277     public void testAdSelectionOutcomes_withNoAdSelectionId_throwsException() throws Exception {
278         ScenarioDispatcher dispatcher =
279                 setupDispatcher(
280                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
281                                 "scenarios/remarketing-cuj-default.json"));
282         AdSelectionFromOutcomesConfig config =
283                 new AdSelectionFromOutcomesConfig.Builder()
284                         .setSeller(
285                                 AdTechIdentifier.fromString(
286                                         dispatcher.getBaseAddressWithPrefix().getHost()))
287                         .setAdSelectionIds(List.of())
288                         .setSelectionLogicUri(
289                                 Uri.parse(
290                                         dispatcher.getBaseAddressWithPrefix()
291                                                 + Scenarios.MEDIATION_LOGIC_PATH))
292                         .setSelectionSignals(makeAdSelectionSignals())
293                         .build();
294 
295         try {
296             Exception selectAdsException =
297                     assertThrows(
298                             ExecutionException.class,
299                             () ->
300                                     mAdSelectionClient
301                                             .selectAds(config)
302                                             .get(TIMEOUT, TimeUnit.SECONDS));
303             assertThat(selectAdsException.getCause()).isInstanceOf(IllegalArgumentException.class);
304         } finally {
305             leaveCustomAudience(SHIRTS_CA);
306         }
307     }
308 
309     /** Test that buyer and seller receive win and loss debug reports (Remarketing CUJ 164). */
310     @Test
testAdSelection_withDebugReporting_happyPath()311     public void testAdSelection_withDebugReporting_happyPath() throws Exception {
312         assumeTrue(isAdIdSupported());
313         ScenarioDispatcher dispatcher =
314                 setupDispatcher(
315                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
316                                 "scenarios/remarketing-cuj-164.json"));
317         AdSelectionConfig adSelectionConfig =
318                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
319 
320         try {
321             joinCustomAudience(SHOES_CA);
322             joinCustomAudience(SHIRTS_CA);
323             setDebugReportingEnabledForTesting(true);
324             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
325             assertThat(result.hasOutcome()).isTrue();
326         } finally {
327             setDebugReportingEnabledForTesting(false);
328             leaveCustomAudience(SHOES_CA);
329             joinCustomAudience(SHIRTS_CA);
330         }
331 
332         assertThat(dispatcher.getCalledPaths())
333                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
334     }
335 
336     /**
337      * Test that buyer and seller do not receive win and loss debug reports if the feature is
338      * disabled (Remarketing CUJ 165).
339      */
340     @Test
testAdSelection_withDebugReportingDisabled_doesNotSend()341     public void testAdSelection_withDebugReportingDisabled_doesNotSend() throws Exception {
342         ScenarioDispatcher dispatcher =
343                 setupDispatcher(
344                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
345                                 "scenarios/remarketing-cuj-165.json"));
346         AdSelectionConfig adSelectionConfig =
347                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
348 
349         try {
350             joinCustomAudience(SHOES_CA);
351             overrideBiddingLogicVersionToV3(true);
352             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
353             assertThat(result.hasOutcome()).isTrue();
354         } finally {
355             overrideBiddingLogicVersionToV3(false);
356             leaveCustomAudience(SHOES_CA);
357         }
358 
359         assertThat(dispatcher.getCalledPaths())
360                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
361     }
362 
363     /**
364      * Test that buyer and seller receive win and loss debug reports with reject reason (Remarketing
365      * CUJ 170).
366      */
367     @Test
testAdSelection_withDebugReportingAndRejectReason_happyPath()368     public void testAdSelection_withDebugReportingAndRejectReason_happyPath() throws Exception {
369         assumeTrue(isAdIdSupported());
370         ScenarioDispatcher dispatcher =
371                 setupDispatcher(
372                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
373                                 "scenarios/remarketing-cuj-170.json"));
374         AdSelectionConfig adSelectionConfig =
375                 makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
376 
377         try {
378             joinCustomAudience(SHOES_CA);
379             joinCustomAudience(SHIRTS_CA);
380             setDebugReportingEnabledForTesting(true);
381             AdSelectionOutcome result = doSelectAds(adSelectionConfig);
382             assertThat(result.hasOutcome()).isTrue();
383         } finally {
384             setDebugReportingEnabledForTesting(false);
385             leaveCustomAudience(SHOES_CA);
386             leaveCustomAudience(SHIRTS_CA);
387         }
388 
389         assertThat(dispatcher.getCalledPaths())
390                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
391     }
392 
393     @Test
testAdSelection_withHighLatencyBackend_doesNotWinAuction()394     public void testAdSelection_withHighLatencyBackend_doesNotWinAuction() throws Exception {
395         ScenarioDispatcher dispatcher =
396                 setupDispatcher(
397                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
398                                 "scenarios/remarketing-cuj-053.json"));
399         AdSelectionConfig config = makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
400 
401         try {
402             joinCustomAudience(SHIRTS_CA);
403             Exception selectAdsException =
404                     assertThrows(
405                             ExecutionException.class,
406                             () ->
407                                     mAdSelectionClient
408                                             .selectAds(config)
409                                             .get(TIMEOUT, TimeUnit.SECONDS));
410             assertThat(selectAdsException.getCause()).isInstanceOf(TimeoutException.class);
411         } finally {
412             leaveCustomAudience(SHIRTS_CA);
413         }
414 
415         assertThat(dispatcher.getCalledPaths())
416                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
417     }
418 
419     @Test
testAdSelection_withInvalidScoringUrl_doesNotWinAuction()420     public void testAdSelection_withInvalidScoringUrl_doesNotWinAuction() throws Exception {
421         // ScenarioDispatcher returns 404 for all paths which are not setup from the json file and
422         // we didn't configure a scoring logic url.
423         ScenarioDispatcher dispatcher =
424                 setupDispatcher(
425                         ScenarioDispatcherFactory.createFromScenarioFileWithRandomPrefix(
426                                 "scenarios/remarketing-cuj-invalid-scoring-logic-url.json"));
427         AdSelectionConfig config = makeAdSelectionConfig(dispatcher.getBaseAddressWithPrefix());
428 
429         try {
430             joinCustomAudience(SHIRTS_CA);
431             Exception selectAdsException =
432                     assertThrows(
433                             ExecutionException.class,
434                             () ->
435                                     mAdSelectionClient
436                                             .selectAds(config)
437                                             .get(TIMEOUT, TimeUnit.SECONDS));
438             assertThat(
439                             selectAdsException.getCause() instanceof TimeoutException
440                                     || selectAdsException.getCause()
441                                             instanceof IllegalStateException)
442                     .isTrue();
443         } finally {
444             leaveCustomAudience(SHIRTS_CA);
445         }
446 
447         assertThat(dispatcher.getCalledPaths())
448                 .containsAtLeastElementsIn(dispatcher.getVerifyCalledPaths());
449     }
450 
isAdIdSupported()451     private boolean isAdIdSupported() {
452         AdIdCompatibleManager adIdCompatibleManager;
453         AdServicesOutcomeReceiverForTests<AdId> callback =
454                 new AdServicesOutcomeReceiverForTests<>();
455         try {
456             adIdCompatibleManager = new AdIdCompatibleManager(sContext);
457             adIdCompatibleManager.getAdId(MoreExecutors.directExecutor(), callback);
458         } catch (IllegalStateException e) {
459             Log.d(TAG, "isAdIdAvailable(): IllegalStateException detected in AdId manager.");
460             return false;
461         }
462 
463         boolean isAdIdAvailable;
464         try {
465             AdId result = callback.assertSuccess();
466             isAdIdAvailable =
467                     !Objects.isNull(result)
468                             && !result.isLimitAdTrackingEnabled()
469                             && !result.getAdId().equals(AdId.ZERO_OUT);
470         } catch (InterruptedException e) {
471             Thread.currentThread().interrupt();
472             Log.d(TAG, "isAdIdSupported(): failed to get AdId due to InterruptedException.");
473             isAdIdAvailable = false;
474         }
475 
476         Log.d(TAG, String.format("isAdIdSupported(): %b", isAdIdAvailable));
477         return isAdIdAvailable;
478     }
479 }
480