chromium/chromeos/ash/services/multidevice_setup/grandfathered_easy_unlock_host_disabler_unittest.cc

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

#include "chromeos/ash/services/multidevice_setup/grandfathered_easy_unlock_host_disabler.h"

#include <memory>

#include "ash/constants/ash_features.h"
#include "base/memory/raw_ptr.h"
#include "base/timer/mock_timer.h"
#include "chromeos/ash/components/multidevice/remote_device_test_util.h"
#include "chromeos/ash/services/device_sync/public/cpp/fake_device_sync_client.h"
#include "chromeos/ash/services/multidevice_setup/fake_host_backend_delegate.h"
#include "components/sync_preferences/testing_pref_service_syncable.h"
#include "testing/gtest/include/gtest/gtest.h"

namespace ash {

namespace multidevice_setup {

namespace {

const char kEasyUnlockHostIdToDisablePrefName[] =
    "multidevice_setup.easy_unlock_host_id_to_disable";
const char kEasyUnlockHostInstanceIdToDisablePrefName[] =
    "multidevice_setup.easy_unlock_host_instance_id_to_disable";

const char kNoDevice[] = "";

const size_t kNumTestDevices = 2;

}  // namespace

class MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest
    : public ::testing::Test {
 public:
  MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest(
      const MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest&) = delete;
  MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest& operator=(
      const MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest&) = delete;

 protected:
  MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest()
      : test_devices_(
            multidevice::CreateRemoteDeviceRefListForTest(kNumTestDevices)) {}
  ~MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest() override = default;

  // testing::Test:
  void SetUp() override {
    for (auto& device : test_devices_) {
      // Don't rely on a legacy device ID if not using v1 DeviceSync, even
      // though we almost always expect one in practice.
      if (!features::ShouldUseV1DeviceSync())
        GetMutableRemoteDevice(device)->public_key.clear();
    }

    fake_host_backend_delegate_ = std::make_unique<FakeHostBackendDelegate>();

    fake_device_sync_client_ =
        std::make_unique<device_sync::FakeDeviceSyncClient>();
    fake_device_sync_client_->set_synced_devices(test_devices_);

    test_pref_service_ =
        std::make_unique<sync_preferences::TestingPrefServiceSyncable>();
    GrandfatheredEasyUnlockHostDisabler::RegisterPrefs(
        test_pref_service_->registry());
  }

  void SetHost(const std::optional<multidevice::RemoteDeviceRef>& host_device,
               multidevice::SoftwareFeature host_type) {
    if (host_type != multidevice::SoftwareFeature::kBetterTogetherHost &&
        host_type != multidevice::SoftwareFeature::kSmartLockHost)
      return;

    for (const auto& remote_device : test_devices_) {
      bool should_be_host =
          host_device != std::nullopt &&
          host_device->GetDeviceId() == remote_device.GetDeviceId() &&
          host_device->instance_id() == remote_device.instance_id();

      GetMutableRemoteDevice(remote_device)->software_features[host_type] =
          should_be_host ? multidevice::SoftwareFeatureState::kEnabled
                         : multidevice::SoftwareFeatureState::kSupported;
    }

    if (host_type == multidevice::SoftwareFeature::kBetterTogetherHost)
      fake_host_backend_delegate_->NotifyHostChangedOnBackend(host_device);
  }

  void InitializeTest(
      std::optional<multidevice::RemoteDeviceRef> initial_device_in_prefs,
      std::optional<multidevice::RemoteDeviceRef> initial_better_together_host,
      std::optional<multidevice::RemoteDeviceRef> initial_easy_unlock_host) {
    test_pref_service_->SetString(kEasyUnlockHostIdToDisablePrefName,
                                  initial_device_in_prefs
                                      ? initial_device_in_prefs->GetDeviceId()
                                      : kNoDevice);
    test_pref_service_->SetString(kEasyUnlockHostInstanceIdToDisablePrefName,
                                  initial_device_in_prefs
                                      ? initial_device_in_prefs->instance_id()
                                      : kNoDevice);

    SetHost(initial_better_together_host,
            multidevice::SoftwareFeature::kBetterTogetherHost);
    SetHost(initial_easy_unlock_host,
            multidevice::SoftwareFeature::kSmartLockHost);

    auto mock_timer = std::make_unique<base::MockOneShotTimer>();
    mock_timer_ = mock_timer.get();

    grandfathered_easy_unlock_host_disabler_ =
        GrandfatheredEasyUnlockHostDisabler::Factory::Create(
            fake_host_backend_delegate_.get(), fake_device_sync_client_.get(),
            test_pref_service_.get(), std::move(mock_timer));
  }

  // Verify that the IDs for |expected_device| are stored in prefs. If
  // |expected_device| is null, prefs should have value |kNoDevice|.
  void VerifyDeviceInPrefs(
      const std::optional<multidevice::RemoteDeviceRef>& expected_device) {
    if (!expected_device) {
      EXPECT_EQ(kNoDevice, test_pref_service_->GetString(
                               kEasyUnlockHostIdToDisablePrefName));
      EXPECT_EQ(kNoDevice, test_pref_service_->GetString(
                               kEasyUnlockHostInstanceIdToDisablePrefName));
      return;
    }

    EXPECT_EQ(
        expected_device->GetDeviceId().empty() ? kNoDevice
                                               : expected_device->GetDeviceId(),
        test_pref_service_->GetString(kEasyUnlockHostIdToDisablePrefName));
    EXPECT_EQ(expected_device->instance_id().empty()
                  ? kNoDevice
                  : expected_device->instance_id(),
              test_pref_service_->GetString(
                  kEasyUnlockHostInstanceIdToDisablePrefName));
  }

  void VerifyEasyUnlockHostDisableRequest(
      int expected_queue_size,
      const std::optional<multidevice::RemoteDeviceRef>& expected_host) {
    EXPECT_EQ(
        expected_queue_size,
        features::ShouldUseV1DeviceSync()
            ? fake_device_sync_client_
                  ->GetSetSoftwareFeatureStateInputsQueueSize()
            : fake_device_sync_client_->GetSetFeatureStatusInputsQueueSize());
    if (expected_queue_size > 0) {
      ASSERT_TRUE(expected_host);
      VerifyLatestEasyUnlockHostDisableRequest(*expected_host);
    }
  }

  void InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult result_code) {
    if (features::ShouldUseV1DeviceSync()) {
      fake_device_sync_client_->InvokePendingSetSoftwareFeatureStateCallback(
          result_code);
    } else {
      fake_device_sync_client_->InvokePendingSetFeatureStatusCallback(
          result_code);
    }
  }

