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

#include "content/browser/webid/disconnect_request.h"

#include <memory>
#include <optional>
#include <set>
#include <string>
#include <vector>

#include "base/run_loop.h"
#include "base/task/sequenced_task_runner.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/scoped_feature_list.h"
#include "components/ukm/test_ukm_recorder.h"
#include "content/browser/webid/metrics.h"
#include "content/browser/webid/test/mock_api_permission_delegate.h"
#include "content/browser/webid/test/mock_idp_network_request_manager.h"
#include "content/browser/webid/test/mock_permission_delegate.h"
#include "content/browser/webid/webid_utils.h"
#include "content/public/test/navigation_simulator.h"
#include "content/test/test_render_view_host.h"
#include "content/test/test_web_contents.h"
#include "net/http/http_status_code.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/webid/federated_request.mojom.h"
#include "url/gurl.h"
#include "url/origin.h"

namespace content::webid {

using ::testing::_;
using ::testing::NiceMock;
using ::testing::Return;
using DisconnectResponse = IdpNetworkRequestManager::DisconnectResponse;
using LoginState = IdentityRequestAccount::LoginState;
using PermissionStatus =
    FederatedIdentityApiPermissionContextDelegate::PermissionStatus;

namespace {

constexpr char kRpUrl[] = "https://rp.example";
constexpr char kProviderUrl[] = "https://idp.example/fedcm.json";
constexpr char kAccountsEndpoint[] = "https://idp.example/accounts";
constexpr char kDisconnectEndpoint[] = "https://idp.example/disconnect";
constexpr char kTokenEndpoint[] = "https://idp.example/token";
constexpr char kLoginUrl[] = "https://idp.example/login";
constexpr char kClientId[] = "client_id_123";

// Not used?
// constexpr char kIdpDisconnectUrl[] = "https://idp.example/disconnect";

struct AccountConfig {
  std::string id;
  std::optional<IdentityRequestAccount::LoginState> login_state;
  bool was_granted_sharing_permission;
};

struct Config {
  std::vector<AccountConfig> accounts;
  FetchStatus config_fetch_status;
  FetchStatus disconnect_fetch_status;
  std::string config_url;
};

Config kValidConfig = {
    /*accounts=*/
    {{"account1", /*idp_claimed_login_state=*/std::nullopt,
      /*was_granted_sharing_permission=*/true}},
    /*config_fetch_status=*/{ParseStatus::kSuccess, net::HTTP_OK},
    /*disconnect_fetch_status=*/{ParseStatus::kSuccess, net::HTTP_OK},
    kProviderUrl};

url::Origin OriginFromString(const std::string& url_string) {
  return url::Origin::Create(GURL(url_string));
}

// Helper class for receiving the Disconnect method callback.
class DisconnectRequestCallbackHelper {
 public:
  DisconnectRequestCallbackHelper() = default;
  ~DisconnectRequestCallbackHelper() = default;

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

  blink::mojom::DisconnectStatus status() const { return status_; }

  // This can only be called once per lifetime of this object.
  base::OnceCallback<void(blink::mojom::DisconnectStatus)> callback() {
    return base::BindOnce(&DisconnectRequestCallbackHelper::ReceiverMethod,
                          base::Unretained(this));
  }

  // Returns when callback() is called, which can be immediately if it has
  // already been called.
  void WaitForCallback() {
    if (was_called_) {
      return;
    }
    wait_for_callback_loop_.Run();
  }

 private:
  void ReceiverMethod(blink::mojom::DisconnectStatus status) {
    status_ = status;
    was_called_ = true;
    wait_for_callback_loop_.Quit();
  }

  bool was_called_ = false;
  base::RunLoop wait_for_callback_loop_;
  blink::mojom::DisconnectStatus status_;
};

class TestIdpNetworkRequestManager : public MockIdpNetworkRequestManager {
 public:
  explicit TestIdpNetworkRequestManager(const Config& config)
      : config_(config) {}
  ~TestIdpNetworkRequestManager() override = default;

