.../modules/media/src/Plugin/Filter/MediaEmbed.php | 16 +- .../media_test_filter/media_test_filter.info.yml | 8 + .../media_test_filter/media_test_filter.module | 25 ++ .../tests/src/Kernel/MediaEmbedFilterTest.php | 358 +++++++++++++++++++++ .../tests/src/Kernel/MediaEmbedFilterTestBase.php | 249 ++++++++++++++ 5 files changed, 655 insertions(+), 1 deletion(-) diff --git a/core/modules/media/src/Plugin/Filter/MediaEmbed.php b/core/modules/media/src/Plugin/Filter/MediaEmbed.php index 40d359ab94..5d967a02da 100644 --- a/core/modules/media/src/Plugin/Filter/MediaEmbed.php +++ b/core/modules/media/src/Plugin/Filter/MediaEmbed.php @@ -139,7 +139,8 @@ protected function renderMissingMedia() { 'src' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')), 'width' => 180, 'height' => 180, - 'alt' => $this->t('Deleted content encountered, site owner alerted.'), + 'alt' => $this->t('Missing media.'), + 'title' => $this->t('Missing media.'), ], ]; } @@ -158,6 +159,11 @@ public function process($text, $langcode) { $uuid = $node->getAttribute('data-entity-uuid'); $view_mode_id = $node->getAttribute('data-view-mode'); + // Delete the attributes this filter reacts to. + $node->removeAttribute('data-entity-type'); + $node->removeAttribute('data-entity-uuid'); + $node->removeAttribute('data-view-mode'); + $media = $this->entityRepository->loadEntityByUuid('media', $uuid); assert($media === NULL || $media instanceof MediaInterface); if (!$media) { @@ -175,6 +181,14 @@ public function process($text, $langcode) { ? $this->renderMedia($media, $view_mode_id, $langcode) : $this->renderMissingMedia(); + // Any attributes not consumed by the filter should be persisted onto + // the rendered embedded entity. For example, `data-align` and + // `data-caption` should persist, so that even when embedded media goes + // missing, at least the caption and visual structure won't get lost. + foreach ($node->attributes as $attribute) { + $build['#attributes'][$attribute->nodeName] = $attribute->nodeValue; + } + $this->renderIntoDomNode($build, $node, $result); } diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml new file mode 100644 index 0000000000..5c4c7838d6 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.info.yml @@ -0,0 +1,8 @@ +name: Media Filter test +description: 'Provides functionality to test the Media Embed filter.' +type: module +package: Testing +version: VERSION +core: 8.x +dependencies: + - drupal:media diff --git a/core/modules/media/tests/modules/media_test_filter/media_test_filter.module b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module new file mode 100644 index 0000000000..b68c58b5b5 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_filter/media_test_filter.module @@ -0,0 +1,25 @@ +addCacheTags(['_media_test_filter_access:' . $entity->getEntityTypeId() . ':' . $entity->id()]); +} + +/** + * Implements hook_entity_view_alter(). + */ +function media_test_filter_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) { + $build['#attributes']['data-media-embed-test-view-mode'] = $display->getMode(); +} diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php new file mode 100644 index 0000000000..3293ec9a6b --- /dev/null +++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTest.php @@ -0,0 +1,358 @@ +createEmbedCode($embed_attributes); + + $result = $this->applyFilter($content); + + $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="' . $expected_view_mode . '"]')); + $this->assertHasAttributes($this->cssSelect('div[data-media-embed-test-view-mode="' . $expected_view_mode . '"]')[0], $expected_attributes); + $this->assertSame($expected_cacheability->getCacheTags(), $result->getCacheTags()); + $this->assertSame($expected_cacheability->getCacheContexts(), $result->getCacheContexts()); + $this->assertSame($expected_cacheability->getCacheMaxAge(), $result->getCacheMaxAge()); + $this->assertSame(['library'], array_keys($result->getAttachments())); + $this->assertSame(['media/filter.caption'], $result->getAttachments()['library']); + } + + /** + * Data provider for testBasics(). + */ + public function providerTestBasics() { + $expected_cacheability_full = (new CacheableMetadata()) + ->setCacheTags([ + '_media_test_filter_access:media:1', + '_media_test_filter_access:user:2', + 'config:image.style.thumbnail', + 'file:1', + 'media:1', + 'media_view', + 'user:2', + ]) + ->setCacheContexts(['timezone', 'user.permissions']) + ->setCacheMaxAge(Cache::PERMANENT); + + return [ + 'data-entity-uuid + data-view-mode=full' => [ + [ + 'data-entity-type' => 'media', + 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID, + 'data-view-mode' => 'full', + ], + 'full', + [], + $expected_cacheability_full, + ], + 'data-entity-uuid + data-view-mode=foobar' => [ + [ + 'data-entity-type' => 'media', + 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID, + 'data-view-mode' => 'foobar', + ], + 'foobar', + [], + (new CacheableMetadata()) + ->setCacheTags([ + '_media_test_filter_access:media:1', + 'config:image.style.medium', + 'file:1', + 'media:1', + 'media_view', + ]) + ->setCacheContexts(['url.site', 'user.permissions']) + ->setCacheMaxAge(Cache::PERMANENT), + ], + 'custom attributes are retained' => [ + [ + 'data-foo' => 'bar', + 'foo' => 'bar', + 'data-entity-type' => 'media', + 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID, + 'data-view-mode' => 'full', + ], + 'full', + [ + 'data-foo' => 'bar', + 'foo' => 'bar', + ], + $expected_cacheability_full, + ], + ]; + } + + /** + * Tests that entity access is respected by embedding an unpublished entity. + * + * @dataProvider providerAccessUnpublished + */ + public function testAccessUnpublished($allowed_to_view_unpublished, $expected_rendered, CacheableMetadata $expected_cacheability, array $expected_attachments) { + // Unpublish the embedded entity so we can test variations in behavior. + $this->embeddedEntity->setUnpublished()->save(); + + // Are we testing as a user who is allowed to view the embedded entity? + if ($allowed_to_view_unpublished) { + $this->container->get('current_user') + ->addRole($this->drupalCreateRole(['view own unpublished media'])); + } + + $content = $this->createEmbedCode([ + 'data-entity-type' => 'media', + 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID, + 'data-view-mode' => 'full', + ]); + $result = $this->applyFilter($content); + + if (!$expected_rendered) { + $this->assertEmpty($this->getRawContent()); + } + else { + $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="full"]')); + } + + // Expected bubbleable metadata. + $this->assertSame($expected_cacheability->getCacheTags(), $result->getCacheTags()); + $this->assertSame($expected_cacheability->getCacheContexts(), $result->getCacheContexts()); + $this->assertSame($expected_cacheability->getCacheMaxAge(), $result->getCacheMaxAge()); + $this->assertSame($expected_attachments, $result->getAttachments()); + } + + /** + * Data provider for testAccessUnpublished(). + */ + public function providerAccessUnpublished() { + return [ + 'user cannot access embedded media media' => [ + FALSE, + FALSE, + (new CacheableMetadata()) + ->setCacheTags([ + '_media_test_filter_access:media:1', + 'media:1', + 'media_view', + ]) + ->setCacheContexts(['user.permissions']) + ->setCacheMaxAge(Cache::PERMANENT), + [], + ], + 'user can access embedded media' => [ + TRUE, + TRUE, + (new CacheableMetadata()) + ->setCacheTags([ + '_media_test_filter_access:media:1', + '_media_test_filter_access:user:2', + 'config:image.style.thumbnail', + 'file:1', + 'media:1', + 'media_view', + 'user:2', + ]) + ->setCacheContexts(['timezone', 'user', 'user.permissions']) + ->setCacheMaxAge(Cache::PERMANENT), + ['library' => ['media/filter.caption']], + ], + ]; + } + + /** + * Tests the indicator for missing entities. + * + * @dataProvider providerMissingEntityIndicator + */ + public function testMissingEntityIndicator($uuid) { + $content = $this->createEmbedCode([ + 'data-entity-type' => 'media', + 'data-entity-uuid' => $uuid, + 'data-view-mode' => 'foobar', + ]); + + // If the UUID being used in the embed is that of the sample entity, first + // assert that it currently results in a functional embed, then delete it. + if ($uuid === static::EMBEDDED_ENTITY_UUID) { + $this->applyFilter($content); + $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]')); + $this->embeddedEntity->delete(); + } + + $this->applyFilter($content); + $this->assertCount(0, $this->cssSelect('div[data-media-embed-test-view-mode="foobar"]')); + $deleted_embed_warning = $this->cssSelect('img')[0]; + $this->assertNotEmpty($deleted_embed_warning); + $this->assertHasAttributes($deleted_embed_warning, [ + 'alt' => 'Missing media.', + 'src' => file_url_transform_relative(file_create_url('core/modules/media/images/icons/no-thumbnail.png')), + 'title' => 'Missing media.', + ]); + } + + /** + * Data provider for testMissingEntityIndicator(). + */ + public function providerMissingEntityIndicator() { + return [ + 'valid UUID but for a deleted entity' => [ + static::EMBEDDED_ENTITY_UUID, + ], + 'node; invalid UUID' => [ + 'invalidUUID', + ], + ]; + } + + /** + * Tests that only tags are processed. + */ + public function testOnlyDrupalMediaTagProcessed() { + $content = $this->createEmbedCode([ + 'data-entity-type' => 'media', + 'data-entity-uuid' => $this->embeddedEntity->uuid(), + 'data-view-mode' => 'full', + ]); + $content = str_replace('drupal-media', 'drupal-entity', $content); + + $filter_result = $this->processText($content, 'en', ['media_embed']); + // If input equals output, the filter didn't change anything. + $this->assertSame($content, $filter_result->getProcessedText()); + } + + /** + * @covers \Drupal\filter\Plugin\Filter\FilterAlign + * @covers \Drupal\filter\Plugin\Filter\FilterCaption + * @dataProvider providerFilterIntegration + */ + public function testFilterIntegration(array $filter_ids, array $additional_attributes, $verification_selector, $expected_verification_success, array $expected_asset_libraries, $prefix = '', $suffix = '') { + $content = $this->createEmbedCode([ + 'data-entity-type' => 'media', + 'data-entity-uuid' => static::EMBEDDED_ENTITY_UUID, + 'data-view-mode' => 'full', + ] + $additional_attributes); + $content = $prefix . $content . $suffix; + + $result = $this->processText($content, 'en', $filter_ids); + $this->setRawContent($result->getProcessedText()); + $this->assertCount($expected_verification_success ? 1 : 0, $this->cssSelect($verification_selector)); + $this->assertCount(1, $this->cssSelect('div[data-media-embed-test-view-mode="full"]')); + $this->assertSame([ + '_media_test_filter_access:media:1', + '_media_test_filter_access:user:2', + 'config:image.style.thumbnail', + 'file:1', + 'media:1', + 'media_view', + 'user:2', + ], $result->getCacheTags()); + $this->assertSame(['timezone', 'user.permissions'], $result->getCacheContexts()); + $this->assertSame(Cache::PERMANENT, $result->getCacheMaxAge()); + $this->assertSame(['library'], array_keys($result->getAttachments())); + $this->assertSame($expected_asset_libraries, $result->getAttachments()['library']); + } + + /** + * Data provider for testFilterIntegration(). + */ + public function providerFilterIntegration() { + $default_asset_libraries = ['media/filter.caption']; + + $caption_additional_attributes = ['data-caption' => 'Yo.']; + $caption_verification_selector = 'figure > figcaption'; + $caption_test_cases = [ + '`data-caption`; only `media_embed` ⇒ caption absent' => [ + ['media_embed'], + $caption_additional_attributes, + $caption_verification_selector, + FALSE, + $default_asset_libraries, + ], + '`data-caption`; `filter_caption` + `media_embed` ⇒ caption present' => [ + ['filter_caption', 'media_embed'], + $caption_additional_attributes, + $caption_verification_selector, + TRUE, + ['filter/caption', 'media/filter.caption'], + ], + '`` + `data-caption`; `filter_caption` + `media_embed` ⇒ caption present, link preserved' => [ + ['filter_caption', 'media_embed'], + $caption_additional_attributes, + 'figure > a[href="https://www.drupal.org"] + figcaption', + TRUE, + ['filter/caption', 'media/filter.caption'], + '', + '', + ], + ]; + + $align_additional_attributes = ['data-align' => 'center']; + $align_verification_selector = 'div[data-media-embed-test-view-mode].align-center'; + $align_test_cases = [ + '`data-align`; `media_embed` ⇒ alignment absent' => [ + ['media_embed'], + $align_additional_attributes, + $align_verification_selector, + FALSE, + $default_asset_libraries, + ], + '`data-align`; `filter_align` + `media_embed` ⇒ alignment present' => [ + ['filter_align', 'media_embed'], + $align_additional_attributes, + $align_verification_selector, + TRUE, + $default_asset_libraries, + ], + '`` + `data-align`; `filter_align` + `media_embed` ⇒ alignment present, link preserved' => [ + ['filter_align', 'media_embed'], + $align_additional_attributes, + 'a[href="https://www.drupal.org"] > div[data-media-embed-test-view-mode].align-center', + TRUE, + $default_asset_libraries, + '', + '', + ], + ]; + + $caption_and_align_test_cases = [ + '`data-caption` + `data-align`; `filter_align` + `filter_caption` + `media_embed` ⇒ aligned caption present' => [ + ['filter_align', 'filter_caption', 'media_embed'], + $align_additional_attributes + $caption_additional_attributes, + 'figure.align-center > figcaption', + TRUE, + ['filter/caption', 'media/filter.caption'], + ], + '`` + `data-caption` + `data-align`; `filter_align` + `filter_caption` + `media_embed` ⇒ aligned caption present, link preserved' => [ + ['filter_align', 'filter_caption', 'media_embed'], + $align_additional_attributes + $caption_additional_attributes, + 'figure.align-center > a[href="https://www.drupal.org"] + figcaption', + TRUE, + ['filter/caption', 'media/filter.caption'], + '', + '', + ], + ]; + + return $caption_test_cases + $align_test_cases + $caption_and_align_test_cases; + } + +} diff --git a/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php new file mode 100644 index 0000000000..79ceb238d8 --- /dev/null +++ b/core/modules/media/tests/src/Kernel/MediaEmbedFilterTestBase.php @@ -0,0 +1,249 @@ +installSchema('file', ['file_usage']); + $this->installSchema('system', 'sequences'); + $this->installEntitySchema('file'); + $this->installEntitySchema('media'); + $this->installEntitySchema('user'); + $this->installConfig('filter'); + $this->installConfig('image'); + $this->installConfig('media'); + $this->installConfig('system'); + + // Create a user with required permissions. Ensure that we don't use user 1 + // because that user is treated in special ways by access control handlers. + $admin_user = $this->drupalCreateUser([]); + $user = $this->drupalCreateUser([ + 'access content', + 'view media', + ]); + $this->container->set('current_user', $user); + + $this->image = File::create([ + 'uri' => $this->getTestFiles('image')[0]->uri, + 'uid' => 2, + ]); + $this->image->setPermanent(); + $this->image->save(); + + // Create a sample media entity to be embedded. + $media_type = $this->createMediaType('image', ['id' => 'image']); + EntityViewMode::create([ + 'id' => 'media.foobar', + 'targetEntityType' => 'media', + 'status' => TRUE, + 'enabled' => TRUE, + 'label' => $this->randomMachineName(), + ])->save(); + EntityViewDisplay::create([ + 'targetEntityType' => 'media', + 'bundle' => $media_type->id(), + 'mode' => 'foobar', + 'status' => TRUE, + ])->removeComponent('thumbnail') + ->removeComponent('created') + ->removeComponent('uid') + ->setComponent('field_media_image', [ + 'label' => 'visually_hidden', + 'type' => 'image', + 'settings' => [ + 'image_style' => 'medium', + 'image_link' => 'file', + ], + 'third_party_settings' => [], + 'weight' => 1, + 'region' => 'content', + ]) + ->save(); + $media = Media::create([ + 'uuid' => static::EMBEDDED_ENTITY_UUID, + 'bundle' => 'image', + 'name' => 'Screaming hairy armadillo', + 'field_media_image' => [ + [ + 'target_id' => $this->image->id(), + 'alt' => 'default alt', + 'title' => 'default title', + ], + ], + ])->setOwner($user); + $media->save(); + $this->embeddedEntity = $media; + } + + /** + * Gets an embed code with given attributes. + * + * @param array $attributes + * The attributes to add. + * + * @return string + * A string containing a drupal-entity dom element. + * + * @see assertEntityEmbedFilterHasRun() + */ + protected function createEmbedCode(array $attributes) { + $dom = Html::load('This placeholder should not be rendered.'); + $xpath = new \DOMXPath($dom); + $drupal_entity = $xpath->query('//drupal-media')[0]; + foreach ($attributes as $attribute => $value) { + $drupal_entity->setAttribute($attribute, $value); + } + return Html::serialize($dom); + } + + /** + * Applies the `@Filter=media_embed` filter to text, pipes to raw content. + * + * @param string $text + * The text string to be filtered. + * @param string $langcode + * The language code of the text to be filtered. + * + * @return \Drupal\filter\FilterProcessResult + * The filtered text, wrapped in a FilterProcessResult object, and possibly + * with associated assets, cacheability metadata and placeholders. + * + * @see \Drupal\Tests\entity_embed\Kernel\EntityEmbedFilterTestBase::createEmbedCode() + * @see \Drupal\KernelTests\AssertContentTrait::setRawContent() + */ + protected function applyFilter($text, $langcode = 'en') { + $this->assertContains('assertContains('This placeholder should not be rendered.', $text); + $filter_result = $this->processText($text, $langcode); + $output = $filter_result->getProcessedText(); + $this->assertNotContains('assertNotContains('This placeholder should not be rendered.', $output); + $this->setRawContent($output); + return $filter_result; + } + + /** + * Assert that the SimpleXMLElement object has the given attributes. + * + * @param \SimpleXMLElement $element + * The SimpleXMLElement object to check. + * @param array $attributes + * An array of attributes. + */ + protected function assertHasAttributes(\SimpleXMLElement $element, array $attributes) { + foreach ($attributes as $attribute => $value) { + $this->assertSame((string) $value, (string) $element[$attribute]); + } + } + + /** + * Processes text through the provided filters. + * + * @param string $text + * The text string to be filtered. + * @param string $langcode + * The language code of the text to be filtered. + * @param string[] $filter_ids + * (optional) The filter plugin IDs to apply to the given text, in the order + * they are being requested to be executed. + * + * @return \Drupal\filter\FilterProcessResult + * The filtered text, wrapped in a FilterProcessResult object, and possibly + * with associated assets, cacheability metadata and placeholders. + * + * @see \Drupal\filter\Element\ProcessedText::preRenderText() + */ + protected function processText($text, $langcode = 'und', array $filter_ids = ['media_embed']) { + $manager = $this->container->get('plugin.manager.filter'); + $bag = new FilterPluginCollection($manager, []); + $filters = []; + foreach ($filter_ids as $filter_id) { + $filters[] = $bag->get($filter_id); + } + + $render_context = new RenderContext(); + /** @var \Drupal\filter\FilterProcessResult $filter_result */ + $filter_result = $this->container->get('renderer')->executeInRenderContext($render_context, function () use ($text, $filters, $langcode) { + $metadata = new BubbleableMetadata(); + foreach ($filters as $filter) { + /** @var \Drupal\filter\FilterProcessResult $result */ + $result = $filter->process($text, $langcode); + $metadata = $metadata->merge($result); + $text = $result->getProcessedText(); + } + return (new FilterProcessResult($text))->merge($metadata); + }); + if (!$render_context->isEmpty()) { + $filter_result = $filter_result->merge($render_context->pop()); + } + return $filter_result; + } + +}