// 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.

#include "extensions/browser/script_executor.h"

#include <string>

#include "base/json/json_writer.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/bind.h"
#include "base/test/values_test_util.h"
#include "base/values.h"
#include "chrome/browser/extensions/extension_browsertest.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/test_navigation_observer.h"
#include "extensions/browser/extension_api_frame_id_map.h"
#include "extensions/browser/extension_registrar.h"
#include "extensions/browser/extension_registry.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension_builder.h"
#include "extensions/common/mojom/css_origin.mojom-shared.h"
#include "extensions/common/mojom/host_id.mojom.h"
#include "extensions/common/mojom/run_location.mojom-shared.h"
#include "extensions/common/user_script.h"
#include "net/dns/mock_host_resolver.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"

static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));

namespace extensions {

namespace {

// A helper object to wait for and collect the results from a script execution.
class ScriptExecutorHelper {
 public:
  ScriptExecutorHelper() = default;
  ScriptExecutorHelper(const ScriptExecutorHelper&) = delete;
  ScriptExecutorHelper& operator=(const ScriptExecutorHelper&) = delete;
  ~ScriptExecutorHelper() = default;

  void Wait() { run_loop_.Run(); }

  ScriptExecutor::ScriptFinishedCallback GetCallback() {
    // The Unretained() is safe because this object is always supposed to
    // outlive the script execution.
    return base::BindOnce(&ScriptExecutorHelper::OnScriptFinished,
                          base::Unretained(this));
  }

  const std::vector<ScriptExecutor::FrameResult>& results() const {
    return results_;
  }

 private:
  void OnScriptFinished(
      std::vector<ScriptExecutor::FrameResult> frame_results) {
    results_ = std::move(frame_results);
    run_loop_.Quit();
  }

  std::vector<ScriptExecutor::FrameResult> results_;
  base::RunLoop run_loop_;
};

}  // namespace

class ScriptExecutorBrowserTest : public ExtensionBrowserTest {
 public:
  ScriptExecutorBrowserTest() = default;
  ScriptExecutorBrowserTest(const ScriptExecutorBrowserTest&) = delete;
  ScriptExecutorBrowserTest& operator=(const ScriptExecutorBrowserTest&) =
      delete;
  ~ScriptExecutorBrowserTest() override = default;

  const Extension* LoadExtensionWithHostPermission(
      const std::string& host_permission) {
    scoped_refptr<const Extension> extension =
        ExtensionBuilder("extension")
            .AddHostPermission(host_permission)
            .Build();
    extension_registrar()->AddExtension(extension);
    EXPECT_TRUE(
        extension_registry()->enabled_extensions().GetByID(extension->id()));
    return extension.get();
  }

  void SetUpOnMainThread() override {
    ExtensionBrowserTest::SetUpOnMainThread();
    host_resolver()->AddRule("*", "127.0.0.1");
    ASSERT_TRUE(embedded_test_server()->Start());
  }

