chromium/ios/chrome/browser/passwords/model/ios_chrome_password_check_manager_unittest.mm

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

#import "ios/chrome/browser/passwords/model/ios_chrome_password_check_manager.h"

#import <memory>
#import <string>
#import <string_view>
#import <vector>

#import "base/functional/bind.h"
#import "base/memory/raw_ptr.h"
#import "base/memory/scoped_refptr.h"
#import "base/strings/strcat.h"
#import "base/strings/string_number_conversions.h"
#import "base/strings/string_util.h"
#import "base/strings/utf_string_conversions.h"
#import "base/test/bind.h"
#import "base/test/metrics/histogram_tester.h"
#import "base/time/time.h"
#import "components/affiliations/core/browser/fake_affiliation_service.h"
#import "components/keyed_service/core/service_access_type.h"
#import "components/password_manager/core/browser/leak_detection/bulk_leak_check_service.h"
#import "components/password_manager/core/browser/leak_detection/mock_bulk_leak_check_service.h"
#import "components/password_manager/core/browser/password_form.h"
#import "components/password_manager/core/browser/password_manager_test_utils.h"
#import "components/password_manager/core/browser/password_store/test_password_store.h"
#import "components/password_manager/core/common/password_manager_features.h"
#import "components/password_manager/core/common/password_manager_pref_names.h"
#import "components/prefs/pref_registry_simple.h"
#import "components/prefs/testing_pref_service.h"
#import "ios/chrome/browser/affiliations/model/ios_chrome_affiliation_service_factory.h"
#import "ios/chrome/browser/passwords/model/ios_chrome_bulk_leak_check_service_factory.h"
#import "ios/chrome/browser/passwords/model/ios_chrome_password_check_manager_factory.h"
#import "ios/chrome/browser/passwords/model/ios_chrome_profile_password_store_factory.h"
#import "ios/chrome/browser/passwords/model/password_checkup_metrics.h"
#import "ios/chrome/browser/shared/model/profile/profile_ios.h"
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/web/public/test/web_task_environment.h"
#import "testing/gmock/include/gmock/gmock.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "testing/platform_test.h"

namespace {
constexpr char kExampleCom1[] = "https://example1.com";
constexpr char kExampleCom2[] = "https://example2.com";

constexpr char16_t kUsername116[] = u"alice";
constexpr char16_t kUsername216[] = u"bob";

constexpr char16_t kPassword116[] = u"strongPa55w0rd!1";
constexpr char16_t kPassword216[] = u"strongPa55w0rd!2";
constexpr char16_t kWeakPassword[] = u"123456";

using password_manager::BulkLeakCheckServiceInterface;
using password_manager::CredentialUIEntry;
using password_manager::InsecureType;
using password_manager::IsLeaked;
using password_manager::LeakCheckCredential;
using password_manager::MockBulkLeakCheckService;
using password_manager::PasswordForm;
using password_manager::TestPasswordStore;
using ::testing::AllOf;
using ::testing::ElementsAre;
using ::testing::Field;
using ::testing::IsEmpty;
using ::testing::Pair;
using ::testing::StrictMock;
using ::testing::UnorderedElementsAre;

struct MockPasswordCheckManagerObserver
    : IOSChromePasswordCheckManager::Observer {
  MOCK_METHOD(void, InsecureCredentialsChanged, (), (override));
  MOCK_METHOD(void,
              PasswordCheckStatusChanged,
              (PasswordCheckState),
              (override));
  MOCK_METHOD(void,
              ManagerWillShutdown,
              (IOSChromePasswordCheckManager*),
              (override));
};

std::unique_ptr<KeyedService> MakeMockPasswordCheckManagerObserver(
    web::BrowserState*) {
  return std::make_unique<MockBulkLeakCheckService>();
}

PasswordForm MakeSavedPassword(std::string_view signon_realm,
                               std::u16string_view username,
                               std::u16string_view password = kPassword116) {
  PasswordForm form;
  form.url = GURL(signon_realm);
  form.signon_realm = std::string(signon_realm);
  form.username_value = std::u16string(username);
  form.password_value = std::u16string(password);
  form.in_store = PasswordForm::Store::kProfileStore;
  // TODO(crbug.com/40774419): Once all places that operate changes on forms
  // via UpdateLogin properly set `password_issues`, setting them to an empty
  // map should be part of the default constructor.
  form.password_issues =
      base::flat_map<InsecureType, password_manager::InsecurityMetadata>();
  return form;
}

void AddIssueToForm(PasswordForm* form,
                    InsecureType type = InsecureType::kLeaked,
                    base::TimeDelta time_since_creation = base::TimeDelta(),
                    bool is_muted = false) {
  form->password_issues.insert_or_assign(
      type, password_manager::InsecurityMetadata(
                base::Time::Now() - time_since_creation,
                password_manager::IsMuted(is_muted),
                password_manager::TriggerBackendNotification(false)));
}

class IOSChromePasswordCheckManagerTest : public PlatformTest {
 public:
  IOSChromePasswordCheckManagerTest() {
    TestChromeBrowserState::Builder builder;
    builder.AddTestingFactory(
        IOSChromeBulkLeakCheckServiceFactory::GetInstance(),
        base::BindRepeating(&MakeMockPasswordCheckManagerObserver));
    builder.AddTestingFactory(
        IOSChromeProfilePasswordStoreFactory::GetInstance(),
        base::BindRepeating(
            &password_manager::BuildPasswordStore<web::BrowserState,
                                                  TestPasswordStore>));
    builder.AddTestingFactory(
        IOSChromeAffiliationServiceFactory::GetInstance(),
        base::BindRepeating(base::BindLambdaForTesting([](web::BrowserState*) {
          return std::unique_ptr<KeyedService>(
              std::make_unique<affiliations::FakeAffiliationService>());
        })));

    browser_state_ = std::move(builder).Build();
    bulk_leak_check_service_ = static_cast<MockBulkLeakCheckService*>(
        IOSChromeBulkLeakCheckServiceFactory::GetForBrowserState(
            browser_state_.get()));
    store_ =
        base::WrapRefCounted(static_cast<password_manager::TestPasswordStore*>(
            IOSChromeProfilePasswordStoreFactory::GetForBrowserState(
                browser_state_.get(), ServiceAccessType::EXPLICIT_ACCESS)
                .get()));
    manager_ = IOSChromePasswordCheckManagerFactory::GetForBrowserState(
        browser_state_.get());
  }

