TCMS.t 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. #!/usr/bin/env perl
  2. # The OpenAPI document that TCMS.pm hands tPSGI, made from tCMS's own routing
  3. # table.
  4. use 5.040;
  5. use re '/aasx';
  6. use warnings FATAL => 'all';
  7. use FindBin;
  8. use FindBin::libs;
  9. use Test::More;
  10. use Test::MockModule qw{strict};
  11. use Test::Fatal qw{exception};
  12. use JSON::MaybeXS ();
  13. use JSON::Validator::Schema::OpenAPIv3 ();
  14. our ( $REPO, $ROOT, $OLDCWD );
  15. BEGIN {
  16. require Cwd;
  17. require File::Copy;
  18. require File::Path;
  19. require File::Temp;
  20. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  21. $OLDCWD = Cwd::getcwd();
  22. $ROOT = File::Temp::tempdir( 'tcms-routes-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  23. # TCMS.pm builds its document at file scope, against whatever cwd is
  24. # current when it loads, and every path in tCMS is relative to that.
  25. $ENV{HOME} = $ROOT;
  26. File::Path::make_path( map { "$ROOT/$_" } qw{config schema data/files logs totp www/statics www/themes} );
  27. File::Copy::copy( "$REPO/schema/$_", "$ROOT/schema/$_" ) or die $! for qw{auth.schema flatfile.schema log.schema};
  28. open( my $fh, '>', "$ROOT/config/default.cfg" ) or die $!;
  29. print {$fh} "[general]\n data_model=FlatFile\n hostname=routes.test.test\n";
  30. close($fh) or die $!;
  31. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  32. }
  33. END {
  34. chdir($OLDCWD) if $OLDCWD;
  35. }
  36. # The routes of posts, as the tag index of a FlatFile site gives them: by the
  37. # name of their callback.
  38. require_ok('Trog::SQLite::TagIndex') or BAIL_OUT("Can't load the tag index");
  39. my $index = Test::MockModule->new('Trog::SQLite::TagIndex');
  40. $index->redefine(
  41. routes => sub {
  42. return (
  43. '/posts/p-one' => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
  44. '/posts/{curly}' => { method => 'GET', callback => 'Trog::Routes::HTML::posts' },
  45. );
  46. }
  47. );
  48. # What a post's route runs, which the routes of posts name.
  49. require_ok('Trog::Routes::HTML') or BAIL_OUT("Can't load the routes");
  50. my $html = Test::MockModule->new('Trog::Routes::HTML');
  51. # It answers with where the request would be sent on, when it says.
  52. $html->redefine( posts => sub ($query) { return [ 200, [], [ exists $query->{to} ? $query->{to} : 'the post' ] ] } );
  53. require_ok('TCMS') or BAIL_OUT("Can't load the dispatcher");
  54. my %openapi = do { no warnings 'once'; %TCMS::openapi };
  55. # Checked as JSON reads it, since tPSGI is not a prerequisite of tCMS: perl's
  56. # booleans become JSON's, and a callback becomes null.
  57. my $json = JSON::MaybeXS->new( allow_unknown => 1, canonical => 1 );
  58. my $schema = JSON::Validator::Schema::OpenAPIv3->new( $json->decode( $json->encode( \%openapi ) ) );
  59. my $paths = $openapi{paths};
  60. subtest 'the document' => sub {
  61. is_deeply( $schema->errors, [], 'is valid OpenAPI 3' ) or diag explain $schema->errors;
  62. ok( !exists $paths->{default}, 'and has no path for the setup route, which is not one' );
  63. ok( !( grep { m/[(]/ } keys(%$paths) ), 'nor any regex left' ) or diag explain [ grep { m/[(]/ } keys(%$paths) ];
  64. };
  65. subtest 'an operation' => sub {
  66. my $manual = $paths->{'/lib/{module+}'}{get};
  67. ok( $manual, 'the manual of a module is a GET of /lib/{module+}' );
  68. is( ref $manual->{'x-tpsgi-callback'}, 'CODE', 'with a callback' );
  69. is( $manual->{'x-tcms-auth'}, 1, 'and its auth as x-tcms-auth' );
  70. is_deeply(
  71. [ grep { $_->{in} eq 'path' } @{ $manual->{parameters} } ],
  72. [ { name => 'module+', in => 'path', required => true, schema => { type => 'string' } } ],
  73. 'and each variable of the template as a required path parameter'
  74. );
  75. my $totp = $paths->{'/totp'}{get};
  76. is( $totp->{'x-tcms-totp_exempt'}, 1, 'totp_exempt is x-tcms-totp_exempt' );
  77. is_deeply( $paths->{'/sitemap/{map}.xml'}{get}{'x-tpsgi-data'}, { xml => 1 }, 'and data is x-tpsgi-data' );
  78. ok( $paths->{'/auth'}{post} && !$paths->{'/auth'}{get}, 'the method is the key of the operation' );
  79. };
  80. subtest 'the routes of posts' => sub {
  81. is( ref $paths->{'/posts/p-one'}{get}{'x-tpsgi-callback'}, 'CODE', 'a post named by its callback is routed' );
  82. ok( $paths->{'/secure/posts/p-one'}{get}, 'and so is its /secure copy' );
  83. # The flag that says setup is done, or the default route answers instead.
  84. open( my $fh, '>', 'config/setup' ) or die $!;
  85. close($fh) or die $!;
  86. my $res = $paths->{'/posts/p-one'}{get}{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $paths->{'/posts/p-one'}{get} } );
  87. is_deeply( $res->[2], ['the post'], 'and it runs the sub that the name names' );
  88. ok( !exists $paths->{'/posts/{curly}'}, 'but a post whose path has a brace is not, since it would be a template' );
  89. };
  90. subtest 'where a redirect may send you' => sub {
  91. my $operation = $paths->{'/posts/p-one'}{get};
  92. my $to = sub ($to) {
  93. return $operation->{'x-tpsgi-callback'}->( { log_dir => "$ROOT/logs", verbose => 0 }, { route => '/posts/p-one', dispatcher => $operation, input => { to => $to } } )->[2][0];
  94. };
  95. is( $to->('/config'), '/config', 'a path on this site is kept' );
  96. is( $to->('https://bogus.test/x'), '/x', 'the path of another site is all that is kept of it' );
  97. is( $to->('https://bogus.test'), '/', 'and another site with no path is the root, not the other site' );
  98. is( $to->('//bogus.test'), '/', 'even without a scheme' );
  99. is( $to->('////bogus.test'), '/bogus.test', 'and a path that starts with two slashes, which names a site, starts with one' );
  100. };
  101. subtest 'the scripts of a route' => sub {
  102. my $post = $paths->{'/posts/p-one'}{get};
  103. is_deeply( $post->{'x-tcms-scripts'}, [qw{highlight.min.js reveal.min.js}], 'a post page has the scripts of one' );
  104. is_deeply( $post->{'x-tcms-authenticated_scripts'}, [qw{post.js post_relations.js}], 'and the editing scripts for a reader who is logged in' );
  105. is_deeply( $paths->{'/secure/posts/p-one'}{get}{'x-tcms-scripts'}, $post->{'x-tcms-scripts'}, 'and so does its /secure copy' );
  106. is_deeply( $paths->{'/metrics'}{get}{'x-tcms-scripts'}, ['chart.min.js'], 'a route that names its own has those' );
  107. ok( !exists $paths->{'/metrics'}{get}{'x-tcms-authenticated_scripts'}, 'and no others' );
  108. ok( !exists $paths->{'/login'}{get}{'x-tcms-scripts'}, 'a route that names none has none' );
  109. no warnings qw{once};
  110. my %declared = %Trog::Bundle::declared;
  111. ok( $declared{'highlight.min+reveal.min'}, 'the bundle of a post page is declared at startup' );
  112. ok( $declared{'highlight.min+reveal.min+post+post_relations'}, 'and the one for a reader who is logged in' );
  113. ok( $declared{'chart.min'}, 'and the bundle of every other route' );
  114. ok( !$declared{'reveal.min+highlight.min'}, 'but no other list' );
  115. local @Theme::scripts = qw{theme.js};
  116. my $themed = { TCMS::build_routes() }->{paths};
  117. is_deeply( $themed->{'/login'}{get}{'x-tcms-scripts'}, [qw{theme.js}], "a theme's scripts go on a route that names none" );
  118. is_deeply( $themed->{'/metrics'}{get}{'x-tcms-scripts'}, [qw{theme.js chart.min.js}], "and before the scripts of a route that names its own" );
  119. is_deeply( $themed->{'/posts/p-one'}{get}{'x-tcms-scripts'}, [qw{theme.js highlight.min.js reveal.min.js}], "and before the scripts of a post page" );
  120. is_deeply( $themed->{'/secure/posts/p-one'}{get}{'x-tcms-scripts'}, [qw{theme.js highlight.min.js reveal.min.js}], "once, on a /secure copy that shares the route of its post" );
  121. is_deeply( $themed->{'/posts/p-one'}{get}{'x-tcms-authenticated_scripts'}, [qw{post.js post_relations.js}], "and not among the scripts for a reader who is logged in, who gets both lists" );
  122. ok( $Trog::Bundle::declared{'theme+chart.min'}, 'the bundle of a themed route is declared' );
  123. @Theme::scripts = ();
  124. is_deeply( { TCMS::build_routes() }->{paths}{'/metrics'}{get}{'x-tcms-scripts'}, ['chart.min.js'], "and building the routes again does not keep the theme's scripts of the last build" );
  125. };
  126. subtest 'validation' => sub {
  127. my $token = '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0';
  128. is_deeply( [ $schema->validate_request( [ get => '/api/auth_change_request/{token}' ], { path => { token => $token } } ) ], [], 'a token that is a UUID is taken' );
  129. ok( scalar $schema->validate_request( [ get => '/api/auth_change_request/{token}' ], { path => { token => "$token|x" } } ), 'and one with more after it is not' );
  130. is_deeply( [ $schema->validate_request( [ get => '/api/requests_per' ], { query => { period => 'week', num_periods => 2 } } ) ], [], 'a period that requests_per knows is taken' );
  131. ok( scalar $schema->validate_request( [ get => '/api/requests_per' ], { query => { period => 'fortnight' } } ), 'and one it does not is refused' );
  132. ok( scalar $schema->validate_request( [ get => '/api/requests_per' ], { query => { num_periods => 'many' } } ), 'as is a count that is not a number' );
  133. };
  134. subtest 'what a route takes from the request' => sub {
  135. my $names = sub ( $operation, $in = 'query' ) {
  136. return [ sort map { $_->{name} } grep { $_->{in} eq $in } @{ $operation->{parameters} // [] } ];
  137. };
  138. my @urchin = qw{utm_campaign utm_content utm_medium utm_source utm_term};
  139. is_deeply(
  140. $names->( $paths->{'/posts/p-one'}{get} ),
  141. [ sort qw{like page limit version older newer format embed nochrome year month}, @urchin ],
  142. 'a page of posts takes its paging, search, feed and embed, and a campaign'
  143. );
  144. is_deeply( $names->( $paths->{'/humans.txt'}{get} ), $names->( $paths->{'/posts/p-one'}{get} ), 'and so does a route of posts that fixes its tag' );
  145. is_deeply( $names->( $paths->{'/search'}{get} ), $names->( $paths->{'/posts/p-one'}{get} ), 'and so does the search, which is a page of posts' );
  146. is_deeply( $names->( $paths->{'/totp'}{get} ), \@urchin, 'and a page that is not one takes no search' );
  147. ok( !( grep { $_ eq 'map' } @{ $names->( $paths->{'/sitemap/{map}.xml'}{get} ) } ), 'a name the path holds is not one of the query too' );
  148. is_deeply( $names->( $paths->{'/sitemap/{map}.xml'}{get}, 'path' ), ['map'], 'but of the path' );
  149. ok( !( grep { $_ eq 'title' || $_ eq 'scripts' || $_ eq 'template' } @{ $names->( $paths->{'/login'}{get} ) } ), 'the login page takes none of the names its page is made of' );
  150. my $form = sub ($operation) { return $operation->{requestBody}{content}{'application/x-www-form-urlencoded'}{schema} };
  151. is_deeply( [ sort keys( %{ $form->( $paths->{'/auth'}{post} )->{properties} } ) ], [qw{app contact_email display_name password to token username}], 'a login takes the fields of its form' );
  152. ok( !exists $form->( $paths->{'/auth'}{post} )->{additionalProperties}, 'and no others' );
  153. ok( $form->( $paths->{'/post/save'}{post} )->{additionalProperties}, 'a post takes the fields of its type too' );
  154. ok( exists $form->( $paths->{'/config/save'}{post} )->{properties}{'general.data_model'}, 'the configuration takes each setting by its name' );
  155. ok( !$paths->{'/post/save'}{post}{parameters}, 'and a POST takes nothing in its query' );
  156. };
  157. subtest 'tPSGI routes by the document' => sub {
  158. plan skip_all => 'tPSGI is not installed' unless eval { require TPSGI::Router; 1 };
  159. my $router;
  160. is( exception { $router = TPSGI::Router->new( 'TCMS.pm' => \%openapi ) }, undef, 'which it can compile, so no route gives a name to two places' );
  161. return unless $router;
  162. my $login = $router->match( 'GET', '/login' );
  163. is_deeply( [ $router->validate( $login, query => { to => '/config' } ) ], [], 'the login page takes where to go next' );
  164. my ($status) = $router->validate( $login, query => { scripts => '//bogus.test/x.js' } );
  165. is( $status, 400, 'and nothing it does not declare' );
  166. ($status) = $router->validate( $router->match( 'POST', '/auth' ), body => { username => 'x', title => '<b>x</b>' }, content_type => 'application/x-www-form-urlencoded' );
  167. is( $status, 400, 'nor does a login' );
  168. };
  169. subtest 'robots' => sub {
  170. my $banned;
  171. my $renderer = Test::MockModule->new('Trog::Renderer');
  172. $renderer->redefine( render => sub { my ( $class, %args ) = @_; $banned = $args{data}{banned}; return [ 200, [], [''] ] } );
  173. TCMS::robots( { domain => 'routes.test.test' } );
  174. ok( ( grep { $_ eq '/avatar/*' } @$banned ), 'a template with no robot_name is disallowed with * for its variable' );
  175. ok( ( grep { $_ eq '/guest/reprovision/log/' } @$banned ), 'and one with a robot_name by that' );
  176. ok( !( grep { m/[{}]/ } @$banned ), 'and no template is written as it is' );
  177. };
  178. done_testing();