From 447a60560194d472b584f3f3e1dc7c127eba4ee1 Mon Sep 17 00:00:00 2001 From: Jess Straatmann Date: Mon, 18 Sep 2023 11:08:53 -0500 Subject: [PATCH] WCMS-16115: Apply HTML filtering to only properties that are configured to allow HTML. (#4013) --- cypress/integration/09_admin_links.spec.js | 2 +- .../config/install/metastore.settings.yml | 1 + .../config/schema/metastore.schema.yml | 8 +- modules/metastore/metastore.links.menu.yml | 4 +- modules/metastore/metastore.post_update.php | 12 +++ modules/metastore/metastore.routing.yml | 2 +- modules/metastore/metastore.services.yml | 1 + .../src/Form/DkanDataSettingsForm.php | 26 ++++-- .../metastore/src/SchemaPropertiesHelper.php | 86 ++++++++++++++++++- modules/metastore/src/Storage/Data.php | 33 +++++-- modules/metastore/src/Storage/DataFactory.php | 13 ++- modules/metastore/src/Storage/NodeData.php | 5 +- .../src/Unit/MetastoreControllerTest.php | 22 ++++- .../src/Unit/SchemaPropertiesHelperTest.php | 65 +++++++++++++- .../tests/src/Unit/Storage/DataTest.php | 62 +++++++++++-- tests/src/Functional/DatasetBTBTest.php | 33 ++++++- 16 files changed, 342 insertions(+), 33 deletions(-) create mode 100644 modules/metastore/metastore.post_update.php diff --git a/cypress/integration/09_admin_links.spec.js b/cypress/integration/09_admin_links.spec.js index 5c1e5fb146..0341da81a6 100755 --- a/cypress/integration/09_admin_links.spec.js +++ b/cypress/integration/09_admin_links.spec.js @@ -9,7 +9,7 @@ context('Administration pages', () => { it('I should see a link for the dataset properties configuration', () => { cy.get('.toolbar-icon-system-admin-dkan').contains('DKAN').next('.toolbar-menu').then($el=>{ cy.wrap($el).invoke('show') - cy.wrap($el).contains('Metastore referencer') + cy.wrap($el).contains('Metastore configuration') }) cy.visit(baseurl + "/admin/dkan/properties") cy.get('.option').should('contain.text', 'Distribution (distribution)') diff --git a/modules/metastore/config/install/metastore.settings.yml b/modules/metastore/config/install/metastore.settings.yml index 214b8a869b..b88d28cb42 100644 --- a/modules/metastore/config/install/metastore.settings.yml +++ b/modules/metastore/config/install/metastore.settings.yml @@ -19,4 +19,5 @@ property_list: 'spatial': '0' 'temporal': '0' 'isPartOf': '0' +html_allowed_properties: [] resource_perspective_display: source diff --git a/modules/metastore/config/schema/metastore.schema.yml b/modules/metastore/config/schema/metastore.schema.yml index fbb22a1080..30f80aef44 100644 --- a/modules/metastore/config/schema/metastore.schema.yml +++ b/modules/metastore/config/schema/metastore.schema.yml @@ -14,6 +14,12 @@ metastore.settings: sequence: type: string label: 'Property' + html_allowed_properties: + type: sequence + label: 'HTML Allowed Properties' + sequence: + type: string + label: 'Property' resource_perspective_display: type: string - label: 'Resource download url display' \ No newline at end of file + label: 'Resource download url display' diff --git a/modules/metastore/metastore.links.menu.yml b/modules/metastore/metastore.links.menu.yml index 753cdaa252..79df4f37ef 100644 --- a/modules/metastore/metastore.links.menu.yml +++ b/modules/metastore/metastore.links.menu.yml @@ -1,7 +1,7 @@ dkan.metastore.config_properties: - title: Metastore referencer + title: Metastore configuration route_name: dkan.metastore.config_properties - description: Configure dataset properties for referencing an API endpoint. + description: Configure dataset properties for referencing sub-schemas and for HTML sanitization. parent: system.admin_dkan weight: 14 diff --git a/modules/metastore/metastore.post_update.php b/modules/metastore/metastore.post_update.php new file mode 100644 index 0000000000..664577c9f5 --- /dev/null +++ b/modules/metastore/metastore.post_update.php @@ -0,0 +1,12 @@ +config('metastore.settings'); - $options = $this->schemaHelper->retrieveSchemaProperties('dataset'); - $default_values = $config->get('property_list'); + $form['description'] = [ '#markup' => $this->t( - 'Select properties from the dataset schema to be available as individual objects. - Each property will be assigned a unique identifier in addition to its original schema value.' + 'Configure the metastore settings.' ), ]; + + $form['html_allowed_properties'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Properties that allow HTML'), + '#description' => $this->t('Metadata properties that may contain HTML elements.'), + '#options' => $this->schemaHelper->retrieveStringSchemaProperties(), + '#default_value' => $config->get('html_allowed_properties') ?: + ['dataset_description', 'distribution_description'], + ]; + $form['property_list'] = [ '#type' => 'checkboxes', - '#title' => $this->t('Dataset properties'), - '#options' => $options, - '#default_value' => $default_values, + '#title' => $this->t('Dataset properties to be stored as separate entities; use caution'), + '#description' => $this->t('Select properties from the dataset schema to be available as individual objects. + Each property will be assigned a unique identifier in addition to its original schema value.'), + '#options' => $this->schemaHelper->retrieveSchemaProperties(), + '#default_value' => $config->get('property_list'), ]; + return parent::buildForm($form, $form_state); } @@ -107,6 +118,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { $this->config('metastore.settings') ->set('property_list', $form_state->getValue('property_list')) + ->set('html_allowed_properties', $form_state->getValue('html_allowed_properties')) ->save(); // Rebuild routes, without clearing all caches. diff --git a/modules/metastore/src/SchemaPropertiesHelper.php b/modules/metastore/src/SchemaPropertiesHelper.php index e1b19d76a3..0ddd326371 100644 --- a/modules/metastore/src/SchemaPropertiesHelper.php +++ b/modules/metastore/src/SchemaPropertiesHelper.php @@ -38,7 +38,7 @@ public function __construct(SchemaRetriever $schemaRetriever) { } /** - * Retrieve schema properties. + * Retrieve dataset schema properties. * * @return array * List of schema properties' title and description. @@ -62,4 +62,88 @@ public function retrieveSchemaProperties(): array { return $property_list; } + /** + * Retrieve all string schema properties. + * + * @return array + * List of schema properties' title and description. + */ + public function retrieveStringSchemaProperties(): array { + // Create a json object from our schema. + $schema = $this->schemaRetriever->retrieve('dataset'); + $schema_object = json_decode($schema); + + return $this->buildPropertyList($schema_object->properties); + } + + /** + * Build a list of JSON schema properties. + * + * @param object $input + * JSON Schema object we're parsing. + * @param string $parent + * Parent object. + * @param array $property_list + * Array we're building of schema properties. + * + * @return array + * List of schema properties' title and description. + * + * @see https://json-schema.org/understanding-json-schema/reference/object.html#properties + */ + private function buildPropertyList($input, string $parent = 'dataset', array &$property_list = []): array { + foreach ($input as $name => $property) { + $this->parseProperty($name, $property, $parent, $property_list); + } + return $property_list; + } + + /** + * Parse a single property from a JSON schema. + * + * @param string $name + * Property name. + * @param mixed $property + * JSON schema "property" object. + * @param string $parent + * The parent JSON Schema propety of the current property. + * @param array $property_list + * Array we're building of schema properties. + */ + private function parseProperty(string $name, $property, string $parent, array &$property_list) { + // Exclude properties starting with @ or that are not proper objects. + if (substr($name, 0, 1) == '@' || gettype($property) != 'object' || !isset($property->type)) { + return; + } + + // Strings can be added directly to the list. + if ($property->type == 'string') { + $title = isset($property->title) ? $property->title . ' (' . $name . ')' : ucfirst($name); + $property_list[$parent . '_' . $name] = ucfirst($parent) . ': ' . $title; + } + // Non-strings (arrays and objects) can be parsed for nested properties. + else { + $this->parseNestedProperties($name, $property, $property_list); + } + } + + /** + * Parse nested schema properties. + * + * @param string $name + * Property ID. + * @param object $property + * JSON Schema "property" object we're parsing. + * @param array $property_list + * Array we're building of schema properties. + */ + private function parseNestedProperties(string $name, $property, array &$property_list = []) { + if (isset($property->properties) && gettype($property->properties == 'object')) { + $property_list = $this->buildPropertyList($property->properties, $name, $property_list); + } + elseif (isset($property->items) && gettype($property->items) == 'object' && isset($property->items->properties)) { + $property_list = $this->buildPropertyList($property->items->properties, $name, $property_list); + } + } + } diff --git a/modules/metastore/src/Storage/Data.php b/modules/metastore/src/Storage/Data.php index 86697688e9..d2a2f81c37 100644 --- a/modules/metastore/src/Storage/Data.php +++ b/modules/metastore/src/Storage/Data.php @@ -3,6 +3,7 @@ namespace Drupal\metastore\Storage; use Drupal\common\LoggerTrait; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -84,13 +85,21 @@ abstract class Data implements MetastoreEntityStorageInterface { */ protected $schemaIdField; + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + /** * Constructor. */ - public function __construct(string $schemaId, EntityTypeManagerInterface $entityTypeManager) { + public function __construct(string $schemaId, EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $config_factory) { $this->entityTypeManager = $entityTypeManager; $this->entityStorage = $this->entityTypeManager->getStorage($this->entityType); $this->schemaId = $schemaId; + $this->configFactory = $config_factory; } /** @@ -303,7 +312,7 @@ public function remove(string $uuid) { public function store($data, string $uuid = NULL): string { $data = json_decode($data); - $data = $this->filterHtml($data); + $data = $this->filterHtml($data, $this->schemaId); $uuid = (!$uuid && isset($data->identifier)) ? $data->identifier : $uuid; @@ -365,7 +374,7 @@ private function updateExistingEntity(ContentEntityInterface $entity, $data): ?s private function createNewEntity(string $uuid, $data) { $title = ''; if ($this->schemaId === 'dataset') { - $title = isset($data->title) ? $data->title : $data->name; + $title = $data->title ?? $data->name; } else { $title = MetastoreService::metadataHash($data->data); @@ -393,20 +402,30 @@ private function createNewEntity(string $uuid, $data) { * * @param mixed $input * Unfiltered input. + * @param string $parent + * The parent schema of a given property. * * @return mixed * Filtered output. */ - private function filterHtml($input) { - // @todo find out if we still need it. + private function filterHtml($input, string $parent = 'dataset') { + $html_allowed = $this->configFactory->get('metastore.settings')->get('html_allowed_properties') + ?: ['dataset_description', 'distribution_description']; switch (gettype($input)) { case "string": return $this->htmlPurifier($input); case "array": case "object": - foreach ($input as &$value) { - $value = $this->filterHtml($value); + foreach ($input as $name => &$value) { + // Only apply filtering to properties that allow HTML. + if (in_array($parent . '_' . $name, $html_allowed)) { + $value = $this->filterHtml($value, $name); + } + // Nested properties; check using parent. + elseif ($name == 'data' && gettype($value) == 'object') { + $value = $this->filterHtml($value, $parent); + } } return $input; diff --git a/modules/metastore/src/Storage/DataFactory.php b/modules/metastore/src/Storage/DataFactory.php index 4841ae34d6..cd712adc3c 100644 --- a/modules/metastore/src/Storage/DataFactory.php +++ b/modules/metastore/src/Storage/DataFactory.php @@ -3,6 +3,7 @@ namespace Drupal\metastore\Storage; use Contracts\FactoryInterface; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManager; /** @@ -24,11 +25,19 @@ class DataFactory implements FactoryInterface { */ private $entityTypeManager; + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + /** * Constructor. */ - public function __construct(EntityTypeManager $entityTypeManager) { + public function __construct(EntityTypeManager $entityTypeManager, ConfigFactoryInterface $config_factory) { $this->entityTypeManager = $entityTypeManager; + $this->configFactory = $config_factory; } /** @@ -79,7 +88,7 @@ private function getEntityTypeBySchema(string $schema_id) : string { * Storage object. */ protected function createNodeInstance(string $identifier) { - return new NodeData($identifier, $this->entityTypeManager); + return new NodeData($identifier, $this->entityTypeManager, $this->configFactory); } /** diff --git a/modules/metastore/src/Storage/NodeData.php b/modules/metastore/src/Storage/NodeData.php index d223346bc1..72c8c49afc 100644 --- a/modules/metastore/src/Storage/NodeData.php +++ b/modules/metastore/src/Storage/NodeData.php @@ -2,6 +2,7 @@ namespace Drupal\metastore\Storage; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; /** @@ -12,14 +13,14 @@ class NodeData extends Data { /** * NodeData constructor. */ - public function __construct(string $schemaId, EntityTypeManagerInterface $entityTypeManager) { + public function __construct(string $schemaId, EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $config_factory) { $this->entityType = 'node'; $this->bundle = 'data'; $this->bundleKey = "type"; $this->labelKey = "title"; $this->schemaIdField = "field_data_type"; $this->metadataField = "field_json_metadata"; - parent::__construct($schemaId, $entityTypeManager); + parent::__construct($schemaId, $entityTypeManager, $config_factory); } /** diff --git a/modules/metastore/tests/src/Unit/MetastoreControllerTest.php b/modules/metastore/tests/src/Unit/MetastoreControllerTest.php index d294098165..21a0f1e1d2 100644 --- a/modules/metastore/tests/src/Unit/MetastoreControllerTest.php +++ b/modules/metastore/tests/src/Unit/MetastoreControllerTest.php @@ -3,6 +3,8 @@ namespace Drupal\Tests\metastore\Unit; use Drupal\Core\Cache\Context\CacheContextsManager; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ImmutableConfig; use Drupal\metastore\DatasetApiDocs; use Drupal\metastore\Exception\ExistingObjectException; use Drupal\metastore\Exception\MissingObjectException; @@ -39,6 +41,18 @@ class MetastoreControllerTest extends TestCase { */ protected $validMetadataFactory; + /** + * List html allowed schema properties properties. + * + * @var string[] + */ + public const HTML_ALLOWED_PROPERTIES = [ + 'dataset_description' => 'dataset_description', + 'distribution_description' => 'distribution_description', + 'dataset_title' => 0, + 'dataset_identifier' => 0, + ]; + protected function setUp(): void { parent::setUp(); $this->validMetadataFactory = MetastoreServiceTest::getValidMetadataFactory($this); @@ -111,7 +125,13 @@ public function testGet() { ->add(QueryInterface::class, 'condition', QueryInterface::class) ->add(QueryInterface::class, 'execute', NULL) ->getMock(); - $nodeDataMock = new NodeData($schema_id, $entityTypeManagerMock); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); + $nodeDataMock = new NodeData($schema_id, $entityTypeManagerMock, $configFactoryMock); $container = $this->getCommonMockChain() ->add(MetastoreService::class, 'getStorage', $nodeDataMock) ->getMock(); diff --git a/modules/metastore/tests/src/Unit/SchemaPropertiesHelperTest.php b/modules/metastore/tests/src/Unit/SchemaPropertiesHelperTest.php index 2da7485556..5853671346 100644 --- a/modules/metastore/tests/src/Unit/SchemaPropertiesHelperTest.php +++ b/modules/metastore/tests/src/Unit/SchemaPropertiesHelperTest.php @@ -14,7 +14,7 @@ class SchemaPropertiesHelperTest extends TestCase { /** - * Test. + * Test to retrieve dataset schema properties. */ public function test() { $schema = '{ @@ -43,4 +43,67 @@ public function test() { $this->assertEquals($expected, $schemaPropertiesHelper->retrieveSchemaProperties()); } + /** + * Test to retrieve string schema properties. + */ + public function testRetrieveStringSchemaProperties() { + $schema = '{ + "type":"object", + "properties":{ + "@type":{ + "type":"string", + "title":"Metadata Context" + }, + "title":{ + "type":"string", + "title":"Title" + }, + "test":{ + "type":"string" + }, + "theme": { + "type":"array", + "items": { + "type": "string", + "title": "Category" + } + }, + "contactPoint":{ + "type":"object", + "properties": { + "fn":{ + "type":"string", + "title":"Contact Name" + } + } + }, + "distribution": { + "type":"array", + "items": { + "type": "object", + "title": "Data File", + "properties": { + "title":{ + "type":"string", + "title":"Title" + } + } + } + } + } + }'; + $expected = [ + 'dataset_title' => 'Dataset: Title (title)', + 'dataset_test' => 'Dataset: Test', + 'contactPoint_fn' => 'ContactPoint: Contact Name (fn)', + 'distribution_title' => 'Distribution: Title (title)' + ]; + + $chain = (new Chain($this)) + ->add(Container::class, 'get', SchemaRetriever::class) + ->add(SchemaRetriever::class, 'retrieve', $schema); + + $schemaPropertiesHelper = SchemaPropertiesHelper::create($chain->getMock()); + $this->assertEquals($expected, $schemaPropertiesHelper->retrieveStringSchemaProperties()); + } } diff --git a/modules/metastore/tests/src/Unit/Storage/DataTest.php b/modules/metastore/tests/src/Unit/Storage/DataTest.php index b37e8d2675..13d786a921 100644 --- a/modules/metastore/tests/src/Unit/Storage/DataTest.php +++ b/modules/metastore/tests/src/Unit/Storage/DataTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\metastore\Unit\Storage; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Config\ImmutableConfig; use Drupal\Core\Entity\EntityTypeManager; use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\Field\FieldItemListInterface; @@ -18,9 +20,27 @@ */ class DataTest extends TestCase { + /** + * List html allowed schema properties properties. + * + * @var string[] + */ + public const HTML_ALLOWED_PROPERTIES = [ + 'dataset_description' => 'dataset_description', + 'distribution_description' => 'distribution_description', + 'dataset_title' => 0, + 'dataset_identifier' => 0, + ]; + public function testGetStorageNode() { + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); - $data = new NodeData('dataset', $this->getEtmChain()->getMock()); + $data = new NodeData('dataset', $this->getEtmChain()->getMock(), $configFactoryMock); $this->assertInstanceOf(NodeStorage::class, $data->getEntityStorage()); } @@ -29,9 +49,15 @@ public function testPublishDatasetNotFound() { $etmMock = $this->getEtmChain() ->add(QueryInterface::class, 'execute', []) ->getMock(); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); $this->expectExceptionMessage('Error: 1 not found.'); - $nodeData = new NodeData('dataset', $etmMock); + $nodeData = new NodeData('dataset', $etmMock, $configFactoryMock); $nodeData->publish('1'); } @@ -43,8 +69,14 @@ public function testPublishDraftDataset() { ->add(Node::class, 'set') ->add(Node::class, 'save') ->getMock(); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); - $nodeData = new NodeData('dataset', $etmMock); + $nodeData = new NodeData('dataset', $etmMock, $configFactoryMock); $result = $nodeData->publish('1'); $this->assertEquals(TRUE, $result); } @@ -55,8 +87,14 @@ public function testPublishDatasetAlreadyPublished() { ->add(Node::class, 'get', FieldItemListInterface::class) ->add(FieldItemListInterface::class, 'getString', 'published') ->getMock(); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); - $nodeData = new NodeData('dataset', $etmMock); + $nodeData = new NodeData('dataset', $etmMock, $configFactoryMock); $result = $nodeData->publish('1'); $this->assertEquals(FALSE, $result); } @@ -86,9 +124,15 @@ public function testCount(): void { $etmMock = $this->getEtmChain() ->add(QueryInterface::class, 'execute', $count) ->getMock(); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); // Create Data object. - $nodeData = new NodeData('dataset', $etmMock); + $nodeData = new NodeData('dataset', $etmMock, $configFactoryMock); // Ensure count matches return value. $this->assertEquals($count, $nodeData->count()); } @@ -115,9 +159,15 @@ public function uuid() { $etmMock = $this->getEtmChain() ->add(NodeStorage::class, 'loadMultiple', $nodes) ->getMock(); + $immutableConfig = (new Chain($this)) + ->add(ImmutableConfig::class, 'get', self::HTML_ALLOWED_PROPERTIES) + ->getMock(); + $configFactoryMock = (new Chain($this)) + ->add(ConfigFactoryInterface::class, 'get', $immutableConfig) + ->getMock(); // Create Data object. - $nodeData = new NodeData('dataset', $etmMock); + $nodeData = new NodeData('dataset', $etmMock, $configFactoryMock); // Ensure the returned uuids match those belonging to the generated nodes. $this->assertEquals($uuids, $nodeData->retrieveIds(1, 5)); } diff --git a/tests/src/Functional/DatasetBTBTest.php b/tests/src/Functional/DatasetBTBTest.php index bb5b3e1db5..dd002b84d5 100644 --- a/tests/src/Functional/DatasetBTBTest.php +++ b/tests/src/Functional/DatasetBTBTest.php @@ -319,6 +319,37 @@ public function testDatastoreImportDeleteLocalResource() { $this->assertDirectoryExists('public://resources/' . $refUuid); } + /** + * Test sanitization of dataset properties. + */ + public function testSanitizeDatasetProperties() { + $config = $this->container->get('config.factory'); + $metastoreSettings = $config->getEditable('metastore.settings'); + + // Set HTML allowed on dataset description. + $metastoreSettings->set('html_allowed_properties', ['dataset_description'])->save(); + + // Title with HTML and an ampersand. + $datasetRootedJsonData = $this->getData(123, 'This & That Right click me', ['1.csv']); + + $uuid = $this->getMetastore()->post('dataset', $datasetRootedJsonData); + + $datasetRootedJsonData = $this->getMetastore()->get('dataset', $uuid); + $retrievedDataset = json_decode((string) $datasetRootedJsonData); + + $this->assertEquals( + $retrievedDataset->title, + 'This & That Right click me' + ); + $this->assertEquals( + $retrievedDataset->description, + 'This & that description. Right click me.' + ); + } + + /** + * Test sanitization of dataset properties. + */ private function datasetPostAndRetrieve(): object { $datasetRootedJsonData = $this->getData(123, 'Test #1', ['district_centerpoints_small.csv']); $dataset = json_decode($datasetRootedJsonData); @@ -391,7 +422,7 @@ private function getData(string $identifier, string $title, array $downloadUrls) $data = new \stdClass(); $data->title = $title; - $data->description = 'Some description.'; + $data->description = 'This & that description. Right click me.'; $data->identifier = $identifier; $data->accessLevel = 'public'; $data->modified = '06-04-2020';