  void RunUntilIdle() { task_env_.RunUntilIdle(); }

  void FastForwardBy(base::TimeDelta time) { task_env_.FastForwardBy(time); }

  ChromeBrowserState* browser_state() { return browser_state_.get(); }
  TestPasswordStore& store() { return *store_; }
  MockBulkLeakCheckService* service() { return bulk_leak_check_service_; }
  IOSChromePasswordCheckManager& manager() { return *manager_; }

 private:
  web::WebTaskEnvironment task_env_{
      base::test::TaskEnvironment::TimeSource::MOCK_TIME};
  std::unique_ptr<ChromeBrowserState> browser_state_;
  raw_ptr<MockBulkLeakCheckService> bulk_leak_check_service_;
  scoped_refptr<TestPasswordStore> store_;
  scoped_refptr<IOSChromePasswordCheckManager> manager_;
};
}  // namespace

// Sets up the password store with a password and unmuted compromised
// credential. Verifies that the result is matching expectation.
TEST_F(IOSChromePasswordCheckManagerTest, GetInsecureCredentials) {
  PasswordForm form = MakeSavedPassword(kExampleCom1, kUsername116);
  AddIssueToForm(&form, InsecureType::kLeaked, base::Minutes(1));
  store().AddLogin(form);
  RunUntilIdle();

  EXPECT_THAT(manager().GetInsecureCredentials(),
              ElementsAre(CredentialUIEntry(form)));
}

// Test that we don't create an entry in the password store if IsLeaked is
// false.
TEST_F(IOSChromePasswordCheckManagerTest, NoLeakedFound) {
  store().AddLogin(MakeSavedPassword(kExampleCom1, kUsername116, kPassword116));
  RunUntilIdle();

  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnCredentialDone(LeakCheckCredential(kUsername116, kPassword116),
                         IsLeaked(false));
  RunUntilIdle();

  EXPECT_THAT(manager().GetInsecureCredentials(), IsEmpty());
}

// Test that a found leak creates a compromised credential in the password
// store.
TEST_F(IOSChromePasswordCheckManagerTest, OnLeakFoundCreatesCredential) {
  PasswordForm form =
      MakeSavedPassword(kExampleCom1, kUsername116, kPassword116);
  store().AddLogin(form);
  RunUntilIdle();

  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnCredentialDone(LeakCheckCredential(kUsername116, kPassword116),
                         IsLeaked(true));
  RunUntilIdle();

  AddIssueToForm(&form, InsecureType::kLeaked, base::Minutes(0));
  EXPECT_THAT(manager().GetInsecureCredentials(),
              ElementsAre(CredentialUIEntry(form)));
}

