PAGI-Server
view release on metacpan or search on metacpan
t/integration/app-file.t view on Meta::CPAN
# Skip if we can't create symlinks (Windows without admin, etc.)
my $test_dir = File::Temp::tempdir(CLEANUP => 1);
my $root_dir = "$test_dir/root";
my $outside_dir = "$test_dir/outside";
mkdir $root_dir or die "Cannot create root dir: $!";
mkdir $outside_dir or die "Cannot create outside dir: $!";
# Create a file outside the root
open my $fh, '>', "$outside_dir/secret.txt" or die;
print $fh "SECRET DATA";
close $fh;
# Create a symlink from inside root to outside
my $symlink_created = eval { symlink("$outside_dir/secret.txt", "$root_dir/escape") };
skip_all("Cannot create symlinks on this system") unless $symlink_created;
my $server = create_server(root => $root_dir);
my $port = $server->port;
my $http = Net::Async::HTTP->new(fail_on_error => 0);
$loop->add($http);
my $response = $http->GET("http://127.0.0.1:$port/escape")->get;
# Should either 403 (blocked) or 404 (symlink not followed)
ok($response->code >= 400, "Symlink escape blocked (status: " . $response->code . ")");
unlike($response->content // '', qr/SECRET DATA/, "Did not expose file outside root");
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
subtest 'Security: URL-encoded traversal blocked' => sub {
my $server = create_server();
my $port = $server->port;
my $http = Net::Async::HTTP->new(fail_on_error => 0);
$loop->add($http);
# URL-encoded traversal attempts
my @paths = (
'/%2e%2e/%2e%2e/%2e%2e/etc/passwd', # ..
'/%252e%252e/%252e%252e/etc/passwd', # double-encoded
'/..%252f..%252f..%252fetc/passwd', # mixed
);
for my $path (@paths) {
my $response = $http->GET("http://127.0.0.1:$port$path")->get;
ok($response->code >= 400, "URL-encoded traversal blocked for $path (status: " . $response->code . ")");
}
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: Large file streaming (verifies file response integration)
# =============================================================================
subtest 'Large file streaming' => sub {
# Create a temp directory with a large file
# Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
# Kept small to minimize buffer pressure on constrained CI environments (e.g., FreeBSD)
my $test_dir = File::Temp::tempdir(CLEANUP => 1);
my $large_content = "X" x (65 * 1024); # 65KB file (exceeds 64KB threshold)
open my $fh, '>', "$test_dir/large.bin" or die;
print $fh $large_content;
close $fh;
my $server = create_server(root => $test_dir);
my $port = $server->port;
my $http = Net::Async::HTTP->new;
$loop->add($http);
my $response = $http->GET("http://127.0.0.1:$port/large.bin")->get;
is($response->code, 200, 'GET /large.bin returns 200');
is(length($response->content), length($large_content), 'Large file length matches');
is($response->content, $large_content, 'Large file content matches');
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: Large file Range request streaming
# =============================================================================
subtest 'Large file Range request' => sub {
# Size is 65KB - just over the 64KB sync_file_threshold to trigger async path
my $test_dir = File::Temp::tempdir(CLEANUP => 1);
my $large_content = "X" x (65 * 1024); # 65KB file (exceeds 64KB threshold)
open my $fh, '>', "$test_dir/large.bin" or die;
print $fh $large_content;
close $fh;
my $server = create_server(root => $test_dir);
my $port = $server->port;
my $http = Net::Async::HTTP->new;
$loop->add($http);
# Request bytes 1000-2000
my $response = $http->GET(
"http://127.0.0.1:$port/large.bin",
headers => ['Range' => 'bytes=1000-2000'],
)->get;
is($response->code, 206, 'Range request returns 206');
is(length($response->content), 1001, 'Partial content length correct');
is($response->content, substr($large_content, 1000, 1001), 'Partial content matches');
like($response->header('Content-Range'), qr/bytes 1000-2000\/\d+/, 'Has Content-Range header');
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: handle_ranges => 0 ignores Range header
# =============================================================================
subtest 'handle_ranges => 0 ignores Range header' => sub {
my $server = create_server(app_opts => { handle_ranges => 0 });
my $port = $server->port;
my $http = Net::Async::HTTP->new;
$loop->add($http);
# Send Range header, but should get full file (200) not partial (206)
my $response = $http->GET(
"http://127.0.0.1:$port/test.txt",
headers => ['Range' => 'bytes=0-4'],
)->get;
is($response->code, 200, 'With handle_ranges => 0, Range request returns 200 (not 206)');
ok(!$response->header('Content-Range'), 'No Content-Range header');
like($response->decoded_content, qr/Hello from PAGI/, 'Full file content returned');
$loop->remove($http);
$server->shutdown->get;
$loop->remove($server);
};
# =============================================================================
# Test: handle_ranges => 1 (default) honors Range header
# =============================================================================
subtest 'handle_ranges => 1 (default) honors Range header' => sub {
( run in 1.020 second using v1.01-cache-2.11-cpan-995e09ba956 )