Chris@0: serializer = $this->container->get('serializer'); Chris@0: Chris@0: // Ensure the anonymous user role has no permissions at all. Chris@0: $user_role = Role::load(RoleInterface::ANONYMOUS_ID); Chris@0: foreach ($user_role->getPermissions() as $permission) { Chris@0: $user_role->revokePermission($permission); Chris@0: } Chris@0: $user_role->save(); Chris@14: assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.'); Chris@0: Chris@0: if (static::$auth !== FALSE) { Chris@0: // Ensure the authenticated user role has no permissions at all. Chris@0: $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); Chris@0: foreach ($user_role->getPermissions() as $permission) { Chris@0: $user_role->revokePermission($permission); Chris@0: } Chris@0: $user_role->save(); Chris@14: assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.'); Chris@0: Chris@0: // Create an account. Chris@0: $this->account = $this->createUser(); Chris@0: } Chris@0: else { Chris@0: // Otherwise, also create an account, so that any test involving User Chris@0: // entities will have the same user IDs regardless of authentication. Chris@0: $this->createUser(); Chris@0: } Chris@0: Chris@0: $this->resourceConfigStorage = $this->container->get('entity_type.manager')->getStorage('rest_resource_config'); Chris@0: Chris@0: // Ensure there's a clean slate: delete all REST resource config entities. Chris@0: $this->resourceConfigStorage->delete($this->resourceConfigStorage->loadMultiple()); Chris@0: $this->refreshTestStateAfterRestConfigChange(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Provisions the REST resource under test. Chris@0: * Chris@0: * @param string[] $formats Chris@0: * The allowed formats for this resource. Chris@0: * @param string[] $authentication Chris@0: * The allowed authentication providers for this resource. Chris@0: */ Chris@17: protected function provisionResource($formats = [], $authentication = [], array $methods = ['GET', 'POST', 'PATCH', 'DELETE']) { Chris@0: $this->resourceConfigStorage->create([ Chris@0: 'id' => static::$resourceConfigId, Chris@0: 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, Chris@0: 'configuration' => [ Chris@17: 'methods' => $methods, Chris@0: 'formats' => $formats, Chris@0: 'authentication' => $authentication, Chris@0: ], Chris@0: 'status' => TRUE, Chris@0: ])->save(); Chris@0: $this->refreshTestStateAfterRestConfigChange(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Refreshes the state of the tester to be in sync with the testee. Chris@0: * Chris@0: * Should be called after every change made to: Chris@0: * - RestResourceConfig entities Chris@0: * - the 'rest.settings' simple configuration Chris@0: */ Chris@0: protected function refreshTestStateAfterRestConfigChange() { Chris@0: // Ensure that the cache tags invalidator has its internal values reset. Chris@0: // Otherwise the http_response cache tag invalidation won't work. Chris@0: $this->refreshVariables(); Chris@0: Chris@0: // Tests using this base class may trigger route rebuilds due to changes to Chris@0: // RestResourceConfig entities or 'rest.settings'. Ensure the test generates Chris@0: // routes using an up-to-date router. Chris@0: \Drupal::service('router.builder')->rebuildIfNeeded(); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Return the expected error message. Chris@0: * Chris@0: * @param string $method Chris@0: * The HTTP method (GET, POST, PATCH, DELETE). Chris@0: * Chris@0: * @return string Chris@0: * The error string. Chris@0: */ Chris@0: protected function getExpectedUnauthorizedAccessMessage($method) { Chris@0: $resource_plugin_id = str_replace('.', ':', static::$resourceConfigId); Chris@0: $permission = 'restful ' . strtolower($method) . ' ' . $resource_plugin_id; Chris@0: return "The '$permission' permission is required."; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Sets up the necessary authorization. Chris@0: * Chris@0: * In case of a test verifying publicly accessible REST resources: grant Chris@0: * permissions to the anonymous user role. Chris@0: * Chris@0: * In case of a test verifying behavior when using a particular authentication Chris@0: * provider: create a user with a particular set of permissions. Chris@0: * Chris@0: * Because of the $method parameter, it's possible to first set up Chris@0: * authentication for only GET, then add POST, et cetera. This then also Chris@0: * allows for verifying a 403 in case of missing authorization. Chris@0: * Chris@0: * @param string $method Chris@0: * The HTTP method for which to set up authentication. Chris@0: * Chris@0: * @see ::grantPermissionsToAnonymousRole() Chris@0: * @see ::grantPermissionsToAuthenticatedRole() Chris@0: */ Chris@0: abstract protected function setUpAuthorization($method); Chris@0: Chris@0: /** Chris@0: * Verifies the error response in case of missing authentication. Chris@14: * Chris@14: * @param string $method Chris@14: * HTTP method. Chris@14: * @param \Psr\Http\Message\ResponseInterface $response Chris@14: * The response to assert. Chris@0: */ Chris@14: abstract protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response); Chris@0: Chris@0: /** Chris@0: * Asserts normalization-specific edge cases. Chris@0: * Chris@0: * (Should be called before sending a well-formed request.) Chris@0: * Chris@0: * @see \GuzzleHttp\ClientInterface::request() Chris@0: * Chris@0: * @param string $method Chris@0: * HTTP method. Chris@0: * @param \Drupal\Core\Url $url Chris@0: * URL to request. Chris@0: * @param array $request_options Chris@0: * Request options to apply. Chris@0: */ Chris@0: abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options); Chris@0: Chris@0: /** Chris@0: * Asserts authentication provider-specific edge cases. Chris@0: * Chris@0: * (Should be called before sending a well-formed request.) Chris@0: * Chris@0: * @see \GuzzleHttp\ClientInterface::request() Chris@0: * Chris@0: * @param string $method Chris@0: * HTTP method. Chris@0: * @param \Drupal\Core\Url $url Chris@0: * URL to request. Chris@0: * @param array $request_options Chris@0: * Request options to apply. Chris@0: */ Chris@0: abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options); Chris@0: Chris@0: /** Chris@14: * Returns the expected cacheability of an unauthorized access response. Chris@14: * Chris@14: * @return \Drupal\Core\Cache\RefinableCacheableDependencyInterface Chris@14: * The expected cacheability. Chris@14: */ Chris@14: abstract protected function getExpectedUnauthorizedAccessCacheability(); Chris@14: Chris@14: /** Chris@0: * Initializes authentication. Chris@0: * Chris@0: * E.g. for cookie authentication, we first need to get a cookie. Chris@0: */ Chris@0: protected function initAuthentication() {} Chris@0: Chris@0: /** Chris@0: * Returns Guzzle request options for authentication. Chris@0: * Chris@0: * @param string $method Chris@0: * The HTTP method for this authenticated request. Chris@0: * Chris@0: * @return array Chris@0: * Guzzle request options to use for authentication. Chris@0: * Chris@0: * @see \GuzzleHttp\ClientInterface::request() Chris@0: */ Chris@0: protected function getAuthenticationRequestOptions($method) { Chris@0: return []; Chris@0: } Chris@0: Chris@0: /** Chris@0: * Grants permissions to the anonymous role. Chris@0: * Chris@0: * @param string[] $permissions Chris@0: * Permissions to grant. Chris@0: */ Chris@0: protected function grantPermissionsToAnonymousRole(array $permissions) { Chris@0: $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Grants permissions to the authenticated role. Chris@0: * Chris@0: * @param string[] $permissions Chris@0: * Permissions to grant. Chris@0: */ Chris@0: protected function grantPermissionsToAuthenticatedRole(array $permissions) { Chris@0: $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Grants permissions to the tested role: anonymous or authenticated. Chris@0: * Chris@0: * @param string[] $permissions Chris@0: * Permissions to grant. Chris@0: * Chris@0: * @see ::grantPermissionsToAuthenticatedRole() Chris@0: * @see ::grantPermissionsToAnonymousRole() Chris@0: */ Chris@0: protected function grantPermissionsToTestedRole(array $permissions) { Chris@0: if (static::$auth) { Chris@0: $this->grantPermissionsToAuthenticatedRole($permissions); Chris@0: } Chris@0: else { Chris@0: $this->grantPermissionsToAnonymousRole($permissions); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Performs a HTTP request. Wraps the Guzzle HTTP client. Chris@0: * Chris@0: * Why wrap the Guzzle HTTP client? Because we want to keep the actual test Chris@0: * code as simple as possible, and hence not require them to specify the Chris@0: * 'http_errors = FALSE' request option, nor do we want them to have to Chris@0: * convert Drupal Url objects to strings. Chris@0: * Chris@0: * We also don't want to follow redirects automatically, to ensure these tests Chris@0: * are able to detect when redirects are added or removed. Chris@0: * Chris@0: * @see \GuzzleHttp\ClientInterface::request() Chris@0: * Chris@0: * @param string $method Chris@0: * HTTP method. Chris@0: * @param \Drupal\Core\Url $url Chris@0: * URL to request. Chris@0: * @param array $request_options Chris@0: * Request options to apply. Chris@0: * Chris@0: * @return \Psr\Http\Message\ResponseInterface Chris@0: */ Chris@0: protected function request($method, Url $url, array $request_options) { Chris@0: $request_options[RequestOptions::HTTP_ERRORS] = FALSE; Chris@0: $request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE; Chris@0: $request_options = $this->decorateWithXdebugCookie($request_options); Chris@16: $client = $this->getHttpClient(); Chris@0: return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Asserts that a resource response has the given status code and body. Chris@0: * Chris@0: * @param int $expected_status_code Chris@0: * The expected response status. Chris@0: * @param string|false $expected_body Chris@0: * The expected response body. FALSE in case this should not be asserted. Chris@0: * @param \Psr\Http\Message\ResponseInterface $response Chris@0: * The response to assert. Chris@14: * @param string[]|false $expected_cache_tags Chris@14: * (optional) The expected cache tags in the X-Drupal-Cache-Tags response Chris@14: * header, or FALSE if that header should be absent. Defaults to FALSE. Chris@14: * @param string[]|false $expected_cache_contexts Chris@14: * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts Chris@14: * response header, or FALSE if that header should be absent. Defaults to Chris@14: * FALSE. Chris@14: * @param string|false $expected_page_cache_header_value Chris@14: * (optional) The expected X-Drupal-Cache response header value, or FALSE if Chris@14: * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults Chris@14: * to FALSE. Chris@14: * @param string|false $expected_dynamic_page_cache_header_value Chris@14: * (optional) The expected X-Drupal-Dynamic-Cache response header value, or Chris@14: * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. Chris@14: * Defaults to FALSE. Chris@0: */ Chris@14: protected function assertResourceResponse($expected_status_code, $expected_body, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { Chris@0: $this->assertSame($expected_status_code, $response->getStatusCode()); Chris@14: if ($expected_status_code === 204) { Chris@14: // DELETE responses should not include a Content-Type header. But Apache Chris@14: // sets it to 'text/html' by default. We also cannot detect the presence Chris@14: // of Apache either here in the CLI. For now having this documented here Chris@14: // is all we can do. Chris@14: // $this->assertSame(FALSE, $response->hasHeader('Content-Type')); Chris@14: $this->assertSame('', (string) $response->getBody()); Chris@14: } Chris@14: else { Chris@14: $this->assertSame([static::$mimeType], $response->getHeader('Content-Type')); Chris@14: if ($expected_body !== FALSE) { Chris@14: $this->assertSame($expected_body, (string) $response->getBody()); Chris@14: } Chris@14: } Chris@14: Chris@14: // Expected cache tags: X-Drupal-Cache-Tags header. Chris@14: $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags')); Chris@14: if (is_array($expected_cache_tags)) { Chris@14: $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); Chris@14: } Chris@14: Chris@14: // Expected cache contexts: X-Drupal-Cache-Contexts header. Chris@14: $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts')); Chris@14: if (is_array($expected_cache_contexts)) { Chris@14: $this->assertSame($expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); Chris@14: } Chris@14: Chris@14: // Expected Page Cache header value: X-Drupal-Cache header. Chris@14: if ($expected_page_cache_header_value !== FALSE) { Chris@14: $this->assertTrue($response->hasHeader('X-Drupal-Cache')); Chris@14: $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]); Chris@14: } Chris@14: else { Chris@14: $this->assertFalse($response->hasHeader('X-Drupal-Cache')); Chris@14: } Chris@14: Chris@14: // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header. Chris@14: if ($expected_dynamic_page_cache_header_value !== FALSE) { Chris@14: $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache')); Chris@14: $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]); Chris@14: } Chris@14: else { Chris@14: $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache')); Chris@0: } Chris@0: } Chris@0: Chris@0: /** Chris@0: * Asserts that a resource error response has the given message. Chris@0: * Chris@0: * @param int $expected_status_code Chris@0: * The expected response status. Chris@0: * @param string $expected_message Chris@0: * The expected error message. Chris@0: * @param \Psr\Http\Message\ResponseInterface $response Chris@0: * The error response to assert. Chris@14: * @param string[]|false $expected_cache_tags Chris@14: * (optional) The expected cache tags in the X-Drupal-Cache-Tags response Chris@14: * header, or FALSE if that header should be absent. Defaults to FALSE. Chris@14: * @param string[]|false $expected_cache_contexts Chris@14: * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts Chris@14: * response header, or FALSE if that header should be absent. Defaults to Chris@14: * FALSE. Chris@14: * @param string|false $expected_page_cache_header_value Chris@14: * (optional) The expected X-Drupal-Cache response header value, or FALSE if Chris@14: * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults Chris@14: * to FALSE. Chris@14: * @param string|false $expected_dynamic_page_cache_header_value Chris@14: * (optional) The expected X-Drupal-Dynamic-Cache response header value, or Chris@14: * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. Chris@14: * Defaults to FALSE. Chris@0: */ Chris@14: protected function assertResourceErrorResponse($expected_status_code, $expected_message, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { Chris@0: $expected_body = ($expected_message !== FALSE) ? $this->serializer->encode(['message' => $expected_message], static::$format) : FALSE; Chris@14: $this->assertResourceResponse($expected_status_code, $expected_body, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value); Chris@0: } Chris@0: Chris@0: /** Chris@0: * Adds the Xdebug cookie to the request options. Chris@0: * Chris@0: * @param array $request_options Chris@0: * The request options. Chris@0: * Chris@0: * @return array Chris@0: * Request options updated with the Xdebug cookie if present. Chris@0: */ Chris@0: protected function decorateWithXdebugCookie(array $request_options) { Chris@0: $session = $this->getSession(); Chris@0: $driver = $session->getDriver(); Chris@0: if ($driver instanceof BrowserKitDriver) { Chris@0: $client = $driver->getClient(); Chris@0: foreach ($client->getCookieJar()->all() as $cookie) { Chris@0: if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) { Chris@0: $request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue(); Chris@0: } Chris@0: else { Chris@0: $request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue(); Chris@0: } Chris@0: } Chris@0: } Chris@0: return $request_options; Chris@0: } Chris@0: Chris@17: /** Chris@17: * Recursively sorts an array by key. Chris@17: * Chris@17: * @param array $array Chris@17: * An array to sort. Chris@17: * Chris@17: * @return array Chris@17: * The sorted array. Chris@17: */ Chris@17: protected static function recursiveKSort(array &$array) { Chris@17: // First, sort the main array. Chris@17: ksort($array); Chris@17: Chris@17: // Then check for child arrays. Chris@17: foreach ($array as $key => &$value) { Chris@17: if (is_array($value)) { Chris@17: static::recursiveKSort($value); Chris@17: } Chris@17: } Chris@17: } Chris@17: Chris@0: }