PAGI-Server

 view release on metacpan or  search on metacpan

t/40-event-validation.t  view on Meta::CPAN

        qr/header name and value must be defined strings/, 'ref name throws');
    like( dies { PAGI::Server::EventValidator::validate_http_send($start->([ ['x-evil', "a\r\nInjected: yes"] ])) },
        qr/contains CR, LF, or null byte/, 'CRLF value throws');
    like( dies { PAGI::Server::EventValidator::validate_http_send($start->([ ["x\x01bad", 'v'] ])) },
        qr/contains control characters/, 'control char in name throws');
    ok( lives { PAGI::Server::EventValidator::validate_http_send($start->([ ['content-type','text/plain'], ['set-cookie','a=1'], ['set-cookie','b=2'] ])) },
        'valid headers with duplicates ok');

    # Shared across families: ws denial start and sse decline start use the same checks
    like( dies { PAGI::Server::EventValidator::validate_websocket_send({ type => 'websocket.http.response.start', status => 401, headers => [ ['h',"v\n"] ] }, { extensions => { 'websocket.http.response' => {} } }) },
        qr/contains CR, LF, or null byte/, 'ws denial headers validated');
    like( dies { PAGI::Server::EventValidator::validate_sse_send({ type => 'sse.http.response.start', status => 404, headers => [ ['h',"v\0"] ] }) },
        qr/contains CR, LF, or null byte/, 'sse decline headers validated');
};

subtest 'unknown event types are rejected per family' => sub {
    like( dies { PAGI::Server::EventValidator::validate_http_send({ type => 'http.response.bod' }) },
        qr/Unrecognized event type 'http\.response\.bod' for http protocol/, 'http typo throws');
    like( dies { PAGI::Server::EventValidator::validate_websocket_send({ type => 'websocket.pong' }) },
        qr/Unrecognized event type .* for websocket protocol/, 'ws unknown throws');
    like( dies { PAGI::Server::EventValidator::validate_sse_send({ type => 'sse.push' }) },
        qr/Unrecognized event type .* for sse protocol/, 'sse unknown throws');
    like( dies { PAGI::Server::EventValidator::validate_http_send({}) },
        qr/Unrecognized event type '' for http protocol/, 'missing type throws');
    ok( lives { PAGI::Server::EventValidator::validate_http_send({ type => 'http.response.body', body => 'x', unknown_extra => 1 }) },
        'extra fields on a known type remain legal');
};

subtest 'extension-gated event types' => sub {
    like( dies { PAGI::Server::EventValidator::validate_http_send({ type => 'http.fullflush' }) },
        qr/Extension not enabled: fullflush/, 'fullflush without extension throws');
    ok( lives { PAGI::Server::EventValidator::validate_http_send({ type => 'http.fullflush' }, { extensions => { fullflush => {} } }) },
        'fullflush with extension ok');
    like( dies { PAGI::Server::EventValidator::validate_websocket_send({ type => 'websocket.http.response.start', status => 401 }) },
        qr/Extension not enabled: websocket\.http\.response/, 'ws denial without extension throws');
    ok( lives { PAGI::Server::EventValidator::validate_websocket_send({ type => 'websocket.http.response.start', status => 401 }, { extensions => { 'websocket.http.response' => {} } }) },
        'ws denial with extension ok');
    like( dies { PAGI::Server::EventValidator::validate_sse_send({ type => 'http.fullflush' }) },
        qr/Extension not enabled: fullflush/, 'sse fullflush without extension throws');
    ok( lives { PAGI::Server::EventValidator::validate_sse_send({ type => 'http.fullflush' }, { extensions => { fullflush => {} } }) },
        'sse fullflush with extension ok');
};

