| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/web_applications/web_app.h" |
| |
| #include <memory> |
| #include <string> |
| #include <string_view> |
| |
| #include "base/base64.h" |
| #include "base/check.h" |
| #include "base/command_line.h" |
| #include "base/files/file.h" |
| #include "base/files/file_path.h" |
| #include "base/files/file_util.h" |
| #include "base/json/json_reader.h" |
| #include "base/json/json_writer.h" |
| #include "base/path_service.h" |
| #include "base/strings/string_number_conversions.h" |
| #include "base/strings/string_util.h" |
| #include "base/values.h" |
| #include "chrome/browser/web_applications/isolated_web_apps/isolated_web_app_integrity_block_data.h" |
| #include "chrome/browser/web_applications/test/fake_web_app_provider.h" |
| #include "chrome/browser/web_applications/test/web_app_install_test_utils.h" |
| #include "chrome/browser/web_applications/test/web_app_test.h" |
| #include "chrome/browser/web_applications/test/web_app_test_utils.h" |
| #include "chrome/browser/web_applications/web_app_constants.h" |
| #include "chrome/browser/web_applications/web_app_helpers.h" |
| #include "chrome/browser/web_applications/web_app_install_info.h" |
| #include "chrome/browser/web_applications/web_app_management_type.h" |
| #include "chrome/browser/web_applications/web_app_registrar.h" |
| #include "chrome/browser/web_applications/web_app_utils.h" |
| #include "chrome/common/chrome_paths.h" |
| #include "components/web_package/signed_web_bundles/ecdsa_p256_public_key.h" |
| #include "components/web_package/signed_web_bundles/ecdsa_p256_sha256_signature.h" |
| #include "components/web_package/signed_web_bundles/signed_web_bundle_signature_stack_entry.h" |
| #include "components/webapps/isolated_web_apps/types/storage_location.h" |
| #include "components/webapps/isolated_web_apps/types/update_channel.h" |
| #include "services/network/public/cpp/permissions_policy/origin_with_possible_wildcards.h" |
| #include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom.h" |
| #include "testing/gtest/include/gtest/gtest.h" |
| #include "url/gurl.h" |
| #include "url/origin.h" |
| |
| namespace web_app { |
| |
| namespace { |
| |
| constexpr std::string_view kGenerateExpectationsMessage = R"( |
| In order to regenerate expectations run |
| the following command: |
| out/<dir>/unit_tests \ |
| --gtest_filter="WebAppTest.*" \ |
| --rebaseline-web-app-expectations |
| )"; |
| |
| base::Value WebAppToPlatformAgnosticDebugValue( |
| std::unique_ptr<WebApp> web_app) { |
| return web_app->AsDebugValueWithOnlyPlatformAgnosticFields(); |
| } |
| |
| base::FilePath GetTestDataDir() { |
| base::FilePath test_data_dir; |
| CHECK(base::PathService::Get(chrome::DIR_TEST_DATA, &test_data_dir)); |
| return test_data_dir; |
| } |
| |
| base::FilePath GetPathRelativeToTestDataDir( |
| const base::FilePath absolute_path) { |
| base::FilePath relative_path; |
| GetTestDataDir().AppendRelativePath(absolute_path, &relative_path); |
| return relative_path; |
| } |
| |
| base::FilePath GetPathToTestFile(std::string_view filename) { |
| return GetTestDataDir().AppendASCII("web_apps").AppendASCII(filename); |
| } |
| |
| std::string GetContentsOrDie(const base::FilePath& filepath) { |
| std::string contents; |
| CHECK(base::ReadFileToString(filepath, &contents)); |
| return contents; |
| } |
| |
| void SetContentsOrDie(const base::FilePath& filepath, |
| std::string_view contents) { |
| CHECK(base::WriteFile(filepath, contents)); |
| } |
| |
| std::string SerializeValueToJsonOrDie(const base::Value& value) { |
| std::string contents; |
| CHECK(base::JSONWriter::WriteWithOptions( |
| value, base::JSONWriter::OPTIONS_PRETTY_PRINT, &contents)); |
| return contents; |
| } |
| |
| base::Value DeserializeValueFromJsonOrDie(std::string_view json) { |
| std::optional<base::Value> value = base::JSONReader::Read(json); |
| CHECK(value.has_value()); |
| return *std::move(value); |
| } |
| |
| bool IsRebaseline() { |
| const base::CommandLine& command_line = |
| *base::CommandLine::ForCurrentProcess(); |
| return command_line.HasSwitch("rebaseline-web-app-expectations"); |
| } |
| |
| void SaveExpectationsContentsOrDie(const base::FilePath path, |
| std::string_view contents) { |
| const std::string current_contents = GetContentsOrDie(path); |
| |
| const base::FilePath test_data_dir_relative_path = |
| GetPathRelativeToTestDataDir(path); |
| |
| if (current_contents != contents) { |
| LOG(INFO) << "New content is generated for " << test_data_dir_relative_path; |
| } else { |
| LOG(INFO) << "No new content is generated for " |
| << test_data_dir_relative_path; |
| } |
| |
| SetContentsOrDie(path, contents); |
| } |
| |
| static constexpr char kEcdsaP256PublicKeyBase64[] = |
| "AxyCBzvQfu1yaF01392k1gu2qCtT1uA2+WfIEhlyJB5S"; |
| static constexpr char kEcdsaP256SHA256SignatureHex[] = |
| "3044022007381524F538B04F99CCC62703F06C87F66EF41BDA18A22D8E57952AA23E53A6" |
| "022063C7F81D3A44798CB95823FA38FC23B15E0483744657FF49E1E83AB8C06B63C2"; |
| |
| IsolatedWebAppIntegrityBlockData CreateIntegrityBlockData() { |
| std::vector<web_package::SignedWebBundleSignatureInfo> signatures; |
| |
| // EcdsaP256SHA256: |
| { |
| auto public_key = *web_package::EcdsaP256PublicKey::Create( |
| *base::Base64Decode(kEcdsaP256PublicKeyBase64)); |
| std::vector<uint8_t> data; |
| CHECK(base::HexStringToBytes(kEcdsaP256SHA256SignatureHex, &data)); |
| auto signature = *web_package::EcdsaP256SHA256Signature::Create(data); |
| signatures.push_back( |
| web_package::SignedWebBundleSignatureInfoEcdsaP256SHA256( |
| std::move(public_key), std::move(signature))); |
| } |
| |
| return IsolatedWebAppIntegrityBlockData(std::move(signatures)); |
| } |
| |
| } // namespace |
| |
| TEST(WebAppTest, HasAnySources) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| |
| EXPECT_FALSE(app.HasAnySources()); |
| for (WebAppManagement::Type source : WebAppManagementTypes::All()) { |
| app.AddSource(source); |
| EXPECT_TRUE(app.HasAnySources()); |
| } |
| |
| for (WebAppManagement::Type source : WebAppManagementTypes::All()) { |
| EXPECT_TRUE(app.HasAnySources()); |
| app.RemoveSource(source); |
| } |
| EXPECT_FALSE(app.HasAnySources()); |
| } |
| |
| TEST(WebAppTest, HasOnlySource) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| |
| for (WebAppManagement::Type source : WebAppManagementTypes::All()) { |
| app.AddSource(source); |
| EXPECT_TRUE(app.HasOnlySource(source)); |
| |
| app.RemoveSource(source); |
| EXPECT_FALSE(app.HasOnlySource(source)); |
| } |
| |
| app.AddSource(WebAppManagement::kMinValue); |
| EXPECT_TRUE(app.HasOnlySource(WebAppManagement::kMinValue)); |
| |
| for (WebAppManagement::Type source : WebAppManagementTypes::All()) { |
| if (source == WebAppManagement::kMinValue) { |
| continue; |
| } |
| app.AddSource(source); |
| EXPECT_FALSE(app.HasOnlySource(source)); |
| EXPECT_FALSE(app.HasOnlySource(WebAppManagement::kMinValue)); |
| } |
| |
| for (WebAppManagement::Type source : WebAppManagementTypes::All()) { |
| if (source == WebAppManagement::kMinValue) { |
| continue; |
| } |
| EXPECT_FALSE(app.HasOnlySource(WebAppManagement::kMinValue)); |
| app.RemoveSource(source); |
| EXPECT_FALSE(app.HasOnlySource(source)); |
| } |
| |
| EXPECT_TRUE(app.HasOnlySource(WebAppManagement::kMinValue)); |
| app.RemoveSource(WebAppManagement::kMinValue); |
| EXPECT_FALSE(app.HasOnlySource(WebAppManagement::kMinValue)); |
| EXPECT_FALSE(app.HasAnySources()); |
| } |
| |
| TEST(WebAppTest, WasInstalledByUser) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| |
| app.AddSource(WebAppManagement::kSync); |
| EXPECT_TRUE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kWebAppStore); |
| EXPECT_TRUE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kSync); |
| EXPECT_TRUE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kWebAppStore); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kOneDriveIntegration); |
| EXPECT_TRUE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kOneDriveIntegration); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kDefault); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kSystem); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kKiosk); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kPolicy); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.AddSource(WebAppManagement::kSubApp); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kDefault); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kSystem); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kKiosk); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kPolicy); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| |
| app.RemoveSource(WebAppManagement::kSubApp); |
| EXPECT_FALSE(app.WasInstalledByUser()); |
| } |
| |
| TEST(WebAppTest, CanUserUninstallWebApp) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| |
| app.AddSource(WebAppManagement::kDefault); |
| EXPECT_TRUE(app.IsPreinstalledApp()); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| |
| app.AddSource(WebAppManagement::kSync); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| app.AddSource(WebAppManagement::kWebAppStore); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| app.AddSource(WebAppManagement::kSubApp); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| app.AddSource(WebAppManagement::kOneDriveIntegration); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| |
| app.AddSource(WebAppManagement::kPolicy); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| app.AddSource(WebAppManagement::kKiosk); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| app.AddSource(WebAppManagement::kSystem); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| |
| app.RemoveSource(WebAppManagement::kSync); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| app.RemoveSource(WebAppManagement::kWebAppStore); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| app.RemoveSource(WebAppManagement::kSubApp); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| |
| app.RemoveSource(WebAppManagement::kSystem); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| |
| app.RemoveSource(WebAppManagement::kKiosk); |
| EXPECT_FALSE(app.CanUserUninstallWebApp()); |
| |
| app.RemoveSource(WebAppManagement::kPolicy); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| |
| app.RemoveSource(WebAppManagement::kOneDriveIntegration); |
| EXPECT_TRUE(app.CanUserUninstallWebApp()); |
| |
| EXPECT_TRUE(app.IsPreinstalledApp()); |
| app.RemoveSource(WebAppManagement::kDefault); |
| EXPECT_FALSE(app.IsPreinstalledApp()); |
| } |
| |
| TEST(WebAppTest, EmptyAppAsDebugValue) { |
| const base::FilePath path_to_test_file = |
| GetPathToTestFile("empty_web_app.json"); |
| const base::Value web_app_debug_value = |
| WebAppToPlatformAgnosticDebugValue(std::make_unique<WebApp>("empty_app")); |
| |
| if (IsRebaseline()) { |
| LOG(INFO) << "Generating expectations empty web app unit test in " |
| << GetPathRelativeToTestDataDir(path_to_test_file); |
| SaveExpectationsContentsOrDie( |
| path_to_test_file, SerializeValueToJsonOrDie(web_app_debug_value)); |
| return; |
| } |
| |
| EXPECT_EQ(DeserializeValueFromJsonOrDie(GetContentsOrDie(path_to_test_file)), |
| web_app_debug_value) |
| << "Debug value of empty web app is unexpected. " |
| << kGenerateExpectationsMessage; |
| } |
| |
| // The values of the SampleApp are randomly generated. This test is mainly |
| // checking that the output is formatted well and doesn't crash. Exact field |
| // values are unimportant. |
| // |
| // If you have made changes and this test is failing, run the test with |
| // `--rebaseline-web-app-expectations` to generate a new `sample_web_app.json`. |
| TEST(WebAppTest, SampleAppAsDebugValue) { |
| const base::FilePath path_to_test_file = |
| GetPathToTestFile("sample_web_app.json"); |
| const base::Value web_app_debug_value = WebAppToPlatformAgnosticDebugValue( |
| test::CreateRandomWebApp({.seed = 1234, .non_zero = true})); |
| |
| if (IsRebaseline()) { |
| LOG(INFO) << "Generating expectations sample web app unit test in " |
| << GetPathRelativeToTestDataDir(path_to_test_file); |
| SaveExpectationsContentsOrDie( |
| path_to_test_file, SerializeValueToJsonOrDie(web_app_debug_value)); |
| return; |
| } |
| |
| EXPECT_EQ(DeserializeValueFromJsonOrDie(GetContentsOrDie(path_to_test_file)), |
| web_app_debug_value) |
| << "Debug value of sample web app is unexpected. " |
| << kGenerateExpectationsMessage; |
| } |
| |
| TEST(WebAppTest, RandomAppAsDebugValue_NoCrash) { |
| for (uint32_t seed = 0; seed < 1000; ++seed) { |
| const base::Value web_app_debug_value = |
| test::CreateRandomWebApp({.seed = seed})->AsDebugValue(); |
| |
| EXPECT_TRUE(web_app_debug_value.is_dict()); |
| EXPECT_TRUE(base::ToString(web_app_debug_value).length() > 10); |
| } |
| } |
| |
| TEST(WebAppTest, IsolationDataStartsEmpty) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| |
| EXPECT_FALSE(app.isolation_data().has_value()); |
| } |
| |
| TEST(WebAppTest, IsolationDataDebugValue) { |
| GURL kStartUrl("isolated-app://random_name"); |
| WebApp app(GenerateManifestIdFromStartUrlOnly(kStartUrl), |
| /*start_url=*/kStartUrl, /*scope=*/kStartUrl); |
| app.SetIsolationData( |
| IsolationData::Builder( |
| IwaStorageOwnedBundle{"random_name", /*dev_mode=*/false}, |
| base::Version("1.0.0")) |
| .Build()); |
| |
| EXPECT_TRUE(app.isolation_data().has_value()); |
| |
| base::Value expected_isolation_data = base::JSONReader::Read(R"|({ |
| "isolated_web_app_location": { |
| "owned_bundle": { |
| "dev_mode": false, |
| "dir_name_ascii": "random_name" |
| } |
| }, |
| "version": "1.0.0", |
| "controlled_frame_partitions (on-disk)": [], |
| "opened_tabs_counter_notification_state": null, |
| "pending_update_info": null, |
| "integrity_block_data": null |
| })|") |
| .value(); |
| |
| base::Value::Dict debug_app = app.AsDebugValue().GetDict().Clone(); |
| base::Value::Dict* debug_isolation_data = |
| debug_app.FindDict("isolation_data"); |
| EXPECT_TRUE(debug_isolation_data != nullptr); |
| EXPECT_EQ(*debug_isolation_data, expected_isolation_data); |
| } |
| |
| TEST(WebAppTest, IsolationDataPendingUpdateInfoDebugValue) { |
| GURL kStartUrl("isolated-app://random_name"); |
| WebApp app(GenerateManifestIdFromStartUrlOnly(kStartUrl), |
| /*start_url=*/kStartUrl, /*scope=*/kStartUrl); |
| |
| static constexpr std::string_view kUpdateManifestUrl = |
| "https://update-manifest.com"; |
| static const UpdateChannel kUpdateChannel = UpdateChannel::default_channel(); |
| |
| auto integrity_block_data = CreateIntegrityBlockData(); |
| app.SetIsolationData( |
| IsolationData::Builder( |
| IwaStorageOwnedBundle{"random_name", /*dev_mode=*/true}, |
| base::Version("1.0.0")) |
| .SetPendingUpdateInfo(IsolationData::PendingUpdateInfo( |
| IwaStorageUnownedBundle{ |
| base::FilePath(FILE_PATH_LITERAL("random_folder"))}, |
| base::Version("2.0.0"), integrity_block_data)) |
| .SetIntegrityBlockData(integrity_block_data) |
| .SetUpdateManifestUrl(GURL(kUpdateManifestUrl)) |
| .SetUpdateChannel(kUpdateChannel) |
| .Build()); |
| |
| EXPECT_TRUE(app.isolation_data().has_value()); |
| |
| auto ib_data_serialized = *base::WriteJson(base::Value::Dict().Set( |
| "signatures", base::Value::List().Append(base::Value::Dict().Set( |
| "ecdsa_p256_sha256", |
| base::Value::Dict() |
| .Set("public_key", kEcdsaP256PublicKeyBase64) |
| .Set("signature", kEcdsaP256SHA256SignatureHex))))); |
| |
| static constexpr std::string_view kExpectedIsolationDataFormat = |
| R"|({ |
| "isolated_web_app_location": { |
| "owned_bundle": { |
| "dev_mode": true, |
| "dir_name_ascii": "random_name" |
| } |
| }, |
| "version": "1.0.0", |
| "controlled_frame_partitions (on-disk)": [], |
| "opened_tabs_counter_notification_state": null, |
| "pending_update_info": { |
| "isolated_web_app_location": { |
| "unowned_bundle": { |
| "path": "random_folder" |
| } |
| }, |
| "version": "2.0.0", |
| "integrity_block_data": $1 |
| }, |
| "integrity_block_data": $2, |
| "update_manifest_url": "$3", |
| "update_channel": "$4" |
| })|"; |
| |
| base::Value expected_isolation_data = |
| *base::JSONReader::Read(base::ReplaceStringPlaceholders( |
| kExpectedIsolationDataFormat, |
| {ib_data_serialized, ib_data_serialized, |
| GURL(kUpdateManifestUrl).spec(), kUpdateChannel.ToString()}, |
| /*offsets=*/nullptr)); |
| |
| base::Value::Dict debug_app = app.AsDebugValue().GetDict().Clone(); |
| base::Value::Dict* debug_isolation_data = |
| debug_app.FindDict("isolation_data"); |
| EXPECT_TRUE(debug_isolation_data != nullptr); |
| EXPECT_EQ(*debug_isolation_data, expected_isolation_data); |
| } |
| |
| TEST(WebAppTest, PermissionsPolicyDebugValue) { |
| WebApp app{GenerateAppId(/*manifest_id_path=*/std::nullopt, |
| GURL("https://example.com"))}; |
| app.SetPermissionsPolicy({ |
| {network::mojom::PermissionsPolicyFeature::kGyroscope, |
| /*allowed_origins=*/{}, |
| /*self_if_matches=*/std::nullopt, |
| /*matches_all_origins=*/false, |
| /*matches_opaque_src=*/true}, |
| {network::mojom::PermissionsPolicyFeature::kGeolocation, |
| /*allowed_origins=*/{}, |
| /*self_if_matches=*/std::nullopt, |
| /*matches_all_origins=*/true, |
| /*matches_opaque_src=*/false}, |
| {network::mojom::PermissionsPolicyFeature::kGamepad, |
| {*network::OriginWithPossibleWildcards::FromOriginAndWildcardsForTest( |
| url::Origin::Create(GURL("https://example.com")), |
| /*has_subdomain_wildcard=*/false), |
| *network::OriginWithPossibleWildcards::FromOriginAndWildcardsForTest( |
| url::Origin::Create(GURL("https://example.net")), |
| /*has_subdomain_wildcard=*/true)}, |
| /*self_if_matches=*/std::nullopt, |
| /*matches_all_origins=*/false, |
| /*matches_opaque_src=*/false}, |
| }); |
| |
| EXPECT_TRUE(!app.permissions_policy().empty()); |
| |
| base::Value expected_permissions_policy = base::JSONReader::Read(R"([ |
| { |
| "allowed_origins": [ ], |
| "feature": "gyroscope", |
| "matches_all_origins": false, |
| "matches_opaque_src": true |
| } |
| , { |
| "allowed_origins": [ ], |
| "feature": "geolocation", |
| "matches_all_origins": true, |
| "matches_opaque_src": false |
| } |
| , { |
| "allowed_origins": [ "https://example.com", "https://*.example.net" ], |
| "feature": "gamepad", |
| "matches_all_origins": false, |
| "matches_opaque_src": false |
| } |
| ])") |
| .value(); |
| |
| base::Value::Dict debug_app = app.AsDebugValue().GetDict().Clone(); |
| base::Value::List* debug_permissions_policy = |
| debug_app.FindList("permissions_policy"); |
| EXPECT_TRUE(debug_permissions_policy != nullptr); |
| EXPECT_EQ(*debug_permissions_policy, expected_permissions_policy); |
| } |
| |
| class WebAppScopeTest : public WebAppTest { |
| public: |
| void SetUp() override { |
| WebAppTest::SetUp(); |
| test::AwaitStartWebAppProviderAndSubsystems(profile()); |
| } |
| }; |
| |
| TEST_F(WebAppScopeTest, TestScopeIgnored) { |
| const GURL kStartUrl("https://www.foo.com/bar/index.html"); |
| const GURL kScopeWithQueryAndFragments = |
| GURL("https://www.foo.com/bar/?query=abc#fragment"); |
| |
| std::unique_ptr<WebAppInstallInfo> install_info = |
| WebAppInstallInfo::CreateWithStartUrlForTesting(kStartUrl); |
| install_info->scope = kScopeWithQueryAndFragments; |
| webapps::AppId app_id = |
| test::InstallWebApp(profile(), std::move(install_info)); |
| |
| EXPECT_EQ(GURL("https://www.foo.com/bar/"), |
| fake_provider().registrar_unsafe().GetAppScope(app_id)); |
| EXPECT_TRUE(fake_provider().registrar_unsafe().IsUrlInAppScope( |
| GURL("https://www.foo.com/bar/"), app_id)); |
| } |
| |
| } // namespace web_app |