TPSGI.t 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. #!/usr/bin/env perl
  2. use 5.040;
  3. use warnings FATAL => 'all';
  4. use re '/aasx';
  5. use Test2::V0;
  6. use Test2::Plugin::NoWarnings;
  7. use File::Temp qw{tempdir};
  8. use Mojo::File ();
  9. use Test::MockModule;
  10. use FindBin::libs;
  11. use TPSGI;
  12. # Every line the log gets, so a test can ask whether the reason for a 500 is
  13. # among them.
  14. package Test::Logger::Capture {
  15. use parent qw{Log::Dispatch::Output};
  16. our @lines;
  17. sub new {
  18. my ( $class, %p ) = @_;
  19. my $self = bless {}, $class;
  20. $self->_basic_init( name => 'capture', min_level => $p{min_level} );
  21. return $self;
  22. }
  23. sub log_message {
  24. my ( $self, %p ) = @_;
  25. push( @lines, $p{message} );
  26. return;
  27. }
  28. }
  29. local $INC{'Test/Logger/Capture.pm'} = __FILE__;
  30. my $dir = tempdir( CLEANUP => 1 );
  31. my %options = (
  32. log_name => "$dir/tpsgi.log",
  33. log_dir => $dir,
  34. verbose => 0,
  35. loggers => ['Test::Logger::Capture'],
  36. );
  37. sub fake_env {
  38. # The fake psgi.errors, which the caller keeps open.
  39. open( my $errors, '>', \my $written ) or die "Cannot open a scalar: $!"; ## no critic (RequireBriefOpen)
  40. return ( { 'psgi.errors' => $errors }, \$written );
  41. }
  42. # TPSGI builds its log once per process, so this has to run before anything
  43. # else here builds it: a logger that will not load is only a failure the first
  44. # time.
  45. subtest 'cannot_start, when building the log is what failed' => sub {
  46. my ( $env, $written ) = fake_env();
  47. my $res;
  48. ok(
  49. lives { $res = TPSGI::cannot_start( "Could not load Bogus.pm!\n", $env, %options, loggers => ['Bogus::Logger::That::Is::Not'] ) },
  50. 'a logger that will not load does not turn the 500 into an exception'
  51. ) or note $@;
  52. is( $res->[0], 500, 'it still answers 500' );
  53. like( $$written, qr/Could[ ]not[ ]load[ ]Bogus\.pm/, 'and psgi.errors still gets the reason' );
  54. };
  55. subtest 'cannot_start' => sub {
  56. local @Test::Logger::Capture::lines;
  57. my ( $env, $written ) = fake_env();
  58. my $res = TPSGI::cannot_start( "Could not load Bogus.pm!\nCan't locate Bogus/Dep.pm in \@INC\n", $env, %options );
  59. is( $res->[0], 500, 'answers 500' );
  60. like( $$written, qr/Can't[ ]locate[ ]Bogus\/Dep\.pm/, 'psgi.errors gets the whole reason' );
  61. my $reason = qr/Could[ ]not[ ]load[ ]Bogus[.]pm!/;
  62. like( \@Test::Logger::Capture::lines, bag { item qr/Could[ ]not[ ]start:[ ]$reason\nCan't[ ]locate/; etc() }, 'and so does the log' );
  63. ok( lives { TPSGI::cannot_start( "no env\n", undef, %options ) }, 'a missing env is not a second failure' ) or note $@;
  64. };
  65. subtest 'cannot_start retires the worker' => sub {
  66. my ($env) = fake_env();
  67. $env->{'psgix.harakiri'} = 1;
  68. TPSGI::cannot_start( "Could not load Bogus.pm!\n", $env, %options );
  69. ok( $env->{'psgix.harakiri.commit'}, 'a server that can retire the worker is told to' );
  70. ($env) = fake_env();
  71. TPSGI::cannot_start( "Could not load Bogus.pm!\n", $env, %options );
  72. ok( !exists $env->{'psgix.harakiri.commit'}, 'a server that cannot is not asked to' );
  73. };
  74. subtest 'cannot_start answers with a page' => sub {
  75. my $error = "Could not load Bogus.pm!\nCan't locate <Bogus/Dep.pm> in \@INC & \"elsewhere\"\n";
  76. my $res = TPSGI::cannot_start( $error, ( fake_env() )[0], %options );
  77. my %head = @{ $res->[1] };
  78. is( $head{'Content-type'}, 'text/html', 'in HTML' );
  79. like( $res->[2][0], qr/<h1>Internal[ ]Server[ ]Error<\/h1>/, 'saying what happened' );
  80. unlike( $res->[2][0], qr/Bogus/, 'and, without TPSGI_DEBUG, not why' );
  81. local $TPSGI::debug = 1;
  82. $res = TPSGI::cannot_start( $error, ( fake_env() )[0], %options );
  83. my $escaped = q{Can't locate &#60;Bogus/Dep.pm&#62; in @INC &#38; &#34;elsewhere&#34;};
  84. like( $res->[2][0], qr/\Q$escaped\E/, 'with TPSGI_DEBUG, the error, escaped' );
  85. unlike( $res->[2][0], qr/<Bogus/, 'none of it read as markup' );
  86. };
  87. subtest 'app, when the request dies' => sub {
  88. local @Test::Logger::Capture::lines;
  89. my ( $env, $written ) = fake_env();
  90. my $mock = Test::MockModule->new( 'TPSGI', no_auto => 1 );
  91. $mock->redefine( respond => sub { die "Can't locate Bogus/Dep.pm in \@INC\n" } );
  92. my $tpsgi = bless( { %options, ip => '0.0.0.0' }, 'TPSGI' );
  93. my $res = $tpsgi->app($env);
  94. is( $res->[0], 500, 'answers 500' );
  95. like( $$written, qr/Can't[ ]locate[ ]Bogus\/Dep\.pm/, 'psgi.errors gets the reason' );
  96. like( \@Test::Logger::Capture::lines, bag { item qr/Request[ ]failed:[ ]Can't[ ]locate[ ]Bogus\/Dep\.pm/; etc() }, 'and so does the log, not just that a 500 happened' );
  97. };
  98. subtest 'serve_ranges' => sub {
  99. local @Test::Logger::Capture::lines;
  100. my $tpsgi = bless( { %options, ip => '0.0.0.0' }, 'TPSGI' );
  101. my $file = "$dir/ten-bytes";
  102. Mojo::File->new($file)->spew('0123456789');
  103. my %args = ( size => 10, headers => { 'Content-type' => 'text/plain' }, fullpath => '/ten-bytes' );
  104. open( my $fh, '<', $file ) or die "Cannot read $file: $!";
  105. my $res = $tpsgi->serve_ranges( $fh, [ [ 0, 4 ] ], %args );
  106. is( ref $res, 'CODE', 'a range inside the file is streamed' );
  107. is( [ grep { /GET[ ]416/ } @Test::Logger::Capture::lines ], [], 'and not logged as a 416' );
  108. close($fh) or die "Cannot close: $!";
  109. open( $fh, '<', $file ) or die "Cannot read $file: $!";
  110. $res = $tpsgi->serve_ranges( $fh, [ [ 0, 4 ], [ 20, 25 ] ], %args );
  111. is( $res->[0], 416, 'a range past the end is refused' );
  112. like( \@Test::Logger::Capture::lines, bag { item qr/GET[ ]416[ ]\/ten-bytes/; etc() }, 'and logged as a 416' );
  113. close($fh) or die "Cannot close: $!";
  114. };
  115. # Writes a router module into $dir, and returns its name relative to $dir.
  116. sub write_router {
  117. my ( $package, $body ) = @_;
  118. Mojo::File->new("$dir/$package.pm")->spew("package $package;\nuse 5.040;\n$body\n1;\n");
  119. return "$package.pm";
  120. }
  121. my %new_options = (
  122. %options,
  123. user => scalar getpwuid($>),
  124. http_user => scalar getgrgid( ( getpwuid($>) )[3] ),
  125. tpsgi_dir => $dir,
  126. );
  127. subtest 'new' => sub {
  128. my $stale = write_router( 'StaleRouter', q{our @routes = ( '/x' => { method => 'GET', callbacks => { '*' => sub { } } } );} );
  129. like(
  130. dies { TPSGI->new( %new_options, routers => [$stale] ) },
  131. qr/StaleRouter[.]pm[ ]defines[ ]\@routes[ ]and[ ]no/,
  132. 'a router that still has @routes dies as it loads'
  133. );
  134. my $router = write_router( 'GoodRouter', <<~'ROUTER' );
  135. our %openapi = (
  136. openapi => '3.0.3',
  137. info => { title => 'good', version => '1' },
  138. paths => {
  139. '/post/{slug}' => {
  140. get => {
  141. 'x-tpsgi-callback' => sub { },
  142. 'x-tpsgi-data' => { from => 'data' },
  143. parameters => [
  144. { name => 'slug', in => 'path', required => true, schema => { type => 'string' } },
  145. { name => 'limit', in => 'query', schema => { type => 'integer' } },
  146. ],
  147. responses => { 200 => { description => 'ok' } },
  148. },
  149. },
  150. '/cgi' => {
  151. get => {
  152. 'x-tpsgi-callback' => sub { return [ 200, [], [ $ENV{QUERY_STRING} ] ] },
  153. 'x-tpsgi-env' => true,
  154. responses => { 200 => { description => 'ok' } },
  155. },
  156. },
  157. '/plain' => {
  158. post => {
  159. 'x-tpsgi-callback' => sub { return [ 200, [], ['ok'] ] },
  160. responses => { 200 => { description => 'ok' } },
  161. },
  162. },
  163. },
  164. );
  165. ROUTER
  166. my $first = TPSGI->new( %new_options, routers => [$router] );
  167. is( $first->routes->match( 'GET', '/post/hello' )->{template}, '/post/{slug}', 'a router with %openapi routes' );
  168. ref_is( TPSGI->new( %new_options, routers => [$router] )->routes, $first->routes, 'and the next request in the worker reuses the router' );
  169. };
  170. subtest 'extract_query' => sub {
  171. my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
  172. my $env = { REQUEST_METHOD => 'GET' };
  173. my $cafe = "caf\N{LATIN SMALL LETTER E WITH ACUTE}";
  174. my $query = $tpsgi->extract_query( "/post/$cafe", $tpsgi->routes->match( 'GET', "/post/$cafe" ), $env );
  175. is( $query->{slug}, $cafe, 'a path parameter, outside ASCII too, is in the query' );
  176. is( $query->{from}, 'data', 'and so is x-tpsgi-data' );
  177. $query = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=5' } );
  178. is( $query->{limit}, 5, 'the query string is in the query' );
  179. is( $query->{slug}, 'x', 'beside the path parameter' );
  180. local @Test::Logger::Capture::lines;
  181. my $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'limit=many' } );
  182. is( ref $res, 'ARRAY', 'a query that fails validation is an answer, not a query' );
  183. is( $res->[0], 400, 'a 400' );
  184. unlike( $res->[2][0], qr/integer/, 'which does not say why' );
  185. $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'junk=1' } );
  186. is( $res->[0], 400, 'and so is one with a key that the operation does not declare' );
  187. $res = $tpsgi->extract_query( '/post/x', $tpsgi->routes->match( 'GET', '/post/x' ), { %$env, QUERY_STRING => 'slug=y' } );
  188. is( $res->[0], 400, 'even when the path declares it' );
  189. };
  190. subtest 'extract_query, with a body' => sub {
  191. my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
  192. my $part = qq{--x\r\nContent-Disposition: form-data; name="file"; filename="f.txt"\r\nContent-Type: text/plain\r\n\r\nhi\r\n--x--\r\n};
  193. my %env = (
  194. REQUEST_METHOD => 'POST',
  195. CONTENT_TYPE => 'multipart/form-data; boundary=x',
  196. CONTENT_LENGTH => length($part),
  197. );
  198. local @Test::Logger::Capture::lines;
  199. open( my $input, '<', \$part ) or die "Cannot open a scalar: $!";
  200. my $res = $tpsgi->extract_query( '/plain', $tpsgi->routes->match( 'POST', '/plain' ), { %env, 'psgi.input' => $input } );
  201. close($input) or die "Cannot close: $!";
  202. is( ref $res eq 'ARRAY' && $res->[0], 400, 'a file, to an operation with no requestBody, is a 400' );
  203. };
  204. subtest 'route, in CGI mode' => sub {
  205. my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
  206. my $res = $tpsgi->respond( { REQUEST_METHOD => 'GET', PATH_INFO => '/cgi', REQUEST_URI => '/cgi?anything=1', QUERY_STRING => 'anything=1', HTTP_HOST => 'test.test' } );
  207. is( $res->[0], 200, 'is not validated, so a key that nothing declares is no 400' );
  208. is( $res->[2][0], 'anything=1', 'and the CGI finds it in %ENV' );
  209. };
  210. subtest 'respond, with a method that the path does not have' => sub {
  211. my $tpsgi = TPSGI->new( %new_options, routers => ['GoodRouter.pm'] );
  212. my $res = $tpsgi->respond( { REQUEST_METHOD => 'POST', PATH_INFO => '/post/x', REQUEST_URI => '/post/x', HTTP_HOST => 'test.test' } );
  213. is( $res->[0], 405, 'is a 405' );
  214. is( { @{ $res->[1] } }->{Allow}, 'GET, HEAD', 'which names the methods it has' );
  215. };
  216. done_testing();