  // Returns the frame with the given `name` from `web_contents`.
  content::RenderFrameHost* GetFrameByName(content::WebContents* web_contents,
                                           const std::string& name) {
    return content::FrameMatchingPredicate(
        web_contents->GetPrimaryPage(),
        base::BindRepeating(&content::FrameMatchesName, name));
  }
};

IN_PROC_BROWSER_TEST_F(ScriptExecutorBrowserTest, MainWorldExecution) {
  const Extension* extension =
      LoadExtensionWithHostPermission("http://example.com/*");

  GURL example_com =
      embedded_test_server()->GetURL("example.com", "/simple.html");
  content::WebContents* web_contents = GetActiveWebContents();
  ASSERT_TRUE(web_contents);

  {
    content::TestNavigationObserver nav_observer(web_contents);
    ASSERT_TRUE(NavigateToURL(web_contents, example_com));
    nav_observer.Wait();
    EXPECT_TRUE(nav_observer.last_navigation_succeeded());
  }

  content::RenderFrameHost* main_frame = web_contents->GetPrimaryMainFrame();

  constexpr char kSetFlagScript[] = "window.mainWorldFlag = 'executionFlag';";
  // NOTE: We *need* this to happen in the main world for the test.
  EXPECT_TRUE(content::ExecJs(main_frame, kSetFlagScript));

  ScriptExecutor script_executor(web_contents);

  ScriptExecutorHelper helper;
  std::vector<mojom::JSSourcePtr> sources;
  sources.push_back(mojom::JSSource::New("window.mainWorldFlag", GURL()));
  script_executor.ExecuteScript(
      mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
      mojom::CodeInjection::NewJs(mojom::JSInjection::New(
          std::move(sources), mojom::ExecutionWorld::kMain,
          /*world_id=*/std::nullopt,
          blink::mojom::WantResultOption::kWantResult,
          blink::mojom::UserActivationOption::kDoNotActivate,
          blink::mojom::PromiseResultOption::kAwait)),
      ScriptExecutor::SPECIFIED_FRAMES, {ExtensionApiFrameIdMap::kTopFrameId},
      mojom::MatchOriginAsFallbackBehavior::kNever,
      mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
      GURL() /* webview_src */, helper.GetCallback());
  helper.Wait();

  ASSERT_EQ(1u, helper.results().size());
  EXPECT_EQ(web_contents->GetLastCommittedURL(), helper.results()[0].url);
  EXPECT_EQ(base::Value("executionFlag"), helper.results()[0].value);
  EXPECT_EQ(0, helper.results()[0].frame_id);
  EXPECT_EQ("", helper.results()[0].error);
}

IN_PROC_BROWSER_TEST_F(ScriptExecutorBrowserTest, MainFrameExecution) {
  const Extension* extension =
      LoadExtensionWithHostPermission("http://example.com/*");

  GURL example_com =
      embedded_test_server()->GetURL("example.com", "/simple.html");
  content::WebContents* web_contents = GetActiveWebContents();
  ASSERT_TRUE(web_contents);

  {
    content::TestNavigationObserver nav_observer(web_contents);
    ASSERT_TRUE(NavigateToURL(web_contents, example_com));
    nav_observer.Wait();
    EXPECT_TRUE(nav_observer.last_navigation_succeeded());
  }

  EXPECT_EQ("OK", base::UTF16ToUTF8(web_contents->GetTitle()));

  ScriptExecutor script_executor(web_contents);
  constexpr char kCode[] =
      R"(let oldTitle = document.title;
         document.title = 'New Title';
         oldTitle;
        )";

  ScriptExecutorHelper helper;
  std::vector<mojom::JSSourcePtr> sources;
  sources.push_back(mojom::JSSource::New(kCode, GURL()));
  script_executor.ExecuteScript(
      mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
      mojom::CodeInjection::NewJs(mojom::JSInjection::New(
          std::move(sources), mojom::ExecutionWorld::kIsolated,
          /*world_id=*/std::nullopt,
          blink::mojom::WantResultOption::kWantResult,
          blink::mojom::UserActivationOption::kDoNotActivate,
          blink::mojom::PromiseResultOption::kAwait)),
      ScriptExecutor::SPECIFIED_FRAMES, {ExtensionApiFrameIdMap::kTopFrameId},
      mojom::MatchOriginAsFallbackBehavior::kNever,
      mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
      GURL() /* webview_src */, helper.GetCallback());
  helper.Wait();
  EXPECT_EQ("New Title", base::UTF16ToUTF8(web_contents->GetTitle()));

  ASSERT_EQ(1u, helper.results().size());
  EXPECT_EQ(web_contents->GetLastCommittedURL(), helper.results()[0].url);
  EXPECT_EQ(base::Value("OK"), helper.results()[0].value);
  EXPECT_EQ(0, helper.results()[0].frame_id);
  EXPECT_EQ("", helper.results()[0].error);
}

