Trog-DataSource-DirIndex.t 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. #!/usr/bin/env perl
  2. # A datasource which turns a directory into posts. Most of this file is about
  3. # which directories it will agree to look at, because that is the part where
  4. # getting it wrong hands out the names of files somebody meant to keep.
  5. use strict;
  6. use warnings;
  7. use FindBin;
  8. use lib "$FindBin::Bin/../lib";
  9. use Test::More;
  10. use Test::MockModule qw{strict};
  11. use Test::Fatal qw{exception};
  12. our ( $REPO, $ROOT, $OLDCWD );
  13. BEGIN {
  14. require Cwd;
  15. require File::Copy;
  16. require File::Path;
  17. require File::Temp;
  18. $REPO = Cwd::abs_path("$FindBin::Bin/..");
  19. $OLDCWD = Cwd::getcwd();
  20. $ROOT = File::Temp::tempdir( 'tcms-dirindex-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
  21. $ENV{HOME} = $ROOT;
  22. # The paths are relative to the tCMS root, so we need one.
  23. File::Path::make_path("$ROOT/$_") for qw{config www/assets/downloads www/assets/private/secrets www/statics outside};
  24. # Trog::Routes::HTML reads the config at load time, and the last subtest
  25. # loads it to check the wizard can see this datasource.
  26. File::Copy::copy( "$REPO/config/default.cfg", "$ROOT/config/default.cfg" ) or die $!;
  27. chdir($ROOT) or die "could not chdir to the sandbox: $!";
  28. }
  29. END {
  30. chdir($OLDCWD) if $OLDCWD;
  31. }
  32. require_ok('Trog::DataSource::DirIndex') or BAIL_OUT("Can't find SUT");
  33. my $log = Test::MockModule->new('Trog::DataSource::DirIndex');
  34. $log->redefine( WARN => sub { note(shift) } );
  35. $log->redefine( DEBUG => sub { note(shift) } );
  36. sub touch {
  37. my ( $path, $content ) = @_;
  38. open( my $fh, '>', $path ) or die "$path: $!";
  39. print {$fh} ( $content // 'x' );
  40. close $fh;
  41. return;
  42. }
  43. touch( 'www/assets/downloads/report.pdf', 'pdf' x 100 );
  44. touch( 'www/assets/downloads/photo.jpg', 'jpg' );
  45. touch( 'www/assets/downloads/notes.txt', 'notes' );
  46. touch( 'www/assets/downloads/.hidden', 'nope' );
  47. touch( 'www/assets/private/secrets/salaries.csv', 'nope' );
  48. touch( 'outside/elsewhere.txt', 'nope' );
  49. mkdir('www/assets/downloads/archive');
  50. my %series = (
  51. directory => 'assets/downloads',
  52. child_form => 'spec_files.tx',
  53. tags => [qw{downloads public}],
  54. visibility => 'public',
  55. user => 'bob',
  56. );
  57. sub names {
  58. my (@posts) = @_;
  59. return [ sort map { $_->{name} } @posts ];
  60. }
  61. subtest 'it lists a directory' => sub {
  62. my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
  63. is_deeply( names(@posts), [ sort qw{archive notes.txt photo.jpg report.pdf} ], 'every entry becomes a post' );
  64. my ($pdf) = grep { $_->{name} eq 'report.pdf' } @posts;
  65. is( $pdf->{title}, 'report.pdf', 'named after the file' );
  66. is( $pdf->{path}, 'assets/downloads/report.pdf', 'carrying its path relative to www' );
  67. is( $pdf->{local_href}, '/assets/downloads/report.pdf', 'and a link to where it actually is' );
  68. is( $pdf->{size}, 300, 'with its size' );
  69. is( $pdf->{size_human}, '300 B', 'as a human reads it' );
  70. is( $pdf->{extension}, 'pdf', 'and its extension' );
  71. is( $pdf->{is_dir}, 0, 'a file is not a directory' );
  72. is( $pdf->{form}, 'spec_files.tx', 'and it says which template renders it' );
  73. ok( $pdf->{created}, 'created is its mtime, so it has a real date' );
  74. my ($dir) = grep { $_->{name} eq 'archive' } @posts;
  75. is( $dir->{is_dir}, 1, 'a directory says so' );
  76. is( $dir->{size}, 0, 'and has no size of its own' );
  77. is( $dir->{extension}, '', 'nor an extension' );
  78. # A template wants to know what it is looking at.
  79. my ($jpg) = grep { $_->{name} eq 'photo.jpg' } @posts;
  80. is( $jpg->{is_image}, 1, 'an image is flagged as one' );
  81. is( $pdf->{is_image}, 0, 'and a PDF is not' );
  82. # Inherited from the series, as every datasource's posts are: the reader
  83. # reached this page by holding the series' acls.
  84. is_deeply( $pdf->{tags}, [qw{downloads public}], 'the series tags come along' );
  85. is( $pdf->{visibility}, 'public', 'and its visibility' );
  86. is( $pdf->{user}, 'bob', 'and its owner' );
  87. };
  88. subtest 'ids are stable and opaque' => sub {
  89. my @first = Trog::DataSource::DirIndex::posts( \%series, {} );
  90. my @second = Trog::DataSource::DirIndex::posts( \%series, {} );
  91. my %one = map { $_->{name} => $_->{id} } @first;
  92. my %two = map { $_->{name} => $_->{id} } @second;
  93. is_deeply( \%one, \%two, 'the same entry gets the same id twice' );
  94. # Pagination hands out page 2 of a list keyed on these, and a filename is
  95. # not a thing to put in a URL.
  96. ok( $one{'report.pdf'} =~ m/^dirindex-[0-9a-f]{32}$/, 'and it is opaque rather than the name' );
  97. isnt( $one{'report.pdf'}, $one{'notes.txt'}, 'different entries get different ids' );
  98. };
  99. subtest 'what it refuses to look at' => sub {
  100. # Everything under www/ is already served to anybody who asks, so listing it
  101. # discloses nothing a guessed URL would not. Everywhere else, the names are
  102. # the disclosure.
  103. my %refused = (
  104. 'somewhere else entirely' => 'outside',
  105. 'an absolute path' => "$ROOT/outside",
  106. 'a walk upwards' => 'assets/../../outside',
  107. 'a sneakier walk' => 'assets/downloads/../../../outside',
  108. 'the private assets' => 'assets/private',
  109. 'inside the private assets' => 'assets/private/secrets',
  110. 'a directory that is not there' => 'assets/nonesuch',
  111. 'a file rather than a directory' => 'assets/downloads/report.pdf',
  112. 'nothing at all' => '',
  113. );
  114. foreach my $why ( sort keys %refused ) {
  115. my @posts = Trog::DataSource::DirIndex::posts( { %series, directory => $refused{$why} }, {} );
  116. is_deeply( \@posts, [], "refuses $why" );
  117. }
  118. my @undef = Trog::DataSource::DirIndex::posts( { %series, directory => undef }, {} );
  119. is_deeply( \@undef, [], 'and a series which names no directory at all' );
  120. };
  121. subtest 'the www prefix is optional' => sub {
  122. my @with = Trog::DataSource::DirIndex::posts( { %series, directory => 'www/assets/downloads' }, {} );
  123. my @without = Trog::DataSource::DirIndex::posts( { %series, directory => 'assets/downloads' }, {} );
  124. is_deeply( names(@with), names(@without), 'either way names the same directory' );
  125. ok( scalar(@with), 'and it is not simply refusing both' );
  126. };
  127. subtest 'dotfiles and escaping symlinks are left out' => sub {
  128. my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
  129. is_deeply( [ grep { $_->{name} =~ m/^\./ } @posts ], [], 'a dotfile is not listed' );
  130. SKIP: {
  131. skip( 'no symlinks here', 2 ) unless eval { symlink( "$ROOT/outside/elsewhere.txt", 'www/assets/downloads/escape.txt' ); 1 };
  132. my @linked = Trog::DataSource::DirIndex::posts( \%series, {} );
  133. is_deeply( [ grep { $_->{name} eq 'escape.txt' } @linked ], [], 'nor a symlink pointing out of the tree' );
  134. symlink( "$ROOT/www/assets/downloads/notes.txt", 'www/assets/downloads/inside.txt' );
  135. my @inside = Trog::DataSource::DirIndex::posts( \%series, {} );
  136. ok( scalar( grep { $_->{name} eq 'inside.txt' } @inside ), 'while one pointing within it is fine' );
  137. unlink('www/assets/downloads/escape.txt');
  138. unlink('www/assets/downloads/inside.txt');
  139. }
  140. };
  141. subtest 'a filename that needs escaping' => sub {
  142. touch('www/assets/downloads/a file & thing #1.txt');
  143. my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
  144. my ($odd) = grep { $_->{name} eq 'a file & thing #1.txt' } @posts;
  145. ok( $odd, 'it is listed under its real name' );
  146. # A filename may legally hold a space, an ampersand or a hash, and none of
  147. # those survive being dropped into an href as they are.
  148. unlike( $odd->{local_href}, qr/[ #]/, 'but its link carries none of them raw' );
  149. like( $odd->{local_href}, qr/^\/assets\/downloads\//, 'and still points where it should' );
  150. unlink('www/assets/downloads/a file & thing #1.txt');
  151. };
  152. subtest 'searching a listing' => sub {
  153. my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
  154. is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'pdf' }, @posts ) ), ['report.pdf'], 'by extension' );
  155. is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'note' }, @posts ) ), ['notes.txt'], 'by name' );
  156. is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'downloads' }, @posts ) ), names(@posts), 'by path' );
  157. is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'nonesuch' }, @posts ) ), [], 'and nothing matches nothing' );
  158. is_deeply( names( Trog::DataSource::DirIndex::filter( {}, @posts ) ), names(@posts), 'no search filters nothing' );
  159. };
  160. subtest 'the order a directory index belongs in' => sub {
  161. my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
  162. my @ordered = Trog::DataSource::DirIndex::order( {}, @posts );
  163. is( $ordered[0]{name}, 'archive', 'directories come first' );
  164. is_deeply(
  165. [ map { $_->{name} } @ordered ],
  166. [qw{archive notes.txt photo.jpg report.pdf}],
  167. 'and then it is by name, which is what every file browser does'
  168. );
  169. # Pagination hands out page 2 of this, so it had better not depend on what
  170. # order readdir felt like today.
  171. is_deeply(
  172. [ map { $_->{name} } Trog::DataSource::DirIndex::order( {}, reverse @posts ) ],
  173. [ map { $_->{name} } @ordered ],
  174. 'whatever order they arrived in'
  175. );
  176. };
  177. subtest 'it asks to be told when the directory changes' => sub {
  178. my @watches;
  179. my $tpsgi = bless {}, 'FakeTPSGI';
  180. {
  181. no warnings qw{once};
  182. *FakeTPSGI::add_watch = sub {
  183. my ( $self, $path, $callback, %options ) = @_;
  184. push( @watches, { path => $path, callback => $callback, %options } );
  185. return 1;
  186. };
  187. *FakeTPSGI::invalidate_renders = sub { push( @{ $_[0]{invalidated} }, $_[1] ); return 1 };
  188. }
  189. Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
  190. is( scalar(@watches), 1, 'a watch is registered' );
  191. is( $watches[0]{path}, 'www/assets/downloads', 'on the directory it is listing' );
  192. like( $watches[0]{key}, qr/DirIndex/, 'under a key naming what registered it' );
  193. # posts() runs per request and builds this closure fresh each time, so
  194. # without an explicit key the default -- the callback's address -- would
  195. # stack another copy of the same callback on every single view.
  196. Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
  197. Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
  198. is( scalar( keys %{ { map { $_->{key} => 1 } @watches } } ), 1, 'and it is the same key every time' );
  199. # The callback uses the tPSGI it is handed, not the one from the request
  200. # that registered it: watches are shared between workers, and whichever
  201. # worker notices the change is the one that has to do the invalidating.
  202. my $other = bless {}, 'FakeTPSGI';
  203. $watches[0]{callback}->( $other, { path => 'www/assets/downloads/new.txt', events => ['CREATE'] } );
  204. is_deeply( $other->{invalidated}, ['html'], 'and it invalidates the html renders when it fires' );
  205. ok( !$tpsgi->{invalidated}, 'through the object it was handed, not the one it closed over' );
  206. };
  207. subtest 'no tPSGI, or one without watches, is not fatal' => sub {
  208. is( exception { Trog::DataSource::DirIndex::posts( \%series, {} ) }, undef, 'no tpsgi at all is fine' );
  209. my $old = bless {}, 'OldTPSGI';
  210. is( exception { Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $old } ) }, undef, 'and neither is one without add_watch' );
  211. my @posts = Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $old } );
  212. ok( scalar(@posts), 'the listing still works; it just goes stale' );
  213. };
  214. subtest 'the wizard can find it' => sub {
  215. require Trog::Routes::HTML;
  216. my $sources = Trog::Routes::HTML::_get_datasources();
  217. ok( scalar( grep { $_ eq 'Trog::DataSource::DirIndex' } @$sources ), 'it is offered as a datasource' );
  218. is( Trog::DataSource::DirIndex->EDITABLE, 0, 'and declares it wants no editor' );
  219. };
  220. done_testing();