chromium/chrome/browser/password_entry_edit/android/internal/java/src/org/chromium/chrome/browser/password_entry_edit/CredentialEditControllerTest.java

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.password_entry_edit;

import static androidx.test.espresso.matcher.ViewMatchers.assertThat;

import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import static org.chromium.chrome.browser.password_entry_edit.CredentialEditMediator.BLOCKED_CREDENTIAL_ACTION_HISTOGRAM;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditMediator.FEDERATED_CREDENTIAL_ACTION_HISTOGRAM;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditMediator.SAVED_PASSWORD_ACTION_HISTOGRAM;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.ALL_KEYS;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.DUPLICATE_USERNAME_ERROR;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.EMPTY_PASSWORD_ERROR;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.FEDERATION_ORIGIN;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.PASSWORD;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.PASSWORD_VISIBLE;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.UI_ACTION_HANDLER;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.UI_DISMISSED_BY_NATIVE;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.URL_OR_APP;
import static org.chromium.chrome.browser.password_entry_edit.CredentialEditProperties.USERNAME;

import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.res.Resources;
import android.os.PersistableBundle;

import androidx.test.core.app.ApplicationProvider;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.annotation.Config;

import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.password_entry_edit.CredentialEditCoordinator.CredentialActionDelegate;
import org.chromium.chrome.browser.password_entry_edit.CredentialEditMediator.CredentialEntryAction;
import org.chromium.chrome.browser.password_manager.ConfirmationDialogHelper;
import org.chromium.chrome.browser.password_manager.settings.PasswordAccessReauthenticationHelper;
import org.chromium.chrome.browser.password_manager.settings.PasswordAccessReauthenticationHelper.ReauthReason;
import org.chromium.ui.base.Clipboard;
import org.chromium.ui.modelutil.PropertyModel;

