chromium/ios/chrome/browser/autofill/model/child_frame_registration_js_unittest.mm

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

#import <Foundation/Foundation.h>

#import <optional>

#import "base/apple/foundation_util.h"
#import "base/strings/string_number_conversions.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/ios/wait_util.h"
#import "base/time/time.h"
#import "base/unguessable_token.h"
#import "components/autofill/core/common/unique_ids.h"
#import "components/autofill/ios/browser/autofill_util.h"
#import "ios/web/public/test/javascript_test.h"
#import "ios/web/public/test/js_test_util.h"
#import "testing/gmock/include/gmock/gmock.h"
#import "testing/gtest/include/gtest/gtest.h"
#import "testing/gtest_mac.h"

@interface FakeScriptMessageHandler : NSObject <WKScriptMessageHandler>

// Number of registration messages received.
@property(nonatomic, assign) int registrationsCount;

@end

@implementation FakeScriptMessageHandler

- (void)userContentController:(WKUserContentController*)userContentController
      didReceiveScriptMessage:(WKScriptMessage*)message {
  if ([message.body isKindOfClass:[NSDictionary class]] &&
      [message.body[@"command"] isKindOfClass:[NSString class]] &&
      [message.body[@"command"] isEqual:@"registerAsChildFrame"]) {
    ++self.registrationsCount;
  }
}

@end

namespace {

using ::testing::IsSubsetOf;
using ::testing::SizeIs;

// Delay for the registration round trip to complete, including all latencies.
constexpr base::TimeDelta kRegistrationDelay = base::Milliseconds(200);

// Gets delay for performing all attempts with the exponential backoff for
// retries and with some extra buffer to deal with other latencies such as the
// delay for the initial request. The delay is computed as follow:
// `base_delay_us` * (2^(`num_attempts` - 1) - 1) + `kRegistrationDelay`.
// `base_delay_us` corresponds to the delay used in the first retry in
// microseconds.
base::TimeDelta GetDelayForAllAttempts(int base_delay_us, int num_attempts) {
  return base::Microseconds(base_delay_us * ((1 << (num_attempts - 1)) - 1)) +
         kRegistrationDelay;
}

// Gets the delay for the next attempt following the previous `num_attempts`.
base::TimeDelta GetDelayForNextAttempt(int base_delay_us, int num_attempts) {
  return base::Microseconds(base_delay_us * ((1 << (num_attempts - 2))));
}

// Extract all tokens from the JS registration call `result` while skipping the
// tokens that can't be deserialized.
std::vector<autofill::RemoteFrameToken> ExtractTokensFromResult(id result) {
  if (!result) {
    return {};
  }
  NSArray<NSString*>* result_array =
      base::apple::ObjCCast<NSArray<NSString*>>(result);
  if (!result_array) {
    return {};
  }

  std::vector<autofill::RemoteFrameToken> extracted_tokens;
  for (NSString* item in result_array) {
    std::optional<base::UnguessableToken> token =
        autofill::DeserializeJavaScriptFrameId(base::SysNSStringToUTF8(item));
    if (token) {
      extracted_tokens.emplace_back(*token);
    }
  }

  return extracted_tokens;
}

class ChildFrameRegistrationJavascriptTest : public web::JavascriptTest {
 protected:
  ChildFrameRegistrationJavascriptTest() {}
  ~ChildFrameRegistrationJavascriptTest() override {}

  void SetUp() override {
    web::JavascriptTest::SetUp();

    AddGCrWebScript();
    AddCommonScript();
    AddMessageScript();
    AddUserScript(@"child_frame_registration_test");
    AddUserScript(@"autofill_form_features");
  }

  // Script that enables xframes on all frames and set registration attempts
  // counter.
  void SetFramesForTesting() {
    NSString* const script =
        @"__gCrWeb.autofill_form_features.setAutofillAcrossIframes(true);"
         "let registrationAttemptsCount = 0;"
         "for (const frame of document.querySelectorAll('iframe')) { "
         "frame.contentWindow.eval('__gCrWeb.autofill_form_features."
         "setAutofillAcrossIframes(true)');"
         "}"
         "window.addEventListener('message', () => "
         "++registrationAttemptsCount);";
    web::test::ExecuteJavaScript(web_view(), script);
  }

