chromium/ios/chrome/browser/autofill/ui_bundled/card_unmask_prompt_view_controller_unittest.mm

// Copyright 2022 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/autofill/ui_bundled/card_unmask_prompt_view_controller.h"
#import "ios/chrome/browser/autofill/ui_bundled/card_unmask_prompt_view_controller+Testing.h"

#import "base/apple/foundation_util.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "base/test/scoped_feature_list.h"
#import "components/autofill/core/browser/autofill_test_utils.h"
#import "components/autofill/core/browser/payments/card_unmask_delegate.h"
#import "components/autofill/core/browser/test_personal_data_manager.h"
#import "components/autofill/core/browser/ui/payments/card_unmask_prompt_controller_impl.h"
#import "components/autofill/core/common/autofill_payments_features.h"
#import "components/prefs/testing_pref_service.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_detail_icon_item.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_link_header_footer_item.h"
#import "ios/chrome/browser/shared/ui/table_view/cells/table_view_text_edit_item.h"
#import "ios/chrome/browser/shared/ui/table_view/legacy_chrome_table_view_controller_test.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/browser/autofill/ui_bundled/card_unmask_prompt_view_bridge.h"
#import "ios/chrome/browser/autofill/ui_bundled/cells/card_unmask_header_item.h"
#import "ios/chrome/browser/autofill/ui_bundled/cells/expiration_date_edit_item+Testing.h"
#import "ios/chrome/browser/autofill/ui_bundled/cells/expiration_date_edit_item.h"
#import "ios/chrome/test/scoped_key_window.h"
#import "testing/gmock/include/gmock/gmock.h"
#import "testing/gtest_mac.h"
#import "testing/platform_test.h"

