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 )