PAGI-Server

 view release on metacpan or  search on metacpan

t/http2/12-error-handling.t  view on Meta::CPAN

use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use IO::Async::Stream;
use Future::AsyncAwait;
use FindBin;
use lib "$FindBin::Bin/../../lib";
use Socket qw(AF_UNIX SOCK_STREAM);

plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';
BEGIN {
    require PAGI::Server::Protocol::HTTP2;
    PAGI::Server::Protocol::HTTP2->available
        or plan(skip_all => 'HTTP/2 not available (Net::HTTP2::nghttp2 0.008+ required)');
}

# ============================================================
# Test: HTTP/2 Error Handling and Edge Cases
# ============================================================
# Tests error paths, cleanup, and edge cases in the HTTP/2
# implementation.

use PAGI::Server::Connection;
use PAGI::Server;
use PAGI::Server::Protocol::HTTP1;
use PAGI::Server::Protocol::HTTP2;

my $loop = IO::Async::Loop->new;
my $protocol = PAGI::Server::Protocol::HTTP1->new;

# ============================================================
# Helpers (same pattern as 11-streaming.t)
# ============================================================

sub create_test_server {
    my (%args) = @_;
    my $server = PAGI::Server->new(
        app   => $args{app} // sub { },
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
        http2 => 1,
        %args,
    );
    $loop->add($server);
    return $server;
}

sub create_h2c_connection {
    my (%overrides) = @_;

    socketpair(my $sock_a, my $sock_b, AF_UNIX, SOCK_STREAM, 0)
        or die "socketpair: $!";
    $sock_a->blocking(0);
    $sock_b->blocking(0);

    my $app = $overrides{app} // sub { };
    my $server = $overrides{server} // create_test_server(app => $app, %overrides);

    my $stream = IO::Async::Stream->new(
        read_handle  => $sock_a,
        write_handle => $sock_a,
        on_read => sub { 0 },
    );

    my $conn = PAGI::Server::Connection->new(
        stream        => $stream,
        app           => $app,
        protocol      => $protocol,
        server        => $server,
        h2_protocol   => $server->{http2_protocol},
        h2c_enabled   => $server->{h2c_enabled},
        max_body_size => $server->{max_body_size},
    );

    $server->add_child($stream);
    $conn->start;

    return ($conn, $stream, $sock_b, $server);
}

sub create_client {
    my (%overrides) = @_;
    require Net::HTTP2::nghttp2::Session;
    return Net::HTTP2::nghttp2::Session->new_client(
        callbacks => {
            on_begin_headers   => $overrides{on_begin_headers}   // sub { 0 },
            on_header          => $overrides{on_header}          // sub { 0 },
            on_frame_recv      => $overrides{on_frame_recv}      // sub { 0 },
            on_data_chunk_recv => $overrides{on_data_chunk_recv} // sub { 0 },
            on_stream_close    => $overrides{on_stream_close}    // sub { 0 },
        },

t/http2/12-error-handling.t  view on Meta::CPAN


    exchange_frames($client, $client_sock, 20);

    is($response_headers{':status'}, '413', 'Server responded with 413');
    ok($stream_closed, 'Stream was closed');
    ok(!$app_saw_body, 'App never saw the request body');

    $stream_io->close_now;
    $loop->remove($server);
};

# ============================================================
# Content-Length early rejection over HTTP/2
# ============================================================
subtest 'content-length exceeding max_body_size rejected early with 413' => sub {
    my $app_called = 0;

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        $app_called = 1;
        await $receive->();
        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [],
        });
        await $send->({
            type => 'http.response.body',
            body => 'ok',
            more => 0,
        });
    };

    my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(
        app           => $app,
        max_body_size => 100,
    );

    my %response_headers;
    my $response_body = '';
    my $stream_closed = 0;
    my $client = create_client(
        on_header => sub {
            my ($sid, $name, $value) = @_;
            $response_headers{$name} = $value;
            return 0;
        },
        on_data_chunk_recv => sub {
            my ($sid, $data) = @_;
            $response_body .= $data;
            return 0;
        },
        on_stream_close => sub {
            $stream_closed = 1;
            return 0;
        },
    );

    h2c_handshake($client, $client_sock);

    # Send POST with content-length > max_body_size using a streaming body
    # The server should reject based on content-length header alone,
    # before any body data arrives
    $client->submit_request(
        method    => 'POST',
        path      => '/upload-cl',
        scheme    => 'http',
        authority => 'localhost',
        headers   => [
            ['content-type', 'application/octet-stream'],
            ['content-length', '50000'],
        ],
        body      => sub { return undef },  # streaming: keep open
    );
    $client_sock->syswrite($client->mem_send);

    exchange_frames($client, $client_sock, 20);

    is($response_headers{':status'}, '413', 'Server responded with 413 based on content-length');
    ok(!$app_called, 'App was never called');

    $stream_io->close_now;
    $loop->remove($server);
};