namespace {

using ::testing::Eq;
using ::testing::Mock;
using ::testing::NiceMock;
using ::testing::Return;

class MockCardUnmaskPromptController
    : public autofill::CardUnmaskPromptControllerImpl {
 public:
  MockCardUnmaskPromptController(
      TestingPrefServiceSimple* pref_service,
      const autofill::CreditCard& card,
      const autofill::CardUnmaskPromptOptions& card_unmask_prompt_options,
      base::WeakPtr<autofill::CardUnmaskDelegate> delegate)
      : CardUnmaskPromptControllerImpl(pref_service,
                                       card,
                                       card_unmask_prompt_options,
                                       delegate) {}

  MockCardUnmaskPromptController(const MockCardUnmaskPromptController&) =
      delete;
  MockCardUnmaskPromptController& operator=(
      const MockCardUnmaskPromptController&) = delete;

  // Mock method to validate that submitting forms forward the data to the
  // controller.
  MOCK_METHOD(void,
              OnUnmaskPromptAccepted,
              (const std::u16string& cvc,
               const std::u16string& exp_month,
               const std::u16string& exp_year,
               bool enable_fido_auth,
               bool was_checkbox_visible),
              (override));

  MOCK_METHOD(const autofill::CreditCard&,
              GetCreditCard,
              (),
              (const, override));

  MOCK_METHOD(bool, ShouldRequestExpirationDate, (), (const, override));

  MOCK_METHOD(bool,
              InputExpirationIsValid,
              (const std::u16string& month, const std::u16string& year),
              (const, override));
};

class MockCardUnmaskPromptViewBridge
    : public autofill::CardUnmaskPromptViewBridge {
 public:
  explicit MockCardUnmaskPromptViewBridge(
      autofill::CardUnmaskPromptControllerImpl* controller,
      autofill::TestPersonalDataManager* personal_data_manager)
      : CardUnmaskPromptViewBridge(
            controller,
            [[UINavigationController alloc] init],
            personal_data_manager,
            /*browser_coordinator_commands_handler=*/nil) {}

  MockCardUnmaskPromptViewBridge(const MockCardUnmaskPromptViewBridge&) =
      delete;
  MockCardUnmaskPromptViewBridge& operator=(
      const MockCardUnmaskPromptViewBridge&) = delete;

  ~MockCardUnmaskPromptViewBridge() override {
    // The production implementation notifies the controller when it gets
    // destroyed. This is not needed in the mock implementation and the
    // controller might be destroyed at this point. Discarding the pointer
    // prevent CardUnmaskPromptViewBridge from referencing it.
    controller_ = nullptr;
  }

  MOCK_METHOD(void, PerformClose, (), (override));
};

class CardUnmaskPromptViewControllerTest
    : public LegacyChromeTableViewControllerTest,
      public ::testing::WithParamInterface<bool> {
 protected:
  CardUnmaskPromptViewControllerTest()
      : virtual_card_enrollment_enabled_(GetParam()) {}

  void SetUp() override {
    feature_list_.InitWithFeatureState(
        autofill::features::kAutofillEnableVirtualCards,
        virtual_card_enrollment_enabled_);

    LegacyChromeTableViewControllerTest::SetUp();
    root_view_controller_ = [[UIViewController alloc] init];

    pref_service_ = std::make_unique<TestingPrefServiceSimple>();

    card_unmask_prompt_controller_ =
        std::make_unique<NiceMock<MockCardUnmaskPromptController>>(
            pref_service_.get(), card_, challenge_option_,
            /*delegate=*/nullptr);
    ON_CALL(*card_unmask_prompt_controller_, GetCreditCard)
        .WillByDefault(testing::ReturnRef(card_));

    personal_data_manager_ =
        std::make_unique<autofill::TestPersonalDataManager>();

    card_unmask_prompt_bridge_ =
        std::make_unique<NiceMock<MockCardUnmaskPromptViewBridge>>(
            card_unmask_prompt_controller_.get(), personal_data_manager_.get());

    CreateController();
  }

  void TearDown() override {
    CardUnmaskPromptViewController* prompt_controller =
        static_cast<CardUnmaskPromptViewController*>(controller());
    // Delete C++ reference in view controller to prevent UAF error.
    [prompt_controller disconnectFromBridge];

    LegacyChromeTableViewControllerTest::TearDown();
  }

  LegacyChromeTableViewController* InstantiateController() override {
    return [[CardUnmaskPromptViewController alloc]
        initWithBridge:(card_unmask_prompt_bridge_.get())];
  }

  // Fetches the model for the header from the tableViewModel.
  CardUnmaskHeaderItem* HeaderITem() {
    return static_cast<CardUnmaskHeaderItem*>(
        [controller().tableViewModel headerForSectionIndex:0]);
  }

  // Helper method that fetches an item from the tableViewModel.
  id GetItem(int item, int section) {
    auto* indexPath = [NSIndexPath indexPathForItem:item inSection:section];
    auto* model = controller().tableViewModel;

    return [model hasItemAtIndexPath:indexPath]
               ? [model itemAtIndexPath:indexPath]
               : nil;
  }

  // Helper method that fetches a cell from the tableView's datasource.
  id GetCell(int item, int section) {
    auto* cvc_controller = controller();
    auto* cvc_index_path = [NSIndexPath indexPathForItem:item
                                               inSection:section];

    return
        [cvc_controller.tableView.dataSource tableView:cvc_controller.tableView
                                 cellForRowAtIndexPath:cvc_index_path];
  }

  // Fetches the card info cell from the tableView's datasource.
  TableViewDetailIconCell* GetCardInfoCell() {
    return virtual_card_enrollment_enabled_ ? GetCell(/*item=*/0, /*section*/ 0)
                                            : nil;
  }

  TableViewDetailIconItem* GetCardInfoItem() {
    return virtual_card_enrollment_enabled_ ? GetItem(/*item=*/0, /*section=*/0)
                                            : nil;
  }

  // Fetches the CVC input cell from the tableView's datasource.
  TableViewTextEditCell* GetCVCInputCell() {
    return GetCell(/*item=*/0, /*section*/ 1);
  }

  // Fetches the model for the CVC input cell from the tableViewModel.
  TableViewTextEditItem* CVCInputItem() {
    return GetItem(/*item=*/0, /*section=*/1);
  }

  // Fetches the model for the expiration date input cell from the
  // tableViewModel.
  ExpirationDateEditItem* ExpirationDateItem() {
    return GetItem(/*item=*/1, /*section=*/1);
  }

  // Fetches the model for the footer from the tableViewModel.
  TableViewLinkHeaderFooterItem* FooterItem() {
    return static_cast<TableViewLinkHeaderFooterItem*>(
        [controller().tableViewModel footerForSectionIndex:1]);
  }

  // Returns the right bar item in the navigationItem.
  UIBarButtonItem* RightBarButtonItem() {
    return controller().navigationItem.rightBarButtonItem;
  }

  // Returns the confirm button in the navigationItem.
  // Validates the button has a title and an action set.
  UIBarButtonItem* ConfirmButton() {
    auto* button = RightBarButtonItem();
    EXPECT_FALSE(button.customView);
    EXPECT_TRUE(button.title.length);
    EXPECT_TRUE(button.action);
    return button;
  }

  // Validates that the header is in the tableViewModel and the instructions
  // match those in card_unmask_prompt_controller_.
  void CheckHeaderAndInstructions() {
    CardUnmaskHeaderItem* header_item = HeaderITem();
    EXPECT_TRUE(header_item);

    NSString* expected_title = base::SysUTF16ToNSString(
        card_unmask_prompt_controller_->GetWindowTitle());
    NSString* expected_instructions = base::SysUTF16ToNSString(
        card_unmask_prompt_controller_->GetInstructionsMessage());

    EXPECT_NSEQ(header_item.titleText, expected_title);
    EXPECT_NSEQ(header_item.instructionsText, expected_instructions);
  }

  // Validates that the right item in the navigation bar displays an activity
  // indicator.
  void CheckLoadingIndicator() {
    auto* right_bar_button_item = RightBarButtonItem();

    EXPECT_TRUE([right_bar_button_item.customView
        isMemberOfClass:[UIActivityIndicatorView class]]);
  }

  // Presents the controller in a ScopedKeyWindow.
  // Used for tests that require running animations.
  void PresentController() {
    scoped_window_.Get().rootViewController = root_view_controller_;

    // Present the view controller.
    __block bool presentation_finished = NO;
    UINavigationController* navigation_controller =
        [[UINavigationController alloc]
            initWithRootViewController:controller()];

    [root_view_controller_ presentViewController:navigation_controller
                                        animated:NO
                                      completion:^{
                                        presentation_finished = YES;
                                      }];

    EXPECT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
        base::test::ios::kWaitForUIElementTimeout, ^bool {
          return presentation_finished;
        }));
  }

  // Simulates submitting either the cvc or expiration date form and verifies
  // the Controller receives the form data.
  void CheckSubmittingForm(NSString* CVC,
                           NSString* month = nil,
                           NSString* year = nil) {
    CVCInputItem().textFieldValue = CVC;

    auto* expiration_date_item = ExpirationDateItem();

    if (expiration_date_item) {
      expiration_date_item.month = month;
      expiration_date_item.year = year;
    }

    auto* prompt_controller =
        static_cast<CardUnmaskPromptViewController*>(controller());

    // Mock expiration date validation so the prompt sends test data.
    ON_CALL(*card_unmask_prompt_controller_, InputExpirationIsValid)
        .WillByDefault(Return(true));

    // ViewController should notify its Controller when the CVC form is
    // submitted.
    EXPECT_CALL(*card_unmask_prompt_controller_,
                OnUnmaskPromptAccepted(Eq(base::SysNSStringToUTF16(CVC)),
                                       Eq(base::SysNSStringToUTF16(month)),
                                       Eq(base::SysNSStringToUTF16(year)),
                                       Eq(false), Eq(false)));

    [prompt_controller onVerifyTapped];
  }

  // Verifies that the update expiration link is in the tableViewModel.
  void CheckUpdateExpirationDateLink() {
    TableViewLinkHeaderFooterItem* footer = FooterItem();

    // Validate that the footer has text and a link.
    EXPECT_TRUE(footer.text.length);
    EXPECT_EQ(footer.urls.count, 1LU);
  }

  // Verifies that the correct items are in the tableViewModel.
  void CheckTableViewModel(bool with_expiration_date_item) {
    // Check expected number of sections and items.
    EXPECT_EQ(NumberOfSections(), 2);
    // First section only has a header when experiment is disabled. It has two
    // (a header and a card info cell) when experiment is enabled.
    EXPECT_EQ(NumberOfItemsInSection(0),
              virtual_card_enrollment_enabled_ ? 1 : 0);
    // CVC and expiration date fields if applicable.
    EXPECT_EQ(NumberOfItemsInSection(1), with_expiration_date_item ? 2 : 1);

    CheckHeaderAndInstructions();

    // Verifying card info is in model.
    EXPECT_EQ(virtual_card_enrollment_enabled_, !!GetCardInfoItem());

    // Verifing CVC field is in model.
    EXPECT_TRUE(CVCInputItem());

    // Verifying expiration date field is updated correctly.
    EXPECT_EQ(with_expiration_date_item, !!ExpirationDateItem());

    // Footer shouldn't be in model.
    EXPECT_FALSE(FooterItem());
  }

  // Validates that the first responder has the given accessibility identifier.
  void CheckFirstResponderHasAccessibilityIdentifier(
      NSString* accessibility_identifier) {
    UIView* first_responder =
        base::apple::ObjCCastStrict<UIView>(GetFirstResponder());

    EXPECT_NSEQ(first_responder.accessibilityIdentifier,
                accessibility_identifier);
  }

  std::unique_ptr<NiceMock<MockCardUnmaskPromptViewBridge>>
      card_unmask_prompt_bridge_;
  std::unique_ptr<NiceMock<MockCardUnmaskPromptController>>
      card_unmask_prompt_controller_;
  autofill::CardUnmaskPromptOptions challenge_option_;
  std::unique_ptr<TestingPrefServiceSimple> pref_service_;
  std::unique_ptr<autofill::TestPersonalDataManager> personal_data_manager_;
  ScopedKeyWindow scoped_window_;
  UIViewController* root_view_controller_;
  base::test::ScopedFeatureList feature_list_;
  autofill::CreditCard card_ = autofill::test::GetMaskedServerCard();
  bool virtual_card_enrollment_enabled_;
};

