blob: eba8359440c70228a5f7ea39dbc79b4c74b71d41 [file] [log] [blame]
// Copyright 2025 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/renderer/local_resource_url_loader_factory.h"
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "base/check.h"
#include "base/containers/flat_map.h"
#include "base/containers/span.h"
#include "base/functional/callback_forward.h"
#include "base/memory/raw_ptr.h"
#include "base/memory/ref_counted_memory.h"
#include "base/memory/scoped_refptr.h"
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "base/test/bind.h"
#include "base/test/task_environment.h"
#include "base/threading/sequence_bound.h"
#include "content/common/web_ui_loading_util.h"
#include "mojo/public/c/system/data_pipe.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_remote.h"
#include "mojo/public/cpp/system/data_pipe_utils.h"
#include "net/base/net_errors.h"
#include "net/http/http_response_headers.h"
#include "net/http/http_version.h"
#include "net/socket/socket.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/mojom/url_loader.mojom.h"
#include "services/network/public/mojom/url_loader_factory.mojom.h"
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "services/network/test/test_url_loader_client.h"
#include "services/network/test/test_url_loader_factory.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "third_party/blink/public/mojom/loader/local_resource_loader_config.mojom.h"
#include "ui/base/resource/mock_resource_bundle_delegate.h"
#include "ui/base/resource/resource_bundle.h"
#include "ui/base/resource/resource_scale_factor.h"
#include "url/origin.h"
#include "url/url_util.h"
namespace {
// A URLLoaderFactory that always sends the string "out-of-process resource" to
// the client.
class FakeURLLoaderFactory : public network::mojom::URLLoaderFactory {
public:
FakeURLLoaderFactory() : receiver_(this) {}
void CreateLoaderAndStart(
mojo::PendingReceiver<network::mojom::URLLoader> loader,
int32_t request_id,
uint32_t options,
const network::ResourceRequest& request,
mojo::PendingRemote<network::mojom::URLLoaderClient> client,
const net::MutableNetworkTrafficAnnotationTag& traffic_annotation)
override {
auto headers = network::mojom::URLResponseHead::New();
auto bytes =
base::MakeRefCounted<base::RefCountedString>("out-of-process resource");
content::webui::SendData(std::move(headers), std::move(client),
std::nullopt, std::move(bytes));
}
void Clone(mojo::PendingReceiver<network::mojom::URLLoaderFactory> receiver)
override {
// Supports only one receiver at a time.
receiver_.reset();
receiver_.Bind(std::move(receiver));
}
private:
mojo::Receiver<network::mojom::URLLoaderFactory> receiver_;
};
class LocalResourceURLLoaderFactoryTest : public ::testing::Test {
public:
void SetUp() override {
source_ = blink::mojom::LocalResourceSource::New();
source_->headers =
net::HttpResponseHeaders::Builder(net::HttpVersion(1, 1), "200 OK")
.Build()
->raw_headers();
UpdateLoaderFactory();
}
protected:
// Used to control the behavior of the mock ResourceBundle.
// Marked non-private so that tests can call EXPECT_CALL on it.
testing::NiceMock<ui::MockResourceBundleDelegate> resource_bundle_delegate_;
content::LocalResourceURLLoaderFactory* loader_factory() {
return loader_factory_.get();
}
void SetShouldReplaceI18nInJs(bool value) {
source_->should_replace_i18n_in_js = value;
UpdateLoaderFactory();
}
void AddReplacementString(const std::string& key, const std::string& value) {
source_->replacement_strings[key] = value;
UpdateLoaderFactory();
}
void AddResourceID(const std::string& path, int id) {
source_->path_to_resource_id_map[path] = id;
UpdateLoaderFactory();
}
std::string ReadAllData(network::TestURLLoaderClient& client) {
std::string result;
CHECK(mojo::BlockingCopyToString(client.response_body_release(), &result));
return result;
}
private:
// Create a config with a |source_| as the single hardcoded entry in the map.
void UpdateLoaderFactory() {
const url::Origin origin = url::Origin::Create(GURL("chrome://sourcename"));
auto config = blink::mojom::LocalResourceLoaderConfig::New();
config->sources[origin] = source_.Clone();
// Create a pipe and pass receiving end to |fake_fallback_factory_|.
mojo::PendingRemote<network::mojom::URLLoaderFactory> pending_remote;
fake_fallback_factory_.Clone(
pending_remote.InitWithNewPipeAndPassReceiver());
// Pass other end to |loader_factory_|.
loader_factory_ = std::make_unique<content::LocalResourceURLLoaderFactory>(
std::move(config), std::move(pending_remote));
}
std::unique_ptr<content::LocalResourceURLLoaderFactory> loader_factory_;
FakeURLLoaderFactory fake_fallback_factory_;
// Intermediate state that is updated by the test and eventually used to
// update the loader factory state.
blink::mojom::LocalResourceSourcePtr source_;
// A ResourceBundle that uses the test's mock delegate.
ui::ResourceBundle resource_bundle_with_mock_delegate_{
&resource_bundle_delegate_};
// Swap in the test ResourceBundle for the lifetime of the test.
ui::ResourceBundle::SharedInstanceSwapperForTesting resource_bundle_swapper_{
&resource_bundle_with_mock_delegate_};
// For CreateLoaderAndStart, which posts a task.
base::test::TaskEnvironment task_environment_;
};
struct CanServeTestCase {
std::optional<int> resource_id;
bool has_resource;
bool can_serve;
};
struct ServeTestCase {
std::string path;
std::string mime_type;
std::string resource_data;
std::string response_body;
};
struct RequestRangeTestCase {
std::string request_range;
int error_code;
std::string resource_data;
std::string response_body;
};
class LocalResourceURLLoaderFactoryCanServeTest
: public LocalResourceURLLoaderFactoryTest,
public ::testing::WithParamInterface<CanServeTestCase> {};
class LocalResourceURLLoaderFactoryServeTest
: public LocalResourceURLLoaderFactoryTest,
public ::testing::WithParamInterface<ServeTestCase> {};
class LocalResourceURLLoaderFactoryRequestRangeTest
: public LocalResourceURLLoaderFactoryTest,
public ::testing::WithParamInterface<RequestRangeTestCase> {};
} // namespace
// Check if loader factory can service a particular request.
TEST_P(LocalResourceURLLoaderFactoryCanServeTest, CanServe) {
const std::string path = "path/to/resource";
if (GetParam().resource_id) {
AddResourceID(path, *GetParam().resource_id);
}
const scoped_refptr<base::RefCountedString> resource_data =
base::MakeRefCounted<base::RefCountedString>("in-process resource");
ON_CALL(resource_bundle_delegate_, HasDataResource)
.WillByDefault(testing::Return(GetParam().has_resource));
ON_CALL(resource_bundle_delegate_, LoadDataResourceBytes)
.WillByDefault(testing::Return(resource_data.get()));
network::TestURLLoaderClient client;
network::ResourceRequest request;
// The test fixture hardcodes the origin to 'chrome://sourcename'.
request.url = GURL("chrome://sourcename/" + path);
mojo::PendingRemote<network::mojom::URLLoader> loader;
loader_factory()->CreateLoaderAndStart(
loader.InitWithNewPipeAndPassReceiver(), 0, 0, request,
client.CreateRemote(), net::MutableNetworkTrafficAnnotationTag());
client.RunUntilComplete();
ASSERT_EQ(net::OK, client.completion_status().error_code);
ASSERT_TRUE(client.response_body().is_valid());
std::string response_body = ReadAllData(client);
if (GetParam().can_serve) {
EXPECT_EQ(response_body, "in-process resource");
} else {
EXPECT_EQ(response_body, "out-of-process resource");
}
}
INSTANTIATE_TEST_SUITE_P(
LocalResourceURLLoaderFactoryCanServeTest,
LocalResourceURLLoaderFactoryCanServeTest,
::testing::Values(
// Resource ID exists and ResourceBundle has the resource.
CanServeTestCase(std::make_optional(1), true, true),
// Resource ID exists but ResourceBundle does not have the resource.
CanServeTestCase(std::make_optional(1), false, false),
// Resource ID does not exist in mapping.
CanServeTestCase(std::nullopt, false, false)));
// Create loader, read bytes from the ResourceBundle and send them to the
// client.
TEST_P(LocalResourceURLLoaderFactoryServeTest, Serve) {
const int resource_id = 1;
const scoped_refptr<base::RefCountedString> resource_data =
base::MakeRefCounted<base::RefCountedString>(GetParam().resource_data);
AddResourceID(GetParam().path, resource_id);
AddReplacementString("foo", "bar");
SetShouldReplaceI18nInJs(true);
// Bypass the fallback by making CanServe return true.
ON_CALL(resource_bundle_delegate_, HasDataResource)
.WillByDefault(testing::Return(true));
EXPECT_CALL(resource_bundle_delegate_,
LoadDataResourceBytes(resource_id,
ui::ResourceScaleFactor::kScaleFactorNone))
.WillOnce(testing::Return(resource_data.get()));
network::TestURLLoaderClient client;
network::ResourceRequest request;
// The test fixture hardcodes the origin to 'chrome://sourcename'.
request.url = GURL("chrome://sourcename/" + GetParam().path);
mojo::PendingRemote<network::mojom::URLLoader> loader;
loader_factory()->CreateLoaderAndStart(
loader.InitWithNewPipeAndPassReceiver(), 0, 0, request,
client.CreateRemote(), net::MutableNetworkTrafficAnnotationTag());
client.RunUntilComplete();
ASSERT_EQ(net::OK, client.completion_status().error_code);
EXPECT_EQ(GetParam().mime_type, client.response_head()->mime_type);
ASSERT_TRUE(client.response_body().is_valid());
std::string response_body = ReadAllData(client);
EXPECT_EQ(GetParam().response_body, response_body);
}
INSTANTIATE_TEST_SUITE_P(
LocalResourceURLLoaderFactoryServeTest,
LocalResourceURLLoaderFactoryServeTest,
::testing::Values(
// MIME type is assumed to be text/html. String replacement occurs.
ServeTestCase("path/to/resource",
"text/html",
"this is $i18n{foo}",
"this is bar"),
// MIME type is text/html. String replacement occurs.
ServeTestCase("path/to/resource.html",
"text/html",
"this is $i18n{foo}",
"this is bar"),
// MIME type is text/css. String replacement occurs.
ServeTestCase("path/to/resource.css",
"text/css",
"this is $i18n{foo}",
"this is bar"),
// MIME type is text/javascript. String replacement only occurs within
// HTML template section.
ServeTestCase("path/to/resource.js",
"text/javascript",
"this is $i18n{foo}",
"this is $i18n{foo}")));
// Request various byte ranges which may or may not be valid.
TEST_P(LocalResourceURLLoaderFactoryRequestRangeTest, RequestRange) {
const std::string path = "path/to/resource";
const int resource_id = 1;
const scoped_refptr<base::RefCountedString> resource_data =
base::MakeRefCounted<base::RefCountedString>(GetParam().resource_data);
AddResourceID(path, resource_id);
// Bypass the fallback by making CanServe return true.
ON_CALL(resource_bundle_delegate_, HasDataResource)
.WillByDefault(testing::Return(true));
EXPECT_CALL(resource_bundle_delegate_,
LoadDataResourceBytes(resource_id,
ui::ResourceScaleFactor::kScaleFactorNone))
.WillOnce(testing::Return(resource_data.get()));
network::TestURLLoaderClient client;
network::ResourceRequest request;
// The test fixture hardcodes the origin to 'chrome://sourcename'.
request.url = GURL("chrome://sourcename/" + path);
request.headers.SetHeader(net::HttpRequestHeaders::kRange,
GetParam().request_range);
mojo::PendingRemote<network::mojom::URLLoader> loader;
loader_factory()->CreateLoaderAndStart(
loader.InitWithNewPipeAndPassReceiver(), 0, 0, request,
client.CreateRemote(), net::MutableNetworkTrafficAnnotationTag());
client.RunUntilComplete();
EXPECT_EQ(GetParam().error_code, client.completion_status().error_code);
if (GetParam().error_code != net::OK) {
return;
}
ASSERT_TRUE(client.response_body().is_valid());
std::string response_body = ReadAllData(client);
EXPECT_EQ(GetParam().response_body, response_body);
}
INSTANTIATE_TEST_SUITE_P(
LocalResourceURLLoaderFactoryRequestRangeTest,
LocalResourceURLLoaderFactoryRequestRangeTest,
::testing::Values(
// Valid range.
RequestRangeTestCase("bytes=3-10",
net::OK,
"resource data",
"ource da"),
// Valid range, but starting byte is greater than resource size.
// Error expected.
RequestRangeTestCase("bytes=100-101",
net::ERR_REQUEST_RANGE_NOT_SATISFIABLE)));