subtest 'lifespan send validation' => sub {
    ok( lives { PAGI::Server::EventValidator::validate_lifespan_send({ type => 'lifespan.startup.complete' }) }, 'startup.complete ok');
    ok( lives { PAGI::Server::EventValidator::validate_lifespan_send({ type => 'lifespan.startup.failed', message => 'db down' }) }, 'startup.failed with message ok');
    like( dies { PAGI::Server::EventValidator::validate_lifespan_send({ type => 'lifespan.startup.done' }) },
        qr/Unrecognized event type .* for lifespan protocol/, 'unknown lifespan type throws');
    like( dies { PAGI::Server::EventValidator::validate_lifespan_send({ type => 'lifespan.shutdown.failed', message => {} }) },
        qr/'message' must be a string/, 'ref message throws');
};

# =============================================================================
# Sequence State Machines
# =============================================================================

subtest 'advance_http transition matrix' => sub {
    my $adv = \&PAGI::Server::EventValidator::advance_http;
    is( $adv->('initial', { type => 'http.response.start', status => 200 }), 'started', 'start -> started');
    is( $adv->('initial', { type => 'http.response.start', status => 200, trailers => 1 }), 'started_t', 'start+trailers -> started_t');
    is( $adv->('started', { type => 'http.response.body', body => 'x', more => 1 }), 'started_i', 'streaming chunk marks inline bytes delivered');
    is( $adv->('started', { type => 'http.response.body', body => 'x' }), 'complete', 'terminal body -> complete');
    is( $adv->('started', { type => 'http.response.body', file => '/tmp/f' }), 'complete', 'file body -> complete');
    is( $adv->('started', { type => 'http.response.body', fh => \*STDOUT, more => 1 }), 'complete', 'fh body is always terminal regardless of more');
    is( $adv->('started_t', { type => 'http.response.body', body => 'x', more => 1 }), 'started_t_i', 'streaming chunk marks inline bytes delivered, trailers still declared');
    is( $adv->('started_t', { type => 'http.response.body', body => 'x', more => 0 }), 'awaiting_trailers', 'terminal body with declared trailers -> awaiting_trailers');
    is( $adv->('awaiting_trailers', { type => 'http.response.trailers', headers => [] }), 'complete', 'trailers -> complete');
    is( $adv->('started', { type => 'http.fullflush' }), 'started', 'fullflush leaves started unchanged');
    is( $adv->('started_t', { type => 'http.fullflush' }), 'started_t', 'fullflush leaves started_t unchanged');
    is( $adv->('awaiting_trailers', { type => 'http.fullflush' }), 'awaiting_trailers', 'fullflush leaves awaiting_trailers unchanged');
    like( dies { $adv->('initial', { type => 'http.response.body', body => 'x' }) }, qr/before http\.response\.start/, 'body before start');
    like( dies { $adv->('initial', { type => 'http.response.trailers' }) }, qr/before http\.response\.start/, 'trailers before start');
    like( dies { $adv->('started', { type => 'http.response.start', status => 200 }) }, qr/duplicate http\.response\.start/, 'duplicate start');
    like( dies { $adv->('started_t', { type => 'http.response.start', status => 200 }) }, qr/duplicate http\.response\.start/, 'duplicate start after trailers declared');
    like( dies { $adv->('started', { type => 'http.response.trailers' }) }, qr/not declared/, 'undeclared trailers');
    like( dies { $adv->('started_t', { type => 'http.response.trailers' }) }, qr/not declared/, 'trailers before body complete');
    like( dies { $adv->('complete', { type => 'http.response.body', body => 'x' }) }, qr/already complete/, 'body after completion');
    like( dies { $adv->('complete', { type => 'http.response.trailers' }) }, qr/already complete/, 'trailers after completion');
    like( dies { $adv->('complete', { type => 'http.response.start', status => 200 }) }, qr/already complete/, 'start after completion');
    like( dies { $adv->('awaiting_trailers', { type => 'http.response.body', body => 'x' }) }, qr/awaiting_trailers/, 'body while awaiting trailers is rejected');
};

