Trog-DataSource-Virt.t 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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. # Reading a console is not a mutation, so this one really runs.
  31. sub screenshot { return 'image/png' }
  32. }
  33. {
  34. package FakeStream;
  35. sub new { bless {}, shift }
  36. sub recv_all { my ( $self, $cb ) = @_; $cb->( $self, 'fake-png-bytes' ); return 1 }
  37. sub finish { 1 }
  38. }
  39. {
  40. package FakeConn;
  41. sub new { bless {}, shift }
  42. sub new_stream { FakeStream->new() }
  43. sub list_all_domains {
  44. return (
  45. FakeDomain->new( name => 'alpha', uuid => 'uuid-alpha', active => 1, state => 1 ),
  46. FakeDomain->new( name => 'beta', uuid => 'uuid-beta', active => 0, state => 5 ),
  47. );
  48. }
  49. }
  50. my $connect_fails = 0;
  51. my $virtmock = Test::MockModule->new('Sys::Virt');
  52. $virtmock->redefine(
  53. new => sub {
  54. die "libvirt error: nope\n" if $connect_fails;
  55. return FakeConn->new();
  56. }
  57. );
  58. my $series = {
  59. child_form => 'guests.tx',
  60. tags => ['guests'],
  61. visibility => 'private',
  62. user => 'specadmin',
  63. hypervisors => [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ],
  64. };
  65. subtest 'guests become posts' => sub {
  66. @CALLS = ();
  67. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  68. is( scalar @posts, 2, 'one post per guest' );
  69. is( $posts[0]{title}, 'alpha', 'named after the guest' );
  70. is( $posts[0]{id}, 'uuid-alpha', "and identified by the guest's uuid, not ours" );
  71. is( $posts[0]{form}, 'guests.tx', "wearing the series' child type" );
  72. is( $posts[0]{state}, 'running', 'with a legible state' );
  73. is( $posts[1]{state}, 'shut off', '...for each of libvirt\'s numeric ones' );
  74. is( $posts[0]{is_active}, 1, 'active guests are marked so' );
  75. is( $posts[1]{is_active}, 0, 'and inactive ones are not' );
  76. # The listing must not take screenshots: that is a per-guest round trip,
  77. # and it is the browser's job via the preview route.
  78. is( $posts[0]{preview}, '/guest/screenshot/hv-1/alpha', 'the preview is a route, not an inline capture' );
  79. is_deeply( \@CALLS, [], 'and nothing was done to any guest merely by listing' );
  80. is_deeply( $posts[0]{tags}, ['guests'], "children inherit the series' tags" );
  81. is( $posts[0]{visibility}, 'private', 'and its visibility' );
  82. };
  83. subtest 'an unreachable hypervisor says so rather than vanishing' => sub {
  84. $connect_fails = 1;
  85. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  86. $connect_fails = 0;
  87. is( scalar @posts, 1, 'we get a post back' );
  88. ok( $posts[0]{unreachable}, 'flagged as unreachable' );
  89. like( $posts[0]{state}, qr/unreachable/, 'saying as much' );
  90. is( $posts[0]{title}, 'spec-hv', 'named for the hypervisor we could not reach' );
  91. };
  92. subtest 'act() drives the right libvirt call, and only on request' => sub {
  93. my %expected = (
  94. poweron => 'create',
  95. poweroff => 'shutdown',
  96. snapshot => 'create_snapshot',
  97. destroy => 'destroy',
  98. );
  99. foreach my $action ( sort keys %expected ) {
  100. @CALLS = ();
  101. my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, 'test:///default', 'alpha', 'specadmin' );
  102. ok( $ok, "$action succeeded" ) or diag($message);
  103. is_deeply( \@CALLS, [ [ $expected{$action}, 'alpha' ] ], "$action called $expected{$action} on the named guest, once" );
  104. }
  105. };
  106. subtest 'act() refuses what it should' => sub {
  107. @CALLS = ();
  108. my ( $ok, $message ) = Trog::DataSource::Virt::act( 'rm-rf', 'test:///default', 'alpha', 'specadmin' );
  109. ok( !$ok, 'an action we do not define is refused' );
  110. like( $message, qr/not something we do/, 'and says so' );
  111. ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', '../../etc/passwd', 'specadmin' );
  112. ok( !$ok, 'a guest name that is a path is refused' );
  113. like( $message, qr/bad guest name/, 'before any connection is made' );
  114. ( $ok, $message ) = Trog::DataSource::Virt::act( 'destroy', 'test:///default', 'nosuchguest', 'specadmin' );
  115. ok( !$ok, 'a guest that is not there is refused' );
  116. like( $message, qr/no guest called/, 'rather than acting on some other one' );
  117. is_deeply( \@CALLS, [], 'and none of those touched a guest' );
  118. $connect_fails = 1;
  119. ( $ok, $message ) = Trog::DataSource::Virt::act( 'poweroff', 'test:///default', 'alpha', 'specadmin' );
  120. $connect_fails = 0;
  121. ok( !$ok, 'an unreachable hypervisor is refused' );
  122. like( $message, qr/could not reach/, 'and reported' );
  123. is_deeply( \@CALLS, [], 'having done nothing' );
  124. };
  125. subtest 'screenshot() validates its inputs before anything else' => sub {
  126. my $hv = { id => 'hv-1', conn_uri => 'test:///default' };
  127. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hv, '../../../etc/passwd' );
  128. is( $path, undef, 'a path-shaped guest name gets no screenshot' );
  129. like( $why, qr/bad guest name/, 'and is named as the reason' );
  130. ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hv, 'alpha/../../beta' );
  131. is( $path, undef, 'nor does one with traversal in the middle' );
  132. # The cache path is built from the hypervisor id too, so it has to be
  133. # checked as well -- otherwise it is a directory traversal of its own.
  134. ( $path, $why ) = Trog::DataSource::Virt::screenshot( { id => '../../..', conn_uri => 'test:///default' }, 'alpha' );
  135. is( $path, undef, 'nor does a path-shaped hypervisor id' );
  136. like( $why, qr/bad hypervisor id/, 'which is reported separately' );
  137. };
  138. subtest 'the guest cache is scoped per hypervisor' => sub {
  139. # Two hypervisors, each with a guest called 'alpha' -- which is the normal
  140. # state of affairs, not a corner case.
  141. my $root = Path::Tiny->tempdir();
  142. local $Trog::DataSource::Virt::screenshot_dir = "$root";
  143. my @paths;
  144. foreach my $id (qw{hv-1 hv-2}) {
  145. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( { id => $id, conn_uri => 'test:///default' }, 'alpha' );
  146. ok( $path, "screenshot taken for $id" ) or diag($why);
  147. push( @paths, $path );
  148. }
  149. isnt( $paths[0], $paths[1], "two hypervisors' guests do not share a cache entry" );
  150. like( $paths[0], qr{/hv-1/alpha\.png$}, 'the path names the hypervisor and the guest' );
  151. };
  152. subtest 'the datasource hook only loads what it should' => sub {
  153. require Trog::Routes::HTML;
  154. my $htmlmock = Test::MockModule->new('Trog::Routes::HTML');
  155. $htmlmock->redefine( WARN => sub { note(shift) } );
  156. my $declared;
  157. my $dmmock = Test::MockModule->new('Trog::DataModule');
  158. $dmmock->redefine( type_meta_for => sub { return { 'x-tcms-datasource' => $declared } } );
  159. my @stored = ( { title => 'a stored post' } );
  160. my $query = { primary_post => { child_form => 'guests.tx' }, user_acls => ['admin'] };
  161. # A type that declares nothing keeps its own posts -- this hook must be
  162. # invisible to the overwhelming majority of pages.
  163. $declared = undef;
  164. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'no datasource declared, posts untouched' );
  165. # The namespace restriction is the point: a sidecar is a file on disk, and
  166. # it must not be able to make us require an arbitrary module.
  167. foreach my $bad ( 'Trog::Routes::HTML', 'File::Path', '../../etc/passwd', 'Trog::DataSource::Virt::act' ) {
  168. $declared = $bad;
  169. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, "'$bad' is refused as a datasource" );
  170. }
  171. # One that does not exist is a warning, not a dead page.
  172. $declared = 'Trog::DataSource::Nonexistent';
  173. is_deeply( [ Trog::Routes::HTML::_datasource_posts( $query, \@stored ) ], \@stored, 'a datasource that will not load falls back' );
  174. # And a real one is actually used.
  175. $declared = 'Trog::DataSource::Virt';
  176. $query->{primary_post}{hypervisors} = [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ];
  177. my @got = Trog::Routes::HTML::_datasource_posts( $query, \@stored );
  178. is( scalar @got, 2, 'a declared datasource replaces the stored posts' );
  179. is( $got[0]{title}, 'alpha', 'with what it built' );
  180. };
  181. subtest 'the guest routes avoid the router\'s own query keys' => sub {
  182. require Trog::Routes::HTML;
  183. # TPSGI applies a route's captures in extract_query, and *then* overwrites
  184. # $query->{domain} with the request's own host. A capture or form field by
  185. # that name therefore never reaches the callback -- which silently aimed
  186. # the screenshot and action routes at a guest named after the web host.
  187. my @reserved = qw{domain route method scheme port user acls to};
  188. my $captures = $Trog::Routes::HTML::routes{'/guest/screenshot/(.*)/(.*)'}{captures};
  189. foreach my $capture (@$captures) {
  190. ok( !( grep { $_ eq $capture } @reserved ), "the screenshot route's '$capture' capture is not a router key" );
  191. }
  192. # The action form posts the same field names the handler reads, so check
  193. # the template rather than trusting that they still agree.
  194. my $form = Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.tx")->slurp_utf8;
  195. foreach my $reserved (@reserved) {
  196. unlike( $form, qr/name="\Q$reserved\E"/, "the guest action form does not post a field called '$reserved'" )
  197. unless $reserved eq 'to'; # 'to' is the redirect target, and is meant to be the router's
  198. }
  199. like( $form, qr/name="guest"/, 'it posts the guest under a name that survives routing' );
  200. };
  201. subtest 'guests show nothing an unprivileged viewer cannot use' => sub {
  202. require Text::Xslate;
  203. # Rendered rather than grepped: what matters is what a given viewer ends up
  204. # looking at, and the template has enough branches that reading it is not
  205. # the same as knowing.
  206. my $tx = Text::Xslate->new(
  207. path => ["$FindBin::Bin/../www/templates/html/components"],
  208. function => { render_it => sub { $_[0] } },
  209. );
  210. my %post = (
  211. id => 'uuid-alpha', title => 'alpha', state => 'running', is_active => 1,
  212. preview => '/guest/screenshot/hv-1/alpha', domain => 'alpha',
  213. hypervisor => 'hv-1', hypervisor_title => 'spec-hv', vcpus => 2,
  214. addpost => 0, unreachable => 0,
  215. );
  216. my %views = (
  217. 'tiled, logged out' => { tiled => 1, can_edit => 0 },
  218. 'untiled, logged out' => { tiled => 0, can_edit => 0 },
  219. 'untiled, admin' => { tiled => 0, can_edit => 1 },
  220. );
  221. foreach my $view ( sort keys %views ) {
  222. my $out = $tx->render( 'forms/guests.tx', { post => \%post, style => '', route => '/vm', %{ $views{$view} } } );
  223. my $admin = $views{$view}{can_edit};
  224. # The guest is always named -- that is the point of the page.
  225. like( $out, qr/alpha/, "$view: the guest is listed" );
  226. # The screenshot route requires the admin acl, so showing the img to
  227. # anyone else is a broken image and nothing else.
  228. is( scalar( () = $out =~ m/<img /g ), $admin ? 1 : 0, "$view: console capture shown only to an admin" );
  229. is( scalar( () = $out =~ m{href="/guest/screenshot}g ), $admin ? 1 : 0, "$view: and so is the link to it" );
  230. # Powering off, snapshotting and destroying likewise.
  231. is( scalar( () = $out =~ m{action="/guest/act"}g ), $admin ? 1 : 0, "$view: controls shown only to an admin" );
  232. foreach my $action (qw{poweroff snapshot destroy}) {
  233. is( scalar( () = $out =~ m/value="\Q$action\E"/g ), $admin ? 1 : 0, "$view: no $action button" ) unless $admin;
  234. }
  235. }
  236. };
  237. subtest 'a hypervisor does not show its connection uri to everyone' => sub {
  238. my $form = Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/hypervisor.tx")->slurp_utf8;
  239. # The uri carries a host and a username, and these pages are public. It is
  240. # rendered in the display half, so it needs its own guard -- the edit half's
  241. # is too late.
  242. my ($display) = $form =~ m/:\s*if\s*\(\s*!\$post\.addpost\s*\)\s*\{(.*?):\s*\}/s;
  243. ok( defined $display, 'the template has a display half' ) or return;
  244. like( $display, qr/\$post\.conn_uri/, 'which mentions the connection uri' );
  245. like( $display, qr/:\s*if\s*\(\s*\$can_edit\s*\).*\$post\.conn_uri/s,
  246. 'behind a $can_edit guard, so only an admin sees it' );
  247. };
  248. done_testing();