chromium/ash/in_session_auth/in_session_auth_dialog_contents_view_unittest.cc

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

#include "ash/in_session_auth/in_session_auth_dialog_contents_view.h"

#include <memory>
#include <optional>
#include <utility>

#include "ash/public/cpp/in_session_auth_token_provider.h"
#include "ash/public/cpp/test/mock_in_session_auth_token_provider.h"
#include "ash/test/ash_test_base.h"
#include "ash/test/test_widget_builder.h"
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "base/memory/raw_ptr.h"
#include "base/test/bind.h"
#include "base/test/test_future.h"
#include "base/time/time.h"
#include "chromeos/ash/components/auth_panel/impl/auth_panel.h"
#include "chromeos/ash/components/auth_panel/impl/views/password_auth_view.h"
#include "chromeos/ash/components/cryptohome/auth_factor.h"
#include "chromeos/ash/components/cryptohome/common_types.h"
#include "chromeos/ash/components/cryptohome/cryptohome_parameters.h"
#include "chromeos/ash/components/cryptohome/error_types.h"
#include "chromeos/ash/components/dbus/cryptohome/UserDataAuth.pb.h"
#include "chromeos/ash/components/dbus/userdataauth/userdataauth_client.h"
#include "chromeos/ash/components/login/auth/auth_performer.h"
#include "chromeos/ash/components/login/auth/mock_auth_performer.h"
#include "chromeos/ash/components/login/auth/public/auth_callbacks.h"
#include "chromeos/ash/components/login/auth/public/auth_session_intent.h"
#include "chromeos/ash/components/login/auth/public/authentication_error.h"
#include "chromeos/ash/components/login/auth/public/cryptohome_key_constants.h"
#include "chromeos/ash/components/login/auth/public/session_auth_factors.h"
#include "chromeos/ash/components/osauth/public/auth_engine_api.h"
#include "chromeos/ash/components/osauth/public/auth_factor_status_consumer.h"
#include "chromeos/ash/components/osauth/public/common_types.h"
#include "chromeos/ash/components/osauth/test_support/mock_auth_hub.h"
#include "chromeos/ash/components/osauth/test_support/mock_auth_hub_connector.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/abseil-cpp/absl/strings/ascii.h"
#include "ui/display/screen.h"
#include "ui/events/event_constants.h"
#include "ui/events/keycodes/keyboard_codes_posix.h"
#include "ui/views/animation/ink_drop.h"
#include "ui/views/controls/textfield/textfield.h"
#include "ui/views/widget/widget_delegate.h"

namespace ash {

namespace {

using ::cryptohome::KeyLabel;
using ::testing::_;

const char kExpectedPassword[] = "expectedpassword";

}  // namespace

class InSessionAuthDialogContentsViewTest : public AshTestBase {
 public:
  InSessionAuthDialogContentsViewTest() = default;

  void SetUp() override {
    AshTestBase::SetUp();
    UserDataAuthClient::InitializeFake();

    auth_hub_ = std::make_unique<MockAuthHub>();
  }

  void TearDown() override {
    // Clean up all UI objects before tearing down Ash Shell in base test.
    auth_panel_test_api_.reset();
    password_auth_view_test_api_.reset();
    contents_view_test_api_.reset();
    contents_view_ = nullptr;
    dialog_.reset();

    AshTestBase::TearDown();
  }

 protected:
  std::unique_ptr<views::Widget> CreateAuthDialogWidget(
      std::unique_ptr<views::View> contents_view) {
    std::unique_ptr<views::Widget> widget = CreateFramelessTestWidget();
    widget->SetContentsView(std::move(contents_view));
    return widget;
  }