subtest 'a response body is inline events or one opaque event, never both' => sub {
    # PAGI::Spec::Www, "Payload kinds do not mix within a response": an
    # application MUST NOT send a file/fh event once inline body bytes have
    # been delivered, and the server MUST fail such a send. A compressing
    # intermediary commits to an encoding before it can know a delegated
    # payload will follow, and cannot compress bytes the server streams on
    # the application's behalf.
    my $adv = \&PAGI::Server::EventValidator::advance_http;

    # Delivering an inline chunk is what closes the door.
    is( $adv->('started', { type => 'http.response.body', body => 'x', more => 1 }),
        'started_i', 'an inline chunk records that inline bytes were delivered');
    is( $adv->('started_t', { type => 'http.response.body', body => 'x', more => 1 }),
        'started_t_i', 'likewise when trailers were declared');

    like( dies { $adv->('started_i', { type => 'http.response.body', file => '/tmp/f' }) },
        qr/after inline body bytes/, 'file after an inline chunk is rejected');
    like( dies { $adv->('started_i', { type => 'http.response.body', fh => \*STDOUT }) },
        qr/after inline body bytes/, 'fh after an inline chunk is rejected');
    like( dies { $adv->('started_t_i', { type => 'http.response.body', file => '/tmp/f' }) },
        qr/after inline body bytes/, 'and with trailers declared');

    # Everything legal still advances exactly as before.
    is( $adv->('started', { type => 'http.response.body', file => '/tmp/f' }), 'complete',
        'a file event alone is still the whole body');
    is( $adv->('started_i', { type => 'http.response.body', body => 'x', more => 1 }),
        'started_i', 'further inline chunks are fine');
    is( $adv->('started_i', { type => 'http.response.body', body => 'x', more => 0 }),
        'complete', 'and terminate normally');
    is( $adv->('started_t_i', { type => 'http.response.body', body => 'x', more => 0 }),
        'awaiting_trailers', 'reaching the trailers phase as before');
    is( $adv->('started_i', { type => 'http.fullflush' }), 'started_i',
        'fullflush leaves the inline marker alone');
};

subtest 'advance_sse close is idempotent, streams stay exclusive' => sub {
    my $adv = \&PAGI::Server::EventValidator::advance_sse;
    is( $adv->('initial', { type => 'sse.start' }), 'streaming', 'start -> streaming');
    is( $adv->('streaming', { type => 'sse.send', data => 'x' }), 'streaming', 'send keeps streaming');
    is( $adv->('streaming', { type => 'sse.comment', comment => 'x' }), 'streaming', 'comment keeps streaming');
    is( $adv->('streaming', { type => 'sse.keepalive', interval => 15 }), 'streaming', 'keepalive keeps streaming');
    is( $adv->('streaming', { type => 'http.fullflush' }), 'streaming', 'fullflush leaves streaming unchanged');
    like( dies { $adv->('initial', { type => 'http.fullflush' }) }, qr/before sse\.start/, 'fullflush before start');
    is( $adv->('streaming', { type => 'sse.close' }), 'closed', 'close -> closed');
    is( $adv->('closed', { type => 'sse.close' }), 'closed', 'second close idempotent');
    is( $adv->('declining', { type => 'sse.http.response.body', more => 1 }), 'declining', 'decline body chunk keeps declining');
    is( $adv->('declining', { type => 'sse.http.response.body' }), 'decline_complete', 'terminal decline body -> decline_complete');
    like( dies { $adv->('closed', { type => 'sse.send', data => 'x' }) }, qr/after sse\.close/, 'send after close');
    is( $adv->('initial', { type => 'sse.http.response.start', status => 404 }), 'declining', 'decline start');
    like( dies { $adv->('initial', { type => 'sse.send', data => 'x' }) }, qr/before sse\.start/, 'send before start');
    like( dies { $adv->('streaming', { type => 'sse.http.response.start', status => 404 }) }, qr/after sse\.start/, 'decline after start');
    like( dies { $adv->('streaming', { type => 'sse.start' }) }, qr/duplicate sse\.start/, 'duplicate start');
    like( dies { $adv->('streaming', { type => 'sse.http.response.body', body => 'x' }) }, qr/after sse\.start/, 'decline body while streaming croaks');
    like( dies { $adv->('declining', { type => 'sse.send', data => 'x' }) }, qr/after sse\.http\.response\.start/, 'stream event while declining');
    like( dies { $adv->('decline_complete', { type => 'sse.close' }) }, qr/decline response already complete/, 'anything after decline complete');
};

