Trog-DataModule-schema.t 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. use 5.040;
  2. use re '/aasx';
  3. use warnings;
  4. use Test::More;
  5. use Test::MockModule qw{strict};
  6. use Test::Deep;
  7. use Test::Fatal qw{exception};
  8. use JSON::MaybeXS();
  9. use Path::Tiny();
  10. use URI::Escape();
  11. use FindBin;
  12. use feature qw{signatures};
  13. no warnings qw{experimental::signatures};
  14. use lib "$FindBin::Bin/../lib";
  15. require_ok('Trog::DataModule') or BAIL_OUT("Can't find SUT");
  16. require_ok('Trog::Routes::HTML');
  17. # Point the SUT at a temp component dir we control and hand it whatever
  18. # sidecars each subtest wants. The layout has to mirror the real one -- the
  19. # component dir with a forms/ inside it -- because schema_for() keys its cache
  20. # on the mtime of forms/, and an earlier version of this fixture flattened the
  21. # two and so could not have caught it being keyed on the wrong directory.
  22. my $tempdir = Path::Tiny->tempdir();
  23. my $formsdir = $tempdir->child('forms');
  24. $formsdir->mkpath();
  25. my %sidecars;
  26. my $thememock = Test::MockModule->new('Trog::Themes');
  27. $thememock->redefine( 'template_dirs', sub { ("$tempdir") } );
  28. $thememock->redefine(
  29. 'themed_file_in_dir',
  30. sub ( $path, $file, @ ) {
  31. return -f "$formsdir/$file" ? "$formsdir/$file" : undef;
  32. }
  33. );
  34. # The logger isn't initialized outside of a running server.
  35. my $logmock = Test::MockModule->new('Trog::DataModule');
  36. $logmock->redefine( 'WARN', sub { note(shift) } );
  37. # The canned editor blocks and their sidecars, which live in the components dir
  38. # itself rather than in forms/ -- preview.json beside preview.tx.
  39. my %components;
  40. my $slurpmock = Test::MockModule->new('File::Slurper');
  41. $slurpmock->redefine(
  42. 'read_text',
  43. sub {
  44. my $name = Path::Tiny::path(shift)->basename;
  45. return exists $sidecars{$name} ? $sidecars{$name} : $components{$name};
  46. }
  47. );
  48. # -f has to agree with %sidecars, and the dir mtime has to move, or the
  49. # mtime-keyed cache will hand back a stale answer.
  50. my $generation = 0;
  51. sub set_sidecars {
  52. %sidecars = @_;
  53. $_->remove foreach $formsdir->children();
  54. $formsdir->child($_)->spew_utf8('{}') foreach keys %sidecars;
  55. $generation++;
  56. utime( time() + $generation, time() + $generation, "$formsdir" );
  57. return;
  58. }
  59. # The same, one directory up. forms/ is a child of this one, so only the files
  60. # get cleared out.
  61. sub set_components {
  62. %components = @_;
  63. $_->remove foreach grep { !$_->is_dir } $tempdir->children();
  64. $tempdir->child($_)->spew_utf8('{}') foreach keys %components;
  65. $generation++;
  66. utime( time() + $generation, time() + $generation, "$tempdir" );
  67. return;
  68. }
  69. sub sidecar { return JSON::MaybeXS::encode_json( { type => 'object', @_ } ) }
  70. subtest 'the base schema applies with no sidecar at all' => sub {
  71. set_sidecars();
  72. my $schema = Trog::DataModule::schema_for('blog.tx');
  73. ok( exists $schema->{properties}{title}, "base properties are there" );
  74. ok( !exists $schema->{properties}{gravy}, "and nothing else is" );
  75. ok( !exists $schema->{required}, "an empty required list isn't emitted - it isn't legal OpenAPI" );
  76. is_deeply(
  77. Trog::DataModule::schema_for(''),
  78. $schema,
  79. "a post with no form still gets the base schema"
  80. );
  81. };
  82. subtest 'a sidecar merges over the base' => sub {
  83. set_sidecars(
  84. 'recipe.json' => sidecar(
  85. required => ['servings'],
  86. properties => { servings => { type => 'integer' }, vegan => { type => 'boolean' } },
  87. )
  88. );
  89. my $schema = Trog::DataModule::schema_for('recipe.tx');
  90. ok( exists $schema->{properties}{servings}, "the sidecar's fields are merged in" );
  91. ok( exists $schema->{properties}{title}, "...without losing the base's" );
  92. is_deeply( $schema->{required}, ['servings'], "required carries over" );
  93. ok(
  94. !exists Trog::DataModule::schema_for('blog.tx')->{properties}{servings},
  95. "and they don't leak onto another post type"
  96. );
  97. };
  98. subtest 'sidecars cannot redefine core post fields' => sub {
  99. set_sidecars(
  100. 'evil.json' => sidecar(
  101. properties => {
  102. id => { type => 'object' },
  103. acls => { type => 'string' },
  104. callback => { type => 'string' },
  105. },
  106. )
  107. );
  108. my $schema = Trog::DataModule::schema_for('evil.tx');
  109. is( $schema->{properties}{id}{type}, 'string', "id keeps its own type" );
  110. is( $schema->{properties}{acls}{type}, 'array', "acls stays an array" );
  111. is( $schema->{properties}{callback}{type}, 'callback', "callback keeps the type that actually checks the sub exists" );
  112. };
  113. subtest 'a malformed sidecar is skipped, not fatal' => sub {
  114. set_sidecars( 'busted.json' => '{ this is not json' );
  115. my $schema;
  116. is( exception { $schema = Trog::DataModule::schema_for('busted.tx') }, undef, "no exception" );
  117. ok( exists $schema->{properties}{title}, "we fall back to the base schema" );
  118. };
  119. subtest 'the cache follows the forms dir mtime' => sub {
  120. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  121. ok( exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings}, "declared" );
  122. set_sidecars();
  123. ok(
  124. !exists Trog::DataModule::schema_for('recipe.tx')->{properties}{servings},
  125. "removing the sidecar is picked up without a restart"
  126. );
  127. };
  128. subtest 'validate() filters, coerces and reports' => sub {
  129. set_sidecars(
  130. 'recipe.json' => sidecar(
  131. properties => {
  132. servings => { type => 'integer' },
  133. vegan => { type => 'boolean' },
  134. photo => { type => 'upload' },
  135. },
  136. )
  137. );
  138. my $post = {
  139. form => 'recipe.tx',
  140. title => 'Cookies',
  141. visibility => 'public',
  142. servings => '12',
  143. vegan => '1',
  144. # The router hands us plenty that was never a post field.
  145. app => 'junk',
  146. to => '/blog',
  147. gravy => 'yes please',
  148. };
  149. is_deeply( [ Trog::DataModule::validate($post) ], [], "a good post has no errors" );
  150. is( $post->{servings}, 12, "strings coerce to the declared type" );
  151. isa_ok( $post->{vegan}, 'JSON::PP::Boolean', "checkbox values become real booleans" );
  152. ok( !exists $post->{app} && !exists $post->{to} && !exists $post->{gravy}, "undescribed keys are filtered out" );
  153. my ($error) = Trog::DataModule::validate( { form => 'recipe.tx', servings => 'lots' } );
  154. like( $error, qr/servings/, "a bad value is reported rather than silently dropped" );
  155. # 'upload' is our own sugar for the string-or-hashref an upload field is,
  156. # depending on which direction it's travelling.
  157. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => { filename => 'x' } } ) ], [], "an upload hashref passes" );
  158. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', photo => '/assets/x.jpg' } ) ], [], "so does the href it becomes" );
  159. };
  160. subtest 'what gets written is checked, not just what was submitted' => sub {
  161. set_sidecars();
  162. # validate() runs on the submitted post. _process() then builds the shape
  163. # that is actually stored, and until it was checked too, a post could be
  164. # made wrong on its way to disk and nobody would ever hear about it.
  165. my $good = { form => 'blog.tx', title => 'Fine', tags => [qw{blog public}], visibility => 'public' };
  166. is_deeply( [ Trog::DataModule::validate_built($good) ], [], 'a well built post passes' );
  167. my $holed = { form => 'blog.tx', title => 'Holed', tags => [ 'public', undef ], visibility => 'public' };
  168. my @errors = Trog::DataModule::validate_built($holed);
  169. ok( scalar(@errors), 'a null in the tags is caught' );
  170. like( "$errors[0]", qr{/tags/1}, 'and named by where it is' );
  171. # It checks rather than filters, unlike validate(): _process() adds fields
  172. # the base schema has never heard of, and throwing those away would discard
  173. # the work it just did.
  174. my $built = {
  175. form => 'blog.tx',
  176. title => 'Built',
  177. tags => ['public'],
  178. visibility => 'public',
  179. content_type => 'text/html',
  180. is_video => 1,
  181. attachments => ['/assets/thing.txt'],
  182. preview => '/assets/thumb.png',
  183. };
  184. is_deeply( [ Trog::DataModule::validate_built($built) ], [], "the fields _process() invents do not fail it" );
  185. ok( exists $built->{is_video}, 'and are not deleted' );
  186. ok( exists $built->{attachments}, 'any of them' );
  187. };
  188. subtest '_process no longer builds a post wrong' => sub {
  189. # The bug: a post saved without a visibility had an undef pushed into its
  190. # tags, which is a tag no query ever matches -- so the post went invisible
  191. # to all but an admin, silently, and the bunk tag was written to disk.
  192. my $post = Trog::DataModule::_process( { form => 'blog.tx', title => 'No Visibility', tags => ['blog'] } );
  193. is_deeply( [ grep { !defined } @{ $post->{tags} } ], [], 'no undef survives into the tags' );
  194. is( $post->{visibility}, 'private', 'and the visibility is defaulted where the tag is made' );
  195. ok( scalar( grep { $_ eq 'private' } @{ $post->{tags} } ), 'so the tag it produces is a real one' );
  196. # Which is the same thing validate_built() is there to notice.
  197. is_deeply( [ Trog::DataModule::validate_built($post) ], [], 'and the result passes the check' );
  198. # An undef among the acls of a private post went the same way.
  199. my $acled = Trog::DataModule::_process( { form => 'blog.tx', title => 'Acled', tags => ['blog'], visibility => 'private', acls => [ 'members', undef ] } );
  200. is_deeply( [ grep { !defined } @{ $acled->{tags} } ], [], 'nor from the acls' );
  201. ok( scalar( grep { $_ eq 'members' } @{ $acled->{tags} } ), 'while the real acl still lands' );
  202. };
  203. subtest 'add() refuses to store a post it built wrong' => sub {
  204. set_sidecars();
  205. # Break _process on purpose: the point is that add() no longer takes its
  206. # word for it. This is the shape the real bug produced.
  207. #
  208. # One mock object for all three, and unmocked by hand at the end.
  209. # Test::MockModule keeps one per package, so a second new() on the same
  210. # package hands back the first -- and leaving it to go out of scope left
  211. # _process broken for every subtest after this one.
  212. my @written;
  213. my $mock = Test::MockModule->new('Trog::DataModule');
  214. $mock->redefine(
  215. '_process',
  216. sub ($post) {
  217. push( @{ $post->{tags} }, undef );
  218. return $post;
  219. }
  220. );
  221. $mock->redefine( 'write', sub { my ( $self, $data ) = @_; push( @written, @$data ); return 1 } );
  222. $mock->redefine( 'get', sub { return () } );
  223. my $model = bless {}, 'Trog::DataModule';
  224. my $err = exception { $model->add( { form => 'blog.tx', title => 'Doomed', tags => ['blog'], visibility => 'public' } ) };
  225. $mock->unmock_all();
  226. ok( $err, 'the save fails' );
  227. is( ref $err, 'ARRAY', 'with the errors the submitter is shown' );
  228. like( "$err->[0]", qr{/tags/}, 'saying what was wrong' );
  229. is_deeply( \@written, [], 'and nothing was written' );
  230. # The mock really is gone, or every subtest after this one is testing it.
  231. my $sane = Trog::DataModule::_process( { form => 'blog.tx', title => 'Sane', tags => ['blog'], visibility => 'public' } );
  232. is_deeply( [ grep { !defined } @{ $sane->{tags} } ], [], '_process is itself again afterwards' );
  233. };
  234. subtest 'empty inputs are treated as absent, except for strings' => sub {
  235. set_sidecars( 'recipe.json' => sidecar( properties => { servings => { type => 'integer' } } ) );
  236. # An untouched number input submits '', which is not an integer and was
  237. # never meant to be one.
  238. my $post = { form => 'recipe.tx', servings => '', title => '' };
  239. is_deeply( [ Trog::DataModule::validate($post) ], [], "no spurious type error" );
  240. ok( !exists $post->{servings}, "the blank number is dropped" );
  241. is( $post->{title}, '', "a blank string field is left exactly as it was before any of this was typed" );
  242. };
  243. subtest 'the custom types actually bite' => sub {
  244. set_sidecars();
  245. my ($bad) = Trog::DataModule::validate( { title => 'x', callback => 'Some::Random::Sub' } );
  246. like( $bad, qr/cannot[ ]be[ ]loaded/, "a callback naming a module that isn't there is refused" );
  247. is_deeply(
  248. [ Trog::DataModule::validate( { title => 'x', callback => 'Trog::Routes::HTML::posts' } ) ],
  249. [],
  250. "a real one is fine"
  251. );
  252. };
  253. subtest 'add() rejects a post that does not validate' => sub {
  254. set_sidecars( 'recipe.json' => sidecar( required => ['servings'], properties => { servings => { type => 'integer' } } ) );
  255. my $written;
  256. {
  257. no warnings qw{once};
  258. @TestData::ISA = ('Trog::DataModule');
  259. *TestData::get = sub { return () };
  260. *TestData::write = sub { $written = $_[1]; return 0 };
  261. }
  262. my $why = exception {
  263. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public' } );
  264. };
  265. is( ref $why, 'ARRAY', "it dies with the error list, not a string" );
  266. like( join( ' ', @$why ), qr/servings/, "which names the offending field" );
  267. # A string die would come back wearing a Carp::Always stack trace, which is
  268. # no use at all to whoever just submitted the form.
  269. unlike( join( ' ', @$why ), qr/DataModule\.pm[ ]line/, "and carries no stack trace" );
  270. ok( !$written, "nothing was written" );
  271. bless( {}, 'TestData' )->add( { form => 'recipe.tx', title => 'Cookies', visibility => 'public', servings => '4' } );
  272. is( $written->[0]{servings}, 4, "the good one went through, coerced" );
  273. };
  274. subtest 'a post always ends up with a visibility' => sub {
  275. set_sidecars();
  276. my $written;
  277. {
  278. no warnings qw{once};
  279. @VisData::ISA = ('Trog::DataModule');
  280. *VisData::get = sub { return () };
  281. *VisData::write = sub { $written = $_[1]; return 0 };
  282. }
  283. # A post type built without a visibility selector submits none. _process
  284. # pushes visibility into the tags, so an undef one puts an undef in there
  285. # and the post becomes invisible to everyone but an admin.
  286. bless( {}, 'VisData' )->add( { title => 'No Visibility', tags => ['sometag'] } );
  287. is( $written->[0]{visibility}, 'private', 'it defaults, rather than staying undef' );
  288. ok( !( grep { !defined $_ } @{ $written->[0]{tags} } ), 'so no undef finds its way into the tags' );
  289. bless( {}, 'VisData' )->add( { title => 'Explicit', visibility => 'public', tags => ['sometag'] } );
  290. is( $written->[0]{visibility}, 'public', 'and an explicit one is left alone' );
  291. };
  292. subtest 'a save message never becomes a URL' => sub {
  293. # The whole shape of the bug: a write that failed deep in File::Slurper,
  294. # decorated by Carp::Always with the entire call stack, went into the
  295. # redirect URL as a query parameter. The result was several thousand
  296. # characters, and tPSGI answered its own redirect with a 419 -- so the user
  297. # saw neither the page they saved from nor the error.
  298. my $realistic = "Couldn't rename data/dnZHxmlXE5 to data/files/: Not a directory at /opt/perl5/lib/File/Slurper/Temp.pm line 56.\n" . "\tFile::Slurper::Temp::write_text(\"data/files/\", \"[{...}]\") called at /opt/perl5/lib/File/Slurper/Temp.pm line 63\n" . ( "\tTrog::Routes::HTML::post_save(HASH(0x5e798a626570)) called at lib/TCMS.pm line 130\n" x 40 );
  299. my $short = Trog::Routes::HTML::_short_error($realistic);
  300. unlike( $short, qr/\n/, 'the trace is gone' );
  301. unlike( $short, qr/line[ ]\d+/, 'and so is the file and line Perl glued on' );
  302. like( $short, qr/^Couldn't[ ]rename[ ]data/, 'leaving what actually went wrong' );
  303. my $cookie = Trog::Routes::HTML::_feedback_cookie( 1, $realistic );
  304. ok( length($cookie) < 2048, 'even the whole untrimmed thing fits in a cookie' )
  305. or diag( 'cookie was ' . length($cookie) . ' bytes' );
  306. like( $cookie, qr/^tcmsfeedback=1:/, 'flagged as a failure' );
  307. like( $cookie, qr/HttpOnly/, 'and not readable by script, since nothing needs to' );
  308. like( $cookie, qr/Max-Age=\d+/, 'and does not outlive the message it carries' );
  309. # One line, because the banner puts it in a JS string literal.
  310. my ($value) = $cookie =~ m/^tcmsfeedback=1:([^;]*)/;
  311. unlike( URI::Escape::uri_unescape($value), qr/\n/, 'the message is one line' );
  312. # Round trips.
  313. my ( $failure, $message ) = Trog::Routes::HTML::_feedback_from_cookie($cookie);
  314. is( $failure, 1, 'the outcome comes back out' );
  315. like( $message, qr/^Couldn't[ ]rename/, 'and so does the message' );
  316. ( $failure, $message ) = Trog::Routes::HTML::_feedback_from_cookie( Trog::Routes::HTML::_feedback_cookie( 0, "Saved post 'Thing'." ) );
  317. is( $failure, 0, 'a success comes back as one' );
  318. is( $message, "Saved post 'Thing'.", 'with its message intact' );
  319. # And nothing at all when there is no cookie to read.
  320. is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('') ], [], 'no cookies, no feedback' );
  321. is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('other=thing') ], [], 'nor other cookies' );
  322. is_deeply( [ Trog::Routes::HTML::_feedback_from_cookie('tcmsfeedback=xyz') ], [], 'nor one that is malformed' );
  323. };
  324. subtest '_wizard_fields zips the parallel arrays' => sub {
  325. # The middle row was left blank. Dropping it must not shift the type of
  326. # the row after it -- this is the whole reason param_required is a select.
  327. my $fields = Trog::Routes::HTML::_wizard_fields(
  328. {
  329. param_name => [ 'Servings', '', 'cook_time', 'id', 'servings' ],
  330. param_type => [ 'number', 'text', 'textarea', 'text', 'text' ],
  331. param_label => [ 'Servings', '', 'Cook Time', 'ID', 'Dupe' ],
  332. param_placeholder => [ '4', '', '45 min', '', '' ],
  333. param_required => [ 1, 0, 0, 0, 0 ],
  334. }
  335. );
  336. is( scalar @$fields, 2, "blank, core-shadowing and duplicate rows are dropped" );
  337. is( $fields->[0]{name}, 'servings', "names are lowercased" );
  338. is( $fields->[0]{required}, 1, "required survives" );
  339. is( $fields->[1]{name}, 'cook_time', "the row after the blank one is still present" );
  340. is( $fields->[1]{type}, 'textarea', "...with its own type, not the blank row's" );
  341. };
  342. subtest '_wizard_sidecar emits a usable OpenAPIv3 schema' => sub {
  343. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  344. 'recipe',
  345. [
  346. { name => 'servings', type => 'number', label => 'Servings', placeholder => '4', required => 1 },
  347. { name => 'vegan', type => 'checkbox', label => 'Vegan?', placeholder => '', required => 0 },
  348. ],
  349. ['inc_tags'],
  350. 'form_common.tx',
  351. );
  352. is( $spec->{type}, 'object', "it's an object schema" );
  353. is( $spec->{properties}{servings}{type}, 'integer', "a number input is an integer" );
  354. is( $spec->{properties}{vegan}{type}, 'boolean', "a checkbox is a boolean" );
  355. is_deeply( $spec->{required}, ['servings'], "only the required rows are required" );
  356. # UI metadata rides along on x- keys so there's only one file to keep in sync.
  357. is( $spec->{properties}{servings}{'x-tcms-label'}, 'Servings', "labels are kept" );
  358. is( $spec->{properties}{servings}{'x-tcms-placeholder'}, '4', "so are placeholders" );
  359. ok( !exists $spec->{properties}{vegan}{'x-tcms-placeholder'}, "but an empty placeholder isn't written out" );
  360. is_deeply( $spec->{'x-tcms-post-type'}{includes}, ['tags.tx'], "the includes are recorded for regeneration" );
  361. };
  362. subtest 'a type records what the wizard was told, where the validator cannot see it' => sub {
  363. my $fields = [
  364. {
  365. name => 'cook_time', type => 'text', label => 'Cook Time', placeholder => '45 minutes',
  366. required => 1, private => 0, indexed => 1, relation_form => '', relation_mode => 'one',
  367. },
  368. {
  369. name => 'everything', type => 'relation', label => '', placeholder => '',
  370. required => 0, private => 0, indexed => 0, relation_form => 'blog.tx', relation_mode => 'all',
  371. },
  372. ];
  373. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  374. 'recipe', $fields, ['inc_tags'], 'form_common.tx', '',
  375. {
  376. description => 'A recipe, with its cook time.',
  377. display => '<div class="r"><: $post.cook_time :></div>',
  378. title_placeholder => 'Bread',
  379. wrapper => 1,
  380. inc_post_title => 1,
  381. inc_post_tags => 0,
  382. }
  383. );
  384. my $type = $spec->{'x-tcms-post-type'};
  385. is( $type->{description}, 'A recipe, with its cook time.', 'the description is recorded' );
  386. is( $type->{display}, '<div class="r"><: $post.cook_time :></div>', 'and the display template' );
  387. is( $type->{title_placeholder}, 'Bread', 'and the title placeholder' );
  388. ok( $type->{wrapper}, 'and the checkboxes which are not includes' );
  389. ok( $type->{inc_post_title}, 'all of them' );
  390. ok( !$type->{inc_post_tags}, 'including the ones that were clear' );
  391. # The point of putting it there: schema_for() keeps properties and required
  392. # and nothing else, so none of this can affect whether a post validates.
  393. set_sidecars( 'recipe.json' => JSON::MaybeXS::encode_json($spec) );
  394. my $schema = Trog::DataModule::schema_for('recipe.tx');
  395. ok( !exists $schema->{'x-tcms-post-type'}, 'and the validator never sees any of it' );
  396. ok( exists $schema->{properties}{cook_time}, 'while the fields it does care about are there' );
  397. is_deeply( [ Trog::DataModule::validate( { form => 'recipe.tx', title => 'x', cook_time => '45 minutes' } ) ], [], 'so a post of the type still saves' );
  398. };
  399. subtest 'a type reads back as the form that made it' => sub {
  400. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  401. 'recipe',
  402. [
  403. {
  404. name => 'cook_time', type => 'number', label => 'Cook Time', placeholder => '45',
  405. required => 1, private => 0, indexed => 1, relation_form => '', relation_mode => 'one',
  406. },
  407. {
  408. name => 'secret_note', type => 'textarea', label => 'Note', placeholder => '',
  409. required => 0, private => 1, indexed => 0, relation_form => '', relation_mode => 'one',
  410. },
  411. {
  412. name => 'picked', type => 'relation', label => 'Picked', placeholder => '',
  413. required => 0, private => 0, indexed => 0, relation_form => 'blog.tx', relation_mode => 'one',
  414. },
  415. {
  416. name => 'everything', type => 'relation', label => '', placeholder => '',
  417. required => 0, private => 0, indexed => 0, relation_form => 'entities.tx', relation_mode => 'all',
  418. },
  419. ],
  420. [qw{inc_preview inc_tags}],
  421. 'form_multi.tx',
  422. 'Trog::DataSource::Virt',
  423. {
  424. description => 'What it is for.',
  425. display => '<div>x</div>',
  426. title_placeholder => 'Bread',
  427. wrapper => 1,
  428. inc_post_title => 0,
  429. inc_post_tags => 1,
  430. }
  431. );
  432. set_sidecars( 'recipe.json' => JSON::MaybeXS::encode_json($spec) );
  433. my $types = Trog::Routes::HTML::_wizard_types( ['recipe.tx'] );
  434. my $back = $types->{'recipe.tx'};
  435. is( $back->{name}, 'recipe', 'the name comes back without the extension' );
  436. is( $back->{description}, 'What it is for.', 'the description comes back' );
  437. is( $back->{display}, '<div>x</div>', 'and the display' );
  438. is( $back->{body_form}, 'form_multi.tx', 'and which body form' );
  439. is( $back->{datasource}, 'Trog::DataSource::Virt', 'and the datasource' );
  440. is( $back->{title_placeholder}, 'Bread', 'and the placeholder' );
  441. is( $back->{wrapper}, 1, 'the ticked boxes come back ticked' );
  442. is( $back->{inc_post_title}, 0, 'the clear ones clear' );
  443. is( $back->{inc_post_tags}, 1, 'individually' );
  444. is( $back->{inc_preview}, 1, 'and the includes are recovered from the list' );
  445. is( $back->{inc_tags}, 1, 'all of them' );
  446. is( $back->{inc_aliases}, 0, 'and only them' );
  447. my %by_name = map { $_->{name} => $_ } @{ $back->{fields} };
  448. is( scalar( keys %by_name ), 4, 'every custom field comes back' );
  449. is( $by_name{cook_time}{type}, 'number', 'with the input it was given' );
  450. is( $by_name{cook_time}{label}, 'Cook Time', 'its label' );
  451. is( $by_name{cook_time}{required}, 1, 'whether it was required' );
  452. is( $by_name{cook_time}{indexed}, 1, 'and whether it was indexed' );
  453. is( $by_name{secret_note}{private}, 1, 'a private field says so' );
  454. is( $by_name{picked}{type}, 'relation', 'a relation says so' );
  455. is( $by_name{picked}{relation_form}, 'blog.tx', 'naming its target' );
  456. is( $by_name{picked}{relation_mode}, 'one', 'and that it picks one' );
  457. # This one stores nothing, so it has no property to be found by -- it exists
  458. # only as a relations entry, and would be lost without looking there.
  459. is( $by_name{everything}{relation_mode}, 'all', 'a pick-everything relation comes back too' );
  460. is( $by_name{everything}{relation_form}, 'entities.tx', 'naming its target' );
  461. # And nothing from the base post schema, which was never a wizard field.
  462. ok( !exists $by_name{title}, 'the base schema fields are not offered as custom ones' );
  463. ok( !exists $by_name{visibility}, 'any of them' );
  464. };
  465. subtest 'the checkboxes outside the editor come back from the template too' => sub {
  466. set_components();
  467. # A hand-written type: a wrapper div, both display blocks, and the
  468. # multi-page body form. Its sidecar records none of that, because nothing
  469. # but a generated one ever did.
  470. set_sidecars(
  471. 'deck.json' => sidecar(),
  472. 'deck.tx' => <<'TEMPLATE',
  473. <div class="post <: $style :>">
  474. :if ( !$post.addpost ) {
  475. : include "post_title.tx";
  476. : include "post_tags.tx";
  477. : }
  478. : include "form_multi.tx";
  479. </div>
  480. TEMPLATE
  481. # And one which opens with its own markup instead of the wrapper, and
  482. # shows neither block -- as file.tx and profile.tx really do.
  483. 'bare.json' => sidecar(),
  484. 'bare.tx' => qq|<!-- a comment first, which is not the wrapper -->\n:if ( !\$post.addpost ) {\n: }\n : include "form_common.tx";\n|,
  485. );
  486. my $types = Trog::Routes::HTML::_wizard_types( [ 'deck.tx', 'bare.tx' ] );
  487. is( $types->{'deck.tx'}{wrapper}, 1, "the wrapper div is seen where a type opens with one" );
  488. is( $types->{'deck.tx'}{inc_post_title}, 1, "as is the post title block" );
  489. is( $types->{'deck.tx'}{inc_post_tags}, 1, "and the post tag block" );
  490. is( $types->{'deck.tx'}{body_form}, 'form_multi.tx', "and which body form carries the post" );
  491. is( $types->{'bare.tx'}{wrapper}, 0, 'a type which opens with its own markup has the box clear' );
  492. is( $types->{'bare.tx'}{inc_post_title}, 0, 'and the blocks it does not include are clear' );
  493. is( $types->{'bare.tx'}{inc_post_tags}, 0, 'both of them' );
  494. is( $types->{'bare.tx'}{body_form}, 'form_common.tx', 'and a single page body form is read back as one' );
  495. };
  496. subtest 'the types are handed to the page as JSON it cannot break out of' => sub {
  497. no warnings qw{once};
  498. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  499. 'nasty', [], [], 'form_common.tx', '',
  500. { description => 'ends a script </script><script>alert(1)</script>', display => '<div>markup</div>' }
  501. );
  502. set_sidecars( 'nasty.json' => JSON::MaybeXS::encode_json($spec) );
  503. my $json = Trog::Routes::HTML::_wizard_types_json( ['nasty.tx'] );
  504. unlike( $json, qr/</, 'no bare < survives, so the script element cannot be closed early' );
  505. my $back = JSON::MaybeXS::decode_json($json);
  506. like( $back->{'nasty.tx'}{description}, qr{</script>}, 'while the text itself is intact once parsed' );
  507. is( $back->{'nasty.tx'}{display}, '<div>markup</div>', 'and so is the display template' );
  508. # A description is free text, but it still goes in a file.
  509. my $long = Trog::Routes::HTML::_wizard_description( 'x' x 9000 );
  510. ok( length($long) <= $Trog::Routes::HTML::wizard_description_max, 'a very long description is capped' );
  511. is( Trog::Routes::HTML::_wizard_description(), '', 'and no description at all is empty rather than undef' );
  512. is( Trog::Routes::HTML::_wizard_description( [ 'an', 'array' ] ), '', 'as is one that is not a string' );
  513. };
  514. subtest 'a canned block declares what it collects, and the types including it get it' => sub {
  515. set_components(
  516. 'preview.json' => sidecar(
  517. properties => {
  518. preview => { type => 'string', 'x-tcms-label' => 'Preview Image' },
  519. preview_file => { type => 'upload', 'x-tcms-label' => 'Preview Image', 'x-tcms-input' => 'file' },
  520. }
  521. )
  522. );
  523. # What the wizard writes for a type with 'Preview image upload' ticked: the
  524. # block spliced into the template, and a sidecar naming only the field
  525. # somebody actually typed into the form.
  526. my $spec = Trog::Routes::HTML::_wizard_sidecar(
  527. 'recipe',
  528. [
  529. {
  530. name => 'cook_time', type => 'number', label => 'Cook Time', placeholder => '45',
  531. required => 0, private => 0, indexed => 0, relation_form => '', relation_mode => 'one',
  532. },
  533. ],
  534. [qw{inc_preview inc_visibility inc_acls}],
  535. 'form_common.tx',
  536. '',
  537. { description => 'What it is for.', display => '', title_placeholder => '' },
  538. );
  539. set_sidecars(
  540. 'recipe.json' => JSON::MaybeXS::encode_json($spec),
  541. 'recipe.tx' => qq| : include "preview.tx";\n : include "form_common.tx";\n|,
  542. );
  543. is_deeply(
  544. Trog::DataModule::includes_for('recipe.tx'),
  545. [qw{preview.tx form_common.tx}],
  546. "the blocks a type splices in are read out of the template it renders"
  547. );
  548. my $schema = Trog::DataModule::schema_for('recipe.tx');
  549. ok( exists $schema->{properties}{preview_file}, "a block's fields land in the schema of every type that includes it" );
  550. ok( exists $schema->{properties}{cook_time}, "alongside the ones the type declares itself" );
  551. # The whole point: validate() drops every field the schema doesn't describe,
  552. # so until the block said what it collects, a generated type threw away the
  553. # preview image its own editor had just uploaded.
  554. my $post = {
  555. form => 'recipe.tx',
  556. title => 'Bread',
  557. preview_file => { tempname => '/tmp/nope', filename => 'loaf.jpg' },
  558. };
  559. is_deeply( [ Trog::DataModule::validate($post) ], [], "a post carrying one still validates" );
  560. ok( exists $post->{preview_file}, "and keeps it rather than having it filtered away" );
  561. # A block never gets to overrule the type that borrows it, nor the base
  562. # schema -- see the merge order in schema_for().
  563. set_components( 'preview.json' => sidecar( properties => { preview => { type => 'integer' }, title => { type => 'integer' } } ) );
  564. $schema = Trog::DataModule::schema_for('recipe.tx');
  565. is( $schema->{properties}{title}{type}, 'string', "and it cannot redefine a base post field" );
  566. };
  567. subtest 'the wizard tells a canned field apart from one somebody typed in' => sub {
  568. set_components(
  569. 'preview.json' => sidecar(
  570. properties => {
  571. preview => { type => 'string' },
  572. preview_file => { type => 'upload' },
  573. }
  574. )
  575. );
  576. # A hand-written type: it declares the block's fields itself, the way every
  577. # stock form does, and records nothing about which blocks it includes.
  578. set_sidecars(
  579. 'blog.json' => sidecar(
  580. properties => {
  581. preview => { type => 'string' },
  582. preview_file => { type => 'upload' },
  583. video_href => { type => 'string', 'x-tcms-input' => 'url', 'x-tcms-label' => 'Video' },
  584. }
  585. ),
  586. 'blog.tx' => qq| : include "preview.tx";\n : include "form_common.tx";\n|,
  587. # And one which declares a field of the same name without including the
  588. # block -- a datasource fills this one in, so it really is the type's.
  589. 'guests.json' => sidecar( properties => { preview => { type => 'string', 'x-tcms-label' => 'Console capture' } } ),
  590. 'guests.tx' => qq| : include "post_title.tx";\n|,
  591. );
  592. my $types = Trog::Routes::HTML::_wizard_types( [ 'blog.tx', 'guests.tx' ] );
  593. is( $types->{'blog.tx'}{inc_preview}, 1, "the box for a block the template includes comes back ticked" );
  594. is_deeply(
  595. [ map { $_->{name} } @{ $types->{'blog.tx'}{fields} } ],
  596. ['video_href'],
  597. "and what that block collects is not offered back as a custom field"
  598. );
  599. is( $types->{'guests.tx'}{inc_preview}, 0, "a type which does not include the block has the box clear" );
  600. is_deeply(
  601. [ map { $_->{name} } @{ $types->{'guests.tx'}{fields} } ],
  602. ['preview'],
  603. "and keeps the field of that name, which is its own rather than the block's"
  604. );
  605. };
  606. subtest '_kolon_safe defuses template syntax' => sub {
  607. my $safe = Trog::Routes::HTML::_kolon_safe(qq{<: \$post.id :>\n: include "evil.tx";});
  608. unlike( $safe, qr/</, "no angle brackets survive" );
  609. unlike( $safe, qr/[\r\n]/, "no newlines survive, so ':' can't start a directive" );
  610. is( Trog::Routes::HTML::_kolon_safe( [] ), '', "a repeated param can't sneak a ref through" );
  611. };
  612. done_testing();