  const multidevice::RemoteDeviceRefList& test_devices() const {
    return test_devices_;
  }

  device_sync::FakeDeviceSyncClient* fake_device_sync_client() const {
    return fake_device_sync_client_.get();
  }

  base::MockOneShotTimer* mock_timer() const { return mock_timer_; }

 private:
  void VerifyLatestEasyUnlockHostDisableRequest(
      const multidevice::RemoteDeviceRef& expected_host) {
    // Verify inputs to SetSoftwareFeatureState().
    if (features::ShouldUseV1DeviceSync()) {
      ASSERT_FALSE(
          fake_device_sync_client_->set_software_feature_state_inputs_queue()
              .empty());
      const device_sync::FakeDeviceSyncClient::SetSoftwareFeatureStateInputs&
          inputs = fake_device_sync_client_
                       ->set_software_feature_state_inputs_queue()
                       .back();
      EXPECT_EQ(expected_host.public_key(), inputs.public_key);
      EXPECT_EQ(multidevice::SoftwareFeature::kSmartLockHost,
                inputs.software_feature);
      EXPECT_FALSE(inputs.enabled);
      EXPECT_FALSE(inputs.is_exclusive);
      return;
    }

    // Verify inputs to SetFeatureStatus().
    ASSERT_FALSE(
        fake_device_sync_client_->set_feature_status_inputs_queue().empty());
    const device_sync::FakeDeviceSyncClient::SetFeatureStatusInputs& inputs =
        fake_device_sync_client_->set_feature_status_inputs_queue().back();
    EXPECT_EQ(expected_host.instance_id(), inputs.device_instance_id);
    EXPECT_EQ(multidevice::SoftwareFeature::kSmartLockHost, inputs.feature);
    EXPECT_EQ(device_sync::FeatureStatusChange::kDisable, inputs.status_change);
  }

