blob: b2fc27b0267624e5f3e919cfa9bac135c58c58e7 [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 "content/browser/attribution_reporting/attribution_interop_parser.h"
#include <sstream>
#include <tuple>
#include <utility>
#include "base/strings/strcat.h"
#include "base/test/values_test_util.h"
#include "base/values.h"
#include "content/public/browser/attribution_reporting.h"
#include "content/public/test/attribution_config.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/abseil-cpp/absl/types/optional.h"
namespace content {
bool operator==(AttributionRateLimitConfig a, AttributionRateLimitConfig b) {
const auto tie = [](AttributionRateLimitConfig config) {
return std::make_tuple(
config.time_window, config.max_source_registration_reporting_origins,
config.max_attribution_reporting_origins, config.max_attributions);
};
return tie(a) == tie(b);
}
bool operator==(AttributionConfig::EventLevelLimit a,
AttributionConfig::EventLevelLimit b) {
const auto tie = [](AttributionConfig::EventLevelLimit config) {
return std::make_tuple(config.navigation_source_trigger_data_cardinality,
config.event_source_trigger_data_cardinality,
config.navigation_source_randomized_response_rate,
config.event_source_randomized_response_rate,
config.max_reports_per_destination,
config.max_attributions_per_navigation_source,
config.max_attributions_per_event_source);
};
return tie(a) == tie(b);
}
bool operator==(AttributionConfig::AggregateLimit a,
AttributionConfig::AggregateLimit b) {
const auto tie = [](AttributionConfig::AggregateLimit config) {
return std::make_tuple(config.max_reports_per_destination,
config.aggregatable_budget_per_source,
config.min_delay, config.delay_span);
};
return tie(a) == tie(b);
}
bool operator==(AttributionConfig a, AttributionConfig b) {
const auto tie = [](AttributionConfig config) {
return std::make_tuple(
config.max_sources_per_origin, config.source_event_id_cardinality,
config.rate_limit, config.event_level_limit, config.aggregate_limit);
};
return tie(a) == tie(b);
}
namespace {
using ::testing::HasSubstr;
using ::testing::Optional;
TEST(AttributionInteropParserTest, ValidInput) {
constexpr char kInputJson[] = R"json({"input": {
"sources": [{
"timestamp": "1643235573123",
"registration_request": {
"attribution_src_url": "https://r.example/path",
"source_origin": "https://s.example",
"source_type": "navigation"
},
"responses": [{
"url": "https://r.example/path",
"response": {
"Attribution-Reporting-Register-Source": {
"destination": "https://d.test",
"source_event_id": "123"
}
}
}]
}],
"triggers": [{
"timestamp": "1643235574123",
"registration_request": {
"attribution_src_url": "https://r.example/path",
"destination_origin": "https://d.example"
},
"responses": [{
"url": "https://r.example/path",
"response": {
"Attribution-Reporting-Register-Event-Trigger": [{
"trigger_data": "7"
}]
}
}]
}]
}})json";
constexpr char kOutputJson[] = R"json({
"sources": [{
"timestamp": "1643235573123",
"source_origin": "https://s.example",
"source_type": "navigation",
"reporting_origin": "https://r.example",
"Attribution-Reporting-Register-Source": {
"destination": "https://d.test",
"source_event_id": "123"
}
}],
"triggers": [{
"timestamp": "1643235574123",
"destination_origin": "https://d.example",
"reporting_origin": "https://r.example",
"Attribution-Reporting-Register-Event-Trigger": [{
"trigger_data": "7"
}]
}]
})json";
base::Value input = base::test::ParseJson(kInputJson);
std::ostringstream error_stream;
EXPECT_THAT(AttributionInteropParser(error_stream)
.SimulatorInputFromInteropInput(input.GetDict()),
Optional(base::test::IsJson(base::test::ParseJson(kOutputJson))));
EXPECT_EQ(error_stream.str(), "");
}
TEST(AttributionInteropParserTest, ValidOutput) {
constexpr char kInputJson[] = R"json({
"event_level_reports": [{
"report_time": "1643235573123",
"report_url": "https://r.example/path",
"report": {
"attribution_destination": "https://d.test",
"randomized_trigger_rate": 0.0024,
"source_event_id": "123",
"source_type": "navigation",
"trigger_data": "7"
}
}],
"aggregatable_reports": [{
"report_time": "1643235573123",
"report_url": "https://r.example/path",
"report": {
"attribution_destination": "https://d.test",
},
"test_info": {
"histograms": [{
"key": "key",
"value": "0x159"
}]
}
}]
})json";
constexpr char kOutputJson[] = R"json({
"event_level_results": [{
"report_time": "1643235573123",
"report_url": "https://r.example/path",
"payload": {
"attribution_destination": "https://d.test",
"randomized_trigger_rate": 0.0024,
"source_event_id": "123",
"source_type": "navigation",
"trigger_data": "7"
}
}],
"aggregatable_results": [{
"report_time": "1643235573123",
"report_url": "https://r.example/path",
"payload": {
"attribution_destination": "https://d.test",
"histograms": [{
"key": "key",
"value": "0x159"
}]
}
}]
})json";
base::Value input = base::test::ParseJson(kInputJson);
std::ostringstream error_stream;
EXPECT_THAT(AttributionInteropParser(error_stream)
.InteropOutputFromSimulatorOutput(std::move(input)),
Optional(base::test::IsJson(base::test::ParseJson(kOutputJson))));
EXPECT_EQ(error_stream.str(), "");
}
TEST(AttributionInteropParserTest, ValidConfig) {
const struct {
const char* json;
bool required;
AttributionConfig expected;
} kTestCases[] = {
{R"json({})json", false, AttributionConfig()},
{R"json({"max_sources_per_origin":"100"})json", false,
AttributionConfig{.max_sources_per_origin = 100}},
{R"json({"max_destinations_per_source_site_reporting_origin":"100"})json",
false,
AttributionConfig{.max_destinations_per_source_site_reporting_origin =
100}},
{R"json({"source_event_id_cardinality":"0"})json", false,
AttributionConfig{.source_event_id_cardinality = absl::nullopt}},
{R"json({"source_event_id_cardinality":"10"})json", false,
AttributionConfig{.source_event_id_cardinality = 10}},
{R"json({"rate_limit_time_window":"30"})json", false,
AttributionConfig{.rate_limit = {.time_window = base::Days(30)}}},
{R"json({"rate_limit_max_source_registration_reporting_origins":"10"})json",
false,
AttributionConfig{
.rate_limit = {.max_source_registration_reporting_origins = 10}}},
{R"json({"rate_limit_max_attribution_reporting_origins":"10"})json",
false,
AttributionConfig{
.rate_limit = {.max_attribution_reporting_origins = 10}}},
{R"json({"rate_limit_max_attributions":"10"})json", false,
AttributionConfig{.rate_limit = {.max_attributions = 10}}},
{R"json({"navigation_source_trigger_data_cardinality":"10"})json", false,
AttributionConfig{
.event_level_limit = {.navigation_source_trigger_data_cardinality =
10}}},
{R"json({"event_source_trigger_data_cardinality":"10"})json", false,
AttributionConfig{
.event_level_limit = {.event_source_trigger_data_cardinality = 10}}},
{R"json({"navigation_source_randomized_response_rate":0.2})json", false,
AttributionConfig{
.event_level_limit = {.navigation_source_randomized_response_rate =
0.2}}},
{R"json({"event_source_randomized_response_rate":0.2})json", false,
AttributionConfig{
.event_level_limit = {.event_source_randomized_response_rate =
0.2}}},
{R"json({"max_event_level_reports_per_destination":"10"})json", false,
AttributionConfig{
.event_level_limit = {.max_reports_per_destination = 10}}},
{R"json({"max_attributions_per_navigation_source":"10"})json", false,
AttributionConfig{
.event_level_limit = {.max_attributions_per_navigation_source =
10}}},
{R"json({"max_attributions_per_event_source":"10"})json", false,
AttributionConfig{
.event_level_limit = {.max_attributions_per_event_source = 10}}},
{R"json({"max_aggregatable_reports_per_destination":"10"})json", false,
AttributionConfig{
.aggregate_limit = {.max_reports_per_destination = 10}}},
{R"json({"aggregatable_budget_per_source":"100"})json", false,
AttributionConfig{
.aggregate_limit = {.aggregatable_budget_per_source = 100}}},
{R"json({"aggregatable_report_min_delay":"0"})json", false,
AttributionConfig{.aggregate_limit = {.min_delay = base::TimeDelta()}}},
{R"json({"aggregatable_report_delay_span":"0"})json", false,
AttributionConfig{.aggregate_limit = {.delay_span = base::TimeDelta()}}},
{R"json({
"max_sources_per_origin":"10",
"max_destinations_per_source_site_reporting_origin":"10",
"source_event_id_cardinality":"100",
"rate_limit_time_window":"10",
"rate_limit_max_source_registration_reporting_origins":"20",
"rate_limit_max_attribution_reporting_origins":"15",
"rate_limit_max_attributions":"10",
"navigation_source_trigger_data_cardinality":"100",
"event_source_trigger_data_cardinality":"10",
"navigation_source_randomized_response_rate":0.2,
"event_source_randomized_response_rate":0.1,
"max_event_level_reports_per_destination":"10",
"max_attributions_per_navigation_source":"5",
"max_attributions_per_event_source":"1",
"max_aggregatable_reports_per_destination":"10",
"aggregatable_budget_per_source":"1000",
"aggregatable_report_min_delay":"10",
"aggregatable_report_delay_span":"20"
})json",
true,
AttributionConfig{
.max_sources_per_origin = 10,
.source_event_id_cardinality = 100,
.max_destinations_per_source_site_reporting_origin = 10,
.rate_limit = {.time_window = base::Days(10),
.max_source_registration_reporting_origins = 20,
.max_attribution_reporting_origins = 15,
.max_attributions = 10},
.event_level_limit = {.navigation_source_trigger_data_cardinality =
100,
.event_source_trigger_data_cardinality = 10,
.navigation_source_randomized_response_rate =
0.2,
.event_source_randomized_response_rate = 0.1,
.max_reports_per_destination = 10,
.max_attributions_per_navigation_source = 5,
.max_attributions_per_event_source = 1},
.aggregate_limit = {.max_reports_per_destination = 10,
.aggregatable_budget_per_source = 1000,
.min_delay = base::Minutes(10),
.delay_span = base::Minutes(20)}}},
};
for (const auto& test_case : kTestCases) {
base::Value json = base::test::ParseJson(test_case.json);
AttributionConfig config;
std::ostringstream error_stream;
EXPECT_TRUE(AttributionInteropParser(error_stream)
.ParseConfig(json, config, test_case.required));
EXPECT_EQ(config, test_case.expected) << json;
EXPECT_EQ(error_stream.str(), "") << json;
}
}
TEST(AttributionInteropParserTest, InvalidConfigPositiveIntegers) {
const char* const kFields[] = {
"max_sources_per_origin",
"max_destinations_per_source_site_reporting_origin",
"rate_limit_time_window",
"rate_limit_max_source_registration_reporting_origins",
"rate_limit_max_attribution_reporting_origins",
"rate_limit_max_attributions",
"navigation_source_trigger_data_cardinality",
"event_source_trigger_data_cardinality",
"max_event_level_reports_per_destination",
"max_attributions_per_navigation_source",
"max_attributions_per_event_source",
"max_aggregatable_reports_per_destination",
"aggregatable_budget_per_source",
};
{
AttributionConfig config;
std::ostringstream error_stream;
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(base::Value::Dict()), config,
/*required=*/true));
for (const char* field : kFields) {
EXPECT_THAT(
error_stream.str(),
HasSubstr(base::StrCat(
{"[\"", field,
"\"]: must be a positive integer formatted as base-10 string"})))
<< field;
}
}
{
AttributionConfig config;
std::ostringstream error_stream;
base::Value::Dict dict;
for (const char* field : kFields) {
dict.Set(field, "0");
}
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(std::move(dict)), config,
/*required=*/false));
for (const char* field : kFields) {
EXPECT_THAT(
error_stream.str(),
HasSubstr(base::StrCat(
{"[\"", field,
"\"]: must be a positive integer formatted as base-10 string"})))
<< field;
}
}
}
TEST(AttributionInteropParserTest, InvalidConfigNonNegativeIntegers) {
const char* const kFields[] = {
"source_event_id_cardinality",
"aggregatable_report_min_delay",
"aggregatable_report_delay_span",
};
{
AttributionConfig config;
std::ostringstream error_stream;
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(base::Value::Dict()), config,
/*required=*/true));
for (const char* field : kFields) {
EXPECT_THAT(error_stream.str(),
HasSubstr(base::StrCat({"[\"", field,
"\"]: must be a non-negative integer "
"formatted as base-10 string"})))
<< field;
}
}
{
AttributionConfig config;
std::ostringstream error_stream;
base::Value::Dict dict;
for (const char* field : kFields) {
dict.Set(field, "-10");
}
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(std::move(dict)), config,
/*required=*/false));
for (const char* field : kFields) {
EXPECT_THAT(error_stream.str(),
HasSubstr(base::StrCat({"[\"", field,
"\"]: must be a non-negative integer "
"formatted as base-10 string"})))
<< field;
}
}
}
TEST(AttributionInteropParserTest, InvalidConfigRandomizedResponseRates) {
const char* const kFields[] = {
"navigation_source_randomized_response_rate",
"event_source_randomized_response_rate",
};
{
AttributionConfig config;
std::ostringstream error_stream;
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(base::Value::Dict()), config,
/*required=*/true));
for (const char* field : kFields) {
EXPECT_THAT(
error_stream.str(),
HasSubstr(base::StrCat(
{"[\"", field,
"\"]: must be a double between 0 and 1 formatted as string"})))
<< field;
}
}
{
AttributionConfig config;
std::ostringstream error_stream;
base::Value::Dict dict;
for (const char* field : kFields) {
dict.Set(field, "1.5");
}
EXPECT_FALSE(AttributionInteropParser(error_stream)
.ParseConfig(base::Value(std::move(dict)), config,
/*required=*/false));
for (const char* field : kFields) {
EXPECT_THAT(
error_stream.str(),
HasSubstr(base::StrCat(
{"[\"", field,
"\"]: must be a double between 0 and 1 formatted as string"})))
<< field;
}
}
}
struct ParseErrorTestCase {
const char* expected_failure_substr;
const char* json;
};
class AttributionInteropParseInputErrorTest
: public testing::TestWithParam<ParseErrorTestCase> {};
TEST_P(AttributionInteropParseInputErrorTest, InvalidInputFails) {
const ParseErrorTestCase& test_case = GetParam();
base::Value value = base::test::ParseJson(test_case.json);
std::ostringstream error_stream;
EXPECT_EQ(AttributionInteropParser(error_stream)
.SimulatorInputFromInteropInput(value.GetDict()),
absl::nullopt);
EXPECT_THAT(error_stream.str(), HasSubstr(test_case.expected_failure_substr));
}
const ParseErrorTestCase kParseInputErrorTestCases[] = {
{
R"(["input"]: must be present)",
R"json({})json",
},
{
R"(["input"]: must be a dictionary)",
R"json({"input": ""})json",
},
{
R"(["input"]["sources"]: must be present)",
R"json({"input": {}})json",
},
{
R"(["input"]["sources"]: must be a list)",
R"json({"input": {"sources": ""}})json",
},
{
R"(["input"]["sources"][0]: must be a dictionary)",
R"json({"input": {"sources": [""]}})json",
},
{
R"(["input"]["sources"][0]["timestamp"]: must be present)",
R"json({"input": {"sources": [{}]}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]: must be present)",
R"json({"input": {"sources": [{}]}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]: must be a dictionary)",
R"json({"input": {
"sources": [{
"registration_request": ""
}]
}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]["attribution_src_url"]: must be present)",
R"json({"input": {
"sources": [{
"registration_request": {}
}]
}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]["attribution_src_url"]: must be a string)",
R"json({"input": {
"sources": [{
"registration_request": {
"attribution_src_url": 1
}
}]
}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]["timestamp"]: must not be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"timestamp": ""
}
}]
}})json",
},
{
R"(["input"]["sources"][0]["registration_request"]["reporting_origin"]: must not be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"reporting_origin": ""
}
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"]: must be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
}
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"]: must be a list)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": ""
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"]: must have size 1)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{}, {}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]: must be a dictionary)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [""]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["url"]: must be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["url"]: must be a string)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{
"url": 1
}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["url"]: must match https://r.test)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{
"url": "https://d.test"
}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["response"]: must be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["response"]: must be a dictionary)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{
"response": ""
}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["response"]["timestamp"]: must not be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{
"response": {
"timestamp": "123"
}
}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["response"]["reporting_origin"]: must not be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test"
},
"responses": [{
"response": {
"reporting_origin": ""
}
}]
}]
}})json",
},
{
R"(["input"]["sources"][0]["responses"][0]["response"]["source_type"]: must not be present)",
R"json({"input": {
"sources": [{
"timestamp": "123",
"registration_request": {
"attribution_src_url": "https://r.test",
"source_type": "event"
},
"responses": [{
"response": {
"source_type": ""
}
}]
}]
}})json",
},
{
R"(["input"]["triggers"]: must be present)",
R"json({"input": {}})json",
},
};
INSTANTIATE_TEST_SUITE_P(AttributionInteropParserInvalidInputs,
AttributionInteropParseInputErrorTest,
::testing::ValuesIn(kParseInputErrorTestCases));
class AttributionInteropParseOutputErrorTest
: public testing::TestWithParam<ParseErrorTestCase> {};
TEST_P(AttributionInteropParseOutputErrorTest, InvalidOutputFails) {
const ParseErrorTestCase& test_case = GetParam();
base::Value value = base::test::ParseJson(test_case.json);
std::ostringstream error_stream;
AttributionInteropParser parser(error_stream);
parser.InteropOutputFromSimulatorOutput(std::move(value));
EXPECT_THAT(error_stream.str(), HasSubstr(test_case.expected_failure_substr));
}
const ParseErrorTestCase kParseOutputErrorTestCases[] = {
{
R"(input root: must be a dictionary)",
R"json(1)json",
},
{
R"(["event_level_reports"]: must be a list)",
R"json({
"event_level_reports": ""
})json",
},
{
R"(["event_level_reports"][0]: must be a dictionary)",
R"json({
"event_level_reports": [""]
})json",
},
{
R"(["event_level_reports"][0]["report"]: must be present)",
R"json({
"event_level_reports": [{}]
})json",
},
{
R"(["event_level_reports"][0]["report_url"]: must be present)",
R"json({
"event_level_reports": [{}]
})json",
},
{
R"(["event_level_reports"][0]["report_time"]: must be present)",
R"json({
"event_level_reports": [{}]
})json",
},
{
R"(["aggregatable_reports"]: must be a list)",
R"json({
"aggregatable_reports": ""
})json",
},
{
R"(["aggregatable_reports"][0]: must be a dictionary)",
R"json({
"aggregatable_reports": [""]
})json",
},
{
R"(["aggregatable_reports"][0]["report_url"]: must be present)",
R"json({
"aggregatable_reports": [{}]
})json",
},
{
R"(["aggregatable_reports"][0]["report_time"]: must be present)",
R"json({
"aggregatable_reports": [{}]
})json",
},
{
R"(["aggregatable_reports"][0]["test_info"]: must be present)",
R"json({
"aggregatable_reports": [{}]
})json",
},
{
R"(["aggregatable_reports"][0]["test_info"]: must be a dictionary)",
R"json({
"aggregatable_reports": [{
"test_info": ""
}]
})json",
},
{
R"(["aggregatable_reports"][0]["report"]: must be present)",
R"json({
"aggregatable_reports": [{
"test_info": {}
}]
})json",
},
{
R"(["aggregatable_reports"][0]["report"]: must be a dictionary)",
R"json({
"aggregatable_reports": [{
"test_info": {},
"report": ""
}]
})json",
},
{
R"(["aggregatable_reports"][0]["report"]["histograms"]: must not be present)",
R"json({
"aggregatable_reports": [{
"report": {
"histograms": ""
},
"test_info": {
"histograms": []
}
}]
})json",
}};
INSTANTIATE_TEST_SUITE_P(AttributionInteropParserInvalidOutputs,
AttributionInteropParseOutputErrorTest,
::testing::ValuesIn(kParseOutputErrorTestCases));
} // namespace
} // namespace content