subtest 'advance_websocket denial and accept are exclusive' => sub {
    my $adv = \&PAGI::Server::EventValidator::advance_websocket;
    is( $adv->('connecting', { type => 'websocket.accept' }), 'accepted', 'accept');
    is( $adv->('connecting', { type => 'websocket.http.response.start', status => 401 }), 'denial', 'denial start');
    is( $adv->('connecting', { type => 'websocket.close' }), 'closed', 'close while connecting');
    is( $adv->('accepted', { type => 'websocket.send', text => 'x' }), 'accepted', 'send keeps accepted');
    is( $adv->('accepted', { type => 'websocket.keepalive', interval => 30 }), 'accepted', 'keepalive keeps accepted');
    is( $adv->('accepted', { type => 'websocket.close' }), 'closed', 'close after accept');
    is( $adv->('denial', { type => 'websocket.http.response.body', more => 1 }), 'denial', 'denial body chunk keeps denial');
    is( $adv->('denial', { type => 'websocket.http.response.body' }), 'denial_complete', 'terminal denial body -> denial_complete');
    like( dies { $adv->('connecting', { type => 'websocket.keepalive', interval => 30 }) }, qr/before websocket\.accept/, 'keepalive before accept');
    like( dies { $adv->('connecting', { type => 'websocket.send', text => 'x' }) }, qr/before websocket\.accept/, 'send before accept');
    like( dies { $adv->('accepted', { type => 'websocket.accept' }) }, qr/after websocket\.accept/, 'duplicate accept');
    like( dies { $adv->('accepted', { type => 'websocket.http.response.start', status => 401 }) }, qr/after websocket\.accept/, 'denial after accept');
    like( dies { $adv->('accepted', { type => 'websocket.http.response.body' }) }, qr/after websocket\.accept/, 'denial body after accept');
    like( dies { $adv->('denial', { type => 'websocket.send', text => 'x' }) }, qr/after websocket\.http\.response\.start/, 'frame while denying');
    like( dies { $adv->('denial', { type => 'websocket.keepalive', interval => 30 }) }, qr/after websocket\.http\.response\.start/, 'keepalive while denying');
    like( dies { $adv->('denial', { type => 'websocket.accept' }) }, qr/after websocket\.http\.response\.start/, 'accept while denying');
    like( dies { $adv->('closed', { type => 'websocket.send', text => 'x' }) }, qr/after websocket\.close/, 'send after close');
    like( dies { $adv->('closed', { type => 'websocket.close' }) }, qr/after websocket\.close/, 'second close also croaks (websocket close is not idempotent)');
    like( dies { $adv->('denial_complete', { type => 'websocket.close' }) }, qr/denial response already complete/, 'anything after denial complete');
};

subtest 'advance_lifespan phases' => sub {
    my $adv = \&PAGI::Server::EventValidator::advance_lifespan;
    is( $adv->('startup_pending', { type => 'lifespan.startup.complete' }), 'running', 'startup completes');
    is( $adv->('startup_pending', { type => 'lifespan.startup.failed' }), 'finished', 'startup fails');
    is( $adv->('shutdown_pending', { type => 'lifespan.shutdown.complete' }), 'finished', 'shutdown completes');
    is( $adv->('shutdown_pending', { type => 'lifespan.shutdown.failed' }), 'finished', 'shutdown fails');
    like( dies { $adv->('startup_pending', { type => 'lifespan.shutdown.complete' }) }, qr/during lifespan phase 'startup_pending'/, 'shutdown result during startup');
    like( dies { $adv->('running', { type => 'lifespan.startup.complete' }) }, qr/during lifespan phase 'running'/, 'late startup result');
    like( dies { $adv->('finished', { type => 'lifespan.startup.complete' }) }, qr/during lifespan phase 'finished'/, 'anything after finished');
};

done_testing;



( run in 1.242 second using v1.01-cache-2.11-cpan-5c0b1e786e0 )