  // Returns the number of attempts performed so far.
  int GetRegistrationAttemptsCount() {
    id result =
        web::test::ExecuteJavaScript(web_view(), @"registrationAttemptsCount");
    return static_cast<int>([result doubleValue]);
  }
};

// Tests that child frames register themselves correctly with their host frame.
TEST_F(ChildFrameRegistrationJavascriptTest, RegisterFrames) {
  NSString* html = @"<body> outer frame"
                    "  <iframe srcdoc='<body>inner frame 1</body>'></iframe>"
                    "  <iframe srcdoc='<body>inner frame 2</body>'></iframe>"
                    "</body>";

  ASSERT_TRUE(LoadHtml(html));

  id result = web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();");

  ASSERT_TRUE(result);
  NSArray<NSString*>* result_array =
      base::apple::ObjCCast<NSArray<NSString*>>(result);
  ASSERT_TRUE(result_array);
  EXPECT_EQ(2u, [result_array count]);
  for (NSString* item in result_array) {
    ASSERT_EQ(32u, [item length]);
    uint64_t unused;
    EXPECT_TRUE(base::HexStringToUInt64(
        base::SysNSStringToUTF8([item substringToIndex:16]), &unused));
    EXPECT_TRUE(base::HexStringToUInt64(
        base::SysNSStringToUTF8([item substringFromIndex:16]), &unused));
  }
}

// Tests that the registration tokens are cached and can be reused.
TEST_F(ChildFrameRegistrationJavascriptTest, RegisterFrames_Cache) {
  NSString* html = @"<body> outer frame"
                    "  <iframe srcdoc='<body>inner frame 1</body>'></iframe>"
                    "  <iframe srcdoc='<body>inner frame 2</body>'></iframe>"
                    "</body>";

  ASSERT_TRUE(LoadHtml(html));

  // Do first registration and extract the tokens.
  id result1 = web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();");
  ASSERT_TRUE(result1);
  std::vector<autofill::RemoteFrameToken> remote_tokens_round1 =
      ExtractTokensFromResult(result1);
  EXPECT_THAT(remote_tokens_round1, SizeIs(2));

  // Do second registration and extract the tokens.
  id result2 = web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();");
  ASSERT_TRUE(result2);
  std::vector<autofill::RemoteFrameToken> remote_tokens_round2 =
      ExtractTokensFromResult(result2);
  EXPECT_THAT(remote_tokens_round2, SizeIs(2));

  // Verify that the cached tokens were reused when registring a second time.
  EXPECT_THAT(remote_tokens_round2, IsSubsetOf(remote_tokens_round1));
}

// Tests that child frames register themselves only once while deduping
// other attempts.
TEST_F(ChildFrameRegistrationJavascriptTest, RegisterFrames_Deduping) {
  FakeScriptMessageHandler* msg_handler =
      [[FakeScriptMessageHandler alloc] init];
  [web_view().configuration.userContentController
      addScriptMessageHandler:msg_handler
                         name:@"FormHandlersMessage"];

  NSString* const html =
      @"<body> outer frame"
       "  <iframe srcdoc='<body>inner frame 1</body>'></iframe>"
       "  <iframe srcdoc='<body>inner frame 2</body>'></iframe>"
       "</body>";
  ASSERT_TRUE(LoadHtml(html));

  SetFramesForTesting();

  ASSERT_TRUE(web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();"));

  // Wait for both frames to register.
  ASSERT_TRUE(
      base::test::ios::WaitUntilConditionOrTimeout(kRegistrationDelay * 5, ^() {
        return msg_handler.registrationsCount == 2;
      }));
  EXPECT_EQ(2, GetRegistrationAttemptsCount());

  // Try re-registering the same frames.
  ASSERT_TRUE(web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();"));

  // Give enough time for the full registration round trip, if it did happen
  // (which would be an error at this point).
  base::test::ios::SpinRunLoopWithMinDelay(kRegistrationDelay);

  // Verify that no re-registration attempt was made as the token was already
  // registered with the browser (virually at least considering the testing
  // context).
  EXPECT_EQ(2, msg_handler.registrationsCount);
  EXPECT_EQ(2, GetRegistrationAttemptsCount());
}

// Tests that there is no further attempts made once the max limit for attempts
// is reached.
TEST_F(ChildFrameRegistrationJavascriptTest,
       RegisterFrames_MaxAttemptsReached) {
  FakeScriptMessageHandler* msg_handler =
      [[FakeScriptMessageHandler alloc] init];
  [web_view().configuration.userContentController
      addScriptMessageHandler:msg_handler
                         name:@"FormHandlersMessage"];

  NSString* const html =
      @"<body> outer frame"
       "  <iframe srcdoc='<body>inner frame 1</body>'></iframe>"
       "  <iframe srcdoc='<body>inner frame 2</body>'></iframe>"
       "</body>";
  ASSERT_TRUE(LoadHtml(html));

  SetFramesForTesting();

  // Change the setTimeout function to reduce the initial delay for registration
  // so this can be testable with the exponential backoff. Only tamper with the
  // delay when the callback function looks like the frame registration callback
  // to avoid altering the functionality for the other callers.
  {
    NSString* const script = @"const oldTimeoutFn = window.setTimeout; "
                              // Change the setTimeout function so it divides
                              // the delay to retry registrations.
                              "window.setTimeout = (fn, d, ...args) => { "
                              // Verify that the callback function is for frame
                              // registration. If yes, divide the timeout delay.
                              "  const r = /\\(\\)=>[a-z]+\\([a-z]+\\*2\\)/;"
                              "  const fs = fn.toString().replace(/\\s/g, '');"
                              "  if (r.test(fs)) { "
                              // Divide the delay by 20 iff this is for
                              // registration.
                              "    oldTimeoutFn(fn, d/20, ...args);"
                              "  } else {"
                              // Leave the delay as is otherwise so it doesn't
                              // alter the functionality for the other callers.
                              "    oldTimeoutFn(fn, d, ...args);"
                              "  }"
                              "};";
    web::test::ExecuteJavaScript(web_view(), script);
  }

  // Set registration counter and mutate the command sent to frames to
  // invalidate the command, emulating a frame not responding.
  {
    NSString* const script =
        @"for (const frame of document.querySelectorAll('iframe')) { "
         "  const oldPostMessage = frame.contentWindow.postMessage;"
         // Wrap the window.postMessage methods of each child frame that needs
         // to be registered.
         "  frame.contentWindow.postMessage = function(message, targetOrigin, "
         "     transfer) {"
         // Only replace the command if this is for sending registration ack.
         // The frame receiving the mutated command won't respond, emulating a
         // unresponsive frame.
         "    if (message.command === 'registerAsChildFrame') {"
         "      ++registrationAttemptsCount;"
         "      message.command = 'invalid';"
         "      oldPostMessage(message, targetOrigin, transfer);"
         "    }"
         "  }"
         "}";
    web::test::ExecuteJavaScript(web_view(), script);
  }

  const int base_delay_us = 2500;
  const int num_attempts_expected = 9;

  ASSERT_TRUE(web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();"));

  // Wait on the expected registration attempts to be done.
  ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
      GetDelayForAllAttempts(base_delay_us, num_attempts_expected) +
          base::Seconds(1),
      ^() {
        return GetRegistrationAttemptsCount() == 18;
      }));

  // Give some time for the next attempt in the case the limit isn't respected.
  // Needs to be the next registration delay after 9 attempts plus some
  // buffer to deal with other latencies.
  base::test::ios::SpinRunLoopWithMinDelay(
      GetDelayForNextAttempt(base_delay_us, num_attempts_expected + 1) +
      kRegistrationDelay);

  // Verify that no registration did really happen since the frames didn't
  // respond.
  EXPECT_EQ(0, msg_handler.registrationsCount);

  // Verify that only 18 attempts (9 per frame) were made.
  EXPECT_EQ(18, GetRegistrationAttemptsCount());
}

// Tests that there is no further attempts made once the max registration
// capacity is reached.
TEST_F(ChildFrameRegistrationJavascriptTest,
       RegisterFrames_MaxRegistrationCapacityReached) {
  FakeScriptMessageHandler* msg_handler =
      [[FakeScriptMessageHandler alloc] init];
  [web_view().configuration.userContentController
      addScriptMessageHandler:msg_handler
                         name:@"FormHandlersMessage"];

  // Make page with 120 frames.
  NSMutableString* html =
      [NSMutableString stringWithString:@"<body> outer frame"];
  for (size_t i = 0; i < 120; ++i) {
    [html
        appendString:[NSString stringWithFormat:@" <iframe srcdoc='<body>inner "
                                                @"frame %zu</body>'></iframe>",
                                                i + 1]];
  }
  [html appendString:@"</body>"];

  ASSERT_TRUE(LoadHtml(html));

  SetFramesForTesting();

  ASSERT_TRUE(web::test::ExecuteJavaScript(
      web_view(), @"__gCrWeb.childFrameRegistrationTesting."
                  @"registerAllChildFrames();"));

  // Wait on the first 100 frames to be registered.
  ASSERT_TRUE(
      base::test::ios::WaitUntilConditionOrTimeout(base::Seconds(1), ^() {
        return msg_handler.registrationsCount == 100;
      }));

  // Give some time for more registrations in the case the limit isn't
  // respected.
  base::test::ios::SpinRunLoopWithMinDelay(base::Milliseconds(500));

  // Verify that only 100 registrations (1 per frame) were made where the ones
  // exceeding the max capacity of the logbook were dropped.
  EXPECT_EQ(100, msg_handler.registrationsCount);
  EXPECT_EQ(100, GetRegistrationAttemptsCount());
}

}  // namespace