Trog-DataModule-schema.t 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. use strict;
  2. use warnings;
  3. use Test::More;
  4. use Test::MockModule qw{strict};
  5. use Test::Deep;
  6. use Test::Fatal qw{exception};
  7. use JSON::MaybeXS();
  8. use Path::Tiny();
  9. use FindBin;
  10. use feature qw{signatures};
  11. no warnings qw{experimental::signatures};
  12. use lib "$FindBin::Bin/../lib";
  13. require_ok('Trog::DataModule') or BAIL_OUT("Can't find SUT");
  14. require_ok('Trog::Routes::HTML');
  15. # Point the SUT at a temp component dir we control and hand it whatever
  16. # sidecars each subtest wants. The layout has to mirror the real one -- the
  17. # component dir with a forms/ inside it -- because schema_for() keys its cache
  18. # on the mtime of forms/, and an earlier version of this fixture flattened the
  19. # two and so could not have caught it being keyed on the wrong directory.
  20. my $tempdir = Path::Tiny->tempdir();
  21. my $formsdir = $tempdir->child('forms');
  22. $formsdir->mkpath();
  23. my %sidecars;
  24. my $thememock = Test::MockModule->new('Trog::Themes');
  25. $thememock->redefine( 'template_dirs', sub { ("$tempdir") } );
  26. $thememock->redefine(
  27. 'themed_file_in_dir',
  28. sub ( $path, $file, @ ) {
  29. return -f "$formsdir/$file" ? "$formsdir/$file" : undef;
  30. }
  31. );
  32. # The logger isn't initialized outside of a running server.
  33. my $logmock = Test::MockModule->new('Trog::DataModule');
  34. $logmock->redefine( 'WARN', sub { note(shift) } );
  35. my $slurpmock = Test::MockModule->new('File::Slurper');
  36. $slurpmock->redefine( 'read_text', sub { $sidecars{ Path::Tiny::path(shift)->basename } } );
  37. # -f has to agree with %sidecars, and the dir mtime has to move, or the
  38. # mtime-keyed cache will hand back a stale answer.
  39. my $generation = 0;
  40. sub set_sidecars {
  41. %sidecars = @_;
  42. $_->remove foreach $formsdir->children();
  43. $formsdir->child($_)->spew_utf8('{}') foreach keys %sidecars;
  44. $generation++;
  45. utime( time() + $generation, time() + $generation, "$formsdir" );
  46. return;
  47. }
  48. sub sidecar { return JSON::MaybeXS::encode_json( { type => 'object', @_ } ) }
  49. subtest 'the base schema applies with no sidecar at all' => sub {
  50. set_sidecars();
  51. my $schema = Trog::DataModule::schema_for('blog.tx');
  52. ok( exists $schema->{properties}{title}, "base properties are there" );
  53. ok( !exists $schema->{properties}{gravy}, "and nothing else is" );
  54. ok( !exists $schema->{required}, "an empty required list isn't emitted - it isn't legal OpenAPI" );
  55. is_deeply(
  56. Trog::DataModule::schema_for(''),
  57. $schema,
  58. "a post with no form still gets the base schema"
  59. );
  60. };
  61. subtest 'a sidecar merges over the base' => sub {
  62. set_sidecars(
  63. 'recipe.json' => sidecar(
  64. required => ['servings'],
  65. properties => { servings => { type => 'integer' }, vegan => { type => 'boolean' } },
  66. )
  67. );
  68. my $schema = Trog::DataModule::schema_for('recipe.tx');
  69. ok( exists $schema->{properties}{servings}, "the sidecar's fields are merged in" );
  70. ok( exists $schema->{properties}{title}, "...without losing the base's" );
  71. is_deeply( $schema->{required}, ['servings'], "required carries over" );
  72. ok(
  73. !exists Trog::DataModule::schema_for('blog.tx')->{properties}{servings},
  74. "and they don't leak onto another post type"
  75. );
  76. };
  77. subtest 'sidecars cannot redefine core post fields' => sub {
  78. set_sidecars(
  79. 'evil.json' => sidecar(
  80. properties => {
  81. id => { type => 'object' },
  82. acls => { type => 'string' },
  83. callback => { type => 'string' },
  84. },
  85. )
  86. );
  87. my $schema = Trog::DataModule::schema_for('evil.tx');
  88. is( $schema->{properties}{id}{type}, 'string', "id keeps its own type" );
  89. is( $schema->{properties}{acls}{type}, 'array', "acls stays an array" );
  90. is( $schema->{properties}{callback}{type}, 'callback', "callback keeps the type that actually checks the sub exists" );
  91. };
  92. subtest 'a malformed sidecar is skipped, not fatal' => sub {
  93. set_sidecars( 'busted.json' => '{ this is not json' );
  94. my $schema;
  95. is( exception { $schema = Trog::DataModule::schema_for('busted.tx') }, undef, "no exception" );
  96. ok( exists $schema->{properties}{title}, "we fall back to the base schema" );
  97. };
  98. subtest 'the cache follows the forms dir mtime' => sub {
  99. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  100. ok( exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings}, "declared" );
  101. set_sidecars();
  102. ok(
  103. !exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings},
  104. "removing the sidecar is picked up without a restart"
  105. );
  106. };
  107. subtest 'validate() filters, coerces and reports' => sub {
  108. set_sidecars(
  109. 'recipe.json' => sidecar(
  110. properties => {
  111. servings => { type => 'integer' },
  112. vegan => { type => 'boolean' },
  113. photo => { type => 'upload' },
  114. },
  115. )
  116. );
  117. my $post = {
  118. form => 'recipe.tx',
  119. title => 'Cookies',
  120. visibility => 'public',
  121. servings => '12',
  122. vegan => '1',
  123. # The router hands us plenty that was never a post field.
  124. app => 'junk',
  125. to => '/blog',
  126. gravy => 'yes please',
  127. };
  128. is_deeply( [ Trog::DataModule::validate($post) ], [], "a good post has no errors" );
  129. is( $post->{servings}, 12, "strings coerce to the declared type" );
  130. isa_ok( $post->{vegan}, 'JSON::PP::Boolean', "checkbox values become real booleans" );
  131. ok( !exists $post->{app} && !exists $post->{to} && !exists $post->{gravy}, "undescribed keys are filtered out" );
  132. my ($error) = Trog::DataModule::validate( { form => 'recipe.tx', servings => 'lots' } );
  133. like( $error, qr/servings/, "a bad value is reported rather than silently dropped" );
  134. # 'upload' is our own sugar for the string-or-hashref an upload field is,
  135. # depending on which direction it's travelling.
  136. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => { filename => 'x' } } ) ], [], "an upload hashref passes" );
  137. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => '/assets/x.jpg' } ) ], [], "so does the href it becomes" );
  138. };
  139. subtest 'what gets written is checked, not just what was submitted' => sub {
  140. set_sidecars();
  141. # validate() runs on the submitted post. _process() then builds the shape
  142. # that is actually stored, and until it was checked too, a post could be
  143. # made wrong on its way to disk and nobody would ever hear about it.
  144. my $good = { form => 'blog.tx', title => 'Fine', tags => [qw{blog public}], visibility => 'public' };
  145. is_deeply( [ Trog::DataModule::validate_built($good) ], [], 'a well built post passes' );
  146. my $holed = { form => 'blog.tx', title => 'Holed', tags => [ 'public', undef ], visibility => 'public' };
  147. my @errors = Trog::DataModule::validate_built($holed);
  148. ok( scalar(@errors), 'a null in the tags is caught' );
  149. like( "$errors[0]", qr{/tags/1}, 'and named by where it is' );
  150. # It checks rather than filters, unlike validate(): _process() adds fields
  151. # the base schema has never heard of, and throwing those away would discard
  152. # the work it just did.
  153. my $built = {
  154. form => 'blog.tx',
  155. title => 'Built',
  156. tags => ['public'],
  157. visibility => 'public',
  158. content_type => 'text/html',
  159. is_video => 1,
  160. attachments => ['/assets/thing.txt'],
  161. preview => '/assets/thumb.png',
  162. };
  163. is_deeply( [ Trog::DataModule::validate_built($built) ], [], "the fields _process() invents do not fail it" );
  164. ok( exists $built->{is_video}, 'and are not deleted' );
  165. ok( exists $built->{attachments}, 'any of them' );
  166. };
  167. subtest '_process no longer builds a post wrong' => sub {
  168. # The bug: a post saved without a visibility had an undef pushed into its
  169. # tags, which is a tag no query ever matches -- so the post went invisible
  170. # to all but an admin, silently, and the bunk tag was written to disk.
  171. my $post = Trog::DataModule::_process( { form => 'blog.tx', title => 'No Visibility', tags => ['blog'] } );
  172. is_deeply( [ grep { !defined } @{ $post->{tags} } ], [], 'no undef survives into the tags' );
  173. is( $post->{visibility}, 'private', 'and the visibility is defaulted where the tag is made' );
  174. ok( scalar( grep { $_ eq 'private' } @{ $post->{tags} } ), 'so the tag it produces is a real one' );
  175. # Which is the same thing validate_built() is there to notice.
  176. is_deeply( [ Trog::DataModule::validate_built($post) ], [], 'and the result passes the check' );
  177. # An undef among the acls of a private post went the same way.
  178. my $acled = Trog::DataModule::_process( { form => 'blog.tx', title => 'Acled', tags => ['blog'], visibility => 'private', acls => [ 'members', undef ] } );
  179. is_deeply( [ grep { !defined } @{ $acled->{tags} } ], [], 'nor from the acls' );
  180. ok( scalar( grep { $_ eq 'members' } @{ $acled->{tags} } ), 'while the real acl still lands' );
  181. };
  182. subtest 'add() refuses to store a post it built wrong' => sub {
  183. set_sidecars();
  184. # Break _process on purpose: the point is that add() no longer takes its
  185. # word for it. This is the shape the real bug produced.
  186. #
  187. # One mock object for all three, and unmocked by hand at the end.
  188. # Test::MockModule keeps one per package, so a second new() on the same
  189. # package hands back the first -- and leaving it to go out of scope left
  190. # _process broken for every subtest after this one.
  191. my @written;
  192. my $mock = Test::MockModule->new('Trog::DataModule');
  193. $mock->redefine(
  194. '_process',
  195. sub ($post) {
  196. push( @{ $post->{tags} }, undef );
  197. return $post;
  198. }
  199. );
  200. $mock->redefine( 'write', sub { my ( $self, $data ) = @_; push( @written, @$data ); return 1 } );
  201. $mock->redefine( 'get', sub { return () } );
  202. my $model = bless {}, 'Trog::DataModule';
  203. my $err = exception { $model->add( { form => 'blog.tx', title => 'Doomed', tags => ['blog'], visibility => 'public' } ) };
  204. $mock->unmock_all();
  205. ok( $err, 'the save fails' );
  206. is( ref $err, 'ARRAY', 'with the errors the submitter is shown' );
  207. like( "$err->[0]", qr{/tags/}, 'saying what was wrong' );
  208. is_deeply( \@written, [], 'and nothing was written' );
  209. # The mock really is gone, or every subtest after this one is testing it.
  210. my $sane = Trog::DataModule::_process( { form => 'blog.tx', title => 'Sane', tags => ['blog'], visibility => 'public' } );
  211. is_deeply( [ grep { !defined } @{ $sane->{tags} } ], [], '_process is itself again afterwards' );
  212. };
  213. subtest 'empty inputs are treated as absent, except for strings' => sub {
  214. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  215. # An untouched number input submits '', which is not an integer and was
  216. # never meant to be one.
  217. my $post = { form => 'recipe.tx', servings => '', title => '' };
  218. is_deeply( [ Trog::DataModule::validate($post) ], [], "no spurious type error" );
  219. ok( !exists $post->{servings}, "the blank number is dropped" );
  220. is( $post->{title}, '', "a blank string field is left exactly as it was before any of this was typed" );
  221. };
  222. subtest 'the custom types actually bite' => sub {
  223. set_sidecars();
  224. my ($bad) = Trog::DataModule::validate( { title => 'x', callback => 'Some::Random::Sub' } );
  225. like( $bad, qr/cannot be loaded/, "a callback naming a module that isn't there is refused" );
  226. is_deeply(
  227. [ Trog::DataModule::validate( { title => 'x', callback => 'Trog::Routes::HTML::posts' } ) ],
  228. [],
  229. "a real one is fine"
  230. );
  231. };
  232. subtest 'add() rejects a post that does not validate' => sub {
  233. set_sidecars( 'recipe.json' => sidecar( required => ['servings'], properties => { servings => { type => 'integer' } } ) );
  234. my $written;
  235. {
  236. no warnings qw{once};
  237. @TestData::ISA = ('Trog::DataModule');
  238. *TestData::get = sub { return () };
  239. *TestData::write = sub { $written = $_[1]; return 0 };
  240. }
  241. my $why = exception {
  242. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public' } );
  243. };
  244. is( ref $why, 'ARRAY', "it dies with the error list, not a string" );
  245. like( join( ' ', @$why ), qr/servings/, "which names the offending field" );
  246. # A string die would come back wearing a Carp::Always stack trace, which is
  247. # no use at all to whoever just submitted the form.
  248. unlike( join( ' ', @$why ), qr/DataModule\.pm line/, "and carries no stack trace" );
  249. ok( !$written, "nothing was written" );
  250. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public', servings => '4' } );
  251. is( $written->[0]{servings}, 4, "the good one went through, coerced" );
  252. };
  253. subtest 'a post always ends up with a visibility' => sub {
  254. set_sidecars();
  255. my $written;
  256. {
  257. no warnings qw{once};
  258. @VisData::ISA = ('Trog::DataModule');
  259. *VisData::get = sub { return () };
  260. *VisData::write = sub { $written = $_[1]; return 0 };
  261. }
  262. # A post type built without a visibility selector submits none. _process
  263. # pushes visibility into the tags, so an undef one puts an undef in there
  264. # and the post becomes invisible to everyone but an admin.
  265. bless( {}, 'VisData' )->add( { title => 'No Visibility', tags => ['sometag'] } );
  266. is( $written->[0]{visibility}, 'private', 'it defaults, rather than staying undef' );
  267. ok( !( grep { !defined $_ } @{ $written->[0]{tags} } ), 'so no undef finds its way into the tags' );
  268. bless( {}, 'VisData' )->add( { title => 'Explicit', visibility => 'public', tags => ['sometag'] } );
  269. is( $written->[0]{visibility}, 'public', 'and an explicit one is left alone' );
  270. };
  271. subtest '_wizard_fields zips the parallel arrays' => sub {
  272. # The middle row was left blank. Dropping it must not shift the type of
  273. # the row after it -- this is the whole reason param_required is a select.
  274. my $fields = Trog::Routes::HTML::_wizard_fields(
  275. {
  276. param_name => [ 'Servings', '', 'cook_time', 'id', 'servings' ],
  277. param_type => [ 'number', 'text', 'textarea', 'text', 'text' ],
  278. param_label => [ 'Servings', '', 'Cook Time', 'ID', 'Dupe' ],
  279. param_placeholder => [ '4', '', '45 min', '', '' ],
  280. param_required => [ 1, 0, 0, 0, 0 ],
  281. }
  282. );
  283. is( scalar @$fields, 2, "blank, core-shadowing and duplicate rows are dropped" );
  284. is( $fields->[0]{name}, 'servings', "names are lowercased" );
  285. is( $fields->[0]{required}, 1, "required survives" );
  286. is( $fields->[1]{name}, 'cook_time', "the row after the blank one is still present" );
  287. is( $fields->[1]{type}, 'textarea', "...with its own type, not the blank row's" );
  288. };
  289. subtest '_wizard_sidecar emits a usable OpenAPIv3 schema' => sub {
  290. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  291. 'recipe',
  292. [
  293. { name => 'servings', type => 'number', label => 'Servings', placeholder => '4', required => 1 },
  294. { name => 'vegan', type => 'checkbox', label => 'Vegan?', placeholder => '', required => 0 },
  295. ],
  296. ['inc_tags'],
  297. 'form_common.tx',
  298. );
  299. is( $spec->{type}, 'object', "it's an object schema" );
  300. is( $spec->{properties}{servings}{type}, 'integer', "a number input is an integer" );
  301. is( $spec->{properties}{vegan}{type}, 'boolean', "a checkbox is a boolean" );
  302. is_deeply( $spec->{required}, ['servings'], "only the required rows are required" );
  303. # UI metadata rides along on x- keys so there's only one file to keep in sync.
  304. is( $spec->{properties}{servings}{'x-tcms-label'}, 'Servings', "labels are kept" );
  305. is( $spec->{properties}{servings}{'x-tcms-placeholder'}, '4', "so are placeholders" );
  306. ok( !exists $spec->{properties}{vegan}{'x-tcms-placeholder'}, "but an empty placeholder isn't written out" );
  307. is_deeply( $spec->{'x-tcms-post-type'}{includes}, ['tags.tx'], "the includes are recorded for regeneration" );
  308. };
  309. subtest '_kolon_safe defuses template syntax' => sub {
  310. my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
  311. unlike( $safe, qr/</, "no angle brackets survive" );
  312. unlike( $safe, qr/[\r\n]/, "no newlines survive, so ':' can't start a directive" );
  313. is( Trog::Routes::HTML::_kolon_safe( [] ), '', "a repeated param can't sneak a ref through" );
  314. };
  315. done_testing();