  void FetchWellKnown(const GURL& provider,
                      FetchWellKnownCallback callback) override {
    has_fetched_well_known_ = true;
    FetchStatus fetch_status = {ParseStatus::kSuccess, net::HTTP_OK};
    IdpNetworkRequestManager::WellKnown well_known;
    std::set<GURL> well_known_urls = {GURL(config_.config_url)};
    well_known.provider_urls = std::move(well_known_urls);
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE,
        base::BindOnce(std::move(callback), fetch_status, well_known));
  }

  void FetchConfig(const GURL& provider,
                   int idp_brand_icon_ideal_size,
                   int idp_brand_icon_minimum_size,
                   FetchConfigCallback callback) override {
    has_fetched_config_ = true;

    IdpNetworkRequestManager::Endpoints endpoints;
    endpoints.accounts = GURL(kAccountsEndpoint);
    endpoints.token = GURL(kTokenEndpoint);
    endpoints.disconnect = GURL(kDisconnectEndpoint);

    IdentityProviderMetadata idp_metadata;
    idp_metadata.config_url = GURL(config_.config_url);
    idp_metadata.idp_login_url = GURL(kLoginUrl);
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE,
        base::BindOnce(std::move(callback), config_.config_fetch_status,
                       endpoints, idp_metadata));
  }

  void SendDisconnectRequest(const GURL& disconnect_url,
                             const std::string& account_hint,
                             const std::string& client_id,
                             DisconnectCallback callback) override {
    has_fetched_disconnect_ = true;
    base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
        FROM_HERE,
        base::BindOnce(std::move(callback), config_.disconnect_fetch_status,
                       account_hint));
  }

  bool has_fetched_well_known_{false};
  bool has_fetched_config_{false};
  bool has_fetched_disconnect_{false};

 private:
  const Config config_;
};

class TestPermissionDelegate : public MockPermissionDelegate {
 public:
  std::optional<bool> GetIdpSigninStatus(
      const url::Origin& idp_origin) override {
    return true;
  }
};

}  // namespace

class DisconnectRequestTest : public RenderViewHostImplTestHarness {
 public:
  DisconnectRequestTest() {
    ukm_recorder_ = std::make_unique<ukm::TestAutoSetUkmRecorder>();
  }
  ~DisconnectRequestTest() override = default;

  void SetUp() override {
    RenderViewHostImplTestHarness::SetUp();

    api_permission_delegate_ = std::make_unique<MockApiPermissionDelegate>();
    permission_delegate_ = std::make_unique<TestPermissionDelegate>();

    static_cast<TestWebContents*>(web_contents())
        ->NavigateAndCommit(GURL(kRpUrl), ui::PAGE_TRANSITION_LINK);
  }

  void TearDown() override {
    network_manager_ = nullptr;
    RenderViewHostImplTestHarness::TearDown();
  }

  void RunDisconnectTest(
      const Config& config,
      blink::mojom::DisconnectStatus expected_disconnect_status,
      RenderFrameHost* rfh = nullptr) {
    auto network_manager =
        std::make_unique<TestIdpNetworkRequestManager>(config);
    network_manager_ = network_manager.get();

    if (!rfh) {
      rfh = static_cast<RenderFrameHost*>(main_test_rfh());
    }
    if (expected_disconnect_status !=
            blink::mojom::DisconnectStatus::kSuccess &&
        config.disconnect_fetch_status.parse_status == ParseStatus::kSuccess) {
      EXPECT_CALL(*permission_delegate_, RevokeSharingPermission(_, _, _, _))
          .Times(0);
    }

    auto fedcm_metrics = std::make_unique<Metrics>(rfh->GetPageUkmSourceId());

    blink::mojom::IdentityCredentialDisconnectOptionsPtr options =
        blink::mojom::IdentityCredentialDisconnectOptions::New();
    options->config = blink::mojom::IdentityProviderConfig::New();
    options->config->config_url = GURL(config.config_url);
    options->config->client_id = kClientId;
    options->account_hint = "accountHint";

    DisconnectRequestCallbackHelper callback_helper;
    request_ = DisconnectRequest::Create(
        std::move(network_manager), permission_delegate_.get(), rfh,
        std::move(fedcm_metrics), std::move(options));
    request_->SetCallbackAndStart(callback_helper.callback(),
                                  api_permission_delegate_.get());
    callback_helper.WaitForCallback();

    EXPECT_EQ(expected_disconnect_status, callback_helper.status());
  }