  multidevice::RemoteDeviceRefList test_devices_;

  std::unique_ptr<FakeHostBackendDelegate> fake_host_backend_delegate_;
  std::unique_ptr<device_sync::FakeDeviceSyncClient> fake_device_sync_client_;
  std::unique_ptr<sync_preferences::TestingPrefServiceSyncable>
      test_pref_service_;
  raw_ptr<base::MockOneShotTimer, DanglingUntriaged> mock_timer_ = nullptr;

  std::unique_ptr<GrandfatheredEasyUnlockHostDisabler>
      grandfathered_easy_unlock_host_disabler_;
};

// Situation #1:
//   BTH = BETTER_TOGETHER_HOST, 0 = disabled, A = devices[0]
//   EUH = EASY_UNLOCK_HOST,     1 = enabled,  B = devices[1]
//
//    | A | B |           | A | B |
// ---+---+---+        ---+---+---+
// BTH| 1 | 0 |        BTH| 0 | 0 |
// ---+---+---+  --->  ---+---+---+
// EUH| 1 | 0 |        EUH| 1 | 0 |
//
// Grandfathering prevents EUH from being disabled automatically. This class
// disables EUH manually.
TEST_F(
    MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
    IfBetterTogetherHostChangedFromOneDeviceToNoDeviceThenDisableEasyUnlock) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyDeviceInPrefs(test_devices()[0]);
  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kSuccess);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  EXPECT_FALSE(mock_timer()->IsRunning());
}

// Situation #2:
//   BTH = BETTER_TOGETHER_HOST, 0 = disabled, A = devices[0]
//   EUH = EASY_UNLOCK_HOST,     1 = enabled,  B = devices[1]
//
//    | A | B |           | A | B |
// ---+---+---+        ---+---+---+
// BTH| 0 | 0 |        BTH| 0 | 1 |
// ---+---+---+  --->  ---+---+---+
// EUH| 1 | 0 |        EUH| 0 | 1 |
//
// The CryptAuth backend (via GmsCore) disables EUH on device A when BTH is
// enabled (exclusively) on another device, B. No action necessary from this
// class.
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfBetterTogetherHostChangedFromNoDeviceToADeviceThenDoNothing) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 std::nullopt /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  SetHost(test_devices()[1], multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);
}

// Situation #3:
//   BTH = BETTER_TOGETHER_HOST, 0 = disabled, A = devices[0]
//   EUH = EASY_UNLOCK_HOST,     1 = enabled,  B = devices[1]
//
//    | A | B |           | A | B |
// ---+---+---+        ---+---+---+
// BTH| 1 | 0 |        BTH| 0 | 1 |
// ---+---+---+  --->  ---+---+---+
// EUH| 1 | 0 |        EUH| 0 | 1 |
//
// The CryptAuth backend (via GmsCore) disables EUH on device A when BTH is
// enabled (exclusively) on another device, B. We still attempt to disable
// EUH in this case to be safe.
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfBetterTogetherHostChangedFromOneDeviceToAnotherThenDisableEasyUnlock) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  SetHost(test_devices()[1], multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyDeviceInPrefs(test_devices()[0]);
  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kSuccess);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  EXPECT_FALSE(mock_timer()->IsRunning());
}

TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfDisablePendingThenConstructorAttemptsToDisableEasyUnlock) {
  InitializeTest(test_devices()[0] /* initial_device_in_prefs */,
                 std::nullopt /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  VerifyDeviceInPrefs(test_devices()[0]);
  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kSuccess);
}