INSTANTIATE_TEST_SUITE_P(,
                         CardUnmaskPromptViewControllerTest,
                         ::testing::Bool());

}  // namespace

// Validates that the CVC form is displayed as the initial state of the
// controller when the card is not expired.
TEST_P(CardUnmaskPromptViewControllerTest, CVCFormDisplayedAsInitialState) {
  CheckController();

  CheckTableViewModel(/*with_expiration_date_item=*/false);

  // Confirm button should be disabled until valid input is entered.
  EXPECT_FALSE(ConfirmButton().enabled);

  // Add controller to view hierarchy to verify CVC field is focused.
  PresentController();
  CheckFirstResponderHasAccessibilityIdentifier(@"CVC_textField");
}

// Validates that the Expiration Date form is displayed as the initial state of
// the controller when the card is expired.
TEST_P(CardUnmaskPromptViewControllerTest,
       ExpirationDateFormDisplayedAsInitialState) {
  // Recreate controller for the expiration date form state.
  ResetController();

  ON_CALL(*card_unmask_prompt_controller_, ShouldRequestExpirationDate)
      .WillByDefault(Return(true));

  CreateController();

  CheckTableViewModel(/*with_expiration_date_item=*/true);

  // Add controller to view hierarchy to verify CVC field is focused.
  PresentController();
  CheckFirstResponderHasAccessibilityIdentifier(@"CVC_textField");
}

