|
30 | 30 | import com.google.gson.JsonPrimitive; |
31 | 31 | import com.microsoft.identity.common.components.AndroidPlatformComponentsFactory; |
32 | 32 | import com.microsoft.identity.common.components.InMemoryStorageSupplier; |
| 33 | +import com.microsoft.identity.common.internal.mocks.MockCommonFlightsManager; |
33 | 34 | import com.microsoft.identity.common.java.authscheme.BearerAuthenticationSchemeInternal; |
34 | 35 | import com.microsoft.identity.common.java.cache.CacheKeyValueDelegate; |
35 | 36 | import com.microsoft.identity.common.java.cache.SharedPreferencesAccountCredentialCacheWithMemoryCache; |
|
40 | 41 | import com.microsoft.identity.common.java.dto.IdTokenRecord; |
41 | 42 | import com.microsoft.identity.common.java.dto.PrimaryRefreshTokenRecord; |
42 | 43 | import com.microsoft.identity.common.java.dto.RefreshTokenRecord; |
| 44 | +import com.microsoft.identity.common.java.flighting.CommonFlight; |
| 45 | +import com.microsoft.identity.common.java.flighting.CommonFlightsManager; |
| 46 | +import com.microsoft.identity.common.java.flighting.IFlightsProvider; |
43 | 47 | import com.microsoft.identity.common.java.interfaces.INameValueStorage; |
| 48 | +import com.microsoft.identity.common.java.util.ported.Predicate; |
44 | 49 | import com.microsoft.identity.common.shadows.ShadowAndroidSdkStorageEncryptionManager; |
45 | 50 |
|
46 | 51 | import org.junit.After; |
47 | 52 | import org.junit.Before; |
48 | 53 | import org.junit.Test; |
49 | 54 | import org.junit.runner.RunWith; |
| 55 | +import org.mockito.Mockito; |
50 | 56 | import org.robolectric.RobolectricTestRunner; |
51 | 57 | import org.robolectric.annotation.Config; |
52 | 58 |
|
53 | 59 | import java.util.HashMap; |
| 60 | +import java.util.Iterator; |
54 | 61 | import java.util.List; |
55 | 62 | import java.util.Locale; |
56 | 63 | import java.util.Map; |
| 64 | +import java.util.Set; |
| 65 | +import java.util.concurrent.atomic.AtomicInteger; |
57 | 66 |
|
58 | 67 | import static com.microsoft.identity.common.java.cache.CacheKeyValueDelegate.CACHE_VALUE_SEPARATOR; |
59 | 68 | import static org.junit.Assert.assertEquals; |
|
62 | 71 | import static org.junit.Assert.assertNotSame; |
63 | 72 | import static org.junit.Assert.assertNull; |
64 | 73 | import static org.junit.Assert.assertTrue; |
| 74 | +import static org.mockito.Mockito.when; |
65 | 75 |
|
66 | 76 | @RunWith(RobolectricTestRunner.class) |
67 | 77 | @Config(shadows = {ShadowAndroidSdkStorageEncryptionManager.class}) |
@@ -2515,4 +2525,322 @@ public void testReturnedAllCredentialsAreCloned() { |
2515 | 2525 | creds1.get(0).setCachedAt("banana"); |
2516 | 2526 | assertNotEquals(creds1.get(0), creds2.get(0)); |
2517 | 2527 | } |
| 2528 | + |
| 2529 | + // ===================================================================== |
| 2530 | + // Flight-gated behavior tests for ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE |
| 2531 | + // ===================================================================== |
| 2532 | + |
| 2533 | + private void enableFilterThenCloneFlight() { |
| 2534 | + CommonFlightsManager.INSTANCE.resetFlightsManager(); |
| 2535 | + final IFlightsProvider mockFlightsProvider = Mockito.mock(IFlightsProvider.class); |
| 2536 | + when(mockFlightsProvider.isFlightEnabled(CommonFlight.ENABLE_FILTER_THEN_CLONE_IN_MEMORY_CACHE)) |
| 2537 | + .thenReturn(true); |
| 2538 | + final MockCommonFlightsManager mockFlightsManager = new MockCommonFlightsManager(); |
| 2539 | + mockFlightsManager.setMockCommonFlightsProvider(mockFlightsProvider); |
| 2540 | + CommonFlightsManager.INSTANCE.initializeCommonFlightsManager(mockFlightsManager); |
| 2541 | + } |
| 2542 | + |
| 2543 | + private void resetFlight() { |
| 2544 | + CommonFlightsManager.INSTANCE.resetFlightsManager(); |
| 2545 | + } |
| 2546 | + |
| 2547 | + @Test |
| 2548 | + public void getAccounts_flightDisabled_returnsMutableListOfClones() { |
| 2549 | + final AccountRecord account = buildDefaultAccountRecord(); |
| 2550 | + mSharedPreferencesAccountCredentialCache.saveAccount(account); |
| 2551 | + |
| 2552 | + final List<AccountRecord> accounts1 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2553 | + assertEquals(1, accounts1.size()); |
| 2554 | + final List<AccountRecord> accounts2 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2555 | + assertEquals(1, accounts2.size()); |
| 2556 | + |
| 2557 | + // The list itself should be a new mutable list each time |
| 2558 | + assertNotSame(accounts1, accounts2); |
| 2559 | + |
| 2560 | + // The returned objects should be clones, not the same references |
| 2561 | + assertNotSame(accounts1.get(0), accounts2.get(0)); |
| 2562 | + assertEquals(accounts1.get(0), accounts2.get(0)); |
| 2563 | + |
| 2564 | + // Mutating a returned object should not affect subsequent retrievals |
| 2565 | + accounts1.get(0).setLocalAccountId("mutated"); |
| 2566 | + final List<AccountRecord> accounts3 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2567 | + assertNotEquals("mutated", accounts3.get(0).getLocalAccountId()); |
| 2568 | + |
| 2569 | + // The returned list should be mutable (no exception thrown) |
| 2570 | + accounts1.add(new AccountRecord()); |
| 2571 | + } |
| 2572 | + |
| 2573 | + @Test |
| 2574 | + public void getAccounts_flightEnabled_stillReturnsMutableListOfClones() { |
| 2575 | + enableFilterThenCloneFlight(); |
| 2576 | + try { |
| 2577 | + final AccountRecord account = buildDefaultAccountRecord(); |
| 2578 | + mSharedPreferencesAccountCredentialCache.saveAccount(account); |
| 2579 | + |
| 2580 | + final List<AccountRecord> accounts1 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2581 | + assertEquals(1, accounts1.size()); |
| 2582 | + final List<AccountRecord> accounts2 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2583 | + assertEquals(1, accounts2.size()); |
| 2584 | + |
| 2585 | + // Even with flight enabled, getAccounts() still returns clones |
| 2586 | + assertNotSame(accounts1.get(0), accounts2.get(0)); |
| 2587 | + assertEquals(accounts1.get(0), accounts2.get(0)); |
| 2588 | + |
| 2589 | + // Mutating a returned object should not affect subsequent retrievals |
| 2590 | + accounts1.get(0).setLocalAccountId("mutated"); |
| 2591 | + final List<AccountRecord> accounts3 = mSharedPreferencesAccountCredentialCache.getAccounts(); |
| 2592 | + assertNotEquals("mutated", accounts3.get(0).getLocalAccountId()); |
| 2593 | + |
| 2594 | + // The returned list should be mutable (no exception thrown) |
| 2595 | + accounts1.add(new AccountRecord()); |
| 2596 | + } finally { |
| 2597 | + resetFlight(); |
| 2598 | + } |
| 2599 | + } |
| 2600 | + |
| 2601 | + @Test |
| 2602 | + public void getCredentials_flightDisabled_returnsMutableListOfClones() { |
| 2603 | + final RefreshTokenRecord rt = buildDefaultRefreshToken(); |
| 2604 | + mSharedPreferencesAccountCredentialCache.saveCredential(rt); |
| 2605 | + |
| 2606 | + final List<Credential> creds1 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2607 | + assertEquals(1, creds1.size()); |
| 2608 | + final List<Credential> creds2 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2609 | + assertEquals(1, creds2.size()); |
| 2610 | + |
| 2611 | + // The list itself should be a new mutable list each time |
| 2612 | + assertNotSame(creds1, creds2); |
| 2613 | + |
| 2614 | + // The returned objects should be clones, not the same references |
| 2615 | + assertNotSame(creds1.get(0), creds2.get(0)); |
| 2616 | + assertEquals(creds1.get(0), creds2.get(0)); |
| 2617 | + |
| 2618 | + // Mutating a returned object should not affect subsequent retrievals |
| 2619 | + creds1.get(0).setCachedAt("mutated"); |
| 2620 | + final List<Credential> creds3 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2621 | + assertNotEquals("mutated", creds3.get(0).getCachedAt()); |
| 2622 | + |
| 2623 | + // The returned list should be mutable (no exception thrown) |
| 2624 | + creds1.add(new RefreshTokenRecord()); |
| 2625 | + } |
| 2626 | + |
| 2627 | + @Test |
| 2628 | + public void getCredentials_flightEnabled_stillReturnsMutableListOfClones() { |
| 2629 | + enableFilterThenCloneFlight(); |
| 2630 | + try { |
| 2631 | + final RefreshTokenRecord rt = buildDefaultRefreshToken(); |
| 2632 | + mSharedPreferencesAccountCredentialCache.saveCredential(rt); |
| 2633 | + |
| 2634 | + final List<Credential> creds1 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2635 | + assertEquals(1, creds1.size()); |
| 2636 | + final List<Credential> creds2 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2637 | + assertEquals(1, creds2.size()); |
| 2638 | + |
| 2639 | + // Even with flight enabled, getCredentials() still returns clones |
| 2640 | + assertNotSame(creds1.get(0), creds2.get(0)); |
| 2641 | + assertEquals(creds1.get(0), creds2.get(0)); |
| 2642 | + |
| 2643 | + // Mutating a returned object should not affect subsequent retrievals |
| 2644 | + creds1.get(0).setCachedAt("mutated"); |
| 2645 | + final List<Credential> creds3 = mSharedPreferencesAccountCredentialCache.getCredentials(); |
| 2646 | + assertNotEquals("mutated", creds3.get(0).getCachedAt()); |
| 2647 | + |
| 2648 | + // The returned list should be mutable (no exception thrown) |
| 2649 | + creds1.add(new RefreshTokenRecord()); |
| 2650 | + } finally { |
| 2651 | + resetFlight(); |
| 2652 | + } |
| 2653 | + } |
| 2654 | + |
| 2655 | + // ===================================================================== |
| 2656 | + // Tests verifying filter-then-clone optimization in FilteredBy methods |
| 2657 | + // ===================================================================== |
| 2658 | + |
| 2659 | + @Test |
| 2660 | + public void getAccountsFilteredBy_flightEnabled_returnsClonedMatches() { |
| 2661 | + enableFilterThenCloneFlight(); |
| 2662 | + try { |
| 2663 | + final AccountRecord account = buildDefaultAccountRecord(); |
| 2664 | + mSharedPreferencesAccountCredentialCache.saveAccount(account); |
| 2665 | + |
| 2666 | + // Also save a non-matching account |
| 2667 | + final AccountRecord otherAccount = new AccountRecord(); |
| 2668 | + otherAccount.setHomeAccountId("other-home-id"); |
| 2669 | + otherAccount.setEnvironment("other.environment.com"); |
| 2670 | + otherAccount.setRealm("other-realm"); |
| 2671 | + otherAccount.setLocalAccountId("other-local-id"); |
| 2672 | + otherAccount.setUsername("other-user"); |
| 2673 | + otherAccount.setAuthorityType(AUTHORITY_TYPE); |
| 2674 | + mSharedPreferencesAccountCredentialCache.saveAccount(otherAccount); |
| 2675 | + |
| 2676 | + // Filter by the default account's realm — should return only one match |
| 2677 | + final List<AccountRecord> filtered = mSharedPreferencesAccountCredentialCache |
| 2678 | + .getAccountsFilteredBy(HOME_ACCOUNT_ID, ENVIRONMENT, REALM); |
| 2679 | + assertEquals(1, filtered.size()); |
| 2680 | + assertEquals(HOME_ACCOUNT_ID, filtered.get(0).getHomeAccountId()); |
| 2681 | + |
| 2682 | + // Returned objects should be clones — mutating should not affect cache |
| 2683 | + filtered.get(0).setLocalAccountId("mutated"); |
| 2684 | + final List<AccountRecord> filtered2 = mSharedPreferencesAccountCredentialCache |
| 2685 | + .getAccountsFilteredBy(HOME_ACCOUNT_ID, ENVIRONMENT, REALM); |
| 2686 | + assertNotEquals("mutated", filtered2.get(0).getLocalAccountId()); |
| 2687 | + } finally { |
| 2688 | + resetFlight(); |
| 2689 | + } |
| 2690 | + } |
| 2691 | + |
| 2692 | + @Test |
| 2693 | + public void getCredentialsFilteredBy_flightEnabled_returnsClonedMatches() { |
| 2694 | + enableFilterThenCloneFlight(); |
| 2695 | + try { |
| 2696 | + final RefreshTokenRecord rt = buildDefaultRefreshToken(); |
| 2697 | + mSharedPreferencesAccountCredentialCache.saveCredential(rt); |
| 2698 | + |
| 2699 | + // Also save a non-matching credential |
| 2700 | + final RefreshTokenRecord otherRt = new RefreshTokenRecord(); |
| 2701 | + otherRt.setCredentialType(CredentialType.RefreshToken.name()); |
| 2702 | + otherRt.setHomeAccountId("other-home-id"); |
| 2703 | + otherRt.setEnvironment("other.environment.com"); |
| 2704 | + otherRt.setClientId("other-client-id"); |
| 2705 | + otherRt.setCachedAt(CACHED_AT); |
| 2706 | + otherRt.setSecret(SECRET); |
| 2707 | + mSharedPreferencesAccountCredentialCache.saveCredential(otherRt); |
| 2708 | + |
| 2709 | + // Filter by the default credential's attributes — should return only one match |
| 2710 | + final List<Credential> filtered = mSharedPreferencesAccountCredentialCache |
| 2711 | + .getCredentialsFilteredBy( |
| 2712 | + HOME_ACCOUNT_ID, ENVIRONMENT, CredentialType.RefreshToken, |
| 2713 | + CLIENT_ID, null, null, null, null, null); |
| 2714 | + assertEquals(1, filtered.size()); |
| 2715 | + assertEquals(CLIENT_ID, filtered.get(0).getClientId()); |
| 2716 | + |
| 2717 | + // Returned objects should be clones — mutating should not affect cache |
| 2718 | + filtered.get(0).setCachedAt("mutated"); |
| 2719 | + final List<Credential> filtered2 = mSharedPreferencesAccountCredentialCache |
| 2720 | + .getCredentialsFilteredBy( |
| 2721 | + HOME_ACCOUNT_ID, ENVIRONMENT, CredentialType.RefreshToken, |
| 2722 | + CLIENT_ID, null, null, null, null, null); |
| 2723 | + assertNotEquals("mutated", filtered2.get(0).getCachedAt()); |
| 2724 | + } finally { |
| 2725 | + resetFlight(); |
| 2726 | + } |
| 2727 | + } |
| 2728 | + |
| 2729 | + // ===================================================================== |
| 2730 | + // Tests verifying removeAccount/removeCredential do not call keySet() |
| 2731 | + // ===================================================================== |
| 2732 | + |
| 2733 | + /** |
| 2734 | + * An {@link INameValueStorage} wrapper that counts calls to {@link #keySet()} and {@link #getAll()}. |
| 2735 | + * Used to verify that the {@code removeAccount()} and {@code removeCredential()} optimization |
| 2736 | + * avoids the expensive decrypt-all path by not calling these methods on the backing storage. |
| 2737 | + */ |
| 2738 | + private static class KeySetTrackingStorage implements INameValueStorage<String> { |
| 2739 | + private final INameValueStorage<String> mDelegate; |
| 2740 | + // AtomicInteger used because the cache performs its initial load on a background thread. |
| 2741 | + private final AtomicInteger mKeySetCallCount = new AtomicInteger(0); |
| 2742 | + private final AtomicInteger mGetAllCallCount = new AtomicInteger(0); |
| 2743 | + |
| 2744 | + KeySetTrackingStorage(final INameValueStorage<String> delegate) { |
| 2745 | + mDelegate = delegate; |
| 2746 | + } |
| 2747 | + |
| 2748 | + @Override |
| 2749 | + public String get(final String name) { |
| 2750 | + return mDelegate.get(name); |
| 2751 | + } |
| 2752 | + |
| 2753 | + @Override |
| 2754 | + public Map<String, String> getAll() { |
| 2755 | + mGetAllCallCount.incrementAndGet(); |
| 2756 | + return mDelegate.getAll(); |
| 2757 | + } |
| 2758 | + |
| 2759 | + @Override |
| 2760 | + public void put(final String name, final String value) { |
| 2761 | + mDelegate.put(name, value); |
| 2762 | + } |
| 2763 | + |
| 2764 | + @Override |
| 2765 | + public void remove(final String name) { |
| 2766 | + mDelegate.remove(name); |
| 2767 | + } |
| 2768 | + |
| 2769 | + @Override |
| 2770 | + public void clear() { |
| 2771 | + mDelegate.clear(); |
| 2772 | + } |
| 2773 | + |
| 2774 | + @Override |
| 2775 | + public Set<String> keySet() { |
| 2776 | + mKeySetCallCount.incrementAndGet(); |
| 2777 | + return mDelegate.keySet(); |
| 2778 | + } |
| 2779 | + |
| 2780 | + @Override |
| 2781 | + public Iterator<Map.Entry<String, String>> getAllFilteredByKey(final Predicate<String> keyFilter) { |
| 2782 | + return mDelegate.getAllFilteredByKey(keyFilter); |
| 2783 | + } |
| 2784 | + |
| 2785 | + int getKeySetCallCount() { |
| 2786 | + return mKeySetCallCount.get(); |
| 2787 | + } |
| 2788 | + |
| 2789 | + int getAllCallCount() { |
| 2790 | + return mGetAllCallCount.get(); |
| 2791 | + } |
| 2792 | + |
| 2793 | + void resetCallCounts() { |
| 2794 | + mKeySetCallCount.set(0); |
| 2795 | + mGetAllCallCount.set(0); |
| 2796 | + } |
| 2797 | + } |
| 2798 | + |
| 2799 | + @Test |
| 2800 | + public void removeAccount_doesNotCallKeySetOrGetAll() throws Exception { |
| 2801 | + final INameValueStorage<String> baseStorage = new InMemoryStorageSupplier() |
| 2802 | + .getEncryptedNameValueStore( |
| 2803 | + sAccountCredentialSharedPreferences + "_removeAccountTest", |
| 2804 | + String.class); |
| 2805 | + final KeySetTrackingStorage trackingStorage = new KeySetTrackingStorage(baseStorage); |
| 2806 | + final SharedPreferencesAccountCredentialCacheWithMemoryCache cache = |
| 2807 | + new SharedPreferencesAccountCredentialCacheWithMemoryCache(mDelegate, trackingStorage); |
| 2808 | + |
| 2809 | + final AccountRecord account = buildDefaultAccountRecord(); |
| 2810 | + cache.saveAccount(account); |
| 2811 | + |
| 2812 | + // Force initial load to complete (getAccounts blocks on waitForInitialLoad), |
| 2813 | + // then reset counters so only removeAccount() calls are measured. |
| 2814 | + cache.getAccounts(); |
| 2815 | + trackingStorage.resetCallCounts(); |
| 2816 | + |
| 2817 | + cache.removeAccount(account); |
| 2818 | + |
| 2819 | + assertEquals("removeAccount() must not call keySet()", 0, trackingStorage.getKeySetCallCount()); |
| 2820 | + assertEquals("removeAccount() must not call getAll()", 0, trackingStorage.getAllCallCount()); |
| 2821 | + } |
| 2822 | + |
| 2823 | + @Test |
| 2824 | + public void removeCredential_doesNotCallKeySetOrGetAll() throws Exception { |
| 2825 | + final INameValueStorage<String> baseStorage = new InMemoryStorageSupplier() |
| 2826 | + .getEncryptedNameValueStore( |
| 2827 | + sAccountCredentialSharedPreferences + "_removeCredentialTest", |
| 2828 | + String.class); |
| 2829 | + final KeySetTrackingStorage trackingStorage = new KeySetTrackingStorage(baseStorage); |
| 2830 | + final SharedPreferencesAccountCredentialCacheWithMemoryCache cache = |
| 2831 | + new SharedPreferencesAccountCredentialCacheWithMemoryCache(mDelegate, trackingStorage); |
| 2832 | + |
| 2833 | + final RefreshTokenRecord rt = buildDefaultRefreshToken(); |
| 2834 | + cache.saveCredential(rt); |
| 2835 | + |
| 2836 | + // Force initial load to complete (getCredentials blocks on waitForInitialLoad), |
| 2837 | + // then reset counters so only removeCredential() calls are measured. |
| 2838 | + cache.getCredentials(); |
| 2839 | + trackingStorage.resetCallCounts(); |
| 2840 | + |
| 2841 | + cache.removeCredential(rt); |
| 2842 | + |
| 2843 | + assertEquals("removeCredential() must not call keySet()", 0, trackingStorage.getKeySetCallCount()); |
| 2844 | + assertEquals("removeCredential() must not call getAll()", 0, trackingStorage.getAllCallCount()); |
| 2845 | + } |
2518 | 2846 | } |
0 commit comments