  void CreateAndShowDialog() {
    auto contents_view = std::make_unique<InSessionAuthDialogContentsView>(
        prompt_,
        base::BindOnce(
            &InSessionAuthDialogContentsViewTest::OnEndAuthentication,
            base::Unretained(this)),
        base::BindRepeating(&InSessionAuthDialogContentsViewTest::
                                OnAuthPanelPreferredSizeChanged,
                            base::Unretained(this)),
        auth_hub_connector_.get(), auth_hub_.get());

    // The order of instantiation is important. Resetting `dialog_`
    // destroys the owned ContentsView, causing our `contents_view_`
    // pointer to dangle.
    contents_view_ = contents_view.get();

    contents_view_test_api_ =
        std::make_unique<InSessionAuthDialogContentsView::TestApi>(
            contents_view_);

    auth_panel_test_api_ =
        std::make_unique<AuthPanel::TestApi>(contents_view_->GetAuthPanel());

    password_auth_view_test_api_.reset();

    dialog_ = CreateAuthDialogWidget(std::move(contents_view));
    CenterDialogOnDisplay();
    dialog_->Show();
  }

  void OnEndAuthentication() { end_authentication_notifications_++; }

  void CenterDialogOnDisplay() {
    auto bounds = display::Screen::GetScreen()->GetPrimaryDisplay().work_area();
    bounds.ClampToCenteredSize(dialog_->GetContentsView()->GetPreferredSize());
    dialog_->SetBounds(bounds);
  }

  void OnAuthPanelPreferredSizeChanged() {
    size_changed_notifications_count_++;
    CenterDialogOnDisplay();
  }

  void InitializeUiWithOnlyPasswordFactor() {
    contents_view_->GetAuthPanel()->InitializeUi(
        AuthFactorsSet{AshAuthFactor::kGaiaPassword},
        auth_hub_connector_.get());
    password_auth_view_test_api_ = std::make_unique<PasswordAuthView::TestApi>(
        auth_panel_test_api_->GetPasswordAuthView());
  }

  void RunPreMouseClickAssertions(views::Button* view) {
    EXPECT_TRUE(view->IsMouseHovered());
    EXPECT_TRUE(view->GetEnabled());
    EXPECT_TRUE(views::InkDrop::Get(view->ink_drop_view())->GetHighlighted());
  }

  void TypePasswordAndAssertEnteredCorrectly(const std::string& password) {
    views::View* password_textfield =
        password_auth_view_test_api_->GetPasswordTextfield();
    LeftClickOn(password_textfield);

    for (char c : password) {
      EXPECT_TRUE(absl::ascii_isalpha(static_cast<unsigned char>(c)));
      GetEventGenerator()->PressAndReleaseKey(
          static_cast<ui::KeyboardCode>(ui::KeyboardCode::VKEY_A + (c - 'a')),
          ui::EF_NONE);
    }

    ASSERT_EQ(password_auth_view_test_api_->GetPasswordTextfield()->GetText(),
              base::UTF8ToUTF16(std::string{kExpectedPassword}));
  }

  void PressCloseButton() {
    auto* generator = GetEventGenerator();
    views::Button* close_dialog_button =
        contents_view_test_api_->GetCloseButton();
    generator->MoveMouseTo(
        close_dialog_button->GetBoundsInScreen().CenterPoint());
    RunPreMouseClickAssertions(close_dialog_button);
    generator->ClickLeftButton();
  }

  void PressAndReleaseEscapeButton() {
    auto* generator = GetEventGenerator();
    generator->PressAndReleaseKey(ui::KeyboardCode::VKEY_ESCAPE);
  }

  void PressAndReleaseEnterButton() {
    auto* generator = GetEventGenerator();
    generator->PressAndReleaseKey(ui::KeyboardCode::VKEY_RETURN);
  }

  void SubmitPassword() {
    views::View* submit_password_button =
        password_auth_view_test_api_->GetSubmitPasswordButton();
    LeftClickOn(submit_password_button);
  }

  void NotifyAuthPanelPasswordFactorReady() {
    contents_view_->GetAuthPanel()->OnFactorStatusesChanged(FactorsStatusMap{
        {AshAuthFactor::kGaiaPassword, AuthFactorState::kFactorReady}});
  }