# ============================================================
# RST_STREAM from client during streaming response
# ============================================================
subtest 'RST_STREAM from client does not crash server' => sub {
    my $send_started = 0;

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        await $receive->();
        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });
        await $send->({
            type => 'http.response.body',
            body => 'chunk1',
            more => 1,
        });
        $send_started = 1;
        # Keep streaming — client will RST_STREAM
        for my $i (2..10) {
            eval {
                await $send->({
                    type => 'http.response.body',
                    body => "chunk$i",
                    more => ($i < 10) ? 1 : 0,
                });
            };
            last if $@;  # Stream may be reset
        }
    };

    my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(app => $app);

    my $stream_id;
    my $client = create_client(
        on_frame_recv => sub {
            my ($f) = @_;
            # Capture the stream ID from the first HEADERS response
            if ($f->{type} == 1 && $f->{stream_id} > 0) {
                $stream_id = $f->{stream_id};
            }
            return 0;
        },
    );

    h2c_handshake($client, $client_sock);

    $client->submit_request(
        method    => 'GET',
        path      => '/streaming-rst',
        scheme    => 'http',
        authority => 'localhost',
    );
    $client_sock->syswrite($client->mem_send);

    # Wait for streaming to start
    for (1..15) {
        $loop->loop_once(0.1);
        my $buf = '';
        $client_sock->sysread($buf, 16384);
        $client->mem_recv($buf) if length($buf);
        my $out = $client->mem_send;
        $client_sock->syswrite($out) if length($out);
        last if $send_started;
    }

    if ($stream_id) {
        # Build RST_STREAM frame manually:
        # Length=4, Type=3, Flags=0, Stream ID, Error Code=8 (CANCEL)
        my $rst_frame = pack('nCCCNN',
            0, 4,  # length high bytes (we need 3-byte length)
            3,     # type = RST_STREAM
            0,     # flags
            $stream_id,
            8,     # error code = CANCEL
        );
        # Actually, HTTP/2 frame format is:
        # 3-byte length + 1-byte type + 1-byte flags + 4-byte stream_id + payload
        $rst_frame = pack('CnCCN',
            0,     # length byte 1 (high)
            4,     # length bytes 2-3
            3,     # type = RST_STREAM
            0,     # flags
            $stream_id,
        ) . pack('N', 8);  # error code
        $client_sock->syswrite($rst_frame);
    }

    # Process the RST_STREAM
    exchange_frames($client, $client_sock, 15);

    # If we got here without crashing, the test passes
    pass('Server survived RST_STREAM from client');

    $stream_io->close_now;
    $loop->remove($server);
};

