Trog-DataSource-DirIndex.t 12 KB

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