  void ExpectDisconnectMetricsAndConsoleError(
      DisconnectStatus status,
      RequesterFrameType requester_frame_type,
      bool should_record_duration) {
    histogram_tester_.ExpectUniqueSample("Blink.FedCm.Status.Disconnect",
                                         status, 1);
    histogram_tester_.ExpectUniqueSample("Blink.FedCm.Disconnect.FrameType",
                                         requester_frame_type, 1);
    histogram_tester_.ExpectTotalCount("Blink.FedCm.Timing.Disconnect",
                                       should_record_duration ? 1 : 0);
    ExpectDisconnectUKM(ukm::builders::Blink_FedCm::kEntryName, status,
                        requester_frame_type, should_record_duration);
    ExpectDisconnectUKM(ukm::builders::Blink_FedCmIdp::kEntryName, status,
                        requester_frame_type, should_record_duration);

    std::vector<std::string> messages =
        RenderFrameHostTester::For(main_rfh())->GetConsoleMessages();
    if (status == DisconnectStatus::kSuccess) {
      EXPECT_TRUE(messages.empty());
    } else {
      ASSERT_EQ(messages.size(), 1u);
      EXPECT_EQ(messages[0], GetDisconnectConsoleErrorMessage(status));
    }
  }

  void ExpectDisconnectUKM(const char* entry_name,
                           DisconnectStatus status,
                           RequesterFrameType requester_frame_type,
                           bool should_record_duration) {
    auto entries = ukm_recorder()->GetEntriesByName(entry_name);

    ASSERT_FALSE(entries.empty())
        << "No " << entry_name << " entry was recorded";

    // There are multiple types of metrics under the same FedCM UKM. We need to
    // make sure that the metric only includes the expected one.
    bool metric_found = false;
    for (const ukm::mojom::UkmEntry* const entry : entries) {
      if (!should_record_duration) {
        EXPECT_FALSE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
            << "Timing.Disconnect must not be present";
      }
      const int64_t* metric =
          ukm_recorder()->GetEntryMetric(entry, "Status.Disconnect");
      if (!metric) {
        EXPECT_FALSE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
            << "Timing.Disconnect must not be present when Status is not "
               "present";
        EXPECT_FALSE(
            ukm_recorder()->GetEntryMetric(entry, "Disconnect.FrameType"))
            << "Disconnect.FrameType must not be present when Status is not "
               "present";
        continue;
      }
      EXPECT_FALSE(metric_found)
          << "Found more than one entry with Status.Disconnect in "
          << entry_name;
      metric_found = true;
      EXPECT_EQ(static_cast<int>(status), *metric)
          << "Unexpected status recorded in " << entry_name;

      metric = ukm_recorder()->GetEntryMetric(entry, "Disconnect.FrameType");
      ASSERT_TRUE(metric)
          << "Disconnect.FrameType must be present when Status is present";
      EXPECT_EQ(static_cast<int>(requester_frame_type), *metric)
          << "Unexpected frame type recorded in " << entry_name;

      if (should_record_duration) {
        EXPECT_TRUE(ukm_recorder()->GetEntryMetric(entry, "Timing.Disconnect"))
            << "Timing.Disconnect must be present in the same entry as "
               "Status.Disconnect";
      }
    }
    EXPECT_TRUE(metric_found)
        << "No Status.Disconnect entry was found in " << entry_name;
  }