// Verifies that the case where the user has no saved passwords is reported
// correctly.
TEST_F(IOSChromePasswordCheckManagerTest, GetPasswordCheckStatusNoPasswords) {
  EXPECT_EQ(PasswordCheckState::kNoPasswords,
            manager().GetPasswordCheckState());
}

// Verifies that the case where the user has saved passwords is reported
// correctly.
TEST_F(IOSChromePasswordCheckManagerTest, GetPasswordCheckStatusIdle) {
  store().AddLogin(MakeSavedPassword(kExampleCom1, kUsername116));
  RunUntilIdle();

  EXPECT_EQ(PasswordCheckState::kIdle, manager().GetPasswordCheckState());
}

// Checks that the default kLastTimePasswordCheckCompleted pref value is
// treated as no completed run yet.
TEST_F(IOSChromePasswordCheckManagerTest,
       LastTimePasswordCheckCompletedNotSet) {
  EXPECT_FALSE(manager().GetLastPasswordCheckTime().has_value());
}

// Checks that a transition into the idle state after starting a check results
// in resetting the kLastTimePasswordCheckCompleted pref to the current time.
TEST_F(IOSChromePasswordCheckManagerTest, LastTimePasswordCheckCompletedReset) {
  FastForwardBy(base::Days(1));

  manager().StartPasswordCheck(
      password_manager::LeakDetectionInitiator::kIosProactivePasswordCheckup);
  RunUntilIdle();

  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnStateChanged(BulkLeakCheckServiceInterface::State::kIdle);

  EXPECT_NE(base::Time(), manager().GetLastPasswordCheckTime());
}

// Checks that insecure credential count metrics are logged after a check has
// finished.
TEST_F(IOSChromePasswordCheckManagerTest, InsecureCredentialCountsMetrics) {
  base::HistogramTester histogram_tester;

  PasswordForm leaked_form = MakeSavedPassword(kExampleCom1, kUsername116);
  AddIssueToForm(&leaked_form, InsecureType::kLeaked, base::Minutes(1));
  store().AddLogin(leaked_form);

  PasswordForm phished_form =
      MakeSavedPassword("https://site1.com", kUsername116);
  AddIssueToForm(&phished_form, InsecureType::kPhished, base::Minutes(1));
  store().AddLogin(phished_form);

  PasswordForm weak_form = MakeSavedPassword("https://site1.com", kUsername116);
  AddIssueToForm(&weak_form, InsecureType::kWeak, base::Minutes(1));
  store().AddLogin(weak_form);

  PasswordForm reused_form =
      MakeSavedPassword("https://site2.com", kUsername116);
  AddIssueToForm(&reused_form, InsecureType::kReused, base::Minutes(1));
  store().AddLogin(reused_form);

  // Adding a muted warning. This shouldn't be counted in the unmuted histogram.
  PasswordForm muted_form =
      MakeSavedPassword("https://site32.com", kUsername216, kPassword216);
  AddIssueToForm(&muted_form, InsecureType::kLeaked, base::Minutes(1),
                 /*is_muted=*/true);
  store().AddLogin(muted_form);

  manager().StartPasswordCheck(
      password_manager::LeakDetectionInitiator::kIosProactivePasswordCheckup);
  RunUntilIdle();

  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnStateChanged(BulkLeakCheckServiceInterface::State::kIdle);

  EXPECT_EQ(histogram_tester.GetTotalSum(
                password_manager::kInsecureCredentialsCountHistogram),
            2);
  EXPECT_EQ(histogram_tester.GetTotalSum(
                password_manager::kUnmutedInsecureCredentialsCountHistogram),
            1);
}

// Tests whether adding and removing an observer works as expected.
TEST_F(IOSChromePasswordCheckManagerTest,
       NotifyObserversAboutInsecureCredentialChanges) {
  PasswordForm form = MakeSavedPassword(kExampleCom1, kUsername116);
  store().AddLogin(form);
  RunUntilIdle();

  StrictMock<MockPasswordCheckManagerObserver> observer;
  manager().AddObserver(&observer);

  // Adding a compromised credential should notify observers.
  EXPECT_CALL(observer, PasswordCheckStatusChanged);
  EXPECT_CALL(observer, InsecureCredentialsChanged);
  AddIssueToForm(&form, InsecureType::kLeaked, base::Minutes(1));
  store().UpdateLogin(form);
  RunUntilIdle();

  // After an observer is removed it should no longer receive notifications.
  manager().RemoveObserver(&observer);
  EXPECT_CALL(observer, InsecureCredentialsChanged).Times(0);
  AddIssueToForm(&form, InsecureType::kPhished, base::Minutes(1));
  store().UpdateLogin(form);
  RunUntilIdle();
}