// Tests injecting multiple JS sources into a frame.
IN_PROC_BROWSER_TEST_F(ScriptExecutorBrowserTest, MultipleSourceExecution) {
  const Extension* extension =
      LoadExtensionWithHostPermission("http://example.com/*");

  GURL example_com =
      embedded_test_server()->GetURL("example.com", "/simple.html");
  content::WebContents* web_contents = GetActiveWebContents();
  ASSERT_TRUE(web_contents);

  {
    content::TestNavigationObserver nav_observer(web_contents);
    ASSERT_TRUE(NavigateToURL(web_contents, example_com));
    nav_observer.Wait();
    EXPECT_TRUE(nav_observer.last_navigation_succeeded());
  }

  EXPECT_EQ("OK", base::UTF16ToUTF8(web_contents->GetTitle()));

  // Inject two pieces of code. Note that the second references a variable set
  // by the first, which thus also exercises injection order (in addition to
  // that they both run).
  ScriptExecutor script_executor(web_contents);
  constexpr char kCode1[] =
      R"(window.newTitle = 'New Title';
         'First Result';)";
  constexpr char kCode2[] =
      R"(document.title = window.newTitle;
         'Second Result';)";

  ScriptExecutorHelper helper;
  std::vector<mojom::JSSourcePtr> sources;
  sources.push_back(mojom::JSSource::New(kCode1, GURL()));
  sources.push_back(mojom::JSSource::New(kCode2, GURL()));
  script_executor.ExecuteScript(
      mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
      mojom::CodeInjection::NewJs(mojom::JSInjection::New(
          std::move(sources), mojom::ExecutionWorld::kIsolated,
          /*world_id=*/std::nullopt,
          blink::mojom::WantResultOption::kWantResult,
          blink::mojom::UserActivationOption::kDoNotActivate,
          blink::mojom::PromiseResultOption::kAwait)),
      ScriptExecutor::SPECIFIED_FRAMES, {ExtensionApiFrameIdMap::kTopFrameId},
      mojom::MatchOriginAsFallbackBehavior::kNever,
      mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
      GURL() /* webview_src */, helper.GetCallback());
  helper.Wait();
  EXPECT_EQ("New Title", base::UTF16ToUTF8(web_contents->GetTitle()));

  ASSERT_EQ(1u, helper.results().size());
  EXPECT_EQ(web_contents->GetLastCommittedURL(), helper.results()[0].url);
  EXPECT_EQ(base::Value("Second Result"), helper.results()[0].value);
  EXPECT_EQ(0, helper.results()[0].frame_id);
  EXPECT_EQ("", helper.results()[0].error);
}