  bool DidFetchAnyEndpoint() {
    return network_manager_->has_fetched_well_known_ ||
           network_manager_->has_fetched_config_ ||
           network_manager_->has_fetched_disconnect_;
  }

  bool DidFetchAllEndpoints() {
    return network_manager_->has_fetched_well_known_ &&
           network_manager_->has_fetched_config_ &&
           network_manager_->has_fetched_disconnect_;
  }

  ukm::TestAutoSetUkmRecorder* ukm_recorder() { return ukm_recorder_.get(); }

 protected:
  raw_ptr<TestIdpNetworkRequestManager> network_manager_;
  std::unique_ptr<MockApiPermissionDelegate> api_permission_delegate_;
  std::unique_ptr<TestPermissionDelegate> permission_delegate_;
  std::unique_ptr<DisconnectRequest> request_;
  base::HistogramTester histogram_tester_;
  std::unique_ptr<ukm::TestAutoSetUkmRecorder> ukm_recorder_;
};

TEST_F(DisconnectRequestTest, Success) {
  Config config = kValidConfig;
  EXPECT_CALL(
      *permission_delegate_,
      HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
                           OriginFromString(kProviderUrl)))
      .WillOnce(Return(true));
  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kRpUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kSuccess);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kSuccess,
                                         RequesterFrameType::kMainFrame,
                                         /*should_record_duration=*/true);
}

TEST_F(DisconnectRequestTest, NotTrustworthyIdP) {
  Config config = kValidConfig;
  config.config_url = "http://idp.example/fedcm.json";
  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kError);
  EXPECT_FALSE(DidFetchAnyEndpoint());

  ExpectDisconnectMetricsAndConsoleError(
      DisconnectStatus::kIdpNotPotentiallyTrustworthy,
      RequesterFrameType::kMainFrame,
      /*should_record_duration=*/false);
}

TEST_F(DisconnectRequestTest,
       NoSharingPermissionButIdpHasThirdPartyCookiesAccessAndClaimsSignin) {
  const char kAccountId[] = "account";

  Config config = kValidConfig;
  config.accounts = {{kAccountId,
                      /*idp_claimed_login_state=*/LoginState::kSignIn,
                      /*was_granted_sharing_permission=*/false}};

  // Pretend the IdP was given third-party cookies access.
  EXPECT_CALL(*api_permission_delegate_,
              HasThirdPartyCookiesAccess(_, GURL(kProviderUrl),
                                         url::Origin::Create(GURL(kRpUrl))))
      .WillOnce(Return(true));
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));

  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kRpUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));
  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kSuccess);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kSuccess,
                                         RequesterFrameType::kMainFrame,
                                         /*should_record_duration=*/true);
}

TEST_F(DisconnectRequestTest, SameSiteIframe) {
  const char kSameSiteIframeUrl[] = "https://rp.example/iframe.html";
  RenderFrameHost* same_site_iframe =
      NavigationSimulator::NavigateAndCommitFromDocument(
          GURL(kSameSiteIframeUrl),
          RenderFrameHostTester::For(web_contents()->GetPrimaryMainFrame())
              ->AppendChild("same_site_iframe"));
  Config config = kValidConfig;
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));
  EXPECT_CALL(*permission_delegate_,
              HasSharingPermission(OriginFromString(kSameSiteIframeUrl),
                                   OriginFromString(kRpUrl),
                                   OriginFromString(kProviderUrl)))
      .WillOnce(Return(true));

  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kSameSiteIframeUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));
  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kSuccess,
                    same_site_iframe);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kSuccess,
                                         RequesterFrameType::kSameSiteIframe,
                                         /*should_record_duration=*/true);
}