/** Tests verifying that the credential edit mediator modifies the model correctly. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class CredentialEditControllerTest {
    private static final String TEST_URL = "https://m.a.xyz/signin";
    private static final String TEST_USERNAME = "TestUsername";
    private static final String NEW_TEST_USERNAME = "TestNewUsername";
    private static final String TEST_PASSWORD = "TestPassword";
    private static final String NEW_TEST_PASSWORD = "TestNewPassword";

    @Mock private PasswordAccessReauthenticationHelper mReauthenticationHelper;

    @Mock private ConfirmationDialogHelper mDeleteDialogHelper;

    @Mock private CredentialActionDelegate mCredentialActionDelegate;

    @Mock private Runnable mHelpLauncher;

    CredentialEditMediator mMediator;
    PropertyModel mModel;

    private void verifyTheClipdataContainSensitiveExtra(ClipData clipData) {
        PersistableBundle extras = clipData.getDescription().getExtras();
        assertTrue(extras.getBoolean("android.content.extra.IS_SENSITIVE"));
    }

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        Clipboard.resetForTesting();
        mMediator =
                new CredentialEditMediator(
                        mReauthenticationHelper,
                        mDeleteDialogHelper,
                        mCredentialActionDelegate,
                        mHelpLauncher,
                        false);
        mModel =
                new PropertyModel.Builder(ALL_KEYS)
                        .with(UI_ACTION_HANDLER, mMediator)
                        .with(URL_OR_APP, TEST_URL)
                        .with(FEDERATION_ORIGIN, "")
                        .build();
        mMediator.initialize(mModel);
    }

    @Test
    public void testSetsCredential() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        assertEquals(TEST_USERNAME, mModel.get(USERNAME));
        assertEquals(TEST_PASSWORD, mModel.get(PASSWORD));
        assertFalse(mModel.get(PASSWORD_VISIBLE));
    }

    @Test
    public void testDismissPropagatesToTheModel() {
        mMediator.dismiss();
        assertTrue(mModel.get(UI_DISMISSED_BY_NATIVE));
    }

    @Test
    public void testMaskingWithoutReauth() {
        mModel.set(PASSWORD_VISIBLE, true);
        mMediator.onMaskOrUnmaskPassword();
        verify(mReauthenticationHelper, never()).canReauthenticate();
        verify(mReauthenticationHelper, never()).reauthenticate(anyInt(), any(Callback.class));
    }

    @Test
    public void testCannotReauthPromptsToast() {
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(false);
        mModel.set(PASSWORD_VISIBLE, false);
        mMediator.onMaskOrUnmaskPassword();
        verify(mReauthenticationHelper).showScreenLockToast(eq(ReauthReason.VIEW_PASSWORD));
        verify(mReauthenticationHelper, never()).reauthenticate(anyInt(), any(Callback.class));
    }

    @Test
    public void testUnmaskTriggersReauthenticate() {
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(true);
        mModel.set(PASSWORD_VISIBLE, false);
        mMediator.onMaskOrUnmaskPassword();
        verify(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.VIEW_PASSWORD), any(Callback.class));
    }

    @Test
    public void testCannotUnmaskIfReauthFailed() {
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(true);
        mModel.set(PASSWORD_VISIBLE, false);
        doAnswer(
                        (invocation) -> {
                            Callback callback = (Callback) invocation.getArguments()[1];
                            callback.onResult(false);
                            return null;
                        })
                .when(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.VIEW_PASSWORD), any(Callback.class));
        mMediator.onMaskOrUnmaskPassword();
        verify(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.VIEW_PASSWORD), any(Callback.class));
        assertFalse(mModel.get(PASSWORD_VISIBLE));
    }

    @Test
    public void testCopyPasswordTriggersReauth() {
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(true);
        mMediator.onCopyPassword(ApplicationProvider.getApplicationContext());
        verify(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.COPY_PASSWORD), any(Callback.class));
    }

    @Test
    public void testCantCopyPasswordIfReauthFails() {
        mModel.set(PASSWORD, TEST_PASSWORD);
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(true);
        doAnswer(
                        (invocation) -> {
                            Callback callback = (Callback) invocation.getArguments()[1];
                            callback.onResult(false);
                            return null;
                        })
                .when(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.COPY_PASSWORD), any(Callback.class));

        Context context = ApplicationProvider.getApplicationContext();
        mMediator.onCopyPassword(context);

        verify(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.COPY_PASSWORD), any(Callback.class));
        ClipboardManager clipboard =
                (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
        assertNull(clipboard.getPrimaryClip());
    }

    @Test
    public void testCanCopyPasswordIfReauthSucceeds() {
        mModel.set(PASSWORD, TEST_PASSWORD);
        when(mReauthenticationHelper.canReauthenticate()).thenReturn(true);
        doAnswer(
                        (invocation) -> {
                            Callback callback = (Callback) invocation.getArguments()[1];
                            callback.onResult(true);
                            return null;
                        })
                .when(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.COPY_PASSWORD), any(Callback.class));
        Context context = ApplicationProvider.getApplicationContext();
        mMediator.onCopyPassword(context);

        verify(mReauthenticationHelper)
                .reauthenticate(eq(ReauthReason.COPY_PASSWORD), any(Callback.class));
        ClipboardManager clipboard =
                (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
        assertNotNull(clipboard.getPrimaryClip());
        assertEquals(TEST_PASSWORD, clipboard.getPrimaryClip().getItemAt(0).getText());
        verifyTheClipdataContainSensitiveExtra(clipboard.getPrimaryClip());
    }

    @Test
    public void callsTheDelegateWithCorrectDataWhenSaving() {
        mModel.set(USERNAME, TEST_USERNAME);
        mModel.set(PASSWORD, TEST_PASSWORD);
        mMediator.onSave();
        verify(mCredentialActionDelegate).saveChanges(TEST_USERNAME, TEST_PASSWORD);
    }

    @Test
    public void testUsernameTextChangedUpdatesModel() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        mMediator.setExistingUsernames(new String[] {TEST_USERNAME});
        mMediator.onUsernameTextChanged(NEW_TEST_USERNAME);
        assertEquals(NEW_TEST_USERNAME, mModel.get(USERNAME));
    }

    @Test
    public void testPasswordTextChangedUpdatesModel() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        mMediator.onPasswordTextChanged(NEW_TEST_PASSWORD);
        assertEquals(NEW_TEST_PASSWORD, mModel.get(PASSWORD));
    }

    @Test
    public void testEmptyPasswordTriggersError() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        mMediator.onPasswordTextChanged("");
        assertTrue(mModel.get(EMPTY_PASSWORD_ERROR));

        mMediator.onPasswordTextChanged(TEST_PASSWORD);
        assertFalse(mModel.get(EMPTY_PASSWORD_ERROR));
    }

    @Test
    public void testDuplicateUsernameTriggersError() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        mMediator.setExistingUsernames(new String[] {TEST_USERNAME, NEW_TEST_USERNAME});

        mMediator.onUsernameTextChanged(NEW_TEST_USERNAME);
        assertTrue(mModel.get(DUPLICATE_USERNAME_ERROR));

        mMediator.onUsernameTextChanged(TEST_USERNAME);
        assertFalse(mModel.get(DUPLICATE_USERNAME_ERROR));
    }

    @Test
    public void testDeletingCredentialPromptsConfirmation() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, false);
        Resources resources = ApplicationProvider.getApplicationContext().getResources();
        when(mDeleteDialogHelper.getResources()).thenReturn(resources);

        String title =
                resources.getString(R.string.password_entry_edit_delete_credential_dialog_title);
        String message =
                resources.getString(R.string.password_entry_edit_deletion_dialog_body, TEST_URL);
        int confirmButtonTextId = R.string.password_entry_edit_delete_credential_dialog_confirm;
        doAnswer(
                        (invocation) -> {
                            Runnable callback = (Runnable) invocation.getArguments()[3];
                            callback.run();
                            return null;
                        })
                .when(mDeleteDialogHelper)
                .showConfirmation(
                        eq(title), eq(message), eq(confirmButtonTextId), any(Runnable.class));

        mMediator.onDelete();

        verify(mDeleteDialogHelper)
                .showConfirmation(
                        eq(title), eq(message), eq(confirmButtonTextId), any(Runnable.class));
        verify(mCredentialActionDelegate).deleteCredential();

        assertThat(
                RecordHistogram.getHistogramValueCountForTesting(
                        SAVED_PASSWORD_ACTION_HISTOGRAM, CredentialEntryAction.DELETED),
                is(1));
    }

    @Test
    public void testDeletingCompromisedCredentialPromptsCorrectMessage() {
        mMediator.setCredential(TEST_USERNAME, TEST_PASSWORD, true);
        Resources resources = ApplicationProvider.getApplicationContext().getResources();
        when(mDeleteDialogHelper.getResources()).thenReturn(resources);

        String title =
                resources.getString(R.string.password_entry_edit_delete_credential_dialog_title);
        String message =
                resources.getString(
                        R.string.password_check_delete_credential_dialog_body, TEST_URL);
        int confirmButtonTextId = R.string.password_entry_edit_delete_credential_dialog_confirm;

        mMediator.onDelete();
        verify(mDeleteDialogHelper)
                .showConfirmation(
                        eq(title), eq(message), eq(confirmButtonTextId), any(Runnable.class));
    }

    @Test
    public void testDeletingFederatedCredentialPromptsConfirmation() {
        initMediatorWithFederatedCredential();
        mMediator.setCredential(TEST_USERNAME, "", false);
        Resources resources = ApplicationProvider.getApplicationContext().getResources();
        when(mDeleteDialogHelper.getResources()).thenReturn(resources);

        String title =
                resources.getString(R.string.password_entry_edit_delete_credential_dialog_title);
        String message =
                resources.getString(R.string.password_entry_edit_deletion_dialog_body, TEST_URL);
        int confirmButtonTextId = R.string.password_entry_edit_delete_credential_dialog_confirm;

        doAnswer(
                        (invocation) -> {
                            Runnable callback = (Runnable) invocation.getArguments()[3];
                            callback.run();
                            return null;
                        })
                .when(mDeleteDialogHelper)
                .showConfirmation(
                        eq(title), eq(message), eq(confirmButtonTextId), any(Runnable.class));

        mMediator.onDelete();

        verify(mDeleteDialogHelper)
                .showConfirmation(
                        eq(title), eq(message), eq(confirmButtonTextId), any(Runnable.class));
        verify(mCredentialActionDelegate).deleteCredential();

        assertThat(
                RecordHistogram.getHistogramValueCountForTesting(
                        FEDERATED_CREDENTIAL_ACTION_HISTOGRAM, CredentialEntryAction.DELETED),
                is(1));
    }

    @Test
    public void testDeletingBlockedCredentialDoesntPromptDialog() {
        mMediator =
                new CredentialEditMediator(
                        mReauthenticationHelper,
                        mDeleteDialogHelper,
                        mCredentialActionDelegate,
                        mHelpLauncher,
                        true);
        mModel =
                new PropertyModel.Builder(ALL_KEYS)
                        .with(UI_ACTION_HANDLER, mMediator)
                        .with(URL_OR_APP, TEST_URL)
                        .with(FEDERATION_ORIGIN, "")
                        .build();
        mMediator.initialize(mModel);

        mMediator.onDelete();

        verify(mDeleteDialogHelper, never()).getResources();
        verify(mDeleteDialogHelper, never())
                .showConfirmation(
                        any(String.class), any(String.class), anyInt(), any(Runnable.class));
        verify(mCredentialActionDelegate).deleteCredential();

        assertThat(
                RecordHistogram.getHistogramValueCountForTesting(
                        BLOCKED_CREDENTIAL_ACTION_HISTOGRAM, CredentialEntryAction.DELETED),
                is(1));
    }

    private void initMediatorWithFederatedCredential() {
        mModel =
                new PropertyModel.Builder(ALL_KEYS)
                        .with(UI_ACTION_HANDLER, mMediator)
                        .with(URL_OR_APP, TEST_URL)
                        .with(FEDERATION_ORIGIN, "accounts.example.com")
                        .build();
        mMediator.initialize(mModel);
    }

    @Test
    public void testHandleHelpCallsHelpLauncher() {
        mMediator.handleHelp();
        verify(mHelpLauncher).run();
    }
}