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

#include "third_party/blink/renderer/modules/clipboard/clipboard_change_event_controller.h"

#include "base/functional/bind.h"
#include "mojo/public/cpp/system/message_pipe.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/clipboard/clipboard.mojom-blink.h"
#include "third_party/blink/public/mojom/permissions/permission.mojom-blink.h"
#include "third_party/blink/public/mojom/permissions/permission_status.mojom-blink.h"
#include "third_party/blink/public/platform/browser_interface_broker_proxy.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/event_type_names.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/navigator.h"
#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
#include "third_party/blink/renderer/core/testing/sim/sim_test.h"
#include "third_party/blink/renderer/modules/clipboard/clipboard.h"
#include "third_party/blink/renderer/modules/clipboard/clipboard_test_utils.h"
#include "third_party/blink/renderer/modules/clipboard/mock_clipboard_permission_service.h"
#include "third_party/blink/renderer/platform/testing/unit_test_helpers.h"

namespace blink {

class ClipboardChangeEventTest : public ClipboardTestBase {
 public:
  void BindMockPermissionService(ExecutionContext* execution_context) {
    execution_context->GetBrowserInterfaceBroker().SetBinderForTesting(
        mojom::blink::PermissionService::Name_,
        base::BindRepeating(&MockClipboardPermissionService::BindRequest,
                            base::Unretained(&permission_service_)));
  }

  void TearDown() override {
    // Clear the binder before `permission_service_` is destroyed; the broker
    // holds it Unretained, so a later request would be a use-after-free.
    GetFrame().DomWindow()->GetBrowserInterfaceBroker().SetBinderForTesting(
        mojom::blink::PermissionService::Name_, {});
    ClipboardTestBase::TearDown();
  }

 protected:
  MockClipboardPermissionService permission_service_;
};

TEST_F(ClipboardChangeEventTest, ClipboardChangeEventFiresWhenFocused) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  // Set the yet-unset optional in SystemClipboard as we will be triggering
  // events artificially.
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  // Clipboardchange event requires a secure origin and page in focus to work.
  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* clipboard_change_event_handler =
      MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange,
                                 clipboard_change_event_handler, false);
  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Set sticky user activation to ensure the event fires immediately
  // Use the same frame context that the controller will use
  LocalDOMWindow* local_dom_window = To<LocalDOMWindow>(execution_context);
  LocalFrame* test_frame = local_dom_window->GetFrame();
  test_frame->SetStickyUserActivationState();

  // Simulate a clipboard change event from the system clipboard.
  clipboard_change_event_controller->DidUpdateData();

  // Wait for any internal callbacks to run.
  test::RunPendingTasks();

  // Expect a single clipboardchange event to be fired.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 1);

  // Clean up the event listener
  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    clipboard_change_event_handler, false);
}

TEST_F(ClipboardChangeEventTest, ClipboardChangeEventNotFiredWhenNotFocused) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  // Set the yet-unset optional in SystemClipboard as we will be triggering
  // events artificially.
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  SetPageFocus(false);

  auto* clipboard_change_event_handler =
      MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange,
                                 clipboard_change_event_handler, false);
  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Simulate a clipboard change event from the system clipboard.
  clipboard_change_event_controller->DidUpdateData();

  // Wait for any internal callbacks to run.
  test::RunPendingTasks();

  // Expect no clipboardchange event to be fired since the page is not focused.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Simulate more clipboard updates
  clipboard_change_event_controller->DidUpdateData();
  clipboard_change_event_controller->DidUpdateData();

  // Set sticky activation on the same frame the controller will use
  GetDocument().domWindow()->GetFrame()->SetStickyUserActivationState();
  SetPageFocus(true);
  test::RunPendingTasks();

  // Expect a single clipboardchange event to be fired
  // now that the page is focused and has user activation.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 1);

  // Clean up the event listener
  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    clipboard_change_event_handler, false);
}

TEST_F(ClipboardChangeEventTest,
       ClipboardChangeEventFiresWithStickyActivation) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  // Set the yet-unset optional in SystemClipboard as we will be triggering
  // events artificially.
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  // Clipboardchange event requires a secure origin and page in focus to work.
  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* clipboard_change_event_handler =
      MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange,
                                 clipboard_change_event_handler, false);
  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Simulate sticky user activation
  GetFrame().SetStickyUserActivationState();

  // Simulate a clipboard change event from the system clipboard.
  clipboard_change_event_controller->DidUpdateData();

  // Wait for any internal callbacks to run.
  test::RunPendingTasks();

  // Expect a single clipboardchange event to be fired due to sticky activation.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 1);

  // Clean up the event listener
  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    clipboard_change_event_handler, false);
}