// Situation #1 where device A is removed from list of synced devices:
//
//    | A | B |           | A | B |
// ---+---+---+        ---+---+---+
// BTH| 1 | 0 |        BTH| 0 | 0 |
// ---+---+---+  --->  ---+---+---+
// EUH| 1 | 0 |        EUH| 1 | 0 |
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfHostToDisableIsNotInListOfSyncedDevicesThenClearPref) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  // Remove device[0] from list
  fake_device_sync_client()->set_synced_devices({test_devices()[1]});

  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);
}

// Situation #1 with failure:
//
//    | A | B |           | A | B |
// ---+---+---+        ---+---+---+
// BTH| 1 | 0 |        BTH| 0 | 0 |
// ---+---+---+  --->  ---+---+---+
// EUH| 1 | 0 |        EUH| 1 | 0 |
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfEasyUnlockDisableUnsuccessfulThenScheduleRetry) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kInternalServerError);

  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);

  VerifyDeviceInPrefs(test_devices()[0]);
  EXPECT_TRUE(mock_timer()->IsRunning());

  mock_timer()->Fire();

  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
}

TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfNoDisablePendingThenConstructorDoesNothing) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 std::nullopt /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);
}

TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfDisablePendingButIsNotCurrentEasyUnlockHostThenClearPref) {
  InitializeTest(test_devices()[0] /* initial_device_in_prefs */,
                 test_devices()[1] /* initial_better_together_host */,
                 test_devices()[1] /* initial_easy_unlock_host */);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);
}

TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfDisablePendingButIsCurrentBetterTogetherHostThenClearPref) {
  InitializeTest(test_devices()[0] /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);
}

// Simulate:
//   - Disable BETTER_TOGETHER_HOST on device 0
//   - GrandfatheredEasyUnlockHostDisabler tries to disable EASY_UNLOCK_HOST on
//     device 0 but fails
//   - Timer is running while we wait to retry
//   - Re-enable BETTER_TOGETHER_HOST on device 0
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfHostChangesWhileRetryTimerIsRunningThenCancelTimerAndClearPref) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);

  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);
  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kInternalServerError);

  EXPECT_TRUE(mock_timer()->IsRunning());

  SetHost(test_devices()[0], multidevice::SoftwareFeature::kBetterTogetherHost);

  VerifyEasyUnlockHostDisableRequest(0 /* expected_queue_size */,
                                     std::nullopt /* expected_host */);

  EXPECT_FALSE(mock_timer()->IsRunning());
  VerifyDeviceInPrefs(std::nullopt /* expected_device */);
}

// Simulate:
//   - Set device 0 as host
//   - Disable host
//   - Set device 1 as host
//   - Disable host
//   - SetSoftwareFeatureState callback for device 0 is called
TEST_F(MultiDeviceSetupGrandfatheredEasyUnlockHostDisablerTest,
       IfDifferentHostDisabledBeforeFirstCallbackThenFirstCallbackDoesNothing) {
  InitializeTest(std::nullopt /* initial_device_in_prefs */,
                 test_devices()[0] /* initial_better_together_host */,
                 test_devices()[0] /* initial_easy_unlock_host */);
  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);
  VerifyEasyUnlockHostDisableRequest(1 /* expected_queue_size */,
                                     test_devices()[0]);

  SetHost(test_devices()[1], multidevice::SoftwareFeature::kBetterTogetherHost);
  SetHost(test_devices()[1], multidevice::SoftwareFeature::kSmartLockHost);
  SetHost(std::nullopt, multidevice::SoftwareFeature::kBetterTogetherHost);
  VerifyEasyUnlockHostDisableRequest(2 /* expected_queue_size */,
                                     test_devices()[1]);
  VerifyDeviceInPrefs(test_devices()[1]);

  InvokePendingEasyUnlockHostDisableRequestCallback(
      device_sync::mojom::NetworkRequestResult::kSuccess);
  VerifyDeviceInPrefs(test_devices()[1]);
}

}  // namespace multidevice_setup

}  // namespace ash