// Tests whether adding and removing an observer works as expected.
TEST_F(IOSChromePasswordCheckManagerTest, NotifyObserversAboutStateChanges) {
  store().AddLogin(MakeSavedPassword(kExampleCom1, kUsername116));
  RunUntilIdle();
  StrictMock<MockPasswordCheckManagerObserver> observer;
  manager().AddObserver(&observer);

  EXPECT_CALL(observer, PasswordCheckStatusChanged(PasswordCheckState::kIdle));
  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnStateChanged(BulkLeakCheckServiceInterface::State::kIdle);

  RunUntilIdle();

  EXPECT_EQ(PasswordCheckState::kIdle, manager().GetPasswordCheckState());

  // After an observer is removed it should no longer receive notifications.
  manager().RemoveObserver(&observer);
  EXPECT_CALL(observer, PasswordCheckStatusChanged).Times(0);
  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnStateChanged(BulkLeakCheckServiceInterface::State::kRunning);
  RunUntilIdle();
}

// Tests expected delay is being added.
TEST_F(IOSChromePasswordCheckManagerTest, CheckFinishedWithDelay) {
  store().AddLogin(MakeSavedPassword(kExampleCom1, kUsername116));

  RunUntilIdle();
  StrictMock<MockPasswordCheckManagerObserver> observer;
  manager().AddObserver(&observer);

  EXPECT_CALL(observer, InsecureCredentialsChanged).Times(2);
  EXPECT_CALL(observer, PasswordCheckStatusChanged(PasswordCheckState::kIdle))
      .Times(2);
  manager().StartPasswordCheck(
      password_manager::LeakDetectionInitiator::kIosProactivePasswordCheckup);
  RunUntilIdle();

  static_cast<BulkLeakCheckServiceInterface::Observer*>(&manager())
      ->OnStateChanged(BulkLeakCheckServiceInterface::State::kIdle);

  // Validate the minimum password check duration of 3 seconds is respected.
  // The test will fail if any PasswordCheckStatusChanged calls are observed in
  // the first 2 seconds after the check was started.
  FastForwardBy(base::Seconds(2));
  // After the minimum delay passes, the check status update should be received.
  EXPECT_CALL(observer, PasswordCheckStatusChanged(PasswordCheckState::kIdle));
  // Advance the clock 1 more second simulating that 3 seconds have passed so
  // the check status update should have been received.
  FastForwardBy(base::Seconds(1));

  manager().RemoveObserver(&observer);
}

// Verify that GetInsecureCredentials returns weak credentials.
TEST_F(IOSChromePasswordCheckManagerTest, WeakCredentialsAreReturned) {
  PasswordForm weak_form =
      MakeSavedPassword(kExampleCom1, kUsername116, kWeakPassword);
  store().AddLogin(weak_form);

  RunUntilIdle();
  manager().StartPasswordCheck(
      password_manager::LeakDetectionInitiator::kIosProactivePasswordCheckup);
  RunUntilIdle();

  EXPECT_THAT(manager().GetInsecureCredentials(),
              ElementsAre(CredentialUIEntry(weak_form)));
}

// Verify that GetInsecureCredentials returns reused credentials.
TEST_F(IOSChromePasswordCheckManagerTest, ReusedCredentialsAreReturned) {
  PasswordForm form_with_same_password_1 =
      MakeSavedPassword(kExampleCom1, kUsername116, kPassword116);
  store().AddLogin(form_with_same_password_1);

  PasswordForm form_with_same_password_2 =
      MakeSavedPassword(kExampleCom2, kUsername216, kPassword116);
  store().AddLogin(form_with_same_password_2);

  RunUntilIdle();
  manager().StartPasswordCheck(
      password_manager::LeakDetectionInitiator::kIosProactivePasswordCheckup);
  RunUntilIdle();

  std::vector<CredentialUIEntry> insecure_credentials =
      manager().GetInsecureCredentials();

  EXPECT_THAT(
      insecure_credentials,
      UnorderedElementsAre(CredentialUIEntry(form_with_same_password_1),
                           CredentialUIEntry(form_with_same_password_2)));
  EXPECT_TRUE(insecure_credentials[0].IsReused());
  EXPECT_TRUE(insecure_credentials[1].IsReused());
}