TEST_F(ClipboardChangeEventTest,
       ClipboardChangeEventNotFiredWithoutStickyActivationOrPermission) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  // Set the yet-unset optional in SystemClipboard as we will be triggering
  // events artificially.
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* clipboard_change_event_handler =
      MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange,
                                 clipboard_change_event_handler, false);

  // Mock permission service to deny clipboard-read permission
  BindMockPermissionService(execution_context);
  EXPECT_CALL(permission_service_, HasPermission(testing::_, testing::_))
      .WillOnce(
          [](mojom::blink::PermissionDescriptorPtr,
             MockClipboardPermissionService::HasPermissionCallback callback) {
            std::move(callback).Run(
                mojom::blink::PermissionStatusWithDetails::New(
                    mojom::blink::PermissionStatus::DENIED, nullptr));
          });

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // No sticky activation and permission will be denied
  EXPECT_FALSE(GetFrame().HasStickyUserActivation());

  // Simulate a clipboard change event from the system clipboard.
  clipboard_change_event_controller->DidUpdateData();

  // Wait for any internal callbacks to run.
  test::RunPendingTasks();

  // Expect no clipboardchange event to be fired since there's no sticky
  // activation and clipboard-read permission is denied.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Clean up the event listener
  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    clipboard_change_event_handler, false);
}

// Note: Test for clipboard-read permission granted case is omitted due to
// mock permission service binding issues in the test environment that cause
// dangling pointer crashes. The permission denial path is successfully tested
// by ClipboardChangeEventNotFiredWithoutStickyActivationOrPermission.

// The permission check is asynchronous, so the document may lose focus between
// the request and its callback. The event must then be deferred until focus is
// regained rather than fired at an unfocused document.
TEST_F(ClipboardChangeEventTest, PermissionCallbackRechecksFocus) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);
  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* listener = MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange, listener,
                                 false);

  // The first request is captured so it can be answered after focus is lost;
  // regaining focus starts a second request, which is granted immediately.
  BindMockPermissionService(execution_context);
  MockClipboardPermissionService::HasPermissionCallback permission_callback;
  EXPECT_CALL(permission_service_, HasPermission(testing::_, testing::_))
      .WillOnce(
          [&](mojom::blink::PermissionDescriptorPtr,
              MockClipboardPermissionService::HasPermissionCallback callback) {
            permission_callback = std::move(callback);
          })
      .WillRepeatedly(
          [](mojom::blink::PermissionDescriptorPtr,
             MockClipboardPermissionService::HasPermissionCallback callback) {
            std::move(callback).Run(
                mojom::blink::PermissionStatusWithDetails::New(
                    mojom::blink::PermissionStatus::GRANTED, nullptr));
          });

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());
  ASSERT_FALSE(GetFrame().HasStickyUserActivation());
  clipboard_change_event_controller->DidUpdateData();
  test::RunPendingTasks();
  ASSERT_TRUE(permission_callback);

  // Focus is lost while the permission request is in flight.
  SetPageFocus(false);
  std::move(permission_callback)
      .Run(mojom::blink::PermissionStatusWithDetails::New(
          mojom::blink::PermissionStatus::GRANTED, nullptr));
  test::RunPendingTasks();

  // The grant arrived while unfocused, so the event must be deferred.
  EXPECT_EQ(listener->Count(), 0);

  // Regaining focus delivers the deferred event.
  SetPageFocus(true);
  test::RunPendingTasks();
  EXPECT_EQ(listener->Count(), 1);

  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    listener, false);
}

TEST_F(ClipboardChangeEventTest,
       StickyActivationTakesPrecedenceOverPermissionCheck) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  // Set the yet-unset optional in SystemClipboard as we will be triggering
  // events artificially.
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* clipboard_change_event_handler =
      MakeGarbageCollected<EventCountingListener>();
  GetDocument().addEventListener(event_type_names::kClipboardchange,
                                 clipboard_change_event_handler, false);

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  EXPECT_EQ(clipboard_change_event_handler->Count(), 0);

  // Set sticky activation - this should bypass permission check entirely
  GetFrame().SetStickyUserActivationState();
  EXPECT_TRUE(GetFrame().HasStickyUserActivation());

  // Simulate a clipboard change event from the system clipboard.
  clipboard_change_event_controller->DidUpdateData();

  // Wait for any internal callbacks to run.
  test::RunPendingTasks();

  // Expect a single clipboardchange event to be fired due to sticky activation,
  // without any permission check. The presence of sticky activation should
  // make the permission check unnecessary.
  EXPECT_EQ(clipboard_change_event_handler->Count(), 1);

  // Clean up the event listener
  GetDocument().removeEventListener(event_type_names::kClipboardchange,
                                    clipboard_change_event_handler, false);
}

