1 /*
2  * Copyright (C) 2023 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 com.android.adservices.service.appsearch;
18 
19 import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
20 import static com.android.dx.mockito.inline.extended.ExtendedMockito.when;
21 
22 import static com.google.common.truth.Truth.assertThat;
23 
24 import static org.junit.Assert.assertThrows;
25 import static org.mockito.ArgumentMatchers.any;
26 import static org.mockito.Mockito.atMost;
27 import static org.mockito.Mockito.verify;
28 
29 import android.content.Context;
30 import android.content.pm.Signature;
31 
32 import androidx.appsearch.app.AppSearchBatchResult;
33 import androidx.appsearch.app.AppSearchResult;
34 import androidx.appsearch.app.AppSearchSession;
35 import androidx.appsearch.app.GenericDocument;
36 import androidx.appsearch.app.GlobalSearchSession;
37 import androidx.appsearch.app.PackageIdentifier;
38 import androidx.appsearch.app.SearchResult;
39 import androidx.appsearch.app.SearchResults;
40 import androidx.appsearch.app.SetSchemaRequest;
41 import androidx.appsearch.app.SetSchemaResponse;
42 import androidx.test.core.app.ApplicationProvider;
43 import androidx.test.filters.SmallTest;
44 
45 import com.android.adservices.AdServicesCommon;
46 import com.android.adservices.common.AdServicesDeviceSupportedRule;
47 import com.android.adservices.concurrency.AdServicesExecutors;
48 import com.android.adservices.mockito.AdServicesExtendedMockitoRule;
49 import com.android.adservices.service.Flags;
50 import com.android.adservices.service.FlagsFactory;
51 import com.android.adservices.service.consent.ConsentConstants;
52 
53 import com.google.common.util.concurrent.Futures;
54 import com.google.common.util.concurrent.ListenableFuture;
55 import com.google.common.util.concurrent.ListeningExecutorService;
56 import com.google.common.util.concurrent.MoreExecutors;
57 
58 import org.junit.Before;
59 import org.junit.Rule;
60 import org.junit.Test;
61 import org.mockito.Mock;
62 import org.mockito.Mockito;
63 import org.mockito.MockitoAnnotations;
64 
65 import java.util.List;
66 import java.util.concurrent.ExecutionException;
67 import java.util.concurrent.Executor;
68 import java.util.concurrent.Executors;
69 import java.util.concurrent.TimeUnit;
70 import java.util.concurrent.TimeoutException;
71 
72 @SmallTest
73 public class AppSearchDaoTest {
74     @Mock SearchResults mSearchResults;
75     @Mock List<SearchResult> mMockPage;
76     @Mock GlobalSearchSession mGlobalSearchSession;
77     @Mock AppSearchSession mAppSearchSession;
78     @Mock Flags mFlags;
79     private final Executor mExecutor = AdServicesExecutors.getBackgroundExecutor();
80     private final Context mContext = ApplicationProvider.getApplicationContext();
81     private String mAdServicesPackageName;
82     private static final String ID = "1";
83     private static final String NAMESPACE = "consent";
84     private static final String API_TYPE = "CONSENT-TOPICS";
85     private static final String CONSENT = "true";
86     private static final String TEST = "test";
87     private static final String SHA =
88             "686d5c450e00ebe600f979300a29234644eade42f24ede07a073f2bc6b94a3a2";
89     private static final PackageIdentifier PACKAGE_IDENTIFIER =
90             new PackageIdentifier(
91                     /* packageName= */ TEST,
92                     /* sha256Certificate= */ new Signature(SHA).toByteArray());
93 
94     private static final int APPSEARCH_READ_TIMEOUT_MS = 500;
95     private static final int APPSEARCH_WRITE_TIMEOUT_MS = 200;
96 
97     @Rule(order = 0)
98     public final AdServicesDeviceSupportedRule adServicesDeviceSupportedRule =
99             new AdServicesDeviceSupportedRule();
100 
101     @Rule(order = 1)
102     public final AdServicesExtendedMockitoRule adServicesExtendedMockitoRule =
103             new AdServicesExtendedMockitoRule.Builder(this).mockStatic(FlagsFactory.class).build();
104 
105     @Before
before()106     public void before() {
107         // TODO(b/347043278): must be set inside @Before so it's not called when device is not
108         // supported
109         mAdServicesPackageName = AppSearchConsentWorker.getAdServicesPackageName(mContext);
110         MockitoAnnotations.initMocks(this);
111         when(mFlags.getAppsearchWriterAllowListOverride()).thenReturn("");
112         when(mFlags.getAppSearchReadTimeout()).thenReturn(APPSEARCH_READ_TIMEOUT_MS);
113         when(mFlags.getAppSearchWriteTimeout()).thenReturn(APPSEARCH_WRITE_TIMEOUT_MS);
114         doReturn(mFlags).when(FlagsFactory::getFlags);
115     }
116 
117     @Test
testIterateSearchResults_emptyPage()118     public void testIterateSearchResults_emptyPage() throws Exception {
119         when(mMockPage.isEmpty()).thenReturn(true);
120         when(mSearchResults.getNextPageAsync()).thenReturn(Futures.immediateFuture(mMockPage));
121         ListenableFuture<AppSearchConsentDao> result =
122                 AppSearchDao.iterateSearchResults(
123                         AppSearchConsentDao.class, mSearchResults, mExecutor);
124         assertThat(result.get()).isEqualTo(null);
125     }
126 
127     @Test
testIterateSearchResults()128     public void testIterateSearchResults() throws Exception {
129         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
130         when(mMockPage.isEmpty()).thenReturn(false);
131         when(mSearchResults.getNextPageAsync()).thenReturn(Futures.immediateFuture(mMockPage));
132         GenericDocument document =
133                 new GenericDocument.Builder(NAMESPACE, ID, dao.getClass().getSimpleName())
134                         .setPropertyString("userId", ID)
135                         .setPropertyString("consent", CONSENT)
136                         .setPropertyString("apiType", API_TYPE)
137                         .build();
138         SearchResult searchResult =
139                 new SearchResult.Builder(TEST, TEST).setGenericDocument(document).build();
140         when(mMockPage.get(0)).thenReturn(searchResult);
141 
142         ListenableFuture<AppSearchConsentDao> result =
143                 AppSearchDao.iterateSearchResults(
144                         AppSearchConsentDao.class, mSearchResults, mExecutor);
145         assertThat(result.get()).isEqualTo(dao);
146     }
147 
148     @Test
testGetAllowedPackages_noOverride()149     public void testGetAllowedPackages_noOverride() {
150         when(mFlags.getAppsearchWriterAllowListOverride()).thenReturn("");
151 
152         String expected =
153                 mAdServicesPackageName.replace(
154                         AdServicesCommon.ADSERVICES_APK_PACKAGE_NAME_SUFFIX,
155                         AdServicesCommon.ADEXTSERVICES_PACKAGE_NAME_SUFFIX);
156 
157         List<String> allowedPackages = AppSearchDao.getAllowedPackages(mAdServicesPackageName);
158 
159         assertThat(allowedPackages.size()).isEqualTo(1);
160         assertThat(allowedPackages.get(0)).isEqualTo(expected);
161     }
162 
163     @Test
testGetAllowedPackages_withOverride()164     public void testGetAllowedPackages_withOverride() {
165         String allowedPackage = "allowed.package";
166         when(mFlags.getAppsearchWriterAllowListOverride()).thenReturn(allowedPackage);
167 
168         List<String> allowedPackages = AppSearchDao.getAllowedPackages(mAdServicesPackageName);
169 
170         assertThat(allowedPackages.size()).isEqualTo(1);
171         assertThat(allowedPackages.get(0)).isEqualTo(allowedPackage);
172     }
173 
174     @Test
testReadConsentData_emptyQuery()175     public void testReadConsentData_emptyQuery() {
176         AppSearchDao dao =
177                 AppSearchDao.readConsentData(
178                         AppSearchConsentDao.class,
179                         Futures.immediateFuture(mGlobalSearchSession),
180                         mExecutor,
181                         NAMESPACE,
182                         null,
183                         mAdServicesPackageName);
184         assertThat(dao).isEqualTo(null);
185 
186         AppSearchDao dao2 =
187                 AppSearchDao.readConsentData(
188                         AppSearchConsentDao.class,
189                         Futures.immediateFuture(mGlobalSearchSession),
190                         mExecutor,
191                         NAMESPACE,
192                         "",
193                         mAdServicesPackageName);
194         assertThat(dao2).isEqualTo(null);
195     }
196 
197     @Test
testReadConsentData()198     public void testReadConsentData() {
199         when(mMockPage.isEmpty()).thenReturn(false);
200         when(mSearchResults.getNextPageAsync()).thenReturn(Futures.immediateFuture(mMockPage));
201         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
202         GenericDocument document =
203                 new GenericDocument.Builder(NAMESPACE, ID, dao.getClass().getSimpleName())
204                         .setPropertyString("userId", ID)
205                         .setPropertyString("consent", CONSENT)
206                         .setPropertyString("apiType", API_TYPE)
207                         .build();
208         SearchResult searchResult =
209                 new SearchResult.Builder(TEST, TEST).setGenericDocument(document).build();
210         when(mMockPage.get(0)).thenReturn(searchResult);
211         when(mGlobalSearchSession.search(any(), any())).thenReturn(mSearchResults);
212         AppSearchDao result =
213                 AppSearchDao.readConsentData(
214                         AppSearchConsentDao.class,
215                         Futures.immediateFuture(mGlobalSearchSession),
216                         mExecutor,
217                         NAMESPACE,
218                         TEST,
219                         mAdServicesPackageName);
220         assertThat(result).isEqualTo(dao);
221     }
222 
223     @Test
testReadConsentData_timeout()224     public void testReadConsentData_timeout() {
225         AppSearchDao result =
226                 AppSearchDao.readConsentData(
227                         AppSearchConsentDao.class,
228                         getLongRunningOperation(mGlobalSearchSession),
229                         mExecutor,
230                         NAMESPACE,
231                         TEST,
232                         mAdServicesPackageName);
233         assertThat(result).isNull();
234     }
235 
getLongRunningOperation(T result)236     private <T> ListenableFuture<T> getLongRunningOperation(T result) {
237         // Wait for a time that's longer than the AppSearch read timeout, then return the result.
238         ListeningExecutorService ls =
239                 MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
240         return ls.submit(
241                 () -> {
242                     TimeUnit.MILLISECONDS.sleep(APPSEARCH_READ_TIMEOUT_MS + 500);
243                     return result;
244                 });
245     }
246 
247     @Test
testReadAppSearchData_emptyQuery()248     public void testReadAppSearchData_emptyQuery() {
249         AppSearchDao dao =
250                 AppSearchDao.readAppSearchSessionData(
251                         AppSearchConsentDao.class,
252                         Futures.immediateFuture(mAppSearchSession),
253                         mExecutor,
254                         NAMESPACE,
255                         null,
256                         mAdServicesPackageName);
257         assertThat(dao).isEqualTo(null);
258 
259         AppSearchDao dao2 =
260                 AppSearchDao.readAppSearchSessionData(
261                         AppSearchConsentDao.class,
262                         Futures.immediateFuture(mAppSearchSession),
263                         mExecutor,
264                         NAMESPACE,
265                         "",
266                         mAdServicesPackageName);
267         assertThat(dao2).isEqualTo(null);
268     }
269 
270     @Test
testReadAppSearchData()271     public void testReadAppSearchData() {
272         when(mMockPage.isEmpty()).thenReturn(false);
273         when(mSearchResults.getNextPageAsync()).thenReturn(Futures.immediateFuture(mMockPage));
274         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
275         GenericDocument document =
276                 new GenericDocument.Builder(NAMESPACE, ID, dao.getClass().getSimpleName())
277                         .setPropertyString("userId", ID)
278                         .setPropertyString("consent", CONSENT)
279                         .setPropertyString("apiType", API_TYPE)
280                         .build();
281         SearchResult searchResult =
282                 new SearchResult.Builder(TEST, TEST).setGenericDocument(document).build();
283         when(mMockPage.get(0)).thenReturn(searchResult);
284         when(mAppSearchSession.search(any(), any())).thenReturn(mSearchResults);
285         AppSearchDao result =
286                 AppSearchDao.readAppSearchSessionData(
287                         AppSearchConsentDao.class,
288                         Futures.immediateFuture(mAppSearchSession),
289                         mExecutor,
290                         NAMESPACE,
291                         TEST,
292                         mAdServicesPackageName);
293         assertThat(result).isEqualTo(dao);
294     }
295 
296     @Test
testWriteConsentData_failure()297     public void testWriteConsentData_failure() {
298         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
299         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
300 
301         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
302         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
303                 .thenReturn(Futures.immediateFuture(mockResponse));
304 
305         AppSearchResult<Void> mockResult =
306                 AppSearchResult.newFailedResult(AppSearchResult.RESULT_INVALID_ARGUMENT, "test");
307         SetSchemaResponse.MigrationFailure failure =
308                 new SetSchemaResponse.MigrationFailure(
309                         /* namespace= */ TEST,
310                         /* documentId= */ TEST,
311                         /* schemaType= */ TEST,
312                         /* failedResult= */ mockResult);
313         when(mockResponse.getMigrationFailures()).thenReturn(List.of(failure));
314         // We can't use the base class instance since writing will fail without the necessary
315         // Document fields defined on the class, so we use a subclass instance.
316         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
317         Exception e =
318                 assertThrows(
319                         RuntimeException.class,
320                         () ->
321                                 dao.writeData(
322                                         Futures.immediateFuture(mockSession),
323                                         List.of(PACKAGE_IDENTIFIER),
324                                         mExecutor));
325         // Schema migration throws a RuntimeException, which gets wrapped into an ExecutionException
326         // by the get() call. The catch block then wraps this into another RuntimeException.
327         assertThat(e).hasMessageThat().isEqualTo(ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE);
328         assertThat(e).hasCauseThat().isNotNull();
329         assertThat(e).hasCauseThat().isInstanceOf(ExecutionException.class);
330         assertThat(e).hasCauseThat().hasCauseThat().isNotNull();
331         assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(RuntimeException.class);
332         assertThat(e)
333                 .hasCauseThat()
334                 .hasCauseThat()
335                 .hasMessageThat()
336                 .isEqualTo(
337                         ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE
338                                 + " Migration failure: [FAILURE(3)]: test");
339     }
340 
341     @Test
testWriteConsentData()342     public void testWriteConsentData() throws Exception {
343         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
344         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
345 
346         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
347         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
348                 .thenReturn(Futures.immediateFuture(mockResponse));
349 
350         verify(mockResponse, atMost(1)).getMigrationFailures();
351         when(mockResponse.getMigrationFailures()).thenReturn(List.of());
352         // We can't use the base class instance since writing will fail without the necessary
353         // Document fields defined on the class, so we use a subclass instance.
354         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
355         AppSearchBatchResult<String, Void> result = Mockito.mock(AppSearchBatchResult.class);
356         when(mockSession.putAsync(any())).thenReturn(Futures.immediateFuture(result));
357 
358         // Verify that no exception is thrown.
359         AppSearchBatchResult<String, Void> output =
360                 dao.writeData(
361                         Futures.immediateFuture(mockSession),
362                         List.of(PACKAGE_IDENTIFIER),
363                         mExecutor);
364         assertThat(output).isNotNull();
365     }
366 
367     @Test
testWriteConsentData_timeout()368     public void testWriteConsentData_timeout() throws Exception {
369         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
370         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
371 
372         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
373         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
374                 .thenReturn(Futures.immediateFuture(mockResponse));
375 
376         verify(mockResponse, atMost(1)).getMigrationFailures();
377         when(mockResponse.getMigrationFailures()).thenReturn(List.of());
378         // We can't use the base class instance since writing will fail without the necessary
379         // Document fields defined on the class, so we use a subclass instance.
380         AppSearchConsentDao dao = new AppSearchConsentDao(ID, ID, NAMESPACE, API_TYPE, CONSENT);
381         AppSearchBatchResult<String, Void> result = Mockito.mock(AppSearchBatchResult.class);
382         when(mockSession.putAsync(any())).thenReturn(getLongRunningOperation(result));
383 
384         // Verify exception due to timeout
385         RuntimeException e =
386                 assertThrows(
387                         RuntimeException.class,
388                         () ->
389                                 dao.writeData(
390                                         Futures.immediateFuture(mockSession),
391                                         List.of(PACKAGE_IDENTIFIER),
392                                         mExecutor));
393         assertThat(e).hasMessageThat().isEqualTo(ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE);
394         assertThat(e).hasCauseThat().isNotNull();
395         assertThat(e).hasCauseThat().isInstanceOf(TimeoutException.class);
396     }
397 
398     @Test
testDeleteConsentData_failure()399     public void testDeleteConsentData_failure() {
400         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
401         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
402 
403         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
404         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
405                 .thenReturn(Futures.immediateFuture(mockResponse));
406 
407         AppSearchResult<String> mockResult =
408                 AppSearchResult.newFailedResult(AppSearchResult.RESULT_INVALID_ARGUMENT, "test");
409         SetSchemaResponse.MigrationFailure failure =
410                 new SetSchemaResponse.MigrationFailure(
411                         /* namespace= */ TEST,
412                         /* documentId= */ TEST,
413                         /* schemaType= */ TEST,
414                         /* failedResult= */ mockResult);
415         when(mockResponse.getMigrationFailures()).thenReturn(List.of(failure));
416         // We can't use the base class instance since writing will fail without the necessary
417         // Document fields defined on the class, so we use a subclass instance.
418         Exception e =
419                 assertThrows(
420                         RuntimeException.class,
421                         () ->
422                                 AppSearchDao.deleteData(
423                                         AppSearchConsentDao.class,
424                                         Futures.immediateFuture(mockSession),
425                                         mExecutor,
426                                         TEST,
427                                         NAMESPACE));
428 
429         // Schema migration throws a RuntimeException, which gets wrapped into an ExecutionException
430         // by the get() call. The catch block then wraps this into another RuntimeException.
431         assertThat(e).hasMessageThat().isEqualTo(ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE);
432         assertThat(e).hasCauseThat().isNotNull();
433         assertThat(e).hasCauseThat().isInstanceOf(ExecutionException.class);
434         assertThat(e).hasCauseThat().hasCauseThat().isNotNull();
435         assertThat(e).hasCauseThat().hasCauseThat().isInstanceOf(RuntimeException.class);
436         assertThat(e)
437                 .hasCauseThat()
438                 .hasCauseThat()
439                 .hasMessageThat()
440                 .isEqualTo(
441                         ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE
442                                 + " Migration failure: [FAILURE(3)]: test");
443     }
444 
445     @Test
testDeleteConsentData()446     public void testDeleteConsentData() throws Exception {
447         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
448         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
449 
450         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
451         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
452                 .thenReturn(Futures.immediateFuture(mockResponse));
453 
454         verify(mockResponse, atMost(1)).getMigrationFailures();
455         when(mockResponse.getMigrationFailures()).thenReturn(List.of());
456         // We can't use the base class instance since writing will fail without the necessary
457         // Document fields defined on the class, so we use a subclass instance.
458         AppSearchBatchResult<String, Void> result = Mockito.mock(AppSearchBatchResult.class);
459         when(mockSession.removeAsync(any())).thenReturn(Futures.immediateFuture(result));
460 
461         // Verify that no exception is thrown.
462         AppSearchBatchResult<String, Void> output =
463                 AppSearchDao.deleteData(
464                         AppSearchConsentDao.class,
465                         Futures.immediateFuture(mockSession),
466                         mExecutor,
467                         TEST,
468                         NAMESPACE);
469         assertThat(output).isNotNull();
470     }
471 
472     @Test
testDeleteConsentData_timeout()473     public void testDeleteConsentData_timeout() throws Exception {
474         AppSearchSession mockSession = Mockito.mock(AppSearchSession.class);
475         verify(mockSession, atMost(1)).setSchemaAsync(any(SetSchemaRequest.class));
476 
477         SetSchemaResponse mockResponse = Mockito.mock(SetSchemaResponse.class);
478         when(mockSession.setSchemaAsync(any(SetSchemaRequest.class)))
479                 .thenReturn(Futures.immediateFuture(mockResponse));
480 
481         verify(mockResponse, atMost(1)).getMigrationFailures();
482         when(mockResponse.getMigrationFailures()).thenReturn(List.of());
483         // We can't use the base class instance since writing will fail without the necessary
484         // Document fields defined on the class, so we use a subclass instance.
485         AppSearchBatchResult<String, Void> result = Mockito.mock(AppSearchBatchResult.class);
486         when(mockSession.removeAsync(any())).thenReturn(getLongRunningOperation(result));
487 
488         // Verify exception due to timeout
489         RuntimeException e =
490                 assertThrows(
491                         RuntimeException.class,
492                         () ->
493                                 AppSearchDao.deleteData(
494                                         AppSearchConsentDao.class,
495                                         Futures.immediateFuture(mockSession),
496                                         mExecutor,
497                                         TEST,
498                                         NAMESPACE));
499         assertThat(e).hasMessageThat().isEqualTo(ConsentConstants.ERROR_MESSAGE_APPSEARCH_FAILURE);
500         assertThat(e).hasCauseThat().isNotNull();
501         assertThat(e).hasCauseThat().isInstanceOf(TimeoutException.class);
502     }
503 }
504