|
|
@@ -0,0 +1,187 @@
|
|
|
+use strict;
|
|
|
+use warnings;
|
|
|
+
|
|
|
+use Test::More;
|
|
|
+use Test::MockModule qw{strict};
|
|
|
+use Test::Fatal qw{exception};
|
|
|
+use Path::Tiny();
|
|
|
+use FindBin;
|
|
|
+
|
|
|
+use lib "$FindBin::Bin/../lib";
|
|
|
+
|
|
|
+# Nothing in this file may ever reach a real hypervisor. Every libvirt object
|
|
|
+# here is a fake that records what was asked of it, which is the only safe way
|
|
|
+# to test the destructive half: the machine this suite runs on is itself a
|
|
|
+# guest of the hypervisor the feature talks to.
|
|
|
+require_ok('Trog::DataSource::Virt') or BAIL_OUT("Can't find SUT");
|
|
|
+
|
|
|
+my $logmock = Test::MockModule->new('Trog::DataSource::Virt');
|
|
|
+$logmock->redefine( WARN => sub { note(shift) } );
|
|
|
+$logmock->redefine( INFO => sub { note(shift) } );
|
|
|
+
|
|
|
+our @CALLS;
|
|
|
+
|
|
|
+{
|
|
|
+ package FakeDomain;
|
|
|
+ sub new { my ( $c, %a ) = @_; return bless {%a}, $c }
|
|
|
+ sub get_name { $_[0]{name} }
|
|
|
+ sub get_uuid_string { $_[0]{uuid} }
|
|
|
+ sub is_active { $_[0]{active} }
|
|
|
+ sub get_info { { state => $_[0]{state}, memory => 1024, nrVirtCpu => 2 } }
|
|
|
+
|
|
|
+ # The operations we must never actually perform: record and return.
|
|
|
+ sub create { push @main::CALLS, [ 'create', $_[0]{name} ]; 1 }
|
|
|
+ sub shutdown { push @main::CALLS, [ 'shutdown', $_[0]{name} ]; 1 }
|
|
|
+ sub destroy { push @main::CALLS, [ 'destroy', $_[0]{name} ]; 1 }
|
|
|
+ sub create_snapshot { push @main::CALLS, [ 'create_snapshot', $_[0]{name} ]; 1 }
|
|
|
+}
|
|
|
+
|
|
|
+{
|
|
|
+ package FakeConn;
|
|
|
+ sub new { bless {}, shift }
|
|
|
+ sub list_all_domains {
|
|
|
+ return (
|
|
|
+ FakeDomain->new( name => 'alpha', uuid => 'uuid-alpha', active => 1, state => 1 ),
|
|
|
+ FakeDomain->new( name => 'beta', uuid => 'uuid-beta', active => 0, state => 5 ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+my $connect_fails = 0;
|
|
|
+my $virtmock = Test::MockModule->new('Sys::Virt');
|
|
|
+$virtmock->redefine(
|
|
|
+ new => sub {
|
|
|
+ die "libvirt error: nope\n" if $connect_fails;
|
|
|
+ return FakeConn->new();
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+my $series = {
|
|
|
+ child_form => 'guests.tx',
|
|
|
+ tags => ['guests'],
|
|
|
+ visibility => 'private',
|
|
|
+ user => 'specadmin',
|
|
|
+ hypervisors => [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ],
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'guests become posts' => sub {
|
|
|
+ @CALLS = ();
|
|
|
+ my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
|
|
|
+
|
|
|
+ is( scalar @posts, 2, 'one post per guest' );
|
|
|
+ is( $posts[0]{title}, 'alpha', 'named after the guest' );
|
|
|
+ is( $posts[0]{id}, 'uuid-alpha', "and identified by the guest's uuid, not ours" );
|
|
|
+ is( $posts[0]{form}, 'guests.tx', "wearing the series' child type" );
|
|
|
+ is( $posts[0]{state}, 'running', 'with a legible state' );
|
|
|
+ is( $posts[1]{state}, 'shut off', '...for each of libvirt\'s numeric ones' );
|
|
|
+ is( $posts[0]{is_active}, 1, 'active guests are marked so' );
|
|
|
+ is( $posts[1]{is_active}, 0, 'and inactive ones are not' );
|
|
|
+
|
|
|
+ # The listing must not take screenshots: that is a per-guest round trip,
|
|
|
+ # and it is the browser's job via the preview route.
|
|
|
+ is( $posts[0]{preview}, '/guest/screenshot/hv-1/alpha', 'the preview is a route, not an inline capture' );
|
|
|
+ is_deeply( \@CALLS, [], 'and nothing was done to any guest merely by listing' );
|
|
|
+
|
|
|
+ is_deeply( $posts[0]{tags}, ['guests'], "children inherit the series' tags" );
|
|
|
+ is( $posts[0]{visibility}, 'private', 'and its visibility' );
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'an unreachable hypervisor says so rather than vanishing' => sub {
|
|
|
+ $connect_fails = 1;
|
|
|
+ my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
|
|
|
+ $connect_fails = 0;
|
|
|
+
|
|
|
+ is( scalar @posts, 1, 'we get a post back' );
|
|
|
+ ok( $posts[0]{unreachable}, 'flagged as unreachable' );
|
|
|
+ like( $posts[0]{state}, qr/unreachable/, 'saying as much' );
|
|
|
+ is( $posts[0]{title}, 'spec-hv', 'named for the hypervisor we could not reach' );
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'act() drives the right libvirt call, and only on request' => sub {
|
|
|
+ my %expected = (
|
|
|
+ poweron => 'create',
|
|
|
+ poweroff => 'shutdown',
|
|
|
+ snapshot => 'create_snapshot',
|
|
|
+ destroy => 'destroy',
|
|
|
+ );
|
|
|
+
|
|
|
+ foreach my $action ( sort keys %expected ) {
|
|
|
+ @CALLS = ();
|
|
|
+ my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, 'test:///default', 'alpha', 'specadmin' );
|
|
|
+ ok( $ok, "$action succeeded" ) or diag($message);
|
|
|
+ is_deeply( \@CALLS, [ [ $expected{$action}, 'alpha' ] ], "$action called $expected{$action} on the named guest, once" );
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'act() refuses what it should' => sub {
|
|
|
+ @CALLS = ();
|
|
|
+
|
|
|
+ my ( $ok, $message ) = Trog::DataSource::Virt::act( 'rm-rf', 'test:///default', 'alpha', 'specadmin' );
|
|
|
+ ok( !$ok, 'an action we do not define is refused' );
|
|
|
+ like( $message, qr/not something we do/, 'and says so' );
|
|
|
+
|
|
|
+ ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', '../../etc/passwd', 'specadmin' );
|
|
|
+ ok( !$ok, 'a guest name that is a path is refused' );
|
|
|
+ like( $message, qr/bad guest name/, 'before any connection is made' );
|
|
|
+
|
|
|
+ ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', 'nosuchguest', 'specadmin' );
|
|
|
+ ok( !$ok, 'a guest that is not there is refused' );
|
|
|
+ like( $message, qr/no guest called/, 'rather than acting on some other one' );
|
|
|
+
|
|
|
+ is_deeply( \@CALLS, [], 'and none of those touched a guest' );
|
|
|
+
|
|
|
+ $connect_fails = 1;
|
|
|
+ ( $ok, $message ) = Trog::DataSource::Virt::act( 'poweroff', 'test:///default', 'alpha', 'specadmin' );
|
|
|
+ $connect_fails = 0;
|
|
|
+ ok( !$ok, 'an unreachable hypervisor is refused' );
|
|
|
+ like( $message, qr/could not reach/, 'and reported' );
|
|
|
+ is_deeply( \@CALLS, [], 'having done nothing' );
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'screenshot() validates the guest name before anything else' => sub {
|
|
|
+ my ( $path, $why ) = Trog::DataSource::Virt::screenshot( 'test:///default', '../../../etc/passwd' );
|
|
|
+ is( $path, undef, 'a path-shaped guest name gets no screenshot' );
|
|
|
+ like( $why, qr/bad domain name/, 'and is named as the reason' );
|
|
|
+
|
|
|
+ ( $path, $why ) = Trog::DataSource::Virt::screenshot( 'test:///default', 'alpha/../../beta' );
|
|
|
+ is( $path, undef, 'nor does one with traversal in the middle' );
|
|
|
+};
|
|
|
+
|
|
|
+subtest 'the datasource hook only loads what it should' => sub {
|
|
|
+ require Trog::Routes::HTML;
|
|
|
+
|
|
|
+ my $htmlmock = Test::MockModule->new('Trog::Routes::HTML');
|
|
|
+ $htmlmock->redefine( WARN => sub { note(shift) } );
|
|
|
+
|
|
|
+ my $declared;
|
|
|
+ my $dmmock = Test::MockModule->new('Trog::DataModule');
|
|
|
+ $dmmock->redefine( type_meta_for => sub { return { 'x-tcms-datasource' => $declared } } );
|
|
|
+
|
|
|
+ my @stored = ( { title => 'a stored post' } );
|
|
|
+ my $query = { primary_post => { child_form => 'guests.tx' }, user_acls => ['admin'] };
|
|
|
+
|
|
|
+ # A type that declares nothing keeps its own posts -- this hook must be
|
|
|
+ # invisible to the overwhelming majority of pages.
|
|
|
+ $declared = undef;
|
|
|
+ is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'no datasource declared, posts untouched' );
|
|
|
+
|
|
|
+ # The namespace restriction is the point: a sidecar is a file on disk, and
|
|
|
+ # it must not be able to make us require an arbitrary module.
|
|
|
+ foreach my $bad ( 'Trog::Routes::HTML', 'File::Path', '../../etc/passwd', 'Trog::DataSource::Virt::act' ) {
|
|
|
+ $declared = $bad;
|
|
|
+ is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, "'$bad' is refused as a datasource" );
|
|
|
+ }
|
|
|
+
|
|
|
+ # One that does not exist is a warning, not a dead page.
|
|
|
+ $declared = 'Trog::DataSource::Nonexistent';
|
|
|
+ is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'a datasource that will not load falls back' );
|
|
|
+
|
|
|
+ # And a real one is actually used.
|
|
|
+ $declared = 'Trog::DataSource::Virt';
|
|
|
+ $query->{primary_post}{hypervisors} = [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ];
|
|
|
+ my @got = Trog::Routes::HTML::_datasource_posts( $query, \@stored );
|
|
|
+ is( scalar @got, 2, 'a declared datasource replaces the stored posts' );
|
|
|
+ is( $got[0]{title}, 'alpha', 'with what it built' );
|
|
|
+};
|
|
|
+
|
|
|
+done_testing();
|