blob: b0268193e8fcfb55c869bfe5c2e91c7fdca91ef8 [file] [log] [blame]
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/dips/dips_bounce_detector.h"
#include <tuple>
#include "base/functional/bind.h"
#include "base/strings/strcat.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/simple_test_clock.h"
#include "base/test/simple_test_tick_clock.h"
#include "base/test/task_environment.h"
#include "base/time/time.h"
#include "base/types/pass_key.h"
#include "chrome/browser/dips/dips_service.h"
#include "chrome/browser/dips/dips_utils.h"
#include "components/ukm/test_ukm_recorder.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
using base::Bucket;
using base::PassKey;
using testing::ElementsAre;
using testing::Eq;
using testing::Gt;
using testing::Pair;
// Encodes data about a bounce (the url, time of bounce, and
// whether it's stateful) for use when testing that the bounce is
// recorded by the DIPSBounceDetector.
using BounceTuple = std::tuple<GURL, base::Time, bool>;
// Encodes data about an event recorded by DIPS event (the url, time of
// bounce, and type of event) for use when testing that the event is recorded
// by the DIPSBounceDetector.
using EventTuple = std::tuple<GURL, base::Time, DIPSRecordedEvent>;
enum class UserGestureStatus { kNoUserGesture, kWithUserGesture };
constexpr auto kNoUserGesture = UserGestureStatus::kNoUserGesture;
constexpr auto kWithUserGesture = UserGestureStatus::kWithUserGesture;
// Returns a simplified URL representation for ease of comparison in tests.
// Just host+path.
std::string FormatURL(const GURL& url) {
return base::StrCat({url.host_piece(), url.path_piece()});
}
void AppendRedirect(std::vector<std::string>* redirects,
const DIPSRedirectInfo& redirect,
const DIPSRedirectChainInfo& chain) {
redirects->push_back(base::StringPrintf(
"[%d/%d] %s -> %s (%s) -> %s", redirect.index + 1, chain.length,
FormatURL(chain.initial_url).c_str(), FormatURL(redirect.url).c_str(),
CookieAccessTypeToString(redirect.access_type).data(),
FormatURL(chain.final_url).c_str()));
}
std::string URLForRedirectSourceId(ukm::TestUkmRecorder* ukm_recorder,
ukm::SourceId source_id) {
return FormatURL(ukm_recorder->GetSourceForSourceId(source_id)->url());
}
class FakeNavigation;
class TestBounceDetectorDelegate : public DIPSBounceDetectorDelegate {
public:
// DIPSBounceDetectorDelegate overrides:
const GURL& GetLastCommittedURL() const override { return committed_url_; }
ukm::SourceId GetPageUkmSourceId() const override { return source_id_; }
void HandleRedirectChain(std::vector<DIPSRedirectInfoPtr> redirects,
DIPSRedirectChainInfoPtr chain) override {
chain->cookie_mode = DIPSCookieMode::kStandard;
for (auto& redirect : redirects) {
redirect->has_interaction = GetSiteHasInteraction(redirect->url);
DCHECK(redirect->access_type != CookieAccessType::kUnknown);
AppendRedirect(&redirects_, *redirect, *chain);
DIPSService::HandleRedirectForTesting(
*redirect, *chain,
base::BindRepeating(&TestBounceDetectorDelegate::RecordBounce,
base::Unretained(this)));
}
}
void RecordEvent(DIPSRecordedEvent event,
const GURL& url,
const base::Time& time) override {
recorded_events_.insert(std::make_tuple(url, time, event));
}
// Get the (committed) URL that the SourceId was generated for.
const std::string& URLForSourceId(ukm::SourceId source_id) {
return url_by_source_id_[source_id];
}
bool GetSiteHasInteraction(const GURL& url) {
return site_has_interaction_[GetSiteForDIPS(url)];
}
void SetSiteHasInteraction(const GURL& url) {
site_has_interaction_[GetSiteForDIPS(url)] = true;
}
void SetCommittedURL(PassKey<FakeNavigation>, const GURL& url) {
committed_url_ = url;
source_id_ = ukm::AssignNewSourceId();
url_by_source_id_[source_id_] = FormatURL(url);
}
std::set<BounceTuple> GetRecordedBounces() const { return recorded_bounces_; }
std::set<EventTuple> GetRecordedEvents() const { return recorded_events_; }
const std::vector<std::string>& redirects() const { return redirects_; }
private:
void RecordBounce(const GURL& url, base::Time time, bool stateful) {
recorded_bounces_.insert(std::make_tuple(url, time, stateful));
}
GURL committed_url_;
ukm::SourceId source_id_;
std::map<ukm::SourceId, std::string> url_by_source_id_;
std::map<std::string, bool> site_has_interaction_;
std::vector<std::string> redirects_;
std::set<BounceTuple> recorded_bounces_;
std::set<EventTuple> recorded_events_;
};
// If you wait this long, even a navigation without user gesture is not
// considered to be a bounce.
const base::TimeDelta kTooLongForRedirect = base::Seconds(10);
class FakeNavigation : public DIPSNavigationHandle {
public:
FakeNavigation(DIPSBounceDetector* detector,
TestBounceDetectorDelegate* parent,
const GURL& url,
bool has_user_gesture)
: detector_(detector),
delegate_(parent),
has_user_gesture_(has_user_gesture) {
chain_.push_back(url);
detector_->DidStartNavigation(this);
}
~FakeNavigation() override { CHECK(finished_); }
FakeNavigation& RedirectTo(std::string url) {
chain_.emplace_back(std::move(url));
return *this;
}
FakeNavigation& AccessCookie(CookieOperation op) {
detector_->OnServerCookiesAccessed(this, GetURL(), op);
return *this;
}
void Finish(bool commit) {
CHECK(!finished_);
finished_ = true;
has_committed_ = commit;
if (commit) {
previous_url_ = delegate_->GetLastCommittedURL();
delegate_->SetCommittedURL(PassKey<FakeNavigation>(), GetURL());
}
detector_->DidFinishNavigation(this);
}
private:
// DIPSNavigationHandle overrides:
bool HasUserGesture() const override { return has_user_gesture_; }
ServerBounceDetectionState* GetServerState() override { return &state_; }
bool HasCommitted() const override { return has_committed_; }
const GURL& GetPreviousPrimaryMainFrameURL() const override {
return previous_url_;
}
const std::vector<GURL>& GetRedirectChain() const override { return chain_; }
raw_ptr<DIPSBounceDetector> detector_;
raw_ptr<TestBounceDetectorDelegate> delegate_;
const bool has_user_gesture_;
bool finished_ = false;
ServerBounceDetectionState state_;
bool has_committed_ = false;
GURL previous_url_;
std::vector<GURL> chain_;
};
class DIPSBounceDetectorTest : public ::testing::Test {
protected:
FakeNavigation StartNavigation(const std::string& url,
UserGestureStatus status) {
return FakeNavigation(&detector_, &delegate_, GURL(url),
status == kWithUserGesture);
}
void NavigateTo(const std::string& url, UserGestureStatus status) {
StartNavigation(url, status).Finish(true);
}
void AccessClientCookie(CookieOperation op) {
detector_.OnClientCookiesAccessed(delegate_.GetLastCommittedURL(), op);
}
void ActivatePage() { detector_.OnUserActivation(); }
void EndRedirectChain() {
// Committing a new navigation that began with a user gesture will terminate
// any previous redirect chain.
NavigateTo("http://endchain", kWithUserGesture);
}
void AdvanceDIPSTime(base::TimeDelta delta) {
test_clock_.Advance(delta);
test_tick_clock_.Advance(delta);
}
const std::string& URLForNavigationSourceId(ukm::SourceId source_id) {
return delegate_.URLForSourceId(source_id);
}
void SetSiteHasInteraction(const std::string& url) {
return delegate_.SetSiteHasInteraction(GURL(url));
}
std::set<BounceTuple> GetRecordedBounces() const {
return delegate_.GetRecordedBounces();
}
BounceTuple MakeBounceTuple(const std::string& url,
const base::Time& time,
bool stateful) {
return std::make_tuple(GURL(url), time, stateful);
}
std::set<EventTuple> GetRecordedEvents() const {
return delegate_.GetRecordedEvents();
}
EventTuple MakeEventTuple(const std::string& url,
const base::Time& time,
DIPSRecordedEvent event) {
return std::make_tuple(GURL(url), time, event);
}
base::Time GetCurrentTime() { return test_clock_.Now(); }
const std::vector<std::string>& redirects() const {
return delegate_.redirects();
}
private:
TestBounceDetectorDelegate delegate_;
base::SimpleTestTickClock test_tick_clock_;
base::SimpleTestClock test_clock_;
DIPSBounceDetector detector_{&delegate_, &test_tick_clock_, &test_clock_};
};
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_Server) {
NavigateTo("http://a.test", kWithUserGesture);
StartNavigation("http://b.test", kWithUserGesture)
.AccessCookie(CookieOperation::kRead)
.RedirectTo("http://c.test")
.AccessCookie(CookieOperation::kChange)
.RedirectTo("http://d.test")
.AccessCookie(CookieOperation::kRead)
.AccessCookie(CookieOperation::kChange)
.RedirectTo("http://e.test")
.Finish(true);
EndRedirectChain();
EXPECT_THAT(redirects(),
testing::ElementsAre(
("[1/3] a.test/ -> b.test/ (Read) -> e.test/"),
("[2/3] a.test/ -> c.test/ (Write) -> e.test/"),
("[3/3] a.test/ -> d.test/ (ReadWrite) -> e.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(
MakeBounceTuple("http://b.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://c.test", GetCurrentTime(),
/*stateful=*/true),
MakeBounceTuple("http://d.test", GetCurrentTime(),
/*stateful=*/true)));
}
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_Client) {
NavigateTo("http://a.test", kWithUserGesture);
NavigateTo("http://b.test", kWithUserGesture);
AdvanceDIPSTime(base::TimeDelta(base::Seconds(1)));
NavigateTo("http://c.test", kNoUserGesture);
EndRedirectChain();
EXPECT_THAT(redirects(), testing::ElementsAre(
("[1/1] a.test/ -> b.test/ (None) -> c.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(MakeBounceTuple(
"http://b.test", GetCurrentTime(), /*stateful=*/false)));
}
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_Client_MergeCookies) {
NavigateTo("http://a.test", kWithUserGesture);
// Server cookie read:
StartNavigation("http://b.test", kWithUserGesture)
.AccessCookie(CookieOperation::kRead)
.Finish(true);
// Client cookie write:
AccessClientCookie(CookieOperation::kChange);
NavigateTo("http://c.test", kNoUserGesture);
EndRedirectChain();
// Redirect cookie access is reported as ReadWrite.
EXPECT_THAT(redirects(),
testing::ElementsAre(
("[1/1] a.test/ -> b.test/ (ReadWrite) -> c.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(MakeBounceTuple(
"http://b.test", GetCurrentTime(), /*stateful=*/true)));
}
TEST_F(DIPSBounceDetectorTest,
DetectStatefulRedirect_Client_LongDelayNotRedirect) {
NavigateTo("http://a.test", kWithUserGesture);
NavigateTo("http://b.test", kWithUserGesture);
AdvanceDIPSTime(kTooLongForRedirect);
NavigateTo("http://c.test", kNoUserGesture);
EndRedirectChain();
EXPECT_THAT(redirects(), testing::IsEmpty());
EXPECT_THAT(GetRecordedBounces(), testing::IsEmpty());
}
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_ServerClientServer) {
NavigateTo("http://a.test", kWithUserGesture);
StartNavigation("http://b.test", kWithUserGesture)
.RedirectTo("http://c.test")
.Finish(true);
StartNavigation("http://d.test", kNoUserGesture)
.RedirectTo("http://e.test")
.Finish(true);
EndRedirectChain();
EXPECT_THAT(redirects(), testing::ElementsAre(
("[1/3] a.test/ -> b.test/ (None) -> e.test/"),
("[2/3] a.test/ -> c.test/ (None) -> e.test/"),
("[3/3] a.test/ -> d.test/ (None) -> e.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(
MakeBounceTuple("http://b.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://c.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://d.test", GetCurrentTime(),
/*stateful=*/false)));
}
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_Server_Uncommitted) {
NavigateTo("http://a.test", kWithUserGesture);
StartNavigation("http://b.test", kWithUserGesture)
.RedirectTo("http://c.test")
.RedirectTo("http://d.test")
.Finish(false);
// Because the previous navigation didn't commit, the following chain still
// starts from http://a.test/.
StartNavigation("http://e.test", kWithUserGesture)
.RedirectTo("http://f.test")
.Finish(true);
EndRedirectChain();
EXPECT_THAT(redirects(), testing::ElementsAre(
("[1/2] a.test/ -> b.test/ (None) -> d.test/"),
("[2/2] a.test/ -> c.test/ (None) -> d.test/"),
("[1/1] a.test/ -> e.test/ (None) -> f.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(
MakeBounceTuple("http://b.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://c.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://e.test", GetCurrentTime(),
/*stateful=*/false)));
}
TEST_F(DIPSBounceDetectorTest, DetectStatefulRedirect_Client_Uncommitted) {
NavigateTo("http://a.test", kWithUserGesture);
NavigateTo("http://b.test", kWithUserGesture);
StartNavigation("http://c.test", kNoUserGesture)
.RedirectTo("http://d.test")
.Finish(false);
// Because the previous navigation didn't commit, the following chain still
// starts from http://a.test/.
StartNavigation("http://e.test", kNoUserGesture)
.RedirectTo("http://f.test")
.Finish(true);
EndRedirectChain();
EXPECT_THAT(redirects(), testing::ElementsAre(
("[1/2] a.test/ -> b.test/ (None) -> d.test/"),
("[2/2] a.test/ -> c.test/ (None) -> d.test/"),
("[1/2] a.test/ -> b.test/ (None) -> f.test/"),
("[2/2] a.test/ -> e.test/ (None) -> f.test/")));
EXPECT_THAT(GetRecordedBounces(),
testing::UnorderedElementsAre(
MakeBounceTuple("http://b.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://c.test", GetCurrentTime(),
/*stateful=*/false),
MakeBounceTuple("http://e.test", GetCurrentTime(),
/*stateful=*/false)));
}
TEST_F(DIPSBounceDetectorTest, InteractionRecording_Throttled) {
base::Time first_time = GetCurrentTime();
NavigateTo("http://a.test", kNoUserGesture);
ActivatePage();
AdvanceDIPSTime(DIPSBounceDetector::kInteractionUpdateInterval / 2);
ActivatePage();
AdvanceDIPSTime(DIPSBounceDetector::kInteractionUpdateInterval / 2);
base::Time last_time = GetCurrentTime();
ActivatePage();
// Verify only the first and last interactions were recorded. The second
// interaction happened less than |kInteractionUpdateInterval| after the
// first, so it should be ignored.
EXPECT_THAT(GetRecordedEvents(), testing::SizeIs(2));
EXPECT_THAT(GetRecordedEvents(),
testing::UnorderedElementsAre(
MakeEventTuple("http://a.test", first_time,
/*event=*/DIPSRecordedEvent::kInteraction),
MakeEventTuple("http://a.test", last_time,
/*event=*/DIPSRecordedEvent::kInteraction)));
}
TEST_F(DIPSBounceDetectorTest, InteractionRecording_NotThrottled_AfterRefresh) {
base::Time first_time = GetCurrentTime();
NavigateTo("http://a.test", kNoUserGesture);
ActivatePage();
AdvanceDIPSTime(DIPSBounceDetector::kInteractionUpdateInterval / 4);
NavigateTo("http://a.test", kWithUserGesture);
AdvanceDIPSTime(DIPSBounceDetector::kInteractionUpdateInterval / 4);
base::Time last_time = GetCurrentTime();
ActivatePage();
// Verify the first and last interactions were both recorded. Despite the last
// interaction happening less than |kInteractionUpdateInterval| after the
// first, it happened after the page was refreshed, so it should be recorded.
EXPECT_THAT(GetRecordedEvents(), testing::SizeIs(2));
EXPECT_THAT(GetRecordedEvents(),
testing::UnorderedElementsAre(
MakeEventTuple("http://a.test", first_time,
/*event=*/DIPSRecordedEvent::kInteraction),
MakeEventTuple("http://a.test", last_time,
/*event=*/DIPSRecordedEvent::kInteraction)));
}
const std::vector<std::string>& GetAllRedirectMetrics() {
static const std::vector<std::string> kAllRedirectMetrics = {
"ClientBounceDelay",
"CookieAccessType",
"HasStickyActivation",
"InitialAndFinalSitesSame",
"RedirectAndFinalSiteSame",
"RedirectAndInitialSiteSame",
"RedirectChainIndex",
"RedirectChainLength",
"RedirectType",
"SiteEngagementLevel",
};
return kAllRedirectMetrics;
}
TEST_F(DIPSBounceDetectorTest, Histograms_UMA) {
base::HistogramTester histograms;
SetSiteHasInteraction("http://b.test");
NavigateTo("http://a.test", kWithUserGesture);
NavigateTo("http://b.test", kWithUserGesture);
AdvanceDIPSTime(base::Seconds(3));
AccessClientCookie(CookieOperation::kRead);
StartNavigation("http://c.test", kNoUserGesture)
.AccessCookie(CookieOperation::kChange)
.RedirectTo("http://d.test")
.Finish(true);
EndRedirectChain();
base::HistogramTester::CountsMap expected_counts;
expected_counts["Privacy.DIPS.BounceCategoryClient.Standard"] = 1;
expected_counts["Privacy.DIPS.BounceCategoryServer.Standard"] = 1;
EXPECT_THAT(histograms.GetTotalCountsForPrefix("Privacy.DIPS.BounceCategory"),
testing::ContainerEq(expected_counts));
// Verify the proper values were recorded. b.test has user engagement and read
// cookies, while c.test has no user engagement and wrote cookies.
EXPECT_THAT(
histograms.GetAllSamples("Privacy.DIPS.BounceCategoryClient.Standard"),
testing::ElementsAre(
// b.test
Bucket((int)RedirectCategory::kReadCookies_HasEngagement, 1)));
EXPECT_THAT(
histograms.GetAllSamples("Privacy.DIPS.BounceCategoryServer.Standard"),
testing::ElementsAre(
// c.test
Bucket((int)RedirectCategory::kWriteCookies_NoEngagement, 1)));
// Verify the time-to-bounce metric was recorded for the client bounce.
histograms.ExpectBucketCount(
"Privacy.DIPS.TimeFromNavigationCommitToClientBounce",
static_cast<base::HistogramBase::Sample>(
base::Seconds(3).InMilliseconds()),
/*expected_count=*/1);
}
TEST_F(DIPSBounceDetectorTest, Histograms_UKM) {
base::test::SingleThreadTaskEnvironment task_environment;
ukm::TestAutoSetUkmRecorder ukm_recorder;
SetSiteHasInteraction("http://c.test");
NavigateTo("http://a.test", kWithUserGesture);
NavigateTo("http://b.test", kWithUserGesture);
AdvanceDIPSTime(base::Seconds(2));
AccessClientCookie(CookieOperation::kRead);
StartNavigation("http://c.test", kNoUserGesture)
.AccessCookie(CookieOperation::kChange)
.RedirectTo("http://d.test")
.Finish(true);
EndRedirectChain();
std::vector<ukm::TestUkmRecorder::HumanReadableUkmEntry> ukm_entries =
ukm_recorder.GetEntries("DIPS.Redirect", GetAllRedirectMetrics());
ASSERT_EQ(2u, ukm_entries.size());
EXPECT_THAT(URLForNavigationSourceId(ukm_entries[0].source_id),
Eq("b.test/"));
EXPECT_THAT(
ukm_entries[0].metrics,
ElementsAre(Pair("ClientBounceDelay", 2),
Pair("CookieAccessType", (int)CookieAccessType::kRead),
Pair("HasStickyActivation", false),
Pair("InitialAndFinalSitesSame", false),
Pair("RedirectAndFinalSiteSame", false),
Pair("RedirectAndInitialSiteSame", false),
Pair("RedirectChainIndex", 0), Pair("RedirectChainLength", 2),
Pair("RedirectType", (int)DIPSRedirectType::kClient),
Pair("SiteEngagementLevel", 0)));
EXPECT_THAT(URLForRedirectSourceId(&ukm_recorder, ukm_entries[1].source_id),
Eq("c.test/"));
EXPECT_THAT(
ukm_entries[1].metrics,
ElementsAre(Pair("ClientBounceDelay", 0),
Pair("CookieAccessType", (int)CookieAccessType::kWrite),
Pair("HasStickyActivation", false),
Pair("InitialAndFinalSitesSame", false),
Pair("RedirectAndFinalSiteSame", false),
Pair("RedirectAndInitialSiteSame", false),
Pair("RedirectChainIndex", 1), Pair("RedirectChainLength", 2),
Pair("RedirectType", (int)DIPSRedirectType::kServer),
Pair("SiteEngagementLevel", 1)));
}