// Tests that scripts that evaluate to promises can be properly waited upon.
IN_PROC_BROWSER_TEST_F(ScriptExecutorBrowserTest, PromisesResolve) {
  const Extension* extension =
      LoadExtensionWithHostPermission("http://example.com/*");

  GURL example_com =
      embedded_test_server()->GetURL("example.com", "/simple.html");
  content::WebContents* web_contents = GetActiveWebContents();
  ASSERT_TRUE(web_contents);

  {
    content::TestNavigationObserver nav_observer(web_contents);
    ASSERT_TRUE(NavigateToURL(web_contents, example_com));
    nav_observer.Wait();
    EXPECT_TRUE(nav_observer.last_navigation_succeeded());
  }

  EXPECT_EQ("OK", base::UTF16ToUTF8(web_contents->GetTitle()));

  ScriptExecutor script_executor(web_contents);

  {
    // Inject two pieces of code. They each evaluate to a promise. The second,
    // `kCode2`, evaluates to a promise that resolves immediately, and then
    // asynchronously resolves the promise from the first, `kCode1`, which
    // changes the title of the page.
    // This guarantees that the renderer code properly waits for *all* results
    // to resolve, and not simply the last one.
    constexpr char kCode1[] =
        R"((new Promise((resolve) => {
              window.resolveFirstPromise = resolve;
           }).then(() => {
              document.title = 'New Title';
           }));)";
    constexpr char kCode2[] =
        R"((new Promise((resolve) => {
              resolve('Second Promise');
              setTimeout(window.resolveFirstPromise, 0);
           }));)";

    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode1, GURL()));
    sources.push_back(mojom::JSSource::New(kCode2, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kAwait)),
        ScriptExecutor::SPECIFIED_FRAMES, {ExtensionApiFrameIdMap::kTopFrameId},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    EXPECT_EQ("New Title", base::UTF16ToUTF8(web_contents->GetTitle()));
    ASSERT_EQ(1u, helper.results().size());
    EXPECT_EQ(web_contents->GetLastCommittedURL(), helper.results()[0].url);
    EXPECT_EQ(base::Value("Second Promise"), helper.results()[0].value);
    EXPECT_EQ(0, helper.results()[0].frame_id);
    EXPECT_EQ("", helper.results()[0].error);
  }

  {
    // Next, inject code that evaluates to a promise, but don't include the
    // "wait_for_promise" flag. The returned result should be the promise
    // itself, which then serializes to an empty object (`{}`).
    constexpr char kCode[] = R"((new Promise((r) => { r('hello'); }));)";

    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kDoNotWait)),
        ScriptExecutor::SPECIFIED_FRAMES, {ExtensionApiFrameIdMap::kTopFrameId},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    ASSERT_EQ(1u, helper.results().size());
    EXPECT_EQ(web_contents->GetLastCommittedURL(), helper.results()[0].url);
    EXPECT_EQ(base::Value(base::Value::Type::DICT), helper.results()[0].value);
    EXPECT_EQ(0, helper.results()[0].frame_id);
    EXPECT_EQ("", helper.results()[0].error);
  }
}