// Validates that the tableViewModel is properly setup for displaying update
// expiration date form.
TEST_P(CardUnmaskPromptViewControllerTest,
       UpdateExpirationDateFormStateHasExpectedItems) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  // Update state of controller to get the instructions for updating the card's
  // expiration date.
  card_unmask_prompt_controller_->NewCardLinkClicked();

  [prompt_controller showUpdateExpirationDateForm];

  CheckTableViewModel(/*with_expiration_date_item=*/true);

  // Add controller to view hierarchy to verify Expiration Date field is
  // focused.
  PresentController();
  CheckFirstResponderHasAccessibilityIdentifier(@"Expiration Date_textField");
}

// Validates the model is properly setup for displaying the expiration card link
// in the controller's footer.
TEST_P(CardUnmaskPromptViewControllerTest,
       ShowUpdateExpirationCardLinkAddsFooterWithLink) {
  // Footer shouldn't be in model before link is shown.
  EXPECT_FALSE(FooterItem());

  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  [prompt_controller showUpdateExpirationDateLink];

  CheckUpdateExpirationDateLink();
}

// Validates that the loading state displays an activity indicator in the
// navigation bar and disables interactions with the input fields.
TEST_P(CardUnmaskPromptViewControllerTest,
       ShowLoadingStateDisplaysActivityIndicatorAndDisablesInteractions) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  EXPECT_TRUE(prompt_controller.tableView.userInteractionEnabled);

  [prompt_controller showLoadingState];

  CheckLoadingIndicator();

  EXPECT_FALSE(prompt_controller.tableView.userInteractionEnabled);
}

