.../rest/src/Tests/EntityResourceTestBase.php | 224 +++++++++++++++++++++ .../rest/src/Tests/NodeHalJsonBasicAuthTest.php | 139 +++++++++++++ .../rest/src/Tests/NodeJsonBasicAuthTest.php | 37 ++++ .../rest/src/Tests/NodeResourceTestBase.php | 138 +++++++++++++ .../rest/src/Tests/NodeXmlBasicAuthTest.php | 43 ++++ core/modules/rest/src/Tests/ResourceTestBase.php | 116 +++++++++++ 6 files changed, 697 insertions(+) diff --git a/core/modules/rest/src/Tests/EntityResourceTestBase.php b/core/modules/rest/src/Tests/EntityResourceTestBase.php new file mode 100644 index 0000000..77855dc --- /dev/null +++ b/core/modules/rest/src/Tests/EntityResourceTestBase.php @@ -0,0 +1,224 @@ +provisionResource('entity.' . static::$entityType, [static::$format], [static::$auth]); + } + + public function setUp() { + parent::setUp(); + + $this->serializer = $this->container->get('serializer'); + + // Set up a HTTP client that accepts relative URLs. + $this->httpClient = $this->container->get('http_client_factory') + ->fromOptions(['base_uri' => $this->baseUrl]); + + // Add field with specific allowed value. + // (allows testing invalid vs valid field value) + + // Add access-protected field to entity type. + // (allows testing with field that cannot be modified) + + // Create an entity. + $this->entity = $this->createEntity(); + } + + /** + * Creates the entity to be tested. + * + * @return \Drupal\Core\Entity\EntityInterface + * The entity to be tested. + */ + abstract protected function createEntity(); + + /** + * Returns the expected normalization of the entity. + * + * @see ::createEntity() + * + * @return array + */ + abstract protected function getExpectedNormalizedEntity(); + + public function testGet() { + // @todo test what happens before provisioning. Edge case: node. + + // Provision REST resource. + $this->provisionEntityResource(); + + /** @var \Psr\Http\Message\ResponseInterface $response */ + $url = $this->entity->toUrl(); + + // Requesting without the appropriate ?_format query argument. + $response = $this->httpClient->get($url->toString()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + + $url->setOption('query', ['_format' => static::$format]); + + // Verify that the entity exists: use a HEAD request. + $response = $this->httpClient->head($url->toString()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['MISS'], $response->getHeader('X-Drupal-Cache')); + $this->assertSame('', (string)$response->getBody()); + $head_headers = $response->getHeaders(); + + // Now do a GET request. And because of the preceding HEAD request, this + // should be a Page Cache hit. + $response = $this->httpClient->get($url->toString()); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); + $this->assertSame(['HIT'], $response->getHeader('X-Drupal-Cache')); + // Comparing the exact serialization is pointless, because the order of + // fields does not matter (at least not yet). That's why we only compare the + // normalized entity with the decoded response: it's comparing PHP arrays + // instead of strings. + $this->assertEquals($this->getExpectedNormalizedEntity(), $this->serializer->decode((string)$response->getBody(), static::$format)); + // Not only assert the normalization, also assert deserialization of the + // response results in the expected object. + $unserialized = $this->serializer->deserialize((string)$response->getBody(), get_class($this->entity), static::$format); + $this->assertSame($unserialized->uuid(), $this->entity->uuid()); + $get_headers = $response->getHeaders(); + + // Verify that the GET and HEAD responses are the same, that the only + // difference is that there's no body. + $ignored_headers = ['Date', 'Content-Length', 'X-Drupal-Cache', 'X-Drupal-Dynamic-Page-Cache']; + foreach ($ignored_headers as $ignored_header) { + unset($head_headers[$ignored_header]); + unset($get_headers[$ignored_header]); + } + $this->assertSame($get_headers, $head_headers); + + // Try to read the entity in an unsupported format. + try { + $url->setOption('query', ['_format' => 'non_existing_format']); + $this->httpClient->get($url->toString()); + $this->fail('No 406 response received.'); + } + catch (ClientException $e) { + $response = $e->getResponse(); + $this->assertSame(406, $response->getStatusCode()); + $this->assertSame(['text/html; charset=UTF-8'], $response->getHeader('Content-Type')); + } + + // Repeat the same request, but this time with the format's MIME type in the + // Accept header. + try { + $headers = ['Accept' => static::$mimeType]; + $url->setOption('query', ['_format' => 'non_existing_format']); + $this->httpClient->get($url->toString(), ['headers' => $headers]); + $this->fail('No 406 response received.'); + } + catch (ClientException $e) { + $response = $e->getResponse(); + $this->assertSame(406, $response->getStatusCode()); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + } + + // Try to read an entity that does not exist. + $non_existing_entity_url = Url::fromRoute('rest.entity.' . static::$entityType . '.GET.' . static::$format); + $non_existing_entity_url->setRouteParameter(static::$entityType, 987654321); + $non_existing_entity_url->setOption('query', ['_format' => static::$format]); + try { + $this->httpClient->get($non_existing_entity_url->toString()); + $this->fail('No 404 response received.'); + } + catch (ClientException $e) { + $response = $e->getResponse(); + $this->assertSame(404, $response->getStatusCode()); + $this->assertSame([static::$expectedErrorMimeType], $response->getHeader('Content-Type')); + $path = str_replace('987654321', '{' . static::$entityType . '}', $non_existing_entity_url->setOption('query', [])->toString()); + + $message = ['message' => 'The "' . static::$entityType . '" parameter was not converted for the path "' . $path . '" (route name: "rest.entity.' . static::$entityType . '.GET.' . static::$format . '")']; + // @todo Either add this to \Drupal\serialization\Encoder\JsonEncoder, or + // figure out how to let tests specify encoder options, and figure out + // whether they should apply to just error responses or to everything. + $encode_options = ['json_encode_options' => JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT]; + $this->assertSame($this->serializer->encode($message, static::$format, $encode_options), (string)$response->getBody()); + } + } + + // @todo try the following request bodies: + // 1. empty (only makes sense for DELETE and GET), assert 400 + // 2. not parseable, assert 400 + // 3. parseable but invalid (with various possible errors, possibly entity type specific ones?), assert 422 + // 4. parseable and valid, assert 201 + public function atestPost() { + // Attempt to use REST resource while not provisioned. + $this->performUnsafeOperation('POST'); + $this->assertSame(404, $this->getSession()->getStatusCode()); + $this->assertFalse(EntityType::loadMultiple(), 'No entity has been created in the database.'); + + // Provision REST resource. + $this->provisionEntityResource(); + + // Try as user without 'create' access for this entity type. + $this->performUnsafeOperation('POST'); + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('not allowed …'); + + // Try as user with sufficient permissions. + $this->performUnsafeOperation('POST'); + $this->assertSame(201, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('…'); + } + + /** + * Simulate common developer mistake when performing an unsafe operation: + * - forget to specify the X-CSRF-Token request header + * - specify in invalid X-CSRF-Token request header value + * + * In either case, the REST module must provide meaningful feedback for DX. + */ + protected function performUnsafeOperation($method) { + // Try without CSRF token + // …request + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('X-CSRF-Token request header is missing'); + // Try with invalid CSRF token + // …request + $this->assertSame(403, $this->getSession()->getStatusCode()); + $this->assertSession()->responseContains('X-CSRF-Token request header is invalid'); + // Try with valid CSRF token + // …request + } + +} diff --git a/core/modules/rest/src/Tests/NodeHalJsonBasicAuthTest.php b/core/modules/rest/src/Tests/NodeHalJsonBasicAuthTest.php new file mode 100644 index 0000000..3ea5d25 --- /dev/null +++ b/core/modules/rest/src/Tests/NodeHalJsonBasicAuthTest.php @@ -0,0 +1,139 @@ +entity->getTranslatableFields(), function (FieldItemListInterface $field) { + return !$field instanceof EntityReferenceFieldItemListInterface; + })); + foreach ($translatable_non_reference_fields as $field_name) { + $normalization[$field_name][0]['lang'] = 'en'; + } + + // In the HAL normalization, reference fields are omitted, except for the + // bundle field. + $bundle_key = $this->entity->getEntityType()->getKey('bundle'); + $reference_fields = array_keys(array_filter($this->entity->getFields(), function (FieldItemListInterface $field) use ($bundle_key) { + if ($field->getName() === $bundle_key) { + return FALSE; + } + return $field instanceof EntityReferenceFieldItemListInterface; + })); + foreach ($reference_fields as $field_name) { + unset($normalization[$field_name]); + } + + // In the HAL normalization, the bundle field omits the 'target_type' and + // 'target_uuid' properties, because it's encoded in the '_links' section. + if ($bundle_key) { + unset($normalization[$bundle_key][0]['target_type']); + unset($normalization[$bundle_key][0]['target_uuid']); + } + + // In the HAL normalization, empty fields are omitted. + foreach ($normalization as $field_name => $data) { + if (empty($normalization[$field_name])) { + unset($normalization[$field_name]); + } + } + + $author = User::load(0); + return $normalization + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/node/1?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/node/camelids', + ], + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + ], + ], + '_embedded' => [ + $this->baseUrl . '/rest/relation/node/camelids/uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'en', + ], + ], + $this->baseUrl . '/rest/relation/node/camelids/revision_uid' => [ + [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/0?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + ], + ], + ], + ]; + } + // methods to provide one-off expectations/data/… +} diff --git a/core/modules/rest/src/Tests/NodeJsonBasicAuthTest.php b/core/modules/rest/src/Tests/NodeJsonBasicAuthTest.php new file mode 100644 index 0000000..c5607ee --- /dev/null +++ b/core/modules/rest/src/Tests/NodeJsonBasicAuthTest.php @@ -0,0 +1,37 @@ + 'Camelids', + 'type' => 'camelids', + ])->save(); + + // Create a "Llama" node. + $node = Node::create(['type' => 'camelids']); + $node->setTitle('Llama') + ->setPublished(TRUE) + ->setCreatedTime(123456789) + ->setChangedTime(123456789) + ->setRevisionCreationTime(123456789) + ->save(); + + return $node; + } + + protected function getExpectedNormalizedEntity() { + assert('$this->entity instanceof \Drupal\Core\Entity\EntityInterface', 'Entity must already have been created.'); + $author = User::load(0); + return [ + 'nid' => [ + ['value' => 1], + ], + 'uuid' => [ + ['value' => $this->entity->uuid()], + ], + 'vid' => [ + ['value' => 1], + ], + 'langcode' => [ + [ + 'value' => 'en', + ], + ], + 'type' => [ + [ + 'target_id' => 'camelids', + 'target_type' => 'node_type', + 'target_uuid' => NodeType::load('camelids')->uuid(), + ], + ], + 'title' => [ + [ + 'value' => 'Llama', + ], + ], + 'status' => [ + [ + 'value' => 1, + ], + ], + 'created' => [ + [ + 'value' => '123456789', + ], + ], + 'changed' => [ + [ + 'value' => '123456789', + ], + ], + 'promote' => [ + [ + 'value' => 1, + ], + ], + 'sticky' => [ + [ + 'value' => '0', + ], + ], + 'revision_timestamp' => [ + [ + 'value' => '123456789', + ], + ], + 'revision_translation_affected' => [ + [ + 'value' => TRUE, + ], + ], + 'default_langcode' => [ + [ + 'value' => TRUE, + ], + ], + 'uid' => [ + [ + 'target_id' => '0', + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => '/user/0', + ], + ], + 'revision_uid' => [ + [ + 'target_id' => '0', + 'target_type' => 'user', + 'target_uuid' => $author->uuid(), + 'url' => $this->baseUrl . '/user/0', + ], + ], + 'revision_log' => [ + ], + ]; + } + + // methods to provide one-off expectations/data/… +} diff --git a/core/modules/rest/src/Tests/NodeXmlBasicAuthTest.php b/core/modules/rest/src/Tests/NodeXmlBasicAuthTest.php new file mode 100644 index 0000000..5e2df00 --- /dev/null +++ b/core/modules/rest/src/Tests/NodeXmlBasicAuthTest.php @@ -0,0 +1,43 @@ +resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config'); + + // Ensure there's a clean slate: delete all REST resource config entities. + $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple()); + } + + /** + * Provisions a REST resource. + * + * @param string $resource_type + * The resource type (REST resource plugin ID). + * @param string[] $formats + * The allowed formats for this resource. + * @param string[] $authentication + * The allowed authentication providers for this resource. + */ + protected function provisionResource($resource_type, $formats = [], $authentication = []) { + $this->resourceConfigStorage->create([ + 'id' => $resource_type, + 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, + 'configuration' => [ + 'methods' => ['GET', 'POST', 'PATCH', 'DELETE'], + 'formats' => $formats, + 'authentication' => $authentication, + ] + ])->save(); + + $this->container->get('router.builder')->rebuild(); + } + +}