  void NotifyAuthPanelFactorListChanged() {
    // Calling `OnFactorListChanged` will destroy the `PasswordAuthView` to
    // which the test api currently holds a pointer. To avoid dangling,
    // invalidate it and recreate it after rebuilding the UI.
    password_auth_view_test_api_ = nullptr;

    contents_view_->GetAuthPanel()->OnFactorListChanged(FactorsStatusMap{
        {AshAuthFactor::kGaiaPassword, AuthFactorState::kCheckingForPresence},
        {AshAuthFactor::kCryptohomePin,
         AuthFactorState::kCheckingForPresence}});

    password_auth_view_test_api_ = std::make_unique<PasswordAuthView::TestApi>(
        auth_panel_test_api_->GetPasswordAuthView());
  }

  std::unique_ptr<MockAuthHubConnector> auth_hub_connector_;
  std::unique_ptr<MockAuthHub> auth_hub_;

  std::unique_ptr<views::Widget> dialog_;

  raw_ptr<InSessionAuthDialogContentsView> contents_view_;

  std::unique_ptr<InSessionAuthDialogContentsView::TestApi>
      contents_view_test_api_;

  std::unique_ptr<AuthPanel::TestApi> auth_panel_test_api_;

  std::unique_ptr<PasswordAuthView::TestApi> password_auth_view_test_api_;

  int size_changed_notifications_count_ = 0;
  int end_authentication_notifications_ = 0;

 private:
  std::optional<std::string> prompt_;
};

// Tests that the authentication dialog correctly notifies
// AuthHub to canel the current attempt.
TEST_F(InSessionAuthDialogContentsViewTest, CancelDialog) {
  CreateAndShowDialog();
  EXPECT_CALL(*auth_hub_, CancelCurrentAttempt);
  PressCloseButton();
}

// Tests that the authentication dialog correctly notifies
// AuthHub to canel the current attempt twice.
TEST_F(InSessionAuthDialogContentsViewTest, CancelDialogTwice) {
  CreateAndShowDialog();
  EXPECT_CALL(*auth_hub_, CancelCurrentAttempt).Times(1);
  PressCloseButton();

  testing::Mock::VerifyAndClearExpectations(auth_hub_.get());

  // And once again.
  CreateAndShowDialog();
  EXPECT_CALL(*auth_hub_, CancelCurrentAttempt).Times(1);
  PressCloseButton();
}

// Tests that the authentication dialog correctly notifies
// AuthHub to cancel the current attempt when a password is typed into
// the input field.
TEST_F(InSessionAuthDialogContentsViewTest, TypePasswordAndCloseDialog) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelPasswordFactorReady();
  TypePasswordAndAssertEnteredCorrectly(kExpectedPassword);

  EXPECT_CALL(*auth_hub_, CancelCurrentAttempt);
  ASSERT_EQ(password_auth_view_test_api_->GetPasswordTextfield()->GetText(),
            base::UTF8ToUTF16(std::string{kExpectedPassword}));

  PressCloseButton();
}

// Tests that `AuthPanel` correctly notifies its parent View that
// the preferred size has changed to new factor becoming available, or
// existing factors becoming unavailable.
TEST_F(InSessionAuthDialogContentsViewTest,
       AuthPanelNotifiesToRecenterDialogWhenPrefferredSizeChangesOnInit) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  ASSERT_EQ(size_changed_notifications_count_, 1);
}

// Tests that `AuthPanel` correctly notifies its parent View that
// the preferred size has changed to new factor becoming available, or
// existing factors becoming unavailable.
TEST_F(
    InSessionAuthDialogContentsViewTest,
    AuthPanelNotifiesToRecenterDialogWhenPrefferredSizeChangesOnFactorsChange) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelFactorListChanged();

  // Called once the UI is initialized with the the initial set of factor, and
  // also when the UI is destroyed and recreated as part of
  // `OnFactorListChanged`.
  ASSERT_EQ(size_changed_notifications_count_, 2);
}