// Tests script execution into a specified set of frames.
IN_PROC_BROWSER_TEST_F(ScriptExecutorBrowserTest, SpecifiedFrames) {
  const Extension* extension =
      LoadExtensionWithHostPermission("http://example.com/*");

  GURL example_com = embedded_test_server()->GetURL(
      "example.com", "/extensions/iframes/main.html");
  content::WebContents* web_contents = GetActiveWebContents();
  ASSERT_TRUE(web_contents);

  {
    content::TestNavigationObserver nav_observer(web_contents);
    ASSERT_TRUE(NavigateToURL(web_contents, example_com));
    nav_observer.Wait();
    EXPECT_TRUE(nav_observer.last_navigation_succeeded());
  }

  // Note: The frame hierarchy for main.html looks like:
  // main
  //   frame1
  //   frame2
  //     frame2_child
  //   frame3
  content::RenderFrameHost* frame1 = GetFrameByName(web_contents, "frame1");
  ASSERT_TRUE(frame1);
  const int frame1_id = ExtensionApiFrameIdMap::GetFrameId(frame1);
  const GURL frame1_url = frame1->GetLastCommittedURL();
  content::RenderFrameHost* frame2 = GetFrameByName(web_contents, "frame2");
  ASSERT_TRUE(frame2);
  const int frame2_id = ExtensionApiFrameIdMap::GetFrameId(frame2);
  const GURL frame2_url = frame2->GetLastCommittedURL();
  content::RenderFrameHost* frame3 = GetFrameByName(web_contents, "frame3");
  ASSERT_TRUE(frame3);
  content::RenderFrameHost* frame2_child =
      GetFrameByName(web_contents, "frame2_child");
  ASSERT_TRUE(frame2_child);
  const int frame2_child_id = ExtensionApiFrameIdMap::GetFrameId(frame2_child);
  const GURL frame2_child_url = frame2_child->GetLastCommittedURL();

  ScriptExecutor script_executor(web_contents);
  // Note: Since other tests verify the code's effects, here we just rely on the
  // execution result as an indication that it ran.
  constexpr char kCode[] = "document.title;";

  const base::Value frame1_result("Frame 1");
  const base::Value frame2_result("Frame 2");
  const base::Value frame2_child_result("Frame 2 Child");

  auto get_result_matcher = [](const base::Value& value, int frame_id,
                               const GURL& url, const std::string& error = "") {
    return ::testing::AllOf(
        ::testing::Field(&ScriptExecutor::FrameResult::value,
                         ::testing::Eq(std::cref(value))),
        ::testing::Field(&ScriptExecutor::FrameResult::frame_id, frame_id),
        ::testing::Field(&ScriptExecutor::FrameResult::url, url),
        ::testing::Field(&ScriptExecutor::FrameResult::error, error));
  };

  {
    // Execute in frames 1 and 2. These are the only frames for which we should
    // get a result.
    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kAwait)),
        ScriptExecutor::SPECIFIED_FRAMES, {frame1_id, frame2_id},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    EXPECT_THAT(helper.results(),
                testing::UnorderedElementsAre(
                    get_result_matcher(frame1_result, frame1_id, frame1_url),
                    get_result_matcher(frame2_result, frame2_id, frame2_url)));
  }

  {
    // Repeat the execution in frames 1 and 2, but include subframes. This
    // should result in frame2_child being added to the results.
    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kAwait)),
        ScriptExecutor::INCLUDE_SUB_FRAMES, {frame1_id, frame2_id},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    EXPECT_THAT(helper.results(),
                testing::UnorderedElementsAre(
                    get_result_matcher(frame1_result, frame1_id, frame1_url),
                    get_result_matcher(frame2_result, frame2_id, frame2_url),
                    get_result_matcher(frame2_child_result, frame2_child_id,
                                       frame2_child_url)));
  }

  // Note: we don't use ExtensionApiFrameIdMap::kInvalidFrameId because we want
  // to target a "potentially valid" frame (emulating a frame that used to
  // exist, but no longer does).
  constexpr int kNonExistentFrameId = 99999;
  EXPECT_EQ(nullptr, ExtensionApiFrameIdMap::GetRenderFrameHostById(
                         web_contents, kNonExistentFrameId));

  {
    // Try injecting into multiple frames when one of the specified frames
    // doesn't exist.
    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kAwait)),
        ScriptExecutor::SPECIFIED_FRAMES,
        {frame1_id, frame2_id, kNonExistentFrameId},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    EXPECT_THAT(helper.results(),
                testing::UnorderedElementsAre(
                    get_result_matcher(frame1_result, frame1_id, frame1_url),
                    get_result_matcher(frame2_result, frame2_id, frame2_url),
                    get_result_matcher(base::Value(), kNonExistentFrameId,
                                       GURL(), "No frame with ID: 99999")));
  }

  {
    // Try injecting into a single non-existent frame.
    ScriptExecutorHelper helper;
    std::vector<mojom::JSSourcePtr> sources;
    sources.push_back(mojom::JSSource::New(kCode, GURL()));
    script_executor.ExecuteScript(
        mojom::HostID(mojom::HostID::HostType::kExtensions, extension->id()),
        mojom::CodeInjection::NewJs(mojom::JSInjection::New(
            std::move(sources), mojom::ExecutionWorld::kIsolated,
            /*world_id=*/std::nullopt,
            blink::mojom::WantResultOption::kWantResult,
            blink::mojom::UserActivationOption::kDoNotActivate,
            blink::mojom::PromiseResultOption::kAwait)),
        ScriptExecutor::SPECIFIED_FRAMES, {kNonExistentFrameId},
        mojom::MatchOriginAsFallbackBehavior::kNever,
        mojom::RunLocation::kDocumentIdle, ScriptExecutor::DEFAULT_PROCESS,
        GURL() /* webview_src */, helper.GetCallback());
    helper.Wait();

    EXPECT_THAT(helper.results(),
                testing::UnorderedElementsAre(
                    get_result_matcher(base::Value(), kNonExistentFrameId,
                                       GURL(), "No frame with ID: 99999")));
  }
}

}  // namespace extensions
