Trog-DataSource-Virt.t 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. use strict;
  2. use warnings;
  3. use Test::More;
  4. use Test::MockModule qw{strict};
  5. use Test::Fatal qw{exception};
  6. use Path::Tiny();
  7. use FindBin;
  8. use lib "$FindBin::Bin/../lib";
  9. # Nothing in this file may ever reach a real hypervisor. Every libvirt object
  10. # here is a fake that records what was asked of it, which is the only safe way
  11. # to test the destructive half: the machine this suite runs on is itself a
  12. # guest of the hypervisor the feature talks to.
  13. require_ok('Trog::DataSource::Virt') or BAIL_OUT("Can't find SUT");
  14. my $logmock = Test::MockModule->new('Trog::DataSource::Virt');
  15. $logmock->redefine( WARN => sub { note(shift) } );
  16. $logmock->redefine( INFO => sub { note(shift) } );
  17. our @CALLS;
  18. {
  19. package FakeDomain;
  20. sub new { my ( $c, %a ) = @_; return bless {%a}, $c }
  21. sub get_name { $_[0]{name} }
  22. sub get_uuid_string { $_[0]{uuid} }
  23. sub is_active { $_[0]{active} }
  24. sub get_info { { state => $_[0]{state}, memory => 1024, nrVirtCpu => 2 } }
  25. # The operations we must never actually perform: record and return.
  26. sub create { push @main::CALLS, [ 'create', $_[0]{name} ]; 1 }
  27. sub shutdown { push @main::CALLS, [ 'shutdown', $_[0]{name} ]; 1 }
  28. sub destroy { push @main::CALLS, [ 'destroy', $_[0]{name} ]; 1 }
  29. sub create_snapshot { push @main::CALLS, [ 'create_snapshot', $_[0]{name} ]; 1 }
  30. }
  31. {
  32. package FakeConn;
  33. sub new { bless {}, shift }
  34. sub list_all_domains {
  35. return (
  36. FakeDomain->new( name => 'alpha', uuid => 'uuid-alpha', active => 1, state => 1 ),
  37. FakeDomain->new( name => 'beta', uuid => 'uuid-beta', active => 0, state => 5 ),
  38. );
  39. }
  40. }
  41. my $connect_fails = 0;
  42. my $virtmock = Test::MockModule->new('Sys::Virt');
  43. $virtmock->redefine(
  44. new => sub {
  45. die "libvirt error: nope\n" if $connect_fails;
  46. return FakeConn->new();
  47. }
  48. );
  49. my $series = {
  50. child_form => 'guests.tx',
  51. tags => ['guests'],
  52. visibility => 'private',
  53. user => 'specadmin',
  54. hypervisors => [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ],
  55. };
  56. subtest 'guests become posts' => sub {
  57. @CALLS = ();
  58. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  59. is( scalar @posts, 2, 'one post per guest' );
  60. is( $posts[0]{title}, 'alpha', 'named after the guest' );
  61. is( $posts[0]{id}, 'uuid-alpha', "and identified by the guest's uuid, not ours" );
  62. is( $posts[0]{form}, 'guests.tx', "wearing the series' child type" );
  63. is( $posts[0]{state}, 'running', 'with a legible state' );
  64. is( $posts[1]{state}, 'shut off', '...for each of libvirt\'s numeric ones' );
  65. is( $posts[0]{is_active}, 1, 'active guests are marked so' );
  66. is( $posts[1]{is_active}, 0, 'and inactive ones are not' );
  67. # The listing must not take screenshots: that is a per-guest round trip,
  68. # and it is the browser's job via the preview route.
  69. is( $posts[0]{preview}, '/guest/screenshot/hv-1/alpha', 'the preview is a route, not an inline capture' );
  70. is_deeply( \@CALLS, [], 'and nothing was done to any guest merely by listing' );
  71. is_deeply( $posts[0]{tags}, ['guests'], "children inherit the series' tags" );
  72. is( $posts[0]{visibility}, 'private', 'and its visibility' );
  73. };
  74. subtest 'an unreachable hypervisor says so rather than vanishing' => sub {
  75. $connect_fails = 1;
  76. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  77. $connect_fails = 0;
  78. is( scalar @posts, 1, 'we get a post back' );
  79. ok( $posts[0]{unreachable}, 'flagged as unreachable' );
  80. like( $posts[0]{state}, qr/unreachable/, 'saying as much' );
  81. is( $posts[0]{title}, 'spec-hv', 'named for the hypervisor we could not reach' );
  82. };
  83. subtest 'act() drives the right libvirt call, and only on request' => sub {
  84. my %expected = (
  85. poweron => 'create',
  86. poweroff => 'shutdown',
  87. snapshot => 'create_snapshot',
  88. destroy => 'destroy',
  89. );
  90. foreach my $action ( sort keys %expected ) {
  91. @CALLS = ();
  92. my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, 'test:///default', 'alpha', 'specadmin' );
  93. ok( $ok, "$action succeeded" ) or diag($message);
  94. is_deeply( \@CALLS, [ [ $expected{$action}, 'alpha' ] ], "$action called $expected{$action} on the named guest, once" );
  95. }
  96. };
  97. subtest 'act() refuses what it should' => sub {
  98. @CALLS = ();
  99. my ( $ok, $message ) = Trog::DataSource::Virt::act( 'rm-rf', 'test:///default', 'alpha', 'specadmin' );
  100. ok( !$ok, 'an action we do not define is refused' );
  101. like( $message, qr/not something we do/, 'and says so' );
  102. ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', '../../etc/passwd', 'specadmin' );
  103. ok( !$ok, 'a guest name that is a path is refused' );
  104. like( $message, qr/bad guest name/, 'before any connection is made' );
  105. ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', 'nosuchguest', 'specadmin' );
  106. ok( !$ok, 'a guest that is not there is refused' );
  107. like( $message, qr/no guest called/, 'rather than acting on some other one' );
  108. is_deeply( \@CALLS, [], 'and none of those touched a guest' );
  109. $connect_fails = 1;
  110. ( $ok, $message ) = Trog::DataSource::Virt::act( 'poweroff', 'test:///default', 'alpha', 'specadmin' );
  111. $connect_fails = 0;
  112. ok( !$ok, 'an unreachable hypervisor is refused' );
  113. like( $message, qr/could not reach/, 'and reported' );
  114. is_deeply( \@CALLS, [], 'having done nothing' );
  115. };
  116. subtest 'screenshot() validates the guest name before anything else' => sub {
  117. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( 'test:///default', '../../../etc/passwd' );
  118. is( $path, undef, 'a path-shaped guest name gets no screenshot' );
  119. like( $why, qr/bad domain name/, 'and is named as the reason' );
  120. ( $path, $why ) = Trog::DataSource::Virt::screenshot( 'test:///default', 'alpha/../../beta' );
  121. is( $path, undef, 'nor does one with traversal in the middle' );
  122. };
  123. subtest 'the datasource hook only loads what it should' => sub {
  124. require Trog::Routes::HTML;
  125. my $htmlmock = Test::MockModule->new('Trog::Routes::HTML');
  126. $htmlmock->redefine( WARN => sub { note(shift) } );
  127. my $declared;
  128. my $dmmock = Test::MockModule->new('Trog::DataModule');
  129. $dmmock->redefine( type_meta_for => sub { return { 'x-tcms-datasource' => $declared } } );
  130. my @stored = ( { title => 'a stored post' } );
  131. my $query = { primary_post => { child_form => 'guests.tx' }, user_acls => ['admin'] };
  132. # A type that declares nothing keeps its own posts -- this hook must be
  133. # invisible to the overwhelming majority of pages.
  134. $declared = undef;
  135. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'no datasource declared, posts untouched' );
  136. # The namespace restriction is the point: a sidecar is a file on disk, and
  137. # it must not be able to make us require an arbitrary module.
  138. foreach my $bad ( 'Trog::Routes::HTML', 'File::Path', '../../etc/passwd', 'Trog::DataSource::Virt::act' ) {
  139. $declared = $bad;
  140. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, "'$bad' is refused as a datasource" );
  141. }
  142. # One that does not exist is a warning, not a dead page.
  143. $declared = 'Trog::DataSource::Nonexistent';
  144. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'a datasource that will not load falls back' );
  145. # And a real one is actually used.
  146. $declared = 'Trog::DataSource::Virt';
  147. $query->{primary_post}{hypervisors} = [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ];
  148. my @got = Trog::Routes::HTML::_datasource_posts( $query, \@stored );
  149. is( scalar @got, 2, 'a declared datasource replaces the stored posts' );
  150. is( $got[0]{title}, 'alpha', 'with what it built' );
  151. };
  152. done_testing();