// Tests that `AuthPanel` correctly calls the submit password callback,
// which in production, calls `AuthEngineApi::AuthenticateWithPassword`.
TEST_F(InSessionAuthDialogContentsViewTest, TypePasswordAndSubmit) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelPasswordFactorReady();
  TypePasswordAndAssertEnteredCorrectly(kExpectedPassword);

  ASSERT_EQ(password_auth_view_test_api_->GetPasswordTextfield()->GetText(),
            base::UTF8ToUTF16(std::string{kExpectedPassword}));

  base::test::TestFuture<AuthHubConnector*, AshAuthFactor, const std::string&>
      future;
  auth_panel_test_api_->SetSubmitPasswordCallback(
      future.GetRepeatingCallback());

  SubmitPassword();

  auto [connector, factor, password] = future.Take();
  EXPECT_EQ(password, std::string{kExpectedPassword});
  EXPECT_EQ(connector, auth_hub_connector_.get());
}

// Tests that `AuthPanel` correctly calls the submit password callback,
// which in production, calls `AuthEngineApi::AuthenticateWithPassword`.
// In this test we show and close the dialog prior to reopening it again and
// submitting a password.
TEST_F(InSessionAuthDialogContentsViewTest, ShowCloseThenSubmitPassword) {
  auto show_and_type_password = [this]() {
    CreateAndShowDialog();
    InitializeUiWithOnlyPasswordFactor();
    NotifyAuthPanelPasswordFactorReady();
    TypePasswordAndAssertEnteredCorrectly(kExpectedPassword);
  };

  show_and_type_password();
  PressCloseButton();

  show_and_type_password();

  base::test::TestFuture<AuthHubConnector*, AshAuthFactor, const std::string&>
      future;
  auth_panel_test_api_->SetSubmitPasswordCallback(
      future.GetRepeatingCallback());

  SubmitPassword();

  auto [connector, factor, password] = future.Take();
  EXPECT_EQ(password, std::string{kExpectedPassword});
  EXPECT_EQ(connector, auth_hub_connector_.get());
}

// Tests that `AuthPanel` correctly notifies parent controller that
// authentication has ended.
TEST_F(InSessionAuthDialogContentsViewTest,
       AuthPanelCorrectlyNotifiesWhenAuthOver) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelPasswordFactorReady();

  contents_view_->GetAuthPanel()->OnEndAuthentication();

  ASSERT_EQ(end_authentication_notifications_, 1);
}

TEST_F(InSessionAuthDialogContentsViewTest, AuthPanelClosesOnEscapePressed) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelPasswordFactorReady();

  EXPECT_CALL(*auth_hub_, CancelCurrentAttempt);
  PressAndReleaseEscapeButton();
}

TEST_F(InSessionAuthDialogContentsViewTest,
       AuthPanelSubmitsPasswordOnEnterPressed) {
  CreateAndShowDialog();
  InitializeUiWithOnlyPasswordFactor();
  NotifyAuthPanelPasswordFactorReady();
  TypePasswordAndAssertEnteredCorrectly(kExpectedPassword);

  ASSERT_EQ(password_auth_view_test_api_->GetPasswordTextfield()->GetText(),
            base::UTF8ToUTF16(std::string{kExpectedPassword}));

  base::test::TestFuture<AuthHubConnector*, AshAuthFactor, const std::string&>
      future;
  auth_panel_test_api_->SetSubmitPasswordCallback(
      future.GetRepeatingCallback());

  PressAndReleaseEnterButton();

  auto [connector, factor, password] = future.Take();
  EXPECT_EQ(password, std::string{kExpectedPassword});
  EXPECT_EQ(connector, auth_hub_connector_.get());
}

}  // namespace ash