TEST_F(DisconnectRequestTest, CrossSiteIframe) {
  // FedCM works due to third party cookies since sharing permission is not set
  // for the cross-site RP.
  const char kCrossSiteIframeUrl[] = "https://otherrp.com";
  RenderFrameHost* cross_site_iframe =
      NavigationSimulator::NavigateAndCommitFromDocument(
          GURL(kCrossSiteIframeUrl),
          RenderFrameHostTester::For(web_contents()->GetPrimaryMainFrame())
              ->AppendChild("cross_site_iframe"));
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));
  EXPECT_CALL(*permission_delegate_,
              HasSharingPermission(OriginFromString(kCrossSiteIframeUrl),
                                   OriginFromString(kRpUrl),
                                   OriginFromString(kProviderUrl)))
      .WillOnce(Return(true));
  Config config = kValidConfig;

  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kCrossSiteIframeUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));
  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kSuccess,
                    cross_site_iframe);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kSuccess,
                                         RequesterFrameType::kCrossSiteIframe,
                                         /*should_record_duration=*/true);
}

TEST_F(DisconnectRequestTest, NoAccountToDisconnect) {
  Config config = kValidConfig;
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));
  EXPECT_CALL(
      *permission_delegate_,
      HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
                           OriginFromString(kProviderUrl)))
      .WillOnce(Return(false));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kError);
  EXPECT_FALSE(DidFetchAnyEndpoint());

  ExpectDisconnectMetricsAndConsoleError(
      DisconnectStatus::kNoAccountToDisconnect, RequesterFrameType::kMainFrame,
      /*should_record_duration=*/false);
}

TEST_F(DisconnectRequestTest, DisabledInSettings) {
  Config config = kValidConfig;
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::BLOCKED_SETTINGS));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kError);
  EXPECT_FALSE(DidFetchAnyEndpoint());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kDisabledInSettings,
                                         RequesterFrameType::kMainFrame,
                                         /*should_record_duration=*/false);
}

TEST_F(DisconnectRequestTest, DisabledInFlags) {
  Config config = kValidConfig;
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::BLOCKED_VARIATIONS));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kError);
  EXPECT_FALSE(DidFetchAnyEndpoint());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kDisabledInFlags,
                                         RequesterFrameType::kMainFrame,
                                         /*should_record_duration=*/false);
}

// Tests that disconnect() succeeds even if FedCM is under embargo (e.g.
// cooldown).
TEST_F(DisconnectRequestTest, SuccessDespiteEmbargo) {
  Config config = kValidConfig;
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::BLOCKED_EMBARGO));
  EXPECT_CALL(
      *permission_delegate_,
      HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
                           OriginFromString(kProviderUrl)))
      .WillOnce(Return(true));
  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kRpUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kSuccess);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(DisconnectStatus::kSuccess,
                                         RequesterFrameType::kMainFrame,
                                         /*should_record_duration=*/true);
}

TEST_F(DisconnectRequestTest, DisconnectBlockedByConnectionAllowlist) {
  Config config = kValidConfig;
  config.disconnect_fetch_status = {ParseStatus::kBlockedByConnectionAllowlist,
                                    net::ERR_NETWORK_ACCESS_REVOKED};
  EXPECT_CALL(*api_permission_delegate_,
              GetApiPermissionStatus(OriginFromString(kRpUrl)))
      .WillOnce(Return(PermissionStatus::GRANTED));
  EXPECT_CALL(
      *permission_delegate_,
      HasSharingPermission(OriginFromString(kRpUrl), OriginFromString(kRpUrl),
                           OriginFromString(kProviderUrl)))
      .WillOnce(Return(true));
  EXPECT_CALL(*permission_delegate_,
              RevokeSharingPermission(OriginFromString(kRpUrl),
                                      OriginFromString(kRpUrl),
                                      OriginFromString(kProviderUrl), _));

  RunDisconnectTest(config, blink::mojom::DisconnectStatus::kError);
  EXPECT_TRUE(DidFetchAllEndpoints());

  ExpectDisconnectMetricsAndConsoleError(
      DisconnectStatus::kDisconnectBlockedByConnectionAllowlist,
      RequesterFrameType::kMainFrame,
      /*should_record_duration=*/true);
}

}  // namespace content::webid
