| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- #!/usr/bin/env perl
- # A datasource which turns a directory into posts. Most of this file is about
- # which directories it will agree to look at, because that is the part where
- # getting it wrong hands out the names of files somebody meant to keep.
- use 5.040;
- use warnings;
- use FindBin;
- use lib "$FindBin::Bin/../lib";
- use Test::More;
- use Test::MockModule qw{strict};
- use Test::Fatal qw{exception};
- our ( $REPO, $ROOT, $OLDCWD );
- BEGIN {
- require Cwd;
- require File::Copy;
- require File::Path;
- require File::Temp;
- $REPO = Cwd::abs_path("$FindBin::Bin/..");
- $OLDCWD = Cwd::getcwd();
- $ROOT = File::Temp::tempdir( 'tcms-dirindex-XXXXXX', TMPDIR => 1, CLEANUP => 1 );
- $ENV{HOME} = $ROOT;
- # The paths are relative to the tCMS root, so we need one.
- File::Path::make_path("$ROOT/$_") for qw{config www/assets/downloads www/assets/private/secrets www/statics outside};
- # Trog::Routes::HTML reads the config at load time, and the last subtest
- # loads it to check the wizard can see this datasource.
- File::Copy::copy( "$REPO/config/default.cfg", "$ROOT/config/default.cfg" ) or die $!;
- chdir($ROOT) or die "could not chdir to the sandbox: $!";
- }
- END {
- chdir($OLDCWD) if $OLDCWD;
- }
- require_ok('Trog::DataSource::DirIndex') or BAIL_OUT("Can't find SUT");
- my $log = Test::MockModule->new('Trog::DataSource::DirIndex');
- $log->redefine( WARN => sub { note(shift) } );
- $log->redefine( DEBUG => sub { note(shift) } );
- sub touch {
- my ( $path, $content ) = @_;
- open( my $fh, '>', $path ) or die "$path: $!";
- print {$fh} ( $content // 'x' );
- close $fh;
- return;
- }
- touch( 'www/assets/downloads/report.pdf', 'pdf' x 100 );
- touch( 'www/assets/downloads/photo.jpg', 'jpg' );
- touch( 'www/assets/downloads/notes.txt', 'notes' );
- touch( 'www/assets/downloads/.hidden', 'nope' );
- touch( 'www/assets/private/secrets/salaries.csv', 'nope' );
- touch( 'outside/elsewhere.txt', 'nope' );
- mkdir('www/assets/downloads/archive');
- my %series = (
- directory => 'assets/downloads',
- child_form => 'spec_files.tx',
- tags => [qw{downloads public}],
- visibility => 'public',
- user => 'bob',
- );
- sub names {
- my (@posts) = @_;
- return [ sort map { $_->{name} } @posts ];
- }
- subtest 'it lists a directory' => sub {
- my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
- is_deeply( names(@posts), [ sort qw{archive notes.txt photo.jpg report.pdf} ], 'every entry becomes a post' );
- my ($pdf) = grep { $_->{name} eq 'report.pdf' } @posts;
- is( $pdf->{title}, 'report.pdf', 'named after the file' );
- is( $pdf->{path}, 'assets/downloads/report.pdf', 'carrying its path relative to www' );
- is( $pdf->{local_href}, '/assets/downloads/report.pdf', 'and a link to where it actually is' );
- is( $pdf->{size}, 300, 'with its size' );
- is( $pdf->{size_human}, '300 B', 'as a human reads it' );
- is( $pdf->{extension}, 'pdf', 'and its extension' );
- is( $pdf->{is_dir}, 0, 'a file is not a directory' );
- is( $pdf->{form}, 'spec_files.tx', 'and it says which template renders it' );
- ok( $pdf->{created}, 'created is its mtime, so it has a real date' );
- my ($dir) = grep { $_->{name} eq 'archive' } @posts;
- is( $dir->{is_dir}, 1, 'a directory says so' );
- is( $dir->{size}, 0, 'and has no size of its own' );
- is( $dir->{extension}, '', 'nor an extension' );
- # A template wants to know what it is looking at.
- my ($jpg) = grep { $_->{name} eq 'photo.jpg' } @posts;
- is( $jpg->{is_image}, 1, 'an image is flagged as one' );
- is( $pdf->{is_image}, 0, 'and a PDF is not' );
- # Inherited from the series, as every datasource's posts are: the reader
- # reached this page by holding the series' acls.
- is_deeply( $pdf->{tags}, [qw{downloads public}], 'the series tags come along' );
- is( $pdf->{visibility}, 'public', 'and its visibility' );
- is( $pdf->{user}, 'bob', 'and its owner' );
- };
- subtest 'ids are stable and opaque' => sub {
- my @first = Trog::DataSource::DirIndex::posts( \%series, {} );
- my @second = Trog::DataSource::DirIndex::posts( \%series, {} );
- my %one = map { $_->{name} => $_->{id} } @first;
- my %two = map { $_->{name} => $_->{id} } @second;
- is_deeply( \%one, \%two, 'the same entry gets the same id twice' );
- # Pagination hands out page 2 of a list keyed on these, and a filename is
- # not a thing to put in a URL.
- ok( $one{'report.pdf'} =~ m/^dirindex-[0-9a-f]{32}$/, 'and it is opaque rather than the name' );
- isnt( $one{'report.pdf'}, $one{'notes.txt'}, 'different entries get different ids' );
- };
- subtest 'what it refuses to look at' => sub {
- # Everything under www/ is already served to anybody who asks, so listing it
- # discloses nothing a guessed URL would not. Everywhere else, the names are
- # the disclosure.
- my %refused = (
- 'somewhere else entirely' => 'outside',
- 'an absolute path' => "$ROOT/outside",
- 'a walk upwards' => 'assets/../../outside',
- 'a sneakier walk' => 'assets/downloads/../../../outside',
- 'the private assets' => 'assets/private',
- 'inside the private assets' => 'assets/private/secrets',
- 'a directory that is not there' => 'assets/nonesuch',
- 'a file rather than a directory' => 'assets/downloads/report.pdf',
- 'nothing at all' => '',
- );
- foreach my $why ( sort keys %refused ) {
- my @posts = Trog::DataSource::DirIndex::posts( { %series, directory => $refused{$why} }, {} );
- is_deeply( \@posts, [], "refuses $why" );
- }
- my @undef = Trog::DataSource::DirIndex::posts( { %series, directory => undef }, {} );
- is_deeply( \@undef, [], 'and a series which names no directory at all' );
- };
- subtest 'the www prefix is optional' => sub {
- my @with = Trog::DataSource::DirIndex::posts( { %series, directory => 'www/assets/downloads' }, {} );
- my @without = Trog::DataSource::DirIndex::posts( { %series, directory => 'assets/downloads' }, {} );
- is_deeply( names(@with), names(@without), 'either way names the same directory' );
- ok( scalar(@with), 'and it is not simply refusing both' );
- };
- subtest 'dotfiles and escaping symlinks are left out' => sub {
- my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
- is_deeply( [ grep { $_->{name} =~ m/^\./ } @posts ], [], 'a dotfile is not listed' );
- SKIP: {
- skip( 'no symlinks here', 2 ) unless eval { symlink( "$ROOT/outside/elsewhere.txt", 'www/assets/downloads/escape.txt' ); 1 };
- my @linked = Trog::DataSource::DirIndex::posts( \%series, {} );
- is_deeply( [ grep { $_->{name} eq 'escape.txt' } @linked ], [], 'nor a symlink pointing out of the tree' );
- symlink( "$ROOT/www/assets/downloads/notes.txt", 'www/assets/downloads/inside.txt' );
- my @inside = Trog::DataSource::DirIndex::posts( \%series, {} );
- ok( scalar( grep { $_->{name} eq 'inside.txt' } @inside ), 'while one pointing within it is fine' );
- unlink('www/assets/downloads/escape.txt');
- unlink('www/assets/downloads/inside.txt');
- }
- };
- subtest 'a filename that needs escaping' => sub {
- touch('www/assets/downloads/a file & thing #1.txt');
- my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
- my ($odd) = grep { $_->{name} eq 'a file & thing #1.txt' } @posts;
- ok( $odd, 'it is listed under its real name' );
- # A filename may legally hold a space, an ampersand or a hash, and none of
- # those survive being dropped into an href as they are.
- unlike( $odd->{local_href}, qr/[ #]/, 'but its link carries none of them raw' );
- like( $odd->{local_href}, qr/^\/assets\/downloads\//, 'and still points where it should' );
- unlink('www/assets/downloads/a file & thing #1.txt');
- };
- subtest 'searching a listing' => sub {
- my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
- is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'pdf' }, @posts ) ), ['report.pdf'], 'by extension' );
- is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'note' }, @posts ) ), ['notes.txt'], 'by name' );
- is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'downloads' }, @posts ) ), names(@posts), 'by path' );
- is_deeply( names( Trog::DataSource::DirIndex::filter( { like => 'nonesuch' }, @posts ) ), [], 'and nothing matches nothing' );
- is_deeply( names( Trog::DataSource::DirIndex::filter( {}, @posts ) ), names(@posts), 'no search filters nothing' );
- };
- subtest 'the order a directory index belongs in' => sub {
- my @posts = Trog::DataSource::DirIndex::posts( \%series, {} );
- my @ordered = Trog::DataSource::DirIndex::order( {}, @posts );
- is( $ordered[0]{name}, 'archive', 'directories come first' );
- is_deeply(
- [ map { $_->{name} } @ordered ],
- [qw{archive notes.txt photo.jpg report.pdf}],
- 'and then it is by name, which is what every file browser does'
- );
- # Pagination hands out page 2 of this, so it had better not depend on what
- # order readdir felt like today.
- is_deeply(
- [ map { $_->{name} } Trog::DataSource::DirIndex::order( {}, reverse @posts ) ],
- [ map { $_->{name} } @ordered ],
- 'whatever order they arrived in'
- );
- };
- subtest 'it asks to be told when the directory changes' => sub {
- my @watches;
- my $tpsgi = bless {}, 'FakeTPSGI';
- {
- no warnings qw{once};
- *FakeTPSGI::add_watch = sub {
- my ( $self, $path, $callback, %options ) = @_;
- push( @watches, { path => $path, callback => $callback, %options } );
- return 1;
- };
- *FakeTPSGI::invalidate_renders = sub { push( @{ $_[0]{invalidated} }, $_[1] ); return 1 };
- }
- Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
- is( scalar(@watches), 1, 'a watch is registered' );
- is( $watches[0]{path}, 'www/assets/downloads', 'on the directory it is listing' );
- like( $watches[0]{key}, qr/DirIndex/, 'under a key naming what registered it' );
- # posts() runs per request and builds this closure fresh each time, so
- # without an explicit key the default -- the callback's address -- would
- # stack another copy of the same callback on every single view.
- Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
- Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $tpsgi } );
- is( scalar( keys %{ { map { $_->{key} => 1 } @watches } } ), 1, 'and it is the same key every time' );
- # The callback uses the tPSGI it is handed, not the one from the request
- # that registered it: watches are shared between workers, and whichever
- # worker notices the change is the one that has to do the invalidating.
- my $other = bless {}, 'FakeTPSGI';
- $watches[0]{callback}->( $other, { path => 'www/assets/downloads/new.txt', events => ['CREATE'] } );
- is_deeply( $other->{invalidated}, ['html'], 'and it invalidates the html renders when it fires' );
- ok( !$tpsgi->{invalidated}, 'through the object it was handed, not the one it closed over' );
- };
- subtest 'no tPSGI, or one without watches, is not fatal' => sub {
- is( exception { Trog::DataSource::DirIndex::posts( \%series, {} ) }, undef, 'no tpsgi at all is fine' );
- my $old = bless {}, 'OldTPSGI';
- is( exception { Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $old } ) }, undef, 'and neither is one without add_watch' );
- my @posts = Trog::DataSource::DirIndex::posts( \%series, { tpsgi => $old } );
- ok( scalar(@posts), 'the listing still works; it just goes stale' );
- };
- subtest 'the wizard can find it' => sub {
- require Trog::Routes::HTML;
- my $sources = Trog::Routes::HTML::_get_datasources();
- ok( scalar( grep { $_ eq 'Trog::DataSource::DirIndex' } @$sources ), 'it is offered as a datasource' );
- is( Trog::DataSource::DirIndex->EDITABLE, 0, 'and declares it wants no editor' );
- };
- done_testing();
|