// Validates that an alert is presented for the error state.
TEST_P(CardUnmaskPromptViewControllerTest, ShowErrorPresentsAlert) {
  PresentController();

  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  NSString* alert_message = @"Test";
  [prompt_controller showErrorAlertWithMessage:alert_message closeOnDismiss:NO];

  // Spin the run loop to trigger the alert presentation animation.
  base::test::ios::SpinRunLoopWithMaxDelay(
      base::test::ios::kWaitForUIElementTimeout);
  ASSERT_TRUE([prompt_controller.presentedViewController
      isKindOfClass:[UIAlertController class]]);
  auto* alertController = static_cast<UIAlertController*>(
      prompt_controller.presentedViewController);
  // Check the state of the alert.
  EXPECT_TRUE(alertController.title.length);
  EXPECT_NSEQ(alertController.message, alert_message);
  EXPECT_EQ(alertController.actions.count, 1LU);

  // Confirm button should be restored.
  EXPECT_TRUE(ConfirmButton());
}

// Verifies that submitting the CVC form forwards the CVC value to
// CardUnmaskPromptController.
TEST_P(CardUnmaskPromptViewControllerTest, TestSubmittingCVCForm) {
  CheckSubmittingForm(/*CVC=*/@"123");
}

// Verifies that submitting the update expiration date form forwards CVC and
// expiration date to CardUnmaskPromptController/
TEST_P(CardUnmaskPromptViewControllerTest, TestSubmittingExpirationDateForm) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());
  [prompt_controller showUpdateExpirationDateForm];

  CheckSubmittingForm(/*CVC=*/@"123", /*month=*/@"09", /*year=*/@"2022");
}

// Verifies that the controller is dismissed after an error.
TEST_P(CardUnmaskPromptViewControllerTest,
       TestDismissingViewControllerAfterError) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  EXPECT_CALL(*card_unmask_prompt_bridge_, PerformClose);

  [prompt_controller onErrorAlertDismissedAndShouldCloseOnDismiss:YES];
}

// Verifies that the expiration date link is displayed after an error.
TEST_P(CardUnmaskPromptViewControllerTest,
       TestShowingUpdateExpirationDateLinkAfterError) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  [prompt_controller onErrorAlertDismissedAndShouldCloseOnDismiss:NO];

  CheckUpdateExpirationDateLink();
}

// Verifies that the expiration date form is displayed after an error.
TEST_P(CardUnmaskPromptViewControllerTest,
       TestShowingUpdateExpirationDateFormAfterError) {
  auto* prompt_controller =
      static_cast<CardUnmaskPromptViewController*>(controller());

  ON_CALL(*card_unmask_prompt_controller_, ShouldRequestExpirationDate)
      .WillByDefault(Return(true));

  [prompt_controller onErrorAlertDismissedAndShouldCloseOnDismiss:NO];

  CheckTableViewModel(/*with_expiration_date_item=*/true);
}

// Verifies that the icon in the CVC input cell is hidden from Voice Over.
TEST_P(CardUnmaskPromptViewControllerTest,
       TestCVCCellIconIsHiddenFromVoiceOver) {
  auto* CVC_cell = GetCVCInputCell();
  ASSERT_TRUE(CVC_cell);
  EXPECT_FALSE(CVC_cell.identifyingIconButton.isAccessibilityElement);
}

// Verifies that the textField in the CVC input cell does not accept more than 4
// digits.
TEST_P(CardUnmaskPromptViewControllerTest,
       TestCVCTextFieldRejectsTooLongCVCValues) {
  auto* CVC_field = GetCVCInputCell().textField;
  ASSERT_TRUE(CVC_field);
  ASSERT_EQ(CVC_field.text.length, 0LU);

  auto* delegate = CVC_field.delegate;
  NSRange start_range = NSMakeRange(0, 0);

  NSString* short_CVC = @"1";
  EXPECT_TRUE([delegate textField:CVC_field
      shouldChangeCharactersInRange:start_range
                  replacementString:short_CVC]);

  NSString* valid_CVC = @"123";
  EXPECT_TRUE([delegate textField:CVC_field
      shouldChangeCharactersInRange:start_range
                  replacementString:valid_CVC]);

  NSString* too_long_CVC = @"12345";
  EXPECT_FALSE([delegate textField:CVC_field
      shouldChangeCharactersInRange:start_range
                  replacementString:too_long_CVC]);
}