TwigExtensionTest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. <?php
  2. namespace Drupal\Tests\Core\Template;
  3. use Drupal\Core\GeneratedLink;
  4. use Drupal\Core\Render\RenderableInterface;
  5. use Drupal\Core\StringTranslation\TranslatableMarkup;
  6. use Drupal\Core\Template\Loader\StringLoader;
  7. use Drupal\Core\Template\TwigEnvironment;
  8. use Drupal\Core\Template\TwigExtension;
  9. use Drupal\Core\Url;
  10. use Drupal\Tests\UnitTestCase;
  11. /**
  12. * Tests the twig extension.
  13. *
  14. * @group Template
  15. *
  16. * @coversDefaultClass \Drupal\Core\Template\TwigExtension
  17. */
  18. class TwigExtensionTest extends UnitTestCase {
  19. /**
  20. * The renderer.
  21. *
  22. * @var \Drupal\Core\Render\RendererInterface|\PHPUnit\Framework\MockObject\MockObject
  23. */
  24. protected $renderer;
  25. /**
  26. * The url generator.
  27. *
  28. * @var \Drupal\Core\Routing\UrlGeneratorInterface|\PHPUnit\Framework\MockObject\MockObject
  29. */
  30. protected $urlGenerator;
  31. /**
  32. * The theme manager.
  33. *
  34. * @var \Drupal\Core\Theme\ThemeManagerInterface|\PHPUnit\Framework\MockObject\MockObject
  35. */
  36. protected $themeManager;
  37. /**
  38. * The date formatter.
  39. *
  40. * @var \Drupal\Core\Datetime\DateFormatterInterface|\PHPUnit\Framework\MockObject\MockObject
  41. */
  42. protected $dateFormatter;
  43. /**
  44. * The system under test.
  45. *
  46. * @var \Drupal\Core\Template\TwigExtension
  47. */
  48. protected $systemUnderTest;
  49. /**
  50. * {@inheritdoc}
  51. */
  52. public function setUp() {
  53. parent::setUp();
  54. $this->renderer = $this->createMock('\Drupal\Core\Render\RendererInterface');
  55. $this->urlGenerator = $this->createMock('\Drupal\Core\Routing\UrlGeneratorInterface');
  56. $this->themeManager = $this->createMock('\Drupal\Core\Theme\ThemeManagerInterface');
  57. $this->dateFormatter = $this->createMock('\Drupal\Core\Datetime\DateFormatterInterface');
  58. $this->systemUnderTest = new TwigExtension($this->renderer, $this->urlGenerator, $this->themeManager, $this->dateFormatter);
  59. }
  60. /**
  61. * Tests the escaping
  62. *
  63. * @dataProvider providerTestEscaping
  64. */
  65. public function testEscaping($template, $expected) {
  66. $loader = new \Twig_Loader_Filesystem();
  67. $twig = new \Twig_Environment($loader, [
  68. 'debug' => TRUE,
  69. 'cache' => FALSE,
  70. 'autoescape' => 'html',
  71. 'optimizations' => 0,
  72. ]);
  73. $twig->addExtension($this->systemUnderTest);
  74. $name = '__string_template_test__';
  75. $nodes = $twig->parse($twig->tokenize(new \Twig_Source($template, $name)));
  76. $this->assertSame($expected, $nodes->getNode('body')
  77. ->getNode(0)
  78. ->getNode('expr') instanceof \Twig_Node_Expression_Filter);
  79. }
  80. /**
  81. * Provides tests data for testEscaping
  82. *
  83. * @return array
  84. * An array of test data each containing of a twig template string and
  85. * a boolean expecting whether the path will be safe.
  86. */
  87. public function providerTestEscaping() {
  88. return [
  89. ['{{ path("foo") }}', FALSE],
  90. ['{{ path("foo", {}) }}', FALSE],
  91. ['{{ path("foo", { foo: "foo" }) }}', FALSE],
  92. ['{{ path("foo", foo) }}', TRUE],
  93. ['{{ path("foo", { foo: foo }) }}', TRUE],
  94. ['{{ path("foo", { foo: ["foo", "bar"] }) }}', TRUE],
  95. ['{{ path("foo", { foo: "foo", bar: "bar" }) }}', TRUE],
  96. ['{{ path(name = "foo", parameters = {}) }}', FALSE],
  97. ['{{ path(name = "foo", parameters = { foo: "foo" }) }}', FALSE],
  98. ['{{ path(name = "foo", parameters = foo) }}', TRUE],
  99. [
  100. '{{ path(name = "foo", parameters = { foo: ["foo", "bar"] }) }}',
  101. TRUE,
  102. ],
  103. ['{{ path(name = "foo", parameters = { foo: foo }) }}', TRUE],
  104. [
  105. '{{ path(name = "foo", parameters = { foo: "foo", bar: "bar" }) }}',
  106. TRUE,
  107. ],
  108. ];
  109. }
  110. /**
  111. * Tests the active_theme function.
  112. */
  113. public function testActiveTheme() {
  114. $active_theme = $this->getMockBuilder('\Drupal\Core\Theme\ActiveTheme')
  115. ->disableOriginalConstructor()
  116. ->getMock();
  117. $active_theme->expects($this->once())
  118. ->method('getName')
  119. ->willReturn('test_theme');
  120. $this->themeManager->expects($this->once())
  121. ->method('getActiveTheme')
  122. ->willReturn($active_theme);
  123. $loader = new StringLoader();
  124. $twig = new \Twig_Environment($loader);
  125. $twig->addExtension($this->systemUnderTest);
  126. $result = $twig->render('{{ active_theme() }}');
  127. $this->assertEquals('test_theme', $result);
  128. }
  129. /**
  130. * Tests the format_date filter.
  131. */
  132. public function testFormatDate() {
  133. $this->dateFormatter->expects($this->exactly(1))
  134. ->method('format')
  135. ->will($this->returnCallback(function ($timestamp) {
  136. return date('Y-m-d', $timestamp);
  137. }));
  138. $loader = new StringLoader();
  139. $twig = new \Twig_Environment($loader);
  140. $twig->addExtension($this->systemUnderTest);
  141. $timestamp = strtotime('1978-11-19');
  142. $result = $twig->render('{{ time|format_date("html_date") }}', ['time' => $timestamp]);
  143. $this->assertEquals('1978-11-19', $result);
  144. }
  145. /**
  146. * Tests the active_theme_path function.
  147. */
  148. public function testActiveThemePath() {
  149. $active_theme = $this->getMockBuilder('\Drupal\Core\Theme\ActiveTheme')
  150. ->disableOriginalConstructor()
  151. ->getMock();
  152. $active_theme
  153. ->expects($this->once())
  154. ->method('getPath')
  155. ->willReturn('foo/bar');
  156. $this->themeManager->expects($this->once())
  157. ->method('getActiveTheme')
  158. ->willReturn($active_theme);
  159. $loader = new StringLoader();
  160. $twig = new \Twig_Environment($loader);
  161. $twig->addExtension($this->systemUnderTest);
  162. $result = $twig->render('{{ active_theme_path() }}');
  163. $this->assertEquals('foo/bar', $result);
  164. }
  165. /**
  166. * Tests the escaping of objects implementing MarkupInterface.
  167. *
  168. * @covers ::escapeFilter
  169. */
  170. public function testSafeStringEscaping() {
  171. $loader = new \Twig_Loader_Filesystem();
  172. $twig = new \Twig_Environment($loader, [
  173. 'debug' => TRUE,
  174. 'cache' => FALSE,
  175. 'autoescape' => 'html',
  176. 'optimizations' => 0,
  177. ]);
  178. // By default, TwigExtension will attempt to cast objects to strings.
  179. // Ensure objects that implement MarkupInterface are unchanged.
  180. $safe_string = $this->createMock('\Drupal\Component\Render\MarkupInterface');
  181. $this->assertSame($safe_string, $this->systemUnderTest->escapeFilter($twig, $safe_string, 'html', 'UTF-8', TRUE));
  182. // Ensure objects that do not implement MarkupInterface are escaped.
  183. $string_object = new TwigExtensionTestString("<script>alert('here');</script>");
  184. $this->assertSame('&lt;script&gt;alert(&#039;here&#039;);&lt;/script&gt;', $this->systemUnderTest->escapeFilter($twig, $string_object, 'html', 'UTF-8', TRUE));
  185. }
  186. /**
  187. * @covers ::safeJoin
  188. */
  189. public function testSafeJoin() {
  190. $this->renderer->expects($this->any())
  191. ->method('render')
  192. ->with(['#markup' => '<strong>will be rendered</strong>', '#printed' => FALSE])
  193. ->willReturn('<strong>will be rendered</strong>');
  194. $twig_environment = $this->prophesize(TwigEnvironment::class)->reveal();
  195. // Simulate t().
  196. $markup = $this->prophesize(TranslatableMarkup::class);
  197. $markup->__toString()->willReturn('<em>will be markup</em>');
  198. $markup = $markup->reveal();
  199. $items = [
  200. '<em>will be escaped</em>',
  201. $markup,
  202. ['#markup' => '<strong>will be rendered</strong>'],
  203. ];
  204. $result = $this->systemUnderTest->safeJoin($twig_environment, $items, '<br/>');
  205. $this->assertEquals('&lt;em&gt;will be escaped&lt;/em&gt;<br/><em>will be markup</em><br/><strong>will be rendered</strong>', $result);
  206. // Ensure safe_join Twig filter supports Traversable variables.
  207. $items = new \ArrayObject([
  208. '<em>will be escaped</em>',
  209. $markup,
  210. ['#markup' => '<strong>will be rendered</strong>'],
  211. ]);
  212. $result = $this->systemUnderTest->safeJoin($twig_environment, $items, ', ');
  213. $this->assertEquals('&lt;em&gt;will be escaped&lt;/em&gt;, <em>will be markup</em>, <strong>will be rendered</strong>', $result);
  214. // Ensure safe_join Twig filter supports empty variables.
  215. $items = NULL;
  216. $result = $this->systemUnderTest->safeJoin($twig_environment, $items, '<br>');
  217. $this->assertEmpty($result);
  218. }
  219. /**
  220. * @dataProvider providerTestRenderVar
  221. */
  222. public function testRenderVar($result, $input) {
  223. $this->renderer->expects($this->any())
  224. ->method('render')
  225. ->with($result += ['#printed' => FALSE])
  226. ->willReturn('Rendered output');
  227. $this->assertEquals('Rendered output', $this->systemUnderTest->renderVar($input));
  228. }
  229. public function providerTestRenderVar() {
  230. $data = [];
  231. $renderable = $this->prophesize(RenderableInterface::class);
  232. $render_array = ['#type' => 'test', '#var' => 'giraffe'];
  233. $renderable->toRenderable()->willReturn($render_array);
  234. $data['renderable'] = [$render_array, $renderable->reveal()];
  235. return $data;
  236. }
  237. /**
  238. * @covers ::escapeFilter
  239. * @covers ::bubbleArgMetadata
  240. */
  241. public function testEscapeWithGeneratedLink() {
  242. $loader = new \Twig_Loader_Filesystem();
  243. $twig = new \Twig_Environment($loader, [
  244. 'debug' => TRUE,
  245. 'cache' => FALSE,
  246. 'autoescape' => 'html',
  247. 'optimizations' => 0,
  248. ]
  249. );
  250. $twig->addExtension($this->systemUnderTest);
  251. $link = new GeneratedLink();
  252. $link->setGeneratedLink('<a href="http://example.com"></a>');
  253. $link->addCacheTags(['foo']);
  254. $link->addAttachments(['library' => ['system/base']]);
  255. $this->renderer->expects($this->atLeastOnce())
  256. ->method('render')
  257. ->with([
  258. "#cache" => [
  259. "contexts" => [],
  260. "tags" => ["foo"],
  261. "max-age" => -1,
  262. ],
  263. "#attached" => ['library' => ['system/base']],
  264. ]);
  265. $result = $this->systemUnderTest->escapeFilter($twig, $link, 'html', NULL, TRUE);
  266. $this->assertEquals('<a href="http://example.com"></a>', $result);
  267. }
  268. /**
  269. * @covers ::renderVar
  270. * @covers ::bubbleArgMetadata
  271. */
  272. public function testRenderVarWithGeneratedLink() {
  273. $link = new GeneratedLink();
  274. $link->setGeneratedLink('<a href="http://example.com"></a>');
  275. $link->addCacheTags(['foo']);
  276. $link->addAttachments(['library' => ['system/base']]);
  277. $this->renderer->expects($this->atLeastOnce())
  278. ->method('render')
  279. ->with([
  280. "#cache" => [
  281. "contexts" => [],
  282. "tags" => ["foo"],
  283. "max-age" => -1,
  284. ],
  285. "#attached" => ['library' => ['system/base']],
  286. ]);
  287. $result = $this->systemUnderTest->renderVar($link);
  288. $this->assertEquals('<a href="http://example.com"></a>', $result);
  289. }
  290. /**
  291. * Tests creating attributes within a Twig template.
  292. *
  293. * @covers ::createAttribute
  294. */
  295. public function testCreateAttribute() {
  296. $name = '__string_template_test_1__';
  297. $loader = new \Twig_Loader_Array([$name => "{% for iteration in iterations %}<div{{ create_attribute(iteration) }}></div>{% endfor %}"]);
  298. $twig = new \Twig_Environment($loader);
  299. $twig->addExtension($this->systemUnderTest);
  300. $iterations = [
  301. ['class' => ['kittens'], 'data-toggle' => 'modal', 'data-lang' => 'es'],
  302. ['id' => 'puppies', 'data-value' => 'foo', 'data-lang' => 'en'],
  303. [],
  304. ];
  305. $result = $twig->render($name, ['iterations' => $iterations]);
  306. $expected = '<div class="kittens" data-toggle="modal" data-lang="es"></div><div id="puppies" data-value="foo" data-lang="en"></div><div></div>';
  307. $this->assertEquals($expected, $result);
  308. // Test default creation of empty attribute object and using its method.
  309. $name = '__string_template_test_2__';
  310. $loader = new \Twig_Loader_Array([$name => "<div{{ create_attribute().addClass('meow') }}></div>"]);
  311. $twig->setLoader($loader);
  312. $result = $twig->render($name);
  313. $expected = '<div class="meow"></div>';
  314. $this->assertEquals($expected, $result);
  315. }
  316. /**
  317. * @covers ::getLink
  318. */
  319. public function testLinkWithOverriddenAttributes() {
  320. $url = Url::fromRoute('<front>', [], ['attributes' => ['class' => ['foo']]]);
  321. $build = $this->systemUnderTest->getLink('test', $url, ['class' => ['bar']]);
  322. $this->assertEquals(['foo', 'bar'], $build['#url']->getOption('attributes')['class']);
  323. }
  324. }
  325. class TwigExtensionTestString {
  326. protected $string;
  327. public function __construct($string) {
  328. $this->string = $string;
  329. }
  330. public function __toString() {
  331. return $this->string;
  332. }
  333. }