Trog-DataSource-Virt.t 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. #!/usr/bin/env perl
  2. use 5.040;
  3. use re '/aasx';
  4. use warnings FATAL => 'all';
  5. use Test::More;
  6. use Test::MockModule qw{strict};
  7. use Test::Fatal qw{exception};
  8. use Path::Tiny();
  9. use JSON::MaybeXS();
  10. use FindBin;
  11. use FindBin::libs;
  12. # Nothing in this file may ever reach a real hypervisor. Every libvirt object
  13. # here is a fake that records what was asked of it, which is the only safe way
  14. # to test the destructive half: the machine this suite runs on is itself a
  15. # guest of the hypervisor the feature talks to.
  16. require_ok('Trog::DataSource::Virt') or BAIL_OUT("Can't find SUT");
  17. my $logmock = Test::MockModule->new('Trog::DataSource::Virt');
  18. $logmock->redefine( WARN => sub { note(shift) } );
  19. $logmock->redefine( INFO => sub { note(shift) } );
  20. our @CALLS;
  21. {
  22. package FakeDomain;
  23. sub new { my ( $c, %a ) = @_; return bless {%a}, $c }
  24. sub get_name ( $self, @ ) { return $self->{name} }
  25. sub get_uuid_string ( $self, @ ) { return $self->{uuid} }
  26. sub is_active ( $self, @ ) { return $self->{active} }
  27. sub get_info ( $self, @ ) { return { state => $self->{state}, memory => 1024, nrVirtCpu => 2 } }
  28. # The operations we must never actually perform: record and return.
  29. sub create ( $self, @ ) { push @main::CALLS, [ 'create', $self->{name} ]; return 1 }
  30. sub shutdown ( $self, @ ) { push @main::CALLS, [ 'shutdown', $self->{name} ]; return 1 }
  31. sub destroy ( $self, @ ) { push @main::CALLS, [ 'destroy', $self->{name} ]; return 1 }
  32. sub create_snapshot ( $self, @ ) { push @main::CALLS, [ 'create_snapshot', $self->{name} ]; return 1 }
  33. # Reading a console is not a mutation, so this one really runs.
  34. sub screenshot { return 'image/png' }
  35. }
  36. {
  37. package FakeStream;
  38. sub new { return bless {}, shift }
  39. sub recv_all { my ( $self, $cb ) = @_; $cb->( $self, 'fake-png-bytes' ); return 1 }
  40. sub finish { return 1 }
  41. }
  42. {
  43. package FakeConn;
  44. sub new { return bless {}, shift }
  45. sub new_stream { return FakeStream->new() }
  46. sub list_all_domains {
  47. return (
  48. FakeDomain->new( name => 'alpha', uuid => 'uuid-alpha', active => 1, state => 1 ),
  49. FakeDomain->new( name => 'beta', uuid => 'uuid-beta', active => 0, state => 5 ),
  50. );
  51. }
  52. }
  53. my $connect_fails = 0;
  54. my $virtmock = Test::MockModule->new('Sys::Virt');
  55. $virtmock->redefine(
  56. new => sub {
  57. die "libvirt error: nope\n" if $connect_fails;
  58. return FakeConn->new();
  59. }
  60. );
  61. # Every subtest below that does not say otherwise runs as a machine which is
  62. # not one of the fake guests -- otherwise the suite's answers would depend on
  63. # what the box running it happens to be called.
  64. $Trog::DataSource::Virt::hostname = 'spec-host-that-is-no-guest';
  65. my $series = {
  66. child_form => 'guests.tx',
  67. tags => ['guests'],
  68. visibility => 'private',
  69. user => 'specadmin',
  70. hypervisors => [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ],
  71. };
  72. subtest 'guests become posts' => sub {
  73. @CALLS = ();
  74. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  75. is( scalar @posts, 2, 'one post per guest' );
  76. is( $posts[0]{title}, 'alpha', 'named after the guest' );
  77. is( $posts[0]{id}, 'uuid-alpha', "and identified by the guest's uuid, not ours" );
  78. is( $posts[0]{form}, 'guests.tx', "wearing the series' child type" );
  79. is( $posts[0]{state}, 'running', 'with a legible state' );
  80. is( $posts[1]{state}, 'shut off', '...for each of libvirt\'s numeric ones' );
  81. is( $posts[0]{is_active}, 1, 'active guests are marked so' );
  82. is( $posts[1]{is_active}, 0, 'and inactive ones are not' );
  83. # The listing must not take screenshots: that is a per-guest round trip,
  84. # and it is the browser's job via the preview route.
  85. is( $posts[0]{preview}, '/guest/screenshot/hv-1/alpha', 'the preview is a route, not an inline capture' );
  86. is_deeply( \@CALLS, [], 'and nothing was done to any guest merely by listing' );
  87. is_deeply( $posts[0]{tags}, ['guests'], "children inherit the series' tags" );
  88. is( $posts[0]{visibility}, 'private', 'and its visibility' );
  89. };
  90. subtest 'the guest this server runs on is marked as such' => sub {
  91. local $Trog::DataSource::Virt::hostname = 'alpha';
  92. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  93. is( $posts[0]{is_self}, 1, 'the guest named after this machine is flagged' );
  94. is( $posts[1]{is_self}, 0, 'and the one that merely lives beside it is not' );
  95. };
  96. subtest 'a guest is this server when it is named like it' => sub {
  97. my @same = (
  98. [ 'foo', 'foo' => 'the same name' ],
  99. [ 'foo', 'FOO' => 'the same name in another case' ],
  100. [ 'foo.troglodyne.net', 'foo.troglodyne.net' => 'both fully qualified' ],
  101. );
  102. foreach my $case (@same) {
  103. my ( $guest, $host, $why ) = @$case;
  104. local $Trog::DataSource::Virt::hostname = $host;
  105. ok( Trog::DataSource::Virt::is_self($guest), "$why is this server" );
  106. }
  107. my @different = (
  108. [ 'beta', 'foo' => 'a different name' ],
  109. [ 'fooling', 'foo' => 'a name this one is only a prefix of' ],
  110. [ 'foo.test.test', 'foo.troglodyne.net' => 'the same label in someone else\'s domain' ],
  111. [ '', 'foo' => 'a guest with no name at all' ],
  112. [ 'foo', '' => 'any guest, when we cannot tell what we are called' ],
  113. );
  114. foreach my $case (@different) {
  115. my ( $guest, $host, $why ) = @$case;
  116. local $Trog::DataSource::Virt::hostname = $host;
  117. ok( !Trog::DataSource::Virt::is_self($guest), "$why is not" );
  118. }
  119. };
  120. subtest 'an unreachable hypervisor says so rather than vanishing' => sub {
  121. $connect_fails = 1;
  122. my @posts = Trog::DataSource::Virt::posts( $series, { user_acls => ['admin'] } );
  123. $connect_fails = 0;
  124. is( scalar @posts, 1, 'we get a post back' );
  125. ok( $posts[0]{unreachable}, 'flagged as unreachable' );
  126. like( $posts[0]{state}, qr/unreachable/, 'saying as much' );
  127. is( $posts[0]{title}, 'spec-hv', 'named for the hypervisor we could not reach' );
  128. };
  129. subtest 'act() drives the right libvirt call, and only on request' => sub {
  130. my %expected = (
  131. poweron => 'create',
  132. poweroff => 'shutdown',
  133. snapshot => 'create_snapshot',
  134. destroy => 'destroy',
  135. );
  136. foreach my $action ( sort keys %expected ) {
  137. @CALLS = ();
  138. my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, qw{test:///default alpha specadmin} );
  139. ok( $ok, "$action succeeded" ) or diag($message);
  140. is_deeply( \@CALLS, [ [ $expected{$action}, 'alpha' ] ], "$action called $expected{$action} on the named guest, once" );
  141. }
  142. };
  143. subtest 'act() refuses what it should' => sub {
  144. @CALLS = ();
  145. my ( $ok, $message ) = Trog::DataSource::Virt::act(qw{rm-rf test:///default alpha specadmin});
  146. ok( !$ok, 'an action we do not define is refused' );
  147. like( $message, qr/not[ ]something[ ]we[ ]do/, 'and says so' );
  148. ( $ok, $message ) = Trog::DataSource::Virt::act(qw{destroy test:///default ../../etc/passwd specadmin});
  149. ok( !$ok, 'a guest name that is a path is refused' );
  150. like( $message, qr/bad[ ]guest[ ]name/, 'before any connection is made' );
  151. ( $ok, $message ) = Trog::DataSource::Virt::act(qw{destroy test:///default nosuchguest specadmin});
  152. ok( !$ok, 'a guest that is not there is refused' );
  153. like( $message, qr/no[ ]guest[ ]called/, 'rather than acting on some other one' );
  154. is_deeply( \@CALLS, [], 'and none of those touched a guest' );
  155. $connect_fails = 1;
  156. ( $ok, $message ) = Trog::DataSource::Virt::act(qw{poweroff test:///default alpha specadmin});
  157. $connect_fails = 0;
  158. ok( !$ok, 'an unreachable hypervisor is refused' );
  159. like( $message, qr/could[ ]not[ ]reach/, 'and reported' );
  160. is_deeply( \@CALLS, [], 'having done nothing' );
  161. };
  162. subtest 'act() will not take the server down with it' => sub {
  163. # Hiding the buttons is not the guard: /guest/act is a POST anybody with
  164. # the admin acl can craft, and if this went through there would be nothing
  165. # left running to say what happened.
  166. local $Trog::DataSource::Virt::hostname = 'alpha';
  167. foreach my $action (qw{poweroff destroy}) {
  168. @CALLS = ();
  169. my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, qw{test:///default alpha specadmin} );
  170. ok( !$ok, "$action on the guest we are running in is refused" );
  171. like( $message, qr/is[ ]this[ ]server/, 'saying why' );
  172. is_deeply( \@CALLS, [], "and the $action never reached libvirt" );
  173. }
  174. # The rest are not destructive, and a guest that cannot be snapshotted is
  175. # less useful than one that can.
  176. foreach my $action (qw{snapshot poweron}) {
  177. @CALLS = ();
  178. my ( $ok, $message ) = Trog::DataSource::Virt::act( $action, qw{test:///default alpha specadmin} );
  179. ok( $ok, "$action is still allowed on it" ) or diag($message);
  180. is( scalar @CALLS, 1, 'and reached libvirt' );
  181. }
  182. # Its neighbours are ordinary guests.
  183. @CALLS = ();
  184. my ($ok) = Trog::DataSource::Virt::act(qw{destroy test:///default beta specadmin});
  185. ok( $ok, 'another guest on the same hypervisor is unaffected' );
  186. is_deeply( \@CALLS, [ [ 'destroy', 'beta' ] ], 'and is destroyed as asked' );
  187. };
  188. subtest 'screenshot() validates its inputs before anything else' => sub {
  189. my $hv = { id => 'hv-1', conn_uri => 'test:///default' };
  190. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hv, '../../../etc/passwd' );
  191. is( $path, undef, 'a path-shaped guest name gets no screenshot' );
  192. like( $why, qr/bad[ ]guest[ ]name/, 'and is named as the reason' );
  193. ( $path, $why ) = Trog::DataSource::Virt::screenshot( $hv, 'alpha/../../beta' );
  194. is( $path, undef, 'nor does one with traversal in the middle' );
  195. # The cache path is built from the hypervisor id too, so it has to be
  196. # checked as well -- otherwise it is a directory traversal of its own.
  197. ( $path, $why ) = Trog::DataSource::Virt::screenshot( { id => '../../..', conn_uri => 'test:///default' }, 'alpha' );
  198. is( $path, undef, 'nor does a path-shaped hypervisor id' );
  199. like( $why, qr/bad[ ]hypervisor[ ]id/, 'which is reported separately' );
  200. };
  201. subtest 'the guest cache is scoped per hypervisor' => sub {
  202. # Two hypervisors, each with a guest called 'alpha' -- which is the normal
  203. # state of affairs, not a corner case.
  204. my $root = Path::Tiny->tempdir();
  205. no warnings 'once';
  206. local $Trog::DataSource::Virt::screenshot_dir = "$root";
  207. my @paths;
  208. foreach my $id (qw{hv-1 hv-2}) {
  209. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( { id => $id, conn_uri => 'test:///default' }, 'alpha' );
  210. ok( $path, "screenshot taken for $id" ) or diag($why);
  211. push( @paths, $path );
  212. }
  213. isnt( $paths[0], $paths[1], "two hypervisors' guests do not share a cache entry" );
  214. like( $paths[0], qr{/hv-1/alpha\.png$}, 'the path names the hypervisor and the guest' );
  215. };
  216. # /dev/full takes the open and the print, and fails the write that close()
  217. # flushes, as a full disk does.
  218. subtest 'a screenshot that could not be cached is not handed out' => sub {
  219. SKIP: {
  220. skip( 'there is no /dev/full here', 2 ) unless -c '/dev/full';
  221. my $root = Path::Tiny->tempdir();
  222. no warnings 'once';
  223. local $Trog::DataSource::Virt::screenshot_dir = "$root";
  224. $root->child('hv-1')->mkpath;
  225. symlink( '/dev/full', "$root/hv-1/alpha.png" ) or die $!;
  226. my ( $path, $why ) = Trog::DataSource::Virt::screenshot( { id => 'hv-1', conn_uri => 'test:///default' }, 'alpha' );
  227. is( $path, undef, 'no path to a screenshot that is not there' );
  228. like( $why, qr/could[ ]not[ ]cache/, 'and it says why' );
  229. }
  230. };
  231. subtest 'the datasource hook only loads what it should' => sub {
  232. require Trog::Routes::HTML;
  233. my $htmlmock = Test::MockModule->new('Trog::Routes::HTML');
  234. $htmlmock->redefine( WARN => sub { note(shift) } );
  235. my $declared;
  236. my $dmmock = Test::MockModule->new('Trog::DataModule');
  237. $dmmock->redefine( type_meta_for => sub { return { 'x-tcms-datasource' => $declared } } );
  238. my @stored = ( { title => 'a stored post' } );
  239. my $query = { primary_post => { child_form => 'guests.tx' }, user_acls => ['admin'] };
  240. # A type that declares nothing keeps its own posts -- this hook must be
  241. # invisible to the overwhelming majority of pages.
  242. $declared = undef;
  243. is_deeply( [ Trog::Routes::HTML::datasource_posts( $query, \@stored ) ], \@stored, 'no datasource declared, posts untouched' );
  244. # The namespace restriction is the point: a sidecar is a file on disk, and
  245. # it must not be able to make us require an arbitrary module.
  246. foreach my $bad (qw{Trog::Routes::HTML File::Path ../../etc/passwd Trog::DataSource::Virt::act}) {
  247. $declared = $bad;
  248. is_deeply( [ Trog::Routes::HTML::datasource_posts( $query, \@stored ) ], \@stored, "'$bad' is refused as a datasource" );
  249. }
  250. # One that does not exist is a warning, not a dead page.
  251. $declared = 'Trog::DataSource::Nonexistent';
  252. is_deeply( [ Trog::Routes::HTML::datasource_posts( $query, \@stored ) ], \@stored, 'a datasource that will not load falls back' );
  253. # And a real one is actually used.
  254. $declared = 'Trog::DataSource::Virt';
  255. $query->{primary_post}{hypervisors} = [ { id => 'hv-1', title => 'spec-hv', conn_uri => 'test:///default' } ];
  256. my @got = Trog::Routes::HTML::datasource_posts( $query, \@stored );
  257. is( scalar @got, 2, 'a declared datasource replaces the stored posts' );
  258. is( $got[0]{title}, 'alpha', 'with what it built' );
  259. };
  260. subtest 'the guest routes avoid the router\'s own query keys' => sub {
  261. require Trog::Routes::HTML;
  262. # TPSGI puts a route's path parameters in the query in extract_query, and
  263. # *then* overwrites $query->{domain} with the request's own host. A path
  264. # variable or form field by that name therefore never reaches the callback
  265. # -- which silently aimed the screenshot and action routes at a guest named
  266. # after the web host.
  267. my @reserved = qw{domain route method scheme port user acls to};
  268. no warnings 'once';
  269. my ($template) = grep { index( $_, '/guest/screenshot/' ) == 0 } keys(%Trog::Routes::HTML::routes);
  270. my @variables = $template =~ m/\{([^{}]+)\}/g;
  271. is_deeply( \@variables, [qw{hypervisor guest}], 'the screenshot route names the hypervisor and the guest' );
  272. foreach my $variable (@variables) {
  273. ok( !( grep { $_ eq $variable } @reserved ), "the screenshot route's '$variable' variable is not a router key" );
  274. }
  275. # The action form posts the same field names the handler reads, so check
  276. # the template rather than trusting that they still agree.
  277. my $form = Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.tx")->slurp_utf8;
  278. foreach my $reserved (@reserved) {
  279. unlike( $form, qr/name="\Q$reserved\E"/, "the guest action form does not post a field called '$reserved'" )
  280. unless $reserved eq 'to'; # 'to' is the redirect target, and is meant to be the router's
  281. }
  282. like( $form, qr/name="guest"/, 'it posts the guest under a name that survives routing' );
  283. };
  284. subtest 'guests show nothing an unprivileged viewer cannot use' => sub {
  285. require Text::Xslate;
  286. # Rendered rather than grepped: what matters is what a given viewer ends up
  287. # looking at, and the template has enough branches that reading it is not
  288. # the same as knowing.
  289. my $tx = Text::Xslate->new(
  290. path => ["$FindBin::Bin/../www/templates/html/components"],
  291. function => { render_it => sub { $_[0] } },
  292. );
  293. my %post = (
  294. form => 'guests.tx',
  295. id => 'uuid-alpha', title => 'alpha', state => 'running', is_active => 1,
  296. preview => '/guest/screenshot/hv-1/alpha', domain => 'alpha',
  297. hypervisor => 'hv-1', hypervisor_title => 'spec-hv', vcpus => 2,
  298. addpost => 0, unreachable => 0,
  299. );
  300. my %views = (
  301. 'tiled, logged out' => { tiled => 1, can_edit => 0 },
  302. 'untiled, logged out' => { tiled => 0, can_edit => 0 },
  303. 'untiled, admin' => { tiled => 0, can_edit => 1 },
  304. );
  305. require Trog::Routes::HTML;
  306. foreach my $view ( sort keys %views ) {
  307. my $admin = $views{$view}{can_edit};
  308. # Redact the way posts() does before rendering, so this covers the
  309. # thing that actually protects the capture -- the template only decides
  310. # whether to draw an img for a value it was given.
  311. my %shown = %post;
  312. Trog::Routes::HTML::redact_private( \%shown ) unless $admin;
  313. my $out = $tx->render( 'forms/guests.tx', { post => \%shown, style => '', route => '/vm', %{ $views{$view} } } );
  314. # The guest is always named -- that is the point of the page.
  315. like( $out, qr/alpha/, "$view: the guest is listed" );
  316. # The screenshot route requires the admin acl, so showing the img to
  317. # anyone else is a broken image and nothing else.
  318. is( scalar( () = $out =~ m/<img[ ]/g ), $admin ? 1 : 0, "$view: console capture shown only to an admin" );
  319. is( scalar( () = $out =~ m{href="/guest/screenshot}g ), $admin ? 1 : 0, "$view: and so is the link to it" );
  320. # Powering off, snapshotting and destroying likewise.
  321. is( scalar( () = $out =~ m{action="/guest/act"}g ), $admin ? 1 : 0, "$view: controls shown only to an admin" );
  322. foreach my $action (qw{poweroff snapshot destroy}) {
  323. is( scalar( () = $out =~ m/value="\Q$action\E"/g ), $admin ? 1 : 0, "$view: no $action button" ) unless $admin;
  324. }
  325. }
  326. };
  327. subtest 'the server itself is drawn without the controls that would kill it' => sub {
  328. require Text::Xslate;
  329. my $tx = Text::Xslate->new(
  330. path => ["$FindBin::Bin/../www/templates/html/components"],
  331. function => { render_it => sub { $_[0] } },
  332. );
  333. my %post = (
  334. form => 'guests.tx',
  335. id => 'uuid-alpha', title => 'alpha', state => 'running', is_active => 1,
  336. preview => '/guest/screenshot/hv-1/alpha', domain => 'alpha',
  337. hypervisor => 'hv-1', hypervisor_title => 'spec-hv', vcpus => 2,
  338. addpost => 0, unreachable => 0,
  339. );
  340. foreach my $tiled ( 0, 1 ) {
  341. my $view = $tiled ? 'tiled' : 'untiled';
  342. my $me = $tx->render(
  343. 'forms/guests.tx',
  344. { post => { %post, is_self => 1 }, style => '', route => '/vm', tiled => $tiled, can_edit => 1 }
  345. );
  346. my $them = $tx->render(
  347. 'forms/guests.tx',
  348. { post => { %post, is_self => 0 }, style => '', route => '/vm', tiled => $tiled, can_edit => 1 }
  349. );
  350. # Said out loud, so an admin knows why this one is different.
  351. like( $me, qr/dom0/, "$view: the guest we are running in says so" );
  352. unlike( $them, qr/dom0/, "$view: and an ordinary guest does not" );
  353. }
  354. # Only the untiled view draws controls at all.
  355. my $me = $tx->render(
  356. 'forms/guests.tx',
  357. { post => { %post, is_self => 1 }, style => '', route => '/vm', tiled => 0, can_edit => 1 }
  358. );
  359. foreach my $action (qw{poweroff destroy}) {
  360. is( scalar( () = $me =~ m/value="\Q$action\E"/g ), 0, "no $action button for the server itself" );
  361. }
  362. is( scalar( () = $me =~ m/value="snapshot"/g ), 1, 'but it can still be snapshotted' );
  363. like( $me, qr{action="/guest/act"}, 'and the form is still there to do it with' );
  364. # A guest that is merely switched off must keep its Power On button -- the
  365. # is_self guard wraps that branch too.
  366. my $off = $tx->render(
  367. 'forms/guests.tx',
  368. { post => { %post, is_self => 0, is_active => 0, state => 'shut off' }, style => '', route => '/vm', tiled => 0, can_edit => 1 }
  369. );
  370. is( scalar( () = $off =~ m/value="poweron"/g ), 1, 'an ordinary stopped guest can still be started' );
  371. };
  372. subtest 'a datasource says whether its posts can be edited' => sub {
  373. require Trog::Routes::HTML;
  374. my $sources = Trog::Routes::HTML::get_datasources();
  375. ok( ( grep { $_ eq 'Trog::DataSource::Virt' } @$sources ), 'the wizard can find this datasource' );
  376. ok( !( grep { !m/^Trog::DataSource::\w+$/ } @$sources ), 'and finds nothing that is not one' );
  377. is( Trog::DataSource::Virt->EDITABLE, 0, 'a guest cannot be edited' );
  378. is( Trog::Routes::HTML::datasource_editable('Trog::DataSource::Virt'), 0, 'so no editor is appropriate' );
  379. # The datastore is the default, and posts in it are written by people.
  380. is( Trog::Routes::HTML::datasource_editable(''), 1, 'the datastore is editable' );
  381. # A datasource that says nothing is assumed editable, since that is what a
  382. # post type is unless it has a reason not to be.
  383. {
  384. no warnings qw{once};
  385. $INC{'Trog/DataSource/SpecMute.pm'} = 1;
  386. @Trog::DataSource::SpecMute::ISA = ();
  387. }
  388. is( Trog::Routes::HTML::datasource_editable('Trog::DataSource::SpecMute'), 1, 'one that declares nothing is assumed editable' );
  389. };
  390. subtest 'the guests type is built by the wizard, from a datasource' => sub {
  391. my $sidecar = JSON::MaybeXS::decode_json( Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.json")->slurp_utf8 );
  392. is( $sidecar->{'x-tcms-datasource'}, 'Trog::DataSource::Virt', 'it names its datasource' );
  393. ok( $sidecar->{'x-tcms-post-type'}{generated}, 'and is wizard generated rather than hand written' );
  394. # The relation is what tells the datasource which hypervisors to ask.
  395. is_deeply(
  396. $sidecar->{'x-tcms-relations'}, { hypervisors => { form => 'hypervisor.tx' } },
  397. 'and keeps the relation the datasource reads'
  398. );
  399. # No editor: a guest is built on every view and has nothing to save.
  400. my $form = Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/guests.tx")->slurp_utf8;
  401. unlike( $form, qr{action="/post/save"}, 'the template offers no editor' );
  402. };
  403. subtest 'the guests type declares its console capture private' => sub {
  404. require Trog::DataModule;
  405. is_deeply(
  406. Trog::DataModule::private_fields_for('guests.tx'), ['preview'],
  407. 'so the url to it is withheld rather than guarded in the template'
  408. );
  409. };
  410. subtest 'private fields are dropped before a non-editor sees them' => sub {
  411. require Trog::Routes::HTML;
  412. require Trog::DataModule;
  413. my $dmmock = Test::MockModule->new('Trog::DataModule');
  414. $dmmock->redefine( private_fields_for => sub { return $_[0] eq 'secretive.tx' ? ['hidden'] : [] } );
  415. my $post = {
  416. form => 'secretive.tx',
  417. title => 'a post',
  418. hidden => 'the secret',
  419. shown => 'not a secret',
  420. # A relation is a post in its own right, with its own private fields.
  421. related => { form => 'secretive.tx', title => 'related', hidden => 'another secret' },
  422. many => [ { form => 'secretive.tx', hidden => 'a third secret' } ],
  423. other => { form => 'ordinary.tx', hidden => 'not declared private here' },
  424. };
  425. Trog::Routes::HTML::redact_private($post);
  426. ok( !exists $post->{hidden}, 'the private field is gone' );
  427. is( $post->{shown}, 'not a secret', 'and the public ones are not' );
  428. is( $post->{title}, 'a post', '...including the ones every post has' );
  429. ok( !exists $post->{related}{hidden}, 'a related post is redacted too' );
  430. ok( !exists $post->{many}[0]{hidden}, 'and so is one reached through a list' );
  431. is( $post->{other}{hidden}, 'not declared private here', 'a type that declares nothing keeps everything' );
  432. };
  433. subtest 'a post that refers to itself does not hang the redactor' => sub {
  434. require Trog::Routes::HTML;
  435. require Trog::DataModule;
  436. my $dmmock = Test::MockModule->new('Trog::DataModule');
  437. $dmmock->redefine( private_fields_for => sub { return ['hidden'] } );
  438. my $post = { form => 'loop.tx', hidden => 'secret' };
  439. $post->{itself} = $post;
  440. # A relation can point back at whatever pulled it in.
  441. is( exception { Trog::Routes::HTML::redact_private($post) }, undef, 'it terminates' );
  442. ok( !exists $post->{hidden}, 'having done the job' );
  443. };
  444. subtest 'a hypervisor does not show its connection uri to everyone' => sub {
  445. require Trog::DataModule;
  446. # The uri carries a host and a username, and these pages are public. It is
  447. # declared private rather than guarded in the template, so the value is
  448. # dropped before rendering and the template needs no guard of its own.
  449. my $private = Trog::DataModule::private_fields_for('hypervisor.tx');
  450. is_deeply( $private, ['conn_uri'], 'the connection uri is declared private' );
  451. my $sidecar = JSON::MaybeXS::decode_json( Path::Tiny->new("$FindBin::Bin/../www/templates/html/components/forms/hypervisor.json")->slurp_utf8 );
  452. ok( $sidecar->{properties}{conn_uri}{'x-tcms-private'}, 'and says so in its sidecar' );
  453. };
  454. done_testing();