// Regression test: FocusedFrameChanged() must not crash when the execution
// context has been destroyed (e.g. during frame detachment). This reproduces
// the null dereference crash in MaybeDispatchClipboardChangeEvent() reported
// in crbug.com/1771962050.
//
// The real crash path is: Frame::Detach() -> DomWindow()->FrameDestroyed()
// (which calls NotifyContextDestroyed(), making GetExecutionContext() return
// null) -> FocusController::FrameDetached() -> SetFocusedFrame(nullptr) ->
// NotifyFocusChangedObservers() -> FocusedFrameChanged() ->
// MaybeDispatchClipboardChangeEvent() -> null dereference.
TEST_F(ClipboardChangeEventTest, NoCrashWhenFocusChangedAfterContextDestroyed) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  // Page is not focused so that DidUpdateData() sets
  // fire_clipboardchange_on_focus_ = true.
  SetPageFocus(false);

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  // Trigger clipboard change while unfocused. OnClipboardChanged() calls
  // MaybeDispatchClipboardChangeEvent() which sees no focus and sets
  // fire_clipboardchange_on_focus_ = true.
  clipboard_change_event_controller->DidUpdateData();
  test::RunPendingTasks();

  // Simulate frame detachment by calling FrameDestroyed() directly on the
  // DomWindow. This calls NotifyContextDestroyed(), making
  // GetExecutionContext() return null (unit tests may call FrameDestroyed()
  // manually per the code comments in LocalDOMWindow).
  GetFrame().DomWindow()->FrameDestroyed();

  // Calling FocusedFrameChanged() with a destroyed context must not crash.
  // Before the fix: null dereference at *To<LocalDOMWindow>(context) in
  // MaybeDispatchClipboardChangeEvent() because fire_clipboardchange_on_focus_
  // is true and GetExecutionContext() returns null.
  clipboard_change_event_controller->FocusedFrameChanged();
}

// Regression test: DidUpdateData() (via OnClipboardChanged) must not crash
// when the execution context has been destroyed during frame detachment.
TEST_F(ClipboardChangeEventTest,
       NoCrashWhenClipboardChangedAfterContextDestroyed) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  SetPageFocus(true);

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  // Simulate frame detachment.
  GetFrame().DomWindow()->FrameDestroyed();

  // OnClipboardChanged() already has a null check, so this should not crash.
  clipboard_change_event_controller->DidUpdateData();
  test::RunPendingTasks();
}

// Regression test: MaybeDispatchClipboardChangeEvent must not crash during
// page teardown when fire_clipboardchange_on_focus_ is true. This simulates
// the exact crash path through Page::WillBeDestroyed() -> Frame::Detach() ->
// FocusController::FrameDetached() -> NotifyFocusChangedObservers().
TEST_F(ClipboardChangeEventTest,
       NoCrashDuringTeardownWithPendingFocusDispatch) {
  ExecutionContext* execution_context = GetFrame().DomWindow();
  GetFrame().GetSystemClipboard()->OnClipboardDataChanged({"text/plain"}, 1);

  SetSecureOrigin(execution_context);
  // First set page focused to establish the focused frame in FocusController
  // (FocusHasChanged sets focused_frame_ = main_frame when focused is true).
  SetPageFocus(true);

  auto* clipboard_change_event_controller =
      MakeGarbageCollected<ClipboardChangeEventController>(
          *GetFrame().DomWindow()->navigator(), &GetDocument());

  // Unfocus the page. This does NOT clear focused_frame_ (only updates
  // is_active_ and is_focused_ flags). hasFocus() will now return false.
  SetPageFocus(false);

  // Trigger clipboard change while unfocused to set
  // fire_clipboardchange_on_focus_ = true.
  clipboard_change_event_controller->DidUpdateData();
  test::RunPendingTasks();

  // Do NOT call FrameDestroyed() — let PageTestBase::TearDown() destroy the
  // page naturally. During teardown: Page::WillBeDestroyed() -> Frame::Detach()
  // -> DomWindow()->FrameDestroyed() (context destroyed) ->
  // FocusController::FrameDetached() -> SetFocusedFrame(nullptr) (because
  // focused_frame_ == main_frame) -> NotifyFocusChangedObservers() ->
  // FocusedFrameChanged() -> MaybeDispatchClipboardChangeEvent().
  // Before the fix: crashes with null dereference.
}

// Regression test for crbug.com/500385607: addEventListener('clipboardchange')
// on a prerendering document must not bind blink.mojom.ClipboardHost.
class ClipboardChangeEventPrerenderTest : public SimTest {};

TEST_F(ClipboardChangeEventPrerenderTest, NoClipboardHostBindWhilePrerendering) {
  InitializePrerenderPageRoot();
  SimRequest resource("https://example.test/", "text/html");
  LoadURL("https://example.test/");
  resource.Complete("<!DOCTYPE html><html><body></body></html>");
  ASSERT_TRUE(GetDocument().IsPrerendering());

  int clipboard_host_bind_count = 0;
  ASSERT_TRUE(
      GetDocument().GetFrame()->GetBrowserInterfaceBroker().SetBinderForTesting(
          mojom::blink::ClipboardHost::Name_,
          base::BindRepeating(
              [](int* c, mojo::ScopedMessagePipeHandle) { ++*c; },
              base::Unretained(&clipboard_host_bind_count))));

  Navigator* navigator = GetDocument().GetFrame()->DomWindow()->navigator();
  Clipboard* clipboard = Clipboard::clipboard(*navigator);
  auto* listener = MakeGarbageCollected<EventCountingListener>();
  clipboard->addEventListener(event_type_names::kClipboardchange, listener);
  test::RunPendingTasks();

  EXPECT_EQ(clipboard_host_bind_count, 0);

  ASSERT_TRUE(
      GetDocument().GetFrame()->GetBrowserInterfaceBroker().SetBinderForTesting(
          mojom::blink::ClipboardHost::Name_, {}));
}

}  // namespace blink