# ============================================================
# Empty POST body (END_STREAM on HEADERS, no DATA frames)
# ============================================================
subtest 'POST with empty body (END_STREAM on HEADERS)' => sub {
    my $received_has_body;
    my $received_event;

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        $received_has_body = $scope->{_has_body};
        $received_event = await $receive->();
        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });
        await $send->({
            type => 'http.response.body',

t/http2/12-error-handling.t  view on Meta::CPAN

    require Protocol::WebSocket::Frame;

    my $ws_accepted = 0;
    my $close_received = 0;
    my $close_code;

    my $app = async sub {
        my ($scope, $receive, $send) = @_;
        if ($scope->{type} eq 'websocket') {
            await $send->({ type => 'websocket.accept' });
            $ws_accepted = 1;

            # Receive connect event
            my $event = await $receive->();

            # Wait for messages/disconnect
            while ($event->{type} ne 'websocket.disconnect') {
                $event = await $receive->();
            }
            $close_code = $event->{code};
            $close_received = 1;
        }
    };

    my ($conn, $stream_io, $client_sock, $server) = create_h2c_connection(app => $app);

    my %stream_status;
    my %stream_data;
    my %stream_closed;
    my $client = create_client(
        on_header => sub {
            my ($sid, $name, $value) = @_;
            $stream_status{$sid} = $value if $name eq ':status';
            return 0;
        },
        on_data_chunk_recv => sub {
            my ($sid, $data) = @_;
            $stream_data{$sid} //= '';
            $stream_data{$sid} .= $data;
            return 0;
        },
        on_stream_close => sub {
            my ($sid, $ec) = @_;
            $stream_closed{$sid} = 1;
            return 0;
        },
    );

    h2c_handshake($client, $client_sock);

    # Send Extended CONNECT for WebSocket
    my $ws_stream_id = $client->submit_request(
        method    => 'CONNECT',
        path      => '/ws/test',
        scheme    => 'http',
        authority => 'localhost',
        headers   => [
            [':protocol', 'websocket'],
            ['sec-websocket-version', '13'],
        ],
        body      => sub { return undef },  # streaming: keep open
    );
    $client_sock->syswrite($client->mem_send);

    # Exchange until WebSocket is accepted
    for (1..15) {
        $loop->loop_once(0.1);
        my $buf = '';
        $client_sock->sysread($buf, 16384);
        $client->mem_recv($buf) if length($buf);
        my $out = $client->mem_send;
        $client_sock->syswrite($out) if length($out);
        last if $ws_accepted;
    }

    ok($ws_accepted, 'WebSocket was accepted');

    if ($ws_accepted && $ws_stream_id) {
        # Note: h2c path may not always deliver the 200 status to the client
        # callback (nghttp2 protocol-level issue with CONNECT). The important
        # assertion is the 1007 close frame below.

        # Send a WebSocket text frame with invalid UTF-8
        my $frame = Protocol::WebSocket::Frame->new(
            type   => 'text',
            buffer => "\xFF\xFE",  # Invalid UTF-8
        );
        my $frame_bytes = $frame->to_bytes;

        $client->submit_data($ws_stream_id, $frame_bytes, 0);
        $client_sock->syswrite($client->mem_send);

        # Exchange frames to process the invalid frame
        exchange_frames($client, $client_sock, 20);

        # The response data should contain a WebSocket close frame with code 1007
        my $response_data = $stream_data{$ws_stream_id} // '';
        if (length($response_data) > 0) {
            my $parse_frame = Protocol::WebSocket::Frame->new;
            $parse_frame->append($response_data);
            my $close_bytes = $parse_frame->next_bytes;
            if (defined $close_bytes && length($close_bytes) >= 2) {
                my $code = unpack('n', substr($close_bytes, 0, 2));
                is($code, 1007, 'Server sent close frame with code 1007');
            } else {
                pass('Server sent close response');
            }
        } else {
            fail('No response data received for close frame');
        }
    }

    $stream_io->close_now;
    $loop->remove($server);
};

# ============================================================
# WebSocket close frame validation over HTTP/2
# ============================================================
subtest 'invalid WS close frame (1-byte payload) triggers 1002' => sub {
    require Protocol::WebSocket::Frame;



( run in 0.788 second using v1.01-cache-2.11-cpan-995e09ba956 )