| // Copyright 2022 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/private_aggregation/private_aggregation_internals_ui.h" |
| |
| #include <memory> |
| #include <optional> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "base/functional/callback.h" |
| #include "base/observer_list.h" |
| #include "base/test/gmock_callback_support.h" |
| #include "base/time/time.h" |
| #include "content/browser/aggregation_service/aggregation_service.h" |
| #include "content/browser/aggregation_service/aggregation_service_observer.h" |
| #include "content/browser/aggregation_service/aggregation_service_storage.h" |
| #include "content/browser/aggregation_service/aggregation_service_test_utils.h" |
| #include "content/browser/private_aggregation/private_aggregation_test_utils.h" |
| #include "content/browser/storage_partition_impl.h" |
| #include "content/public/browser/browser_context.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/content_browser_test.h" |
| #include "content/public/test/content_browser_test_utils.h" |
| #include "content/shell/browser/shell.h" |
| #include "testing/gmock/include/gmock/gmock.h" |
| #include "url/gurl.h" |
| |
| namespace content { |
| |
| namespace { |
| |
| using GetPendingReportsCallback = base::OnceCallback<void( |
| std::vector<AggregationServiceStorage::RequestAndId>)>; |
| |
| constexpr char kPrivateAggregationInternalsUrl[] = |
| "chrome://private-aggregation-internals/"; |
| |
| const std::u16string kCompleteTitle = u"Complete"; |
| |
| class PrivateAggregationInternalsWebUiBrowserTest : public ContentBrowserTest { |
| public: |
| PrivateAggregationInternalsWebUiBrowserTest() = default; |
| |
| void SetUpOnMainThread() override { |
| ContentBrowserTest::SetUpOnMainThread(); |
| |
| auto aggregation_service = std::make_unique<MockAggregationService>(); |
| |
| ON_CALL(*aggregation_service, GetPendingReportRequestsForWebUI) |
| .WillByDefault([](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder().Build()); |
| }); |
| |
| storage_partition_impl()->OverrideAggregationServiceForTesting( |
| std::move(aggregation_service)); |
| |
| storage_partition_impl()->OverridePrivateAggregationManagerForTesting( |
| std::make_unique<MockPrivateAggregationManagerImpl>( |
| storage_partition_impl())); |
| } |
| |
| // Executing javascript in the WebUI requires using an isolated world in which |
| // to execute the script because WebUI has a default CSP policy denying |
| // "eval()", which is what EvalJs uses under the hood. |
| bool ExecJsInWebUI(const std::string& script) { |
| return ExecJs(shell()->web_contents()->GetPrimaryMainFrame(), script, |
| EXECUTE_SCRIPT_DEFAULT_OPTIONS, /*world_id=*/1); |
| } |
| |
| // Registers a mutation observer that sets the window title to `title` when |
| // the report table is empty. |
| void SetTitleOnReportsTableEmpty(const std::u16string& title) { |
| static constexpr char kObserveEmptyReportsTableScript[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const obs = new MutationObserver((_, obs) => { |
| const kEmptyRowText = 'No sent or pending reports.'; |
| if (table.children.length === 1 && |
| table.children[0].children[0].textContent === kEmptyRowText) { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| EXPECT_TRUE( |
| ExecJsInWebUI(JsReplace(kObserveEmptyReportsTableScript, title))); |
| } |
| |
| void ClickRefreshButton() { |
| EXPECT_TRUE(ExecJsInWebUI("document.getElementById('refresh').click();")); |
| } |
| |
| protected: |
| StoragePartitionImpl* storage_partition_impl() { |
| return static_cast<StoragePartitionImpl*>( |
| shell() |
| ->web_contents() |
| ->GetBrowserContext() |
| ->GetDefaultStoragePartition()); |
| } |
| |
| MockAggregationService& aggregation_service() { |
| return static_cast<MockAggregationService&>( |
| *storage_partition_impl()->GetAggregationService()); |
| } |
| |
| MockPrivateAggregationManagerImpl& private_aggregation_manager() { |
| return static_cast<MockPrivateAggregationManagerImpl&>( |
| *storage_partition_impl()->GetPrivateAggregationManager()); |
| } |
| }; |
| |
| IN_PROC_BROWSER_TEST_F(PrivateAggregationInternalsWebUiBrowserTest, |
| NavigationUrl_ResolvedToWebUI) { |
| EXPECT_TRUE(NavigateToURL(shell(), GURL(kPrivateAggregationInternalsUrl))); |
| |
| EXPECT_EQ(true, EvalJs(shell()->web_contents()->GetPrimaryMainFrame(), |
| "document.body.innerHTML.includes('Private " |
| "Aggregation API Internals');", |
| EXECUTE_SCRIPT_DEFAULT_OPTIONS, /*world_id=*/1)); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(PrivateAggregationInternalsWebUiBrowserTest, |
| WebUIShownWithNoReports_NoReportsDisplayed) { |
| EXPECT_TRUE(NavigateToURL(shell(), GURL(kPrivateAggregationInternalsUrl))); |
| |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle); |
| SetTitleOnReportsTableEmpty(kCompleteTitle); |
| ClickRefreshButton(); |
| EXPECT_EQ(kCompleteTitle, title_watcher.WaitAndGetTitle()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(PrivateAggregationInternalsWebUiBrowserTest, |
| WebUIShownWithReports_ReportsDisplayed) { |
| EXPECT_TRUE(NavigateToURL(shell(), GURL(kPrivateAggregationInternalsUrl))); |
| |
| base::Time now = base::Time::Now(); |
| |
| ON_CALL(aggregation_service(), GetPendingReportRequestsForWebUI) |
| .WillByDefault([now](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder() |
| .AddRequestWithID( |
| aggregation_service::CreateExampleRequestWithReportTime( |
| now), |
| AggregationServiceStorage::RequestId(20)) |
| .Build()); |
| }); |
| |
| aggregation_service::TestHpkeKey hpke_key{/*key_id=*/"id123"}; |
| AggregatableReportRequest request_1 = |
| aggregation_service::CreateExampleRequest(); |
| std::optional<AggregatableReport> report_1 = |
| AggregatableReport::Provider().CreateFromRequestAndPublicKeys( |
| request_1, {hpke_key.GetPublicKey()}); |
| |
| aggregation_service().NotifyReportHandled( |
| std::move(request_1), AggregationServiceStorage::RequestId(1), |
| std::move(report_1), /*report_handled_time=*/now + base::Hours(1), |
| AggregationServiceObserver::ReportStatus::kSent); |
| |
| AggregatableReportRequest request_2 = |
| aggregation_service::CreateExampleRequest(); |
| aggregation_service().NotifyReportHandled( |
| std::move(request_2), AggregationServiceStorage::RequestId(2), |
| /*report=*/std::nullopt, |
| /*report_handled_time=*/now + base::Hours(2), |
| AggregationServiceObserver::ReportStatus::kFailedToAssemble); |
| |
| AggregatableReportRequest request_3 = |
| aggregation_service::CreateExampleRequest(); |
| std::optional<AggregatableReport> report_3 = |
| AggregatableReport::Provider().CreateFromRequestAndPublicKeys( |
| request_3, {hpke_key.GetPublicKey()}); |
| |
| aggregation_service().NotifyReportHandled( |
| std::move(request_3), AggregationServiceStorage::RequestId(3), |
| std::move(report_3), |
| /*report_handled_time=*/now + base::Hours(3), |
| AggregationServiceObserver::ReportStatus::kFailedToSend); |
| |
| { |
| static constexpr char wait_script[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const cell = (a, b) => table.children[a]?.children[b]?.textContent; |
| const obs = new MutationObserver((_, obs) => { |
| const kExpectedBodyStr = |
| '[ { "bucket": { "high": "0", "low": "123" }, "value": 456 }]'; |
| if (table.children.length === 4 && |
| cell(0, 1) === 'Pending' && |
| cell(0, 2) === 'https://reporting.example/example-path' && |
| cell(0, 3) === (new Date($2)).toLocaleString() && |
| cell(0, 4) === 'example-api' && |
| cell(0, 5) === '' && |
| cell(0, 6) === kExpectedBodyStr && |
| cell(1, 1) === 'Sent' && |
| cell(2, 1) === 'Failed to assemble' && |
| cell(3, 1) === 'Failed to send') { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| EXPECT_TRUE(ExecJsInWebUI(JsReplace( |
| wait_script, kCompleteTitle, (now).InMillisecondsFSinceUnixEpoch()))); |
| |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle); |
| ClickRefreshButton(); |
| EXPECT_EQ(kCompleteTitle, title_watcher.WaitAndGetTitle()); |
| } |
| |
| { |
| static constexpr char wait_script[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const cell = (a, b) => table.children[a]?.children[b]?.textContent; |
| const obs = new MutationObserver((_, obs) => { |
| if (table.children.length === 4 && |
| cell(0, 1) === 'Failed to assemble' && |
| cell(1, 1) === 'Failed to send' && |
| cell(2, 1) === 'Pending' && |
| cell(3, 1) === 'Sent') { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| |
| const std::u16string kCompleteTitle2 = u"Complete2"; |
| EXPECT_TRUE(ExecJsInWebUI(JsReplace(wait_script, kCompleteTitle2))); |
| |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle2); |
| // Sort by status ascending. |
| EXPECT_TRUE( |
| ExecJsInWebUI("document.querySelector('#reportTable')" |
| ".shadowRoot.querySelectorAll('th')[1].click();")); |
| EXPECT_EQ(kCompleteTitle2, title_watcher.WaitAndGetTitle()); |
| } |
| |
| { |
| static constexpr char wait_script[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const cell = (a, b) => table.children[a]?.children[b]?.textContent; |
| const obs = new MutationObserver((_, obs) => { |
| if (table.children.length === 4 && |
| cell(0, 1) === 'Sent' && |
| cell(1, 1) === 'Pending' && |
| cell(2, 1) === 'Failed to send' && |
| cell(3, 1) === 'Failed to assemble') { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| |
| const std::u16string kCompleteTitle3 = u"Complete3"; |
| EXPECT_TRUE(ExecJsInWebUI(JsReplace(wait_script, kCompleteTitle3))); |
| |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle3); |
| // Sort by status descending. |
| EXPECT_TRUE( |
| ExecJsInWebUI("document.querySelector('#reportTable')" |
| ".shadowRoot.querySelectorAll('th')[1].click();")); |
| EXPECT_EQ(kCompleteTitle3, title_watcher.WaitAndGetTitle()); |
| } |
| } |
| |
| IN_PROC_BROWSER_TEST_F(PrivateAggregationInternalsWebUiBrowserTest, |
| WebUISendReports_ReportsRemoved) { |
| EXPECT_CALL(aggregation_service(), GetPendingReportRequestsForWebUI) |
| .WillOnce([](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder().Build()); |
| }) // on page loaded |
| .WillOnce([](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder() |
| .AddRequestWithID(aggregation_service::CreateExampleRequest(), |
| AggregationServiceStorage::RequestId(5)) |
| .Build()); |
| }) // on page refresh |
| .WillOnce([](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder().Build()); |
| }); // on page updated |
| |
| EXPECT_TRUE(NavigateToURL(shell(), GURL(kPrivateAggregationInternalsUrl))); |
| |
| EXPECT_CALL(aggregation_service(), |
| SendReportsForWebUI( |
| testing::ElementsAre(AggregationServiceStorage::RequestId(5)), |
| testing::_)) |
| .WillOnce(base::test::RunOnceCallback<1>()); |
| |
| static constexpr char wait_script[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const obs = new MutationObserver((_, obs) => { |
| if (table.children.length === 1 && |
| table.children[0].children[1].textContent === 'Pending') { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| EXPECT_TRUE(ExecJsInWebUI(JsReplace(wait_script, kCompleteTitle))); |
| |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle); |
| ClickRefreshButton(); |
| EXPECT_EQ(kCompleteTitle, title_watcher.WaitAndGetTitle()); |
| |
| // Click the send reports button and expect that the report table is emptied. |
| const std::u16string kSentTitle = u"Sent"; |
| TitleWatcher sent_title_watcher(shell()->web_contents(), kSentTitle); |
| SetTitleOnReportsTableEmpty(kSentTitle); |
| |
| EXPECT_TRUE(ExecJsInWebUI( |
| R"(document.querySelector('#reportTable') |
| .shadowRoot.querySelector('input[type="checkbox"]').click();)")); |
| EXPECT_TRUE( |
| ExecJsInWebUI("document.getElementById('send-reports').click();")); |
| |
| // The real aggregation service would do this itself, but the test aggregation |
| // service requires manual triggering. |
| aggregation_service().NotifyRequestStorageModified(); |
| |
| EXPECT_EQ(kSentTitle, sent_title_watcher.WaitAndGetTitle()); |
| } |
| |
| IN_PROC_BROWSER_TEST_F(PrivateAggregationInternalsWebUiBrowserTest, |
| WebUIClearStorage_ReportsRemoved) { |
| EXPECT_TRUE(NavigateToURL(shell(), GURL(kPrivateAggregationInternalsUrl))); |
| |
| ON_CALL(aggregation_service(), GetPendingReportRequestsForWebUI) |
| .WillByDefault([](GetPendingReportsCallback callback) { |
| std::move(callback).Run( |
| AggregatableReportRequestsAndIdsBuilder() |
| .AddRequestWithID(aggregation_service::CreateExampleRequest(), |
| AggregationServiceStorage::RequestId(5)) |
| .Build()); |
| }); |
| |
| aggregation_service::TestHpkeKey hpke_key{/*key_id=*/"id123"}; |
| AggregatableReportRequest request = |
| aggregation_service::CreateExampleRequest(); |
| std::optional<AggregatableReport> report = |
| AggregatableReport::Provider().CreateFromRequestAndPublicKeys( |
| request, {hpke_key.GetPublicKey()}); |
| |
| aggregation_service().NotifyReportHandled( |
| std::move(request), AggregationServiceStorage::RequestId(10), |
| std::move(report), |
| /*report_handled_time=*/base::Time::Now() + base::Hours(1), |
| AggregationServiceObserver::ReportStatus::kSent); |
| |
| EXPECT_CALL(aggregation_service(), ClearData) |
| .WillOnce(base::test::RunOnceCallback<3>()); |
| |
| EXPECT_CALL(private_aggregation_manager(), ClearBudgetData) |
| .WillOnce(base::test::RunOnceCallback<3>()); |
| |
| // Verify both rows get rendered. |
| static constexpr char wait_script[] = R"( |
| const table = document.querySelector('#reportTable') |
| .shadowRoot.querySelector('tbody'); |
| const obs = new MutationObserver((_, obs) => { |
| if (table.children.length === 2 && |
| table.children[0].children[1].textContent === 'Pending' && |
| table.children[1].children[1].textContent === 'Sent') { |
| obs.disconnect(); |
| document.title = $1; |
| } |
| }); |
| obs.observe(table, {'childList': true});)"; |
| EXPECT_TRUE(ExecJsInWebUI(JsReplace(wait_script, kCompleteTitle))); |
| |
| // Wait for the table to be rendered. |
| TitleWatcher title_watcher(shell()->web_contents(), kCompleteTitle); |
| ClickRefreshButton(); |
| EXPECT_EQ(kCompleteTitle, title_watcher.WaitAndGetTitle()); |
| |
| // Click the clear-data button and expect that the report table is emptied. |
| const std::u16string kDeleteTitle = u"Delete"; |
| TitleWatcher delete_title_watcher(shell()->web_contents(), kDeleteTitle); |
| SetTitleOnReportsTableEmpty(kDeleteTitle); |
| |
| EXPECT_TRUE(ExecJsInWebUI("document.getElementById('clear-data').click();")); |
| |
| EXPECT_EQ(kDeleteTitle, delete_title_watcher.WaitAndGetTitle()); |
| } |
| |
| } // namespace |
| |
| } // namespace content |