Net-Blossom-Server
view release on metacpan or search on metacpan
t/19-MirrorFetcherHTTP.t view on Meta::CPAN
sub new {
my ($class) = @_;
return bless {}, $class;
}
sub start {
return 1;
}
sub write {
die "sink write failed";
}
}
sub error_status(&) {
my ($code) = @_;
my $error = dies { $code->() };
isa_ok($error, 'Net::Blossom::Server::Error');
return $error->status;
}
subtest 'constructs allowlist-only HTTP mirror fetcher' => sub {
my $ua = Local::UA->new;
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['CDN.Example', 'media.example'],
max_bytes => 1024,
timeout => 3,
user_agent => $ua,
);
isa_ok($fetcher, 'Net::Blossom::Server::MirrorFetcher::HTTP');
is_deeply($fetcher->allowed_hosts, ['cdn.example', 'media.example'], 'hosts normalized');
is($fetcher->max_bytes, 1024, 'max bytes accessor');
is($fetcher->timeout, 3, 'timeout accessor');
is($fetcher->user_agent, $ua, 'user agent accessor');
my $hosts = $fetcher->allowed_hosts;
push @$hosts, 'mutated.example';
is_deeply($fetcher->allowed_hosts, ['cdn.example', 'media.example'], 'allowed_hosts does not alias');
};
subtest 'validates constructor policy' => sub {
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(max_bytes => 1024) },
qr/allowed_hosts is required/, 'allowed_hosts required');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => [], max_bytes => 1024) },
qr/allowed_hosts must not be empty/, 'allowed_hosts cannot be empty');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['https://cdn.example'], max_bytes => 1024) },
qr/allowed_hosts must contain host names only/, 'allowed_hosts rejects URLs');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['cdn.example']) },
qr/max_bytes is required/, 'max_bytes required');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['cdn.example'], max_bytes => 0) },
qr/max_bytes must be a positive integer/, 'max_bytes positive');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['cdn.example'], max_bytes => 1024, timeout => 0) },
qr/timeout must be a positive integer/, 'timeout positive');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['cdn.example'], max_bytes => 1024, user_agent => bless {}, 'Local::NoRequest') },
qr/user_agent must provide request/, 'user agent contract required');
like(dies { Net::Blossom::Server::MirrorFetcher::HTTP->new(allowed_hosts => ['cdn.example'], max_bytes => 1024, bogus => 1) },
qr/unknown argument\(s\): bogus/, 'unknown arguments rejected');
};
subtest 'fetch_blob requires a streaming sink' => sub {
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new,
);
like(dies { $fetcher->fetch_blob('https://cdn.example/blob.bin') },
qr/sink is required/, 'sink required');
like(dies { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => bless {}, 'Local::NoSink') },
qr/sink must provide start and write/, 'sink contract required');
};
subtest 'fetch_blob accepts only allowed HTTP URLs' => sub {
my $ua = Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => { 'content-type' => 'text/plain', 'content-length' => 4 },
content => 'body',
});
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => $ua,
);
my $sink = Local::Sink->new;
my $result = $fetcher->fetch_blob('https://cdn.example/path/blob.txt?download=1', sink => $sink);
is_deeply($result, {
type => 'text/plain',
content_length => 4,
}, 'allowed URL returns metadata without buffering body');
is_deeply($sink->{starts}, [{ type => 'text/plain', content_length => 4 }], 'sink receives metadata');
is_deeply($sink->{chunks}, ['body'], 'sink receives body chunks');
my ($request) = $ua->requests;
is($request->[0], 'GET', 'uses GET');
is($request->[1], 'https://cdn.example/path/blob.txt?download=1', 'request URL');
is(ref($request->[2]{data_callback}), 'CODE', 'streams through data_callback');
is(error_status { $fetcher->fetch_blob('ftp://cdn.example/blob.bin', sink => Local::Sink->new) },
400, 'non-http URL rejected');
is(error_status { $fetcher->fetch_blob('https://user:pass@cdn.example/blob.bin', sink => Local::Sink->new) },
400, 'userinfo URL rejected');
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin#frag', sink => Local::Sink->new) },
400, 'fragment URL rejected');
is(error_status { $fetcher->fetch_blob('https://blocked.example/blob.bin', sink => Local::Sink->new) },
403, 'non-allowlisted host rejected');
};
subtest 'fetch_blob preserves sink write failures' => sub {
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => { 'content-type' => 'text/plain' },
content => 'body',
t/19-MirrorFetcherHTTP.t view on Meta::CPAN
max_bytes => 1024,
user_agent => $ua,
);
# An allowlisted host name must not become a way to reach arbitrary ports
# (e.g. internal services co-located on that host).
is(error_status { $fetcher->fetch_blob('http://cdn.example:22/blob.bin', sink => Local::Sink->new) },
403, 'non-default http port rejected');
is(error_status { $fetcher->fetch_blob('https://cdn.example:8443/blob.bin', sink => Local::Sink->new) },
403, 'non-default https port rejected');
# The scheme default port, explicit or implicit, is allowed.
ok($fetcher->fetch_blob('https://cdn.example/blob.txt', sink => Local::Sink->new), 'implicit default port allowed');
ok($fetcher->fetch_blob('https://cdn.example:443/blob.txt', sink => Local::Sink->new), 'explicit default https port allowed');
ok($fetcher->fetch_blob('http://cdn.example:80/blob.txt', sink => Local::Sink->new), 'explicit default http port allowed');
};
subtest 'fetch_blob rejects redirects and origin failures' => sub {
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new(response => {
success => 0,
status => 302,
reason => 'Found',
headers => { location => 'https://other.example/blob.bin' },
content => '',
}),
);
my $sink = Local::Sink->new;
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => $sink) },
502, 'redirect response rejected');
is_deeply($sink->{chunks}, [], 'redirect body not streamed');
$fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new(die => 1),
);
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => Local::Sink->new) },
502, 'client exception rejected');
};
subtest 'fetch_blob enforces response size limits' => sub {
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 4,
user_agent => Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => { 'content-length' => 5 },
content => 'hello',
}),
);
my $sink = Local::Sink->new;
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => $sink) },
413, 'Content-Length over max rejected');
is_deeply($sink->{chunks}, [], 'oversized Content-Length is rejected before streaming body');
$fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 4,
user_agent => Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => {},
body_chunks => ['he', 'llo'],
}),
);
$sink = Local::Sink->new;
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => $sink) },
413, 'stream over max rejected');
is_deeply($sink->{chunks}, ['he'], 'stream is stopped before oversized chunk is written');
};
subtest 'fetch_blob defaults content type and rejects bad metadata' => sub {
my $fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => {},
content => 'body',
}),
);
my $sink = Local::Sink->new;
is_deeply($fetcher->fetch_blob('https://cdn.example/blob.bin', sink => $sink), {
type => 'application/octet-stream',
}, 'missing content type defaults');
is_deeply($sink->{starts}, [{ type => 'application/octet-stream' }], 'default content type passed to sink');
is($sink->content, 'body', 'body streamed to sink');
$fetcher = Net::Blossom::Server::MirrorFetcher::HTTP->new(
allowed_hosts => ['cdn.example'],
max_bytes => 1024,
user_agent => Local::UA->new(response => {
success => 1,
status => 200,
reason => 'OK',
headers => { 'content-length' => 'abc' },
content => 'body',
}),
);
is(error_status { $fetcher->fetch_blob('https://cdn.example/blob.bin', sink => Local::Sink->new) },
502, 'invalid Content-Length rejected');
};
done_testing;
( run in 1.050 second using v1.01-cache-2.11-cpan-995e09ba956 )