first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
name: 'Configuration test REST'
type: module
package: Testing
# version: VERSION
dependencies:
- drupal:config_test
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,35 @@
<?php
/**
* @file
* Contains hook implementations for testing REST module.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_entity_type_alter().
*/
function config_test_rest_entity_type_alter(array &$entity_types) {
// Undo part of what config_test_entity_type_alter() did: remove this
// config_test_no_status entity type, because it uses the same entity class as
// the config_test entity type, which makes REST deserialization impossible.
unset($entity_types['config_test_no_status']);
}
/**
* Implements hook_ENTITY_TYPE_access().
*/
function config_test_rest_config_test_access(EntityInterface $entity, $operation, AccountInterface $account) {
// Add permission, so that EntityResourceTestBase's scenarios can test access
// being denied. By default, all access is always allowed for the config_test
// config entity.
$access_result = AccessResult::forbiddenIf(!$account->hasPermission('view config_test'))->cachePerPermissions();
if (!$access_result->isAllowed() && $access_result instanceof AccessResultReasonInterface) {
$access_result->setReason("The 'view config_test' permission is required.");
}
return $access_result;
}

View File

@@ -0,0 +1,2 @@
view config_test:
title: 'View ConfigTest entities'

View File

@@ -0,0 +1,10 @@
name: 'REST test'
type: module
description: 'Provides test hooks and resources for REST module.'
package: Testing
# version: VERSION
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,72 @@
<?php
/**
* @file
* Contains hook implementations for testing REST module.
*/
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Access\AccessResult;
/**
* Implements hook_entity_field_access().
*
* @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::setUp()
*/
function rest_test_entity_field_access($operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPost()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'field_rest_test') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
case 'edit':
return AccessResult::forbidden();
}
}
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'field_rest_test_multivalue') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
}
}
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testGet()
// @see \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase::testPatch()
if ($field_definition->getName() === 'rest_test_validation') {
switch ($operation) {
case 'view':
// Never ever allow this field to be viewed: this lets
// EntityResourceTestBase::testGet() test in a "vanilla" way.
return AccessResult::forbidden();
}
}
// No opinion.
return AccessResult::neutral();
}
/**
* Implements hook_entity_base_field_info().
*/
function rest_test_entity_base_field_info(EntityTypeInterface $entity_type) {
$fields = [];
$fields['rest_test_validation'] = BaseFieldDefinition::create('string')
->setLabel(t('REST test validation field'))
->setDescription(t('A text field with some special validations attached used for testing purposes'))
->addConstraint('rest_test_validation');
return $fields;
}

View File

@@ -0,0 +1,18 @@
services:
rest_test.authentication.test_auth:
class: Drupal\rest_test\Authentication\Provider\TestAuth
tags:
- { name: authentication_provider, provider_id: 'rest_test_auth' }
rest_test.authentication.test_auth_global:
class: Drupal\rest_test\Authentication\Provider\TestAuthGlobal
tags:
- { name: authentication_provider, provider_id: 'rest_test_auth_global', global: TRUE }
rest_test.page_cache_request_policy.deny_test_auth_requests:
class: Drupal\rest_test\PageCache\RequestPolicy\DenyTestAuthRequests
public: false
tags:
- { name: page_cache_request_policy }
rest_test.encoder.foobar:
class: Drupal\serialization\Encoder\JsonEncoder
tags:
- { name: encoder, format: foobar }

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\rest_test\Authentication\Provider;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Authentication provider for testing purposes.
*/
class TestAuth implements AuthenticationProviderInterface {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return $request->headers->has('REST-test-auth');
}
/**
* {@inheritdoc}
*/
public function authenticate(Request $request) {
return NULL;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\rest_test\Authentication\Provider;
use Drupal\Core\Authentication\AuthenticationProviderInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Global authentication provider for testing purposes.
*/
class TestAuthGlobal implements AuthenticationProviderInterface {
/**
* {@inheritdoc}
*/
public function applies(Request $request) {
return $request->headers->has('REST-test-auth-global');
}
/**
* {@inheritdoc}
*/
public function authenticate(Request $request) {
return NULL;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Drupal\rest_test\PageCache\RequestPolicy;
use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Cache policy for pages requested with REST Test Auth.
*
* This policy disallows caching of requests that use the REST Test Auth
* authentication provider for security reasons (just like basic_auth).
* Otherwise responses for authenticated requests can get into the page cache
* and could be delivered to unprivileged users.
*
* @see \Drupal\rest_test\Authentication\Provider\TestAuth
* @see \Drupal\rest_test\Authentication\Provider\TestAuthGlobal
* @see \Drupal\basic_auth\PageCache\DisallowBasicAuthRequests
*/
class DenyTestAuthRequests implements RequestPolicyInterface {
/**
* {@inheritdoc}
*/
public function check(Request $request) {
if ($request->headers->has('REST-test-auth') || $request->headers->has('REST-test-auth-global')) {
return self::DENY;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Drupal\rest_test\Plugin\Validation\Constraint;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;
/**
* Adds some validations for a REST test field.
*
* @see \Drupal\Core\TypedData\OptionsProviderInterface
*/
#[Constraint(
id: 'rest_test_validation',
label: new TranslatableMarkup('REST test validation', [], ['context' => 'Validation'])
)]
class RestTestConstraint extends SymfonyConstraint {
public $message = 'REST test validation failed';
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Drupal\rest_test\Plugin\Validation\Constraint;
use Drupal\Core\Field\FieldItemListInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
/**
* Validator for \Drupal\rest_test\Plugin\Validation\Constraint\RestTestConstraint.
*/
class RestTestConstraintValidator extends ConstraintValidator {
/**
* {@inheritdoc}
*/
public function validate($value, Constraint $constraint) {
if ($value instanceof FieldItemListInterface) {
$value = $value->getValue();
if (!empty($value[0]['value']) && $value[0]['value'] === 'ALWAYS_FAIL') {
$this->context->addViolation($constraint->message);
}
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Drupal\rest_test\Plugin\rest\resource;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\rest\Attribute\RestResource;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\ResourceResponse;
/**
* Class used to test that serialization_class is optional.
*/
#[RestResource(
id: "serialization_test",
label: new TranslatableMarkup("Optional serialization_class"),
serialization_class: "",
uri_paths: []
)]
class NoSerializationClassTestResource extends ResourceBase {
/**
* Responds to a POST request.
*
* @param array $data
* An array with the payload.
*
* @return \Drupal\rest\ResourceResponse
*/
public function post(array $data) {
return new ResourceResponse($data);
}
}

View File

@@ -0,0 +1,13 @@
name: 'REST test views'
type: module
description: 'Provides default views for views REST tests.'
package: Testing
# version: VERSION
dependencies:
- drupal:rest
- drupal:views
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,21 @@
<?php
/**
* @file
* Test hook implementations for the REST views test module.
*/
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_post_execute().
*/
function rest_test_views_views_post_execute(ViewExecutable $view) {
// Attach a custom header to the test_data_export view.
if ($view->id() === 'test_serializer_display_entity') {
if ($value = \Drupal::state()->get('rest_test_views_set_header', FALSE)) {
$view->getResponse()->headers->set('Custom-Header', $value);
}
}
}

View File

@@ -0,0 +1,276 @@
langcode: en
status: true
dependencies:
config:
- node.type.article
module:
- node
- rest
- user
id: test_excluded_field_token_display
label: 'Test Excluded Field Token Display'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: serializer
row:
type: fields
options:
inline: { }
separator: ''
hide_empty: false
default_field_elements: true
fields:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: true
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: false
ellipsis: false
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: string
settings:
link_to_entity: false
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
entity_type: node
entity_field: title
plugin_id: field
nothing:
id: nothing
table: views
field: nothing
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: true
text: '{{ title }}'
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: false
plugin_id: custom
filters:
status:
value: '1'
table: node_field_data
field: status
plugin_id: boolean
entity_type: node
entity_field: status
id: status
expose:
operator: ''
group: 1
type:
id: type
table: node_field_data
field: type
value:
article: article
entity_type: node
entity_field: type
plugin_id: bundle
sorts:
nid:
id: nid
table: node_field_data
field: nid
order: DESC
entity_type: node
entity_field: nid
plugin_id: standard
relationship: none
group_type: group
admin_label: ''
exposed: false
expose:
label: ''
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: 'REST export'
position: 1
display_options:
display_extenders: { }
path: rest/test/excluded-field-token
pager:
type: some
options:
items_per_page: 10
offset: 0
style:
type: serializer
options:
formats:
json: json
row:
type: data_field
options:
field_options:
title:
alias: ''
raw_output: false
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@@ -0,0 +1,210 @@
langcode: en
status: true
dependencies:
config:
- node.type.article
module:
- node
- rest
- serialization
- user
id: test_field_counter_display
label: 'Test Field Counter Display'
module: views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: 0
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
options: { }
query:
type: views_query
options:
disable_sql_rewrite: false
distinct: false
replica: false
query_comment: ''
query_tags: { }
exposed_form:
type: basic
options:
submit_button: Apply
reset_button: false
reset_button_label: Reset
exposed_sorts_label: 'Sort by'
expose_sort_order: true
sort_asc_label: Asc
sort_desc_label: Desc
pager:
type: mini
options:
items_per_page: 10
offset: 0
id: 0
total_pages: null
expose:
items_per_page: false
items_per_page_label: 'Items per page'
items_per_page_options: '5, 10, 25, 50'
items_per_page_options_all: false
items_per_page_options_all_label: '- All -'
offset: false
offset_label: Offset
tags:
previous:
next:
style:
type: serializer
options:
formats:
json: json
row:
type: data_field
options:
field_options:
title:
alias: ''
raw_output: false
fields:
counter:
id: counter
table: views
field: counter
relationship: none
group_type: group
admin_label: ''
label: ''
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: false
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
counter_start: 1
plugin_id: counter
filters:
status:
value: '1'
table: node_field_data
field: status
plugin_id: boolean
entity_type: node
entity_field: status
id: status
expose:
operator: ''
operator_limit_selection: false
operator_list: { }
group: 1
type:
id: type
table: node_field_data
field: type
value:
article: article
entity_type: node
entity_field: type
plugin_id: bundle
expose:
operator_limit_selection: false
operator_list: { }
sorts:
nid:
id: nid
table: node_field_data
field: nid
relationship: none
group_type: group
admin_label: ''
order: DESC
exposed: false
expose:
label: ''
entity_type: node
entity_field: nid
plugin_id: standard
header: { }
footer: { }
empty: { }
relationships: { }
arguments: { }
display_extenders: { }
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- request_format
- url.query_args
- 'user.node_grants:view'
- user.permissions
tags: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: 'REST export'
position: 1
display_options:
display_extenders: { }
path: rest/test/field-counter
pager:
type: some
options:
items_per_page: 10
offset: 0
defaults:
style: true
row: true
cache_metadata:
max-age: -1
contexts:
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags: { }

View File

@@ -0,0 +1,54 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_display_entity
label: 'Test serialize display entity rows'
module: rest
description: ''
tag: ''
base_table: entity_test
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
sorts:
id:
id: standard
table: entity_test
field: id
order: DESC
plugin_id: date
entity_type: entity_test
entity_field: id
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/entity

View File

@@ -0,0 +1,46 @@
langcode: en
status: true
dependencies:
module:
- entity_test
- rest
id: test_serializer_display_entity_translated
label: 'Test serialize translated entity rows'
module: rest
description: ''
tag: ''
base_table: entity_test_mul_property_data
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
title: 'Test serialize translated entity rows'
rendering_language: '***LANGUAGE_entity_translation***'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/translated_entity

View File

@@ -0,0 +1,109 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_display_field
label: 'Test serializer display field rows'
module: rest
description: ''
tag: ''
base_table: views_test_data
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
name:
id: name
table: views_test_data
field: name
label: ''
plugin_id: string
nothing:
id: nothing
table: views
field: nothing
relationship: none
group_type: group
admin_label: ''
label: 'Custom text'
exclude: false
alter:
alter_text: true
text: TEST
plugin_id: custom
created:
id: created
table: views_test_data
field: created
type: timestamp
settings:
date_format: medium
custom_date_format: ''
timezone: ''
plugin_id: field
sorts:
created:
id: created
table: views_test_data
field: created
order: DESC
plugin_id: date
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/field
access:
type: none
style:
type: serializer
row:
type: data_field
rest_export_2:
display_plugin: rest_export
id: rest_export_2
display_title: 'serialize - access denied'
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/denied
access:
type: perm
options:
perm: 'administer views'
style:
type: serializer
row:
type: data_field

View File

@@ -0,0 +1,171 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
module:
- field
- node
- rest
- rest_test_views
- user
id: test_serializer_node_display_field
label: 'Test serializer display field rows for entity fields'
module: rest_test_views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'administer views'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
nid:
id: nid
table: node_field_data
field: nid
plugin_id: field
entity_type: node
entity_field: nid
title:
id: title
table: node_field_data
field: title
label: Title
exclude: false
alter:
alter_text: false
element_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
entity_type: node
entity_field: title
type: string
settings:
link_to_entity: true
plugin_id: field
body:
id: body
table: node__body
field: body
relationship: none
group_type: group
admin_label: ''
label: Body
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: text_default
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
plugin_id: field
entity_type: node
entity_field: body
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/node-field
access:
type: none
style:
type: serializer
row:
type: data_field
rest_export_2:
display_plugin: rest_export
id: rest_export_2
display_title: 'REST export 2'
position: 2
display_options:
display_extenders: { }
auth:
basic_auth: basic_auth
path: test/serialize/auth_with_perm
cache_metadata:
max-age: -1
contexts:
- 'languages:language_content'
- 'languages:language_interface'
- request_format
- 'user.node_grants:view'
- user.permissions
tags:
- 'config:field.storage.node.body'

View File

@@ -0,0 +1,171 @@
langcode: en
status: true
dependencies:
config:
- field.storage.node.body
module:
- field
- node
- rest
- rest_test_views
- user
id: test_serializer_node_exposed_filter
label: 'Test serializer display for exposed filters'
module: rest_test_views
description: ''
tag: ''
base_table: node_field_data
base_field: nid
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_field
fields:
nid:
id: nid
table: node_field_data
field: nid
plugin_id: field
entity_type: node
entity_field: nid
body:
id: body
table: node__body
field: body
relationship: none
group_type: group
admin_label: ''
label: Body
exclude: false
alter:
alter_text: false
text: ''
make_link: false
path: ''
absolute: false
external: false
replace_spaces: false
path_case: none
trim_whitespace: false
alt: ''
rel: ''
link_class: ''
prefix: ''
suffix: ''
target: ''
nl2br: false
max_length: 0
word_boundary: true
ellipsis: true
more_link: false
more_link_text: ''
more_link_path: ''
strip_tags: false
trim: false
preserve_tags: ''
html: false
element_type: ''
element_class: ''
element_label_type: ''
element_label_class: ''
element_label_colon: true
element_wrapper_type: ''
element_wrapper_class: ''
element_default_classes: true
empty: ''
hide_empty: false
empty_zero: false
hide_alter_empty: true
click_sort_column: value
type: text_default
settings: { }
group_column: value
group_columns: { }
group_rows: true
delta_limit: 0
delta_offset: 0
delta_reversed: false
delta_first_last: false
multi_type: separator
separator: ', '
field_api_classes: false
plugin_id: field
entity_type: node
entity_field: body
filters:
title:
id: title
table: node_field_data
field: title
relationship: none
group_type: group
admin_label: ''
operator: starts
value: ''
group: 1
exposed: true
expose:
operator_id: title_op
label: Title
description: ''
use_operator: false
operator: title_op
identifier: title
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
anonymous: '0'
administrator: '0'
is_grouped: false
group_info:
label: ''
description: ''
identifier: ''
optional: true
widget: select
multiple: false
remember: false
default_group: All
default_group_multiple: { }
group_items: { }
entity_type: node
entity_field: title
plugin_id: string
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
style: false
row: false
path: test/serialize/node-exposed-filter
access:
type: none
style:
type: serializer
row:
type: data_field

View File

@@ -0,0 +1,69 @@
langcode: en
status: true
dependencies:
module:
- rest
- user
id: test_serializer_shared_path
label: 'Test serializer shared path'
module: rest
description: ''
tag: ''
base_table: entity_test
base_field: id
display:
default:
display_plugin: default
id: default
display_title: Default
position: null
display_options:
access:
type: perm
options:
perm: 'access content'
cache:
type: tag
query:
type: views_query
exposed_form:
type: basic
style:
type: serializer
row:
type: data_entity
sorts:
id:
id: standard
table: entity_test
field: id
order: DESC
plugin_id: date
entity_type: entity_test
entity_field: id
title: 'Test serialize'
arguments: { }
rest_export_1:
display_plugin: rest_export
id: rest_export_1
display_title: serializer
position: null
display_options:
defaults:
access: false
path: test/serialize/shared
page_1:
display_plugin: page
id: page_1
display_title: page
position: null
display_options:
defaults:
access: false
style: false
row: false
style:
type: default
row:
type: entity:entity_test
path: test/serialize/shared

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use Psr\Http\Message\ResponseInterface;
/**
* Defines a trait for testing with no authentication provider.
*
* This is intended to be used with
* \Drupal\Tests\rest\Functional\ResourceTestBase.
*
* Characteristics:
* - When no authentication provider is being used, there also cannot be any
* particular error response for missing authentication, since by definition
* there is not any authentication.
* - For the same reason, there are no authentication edge cases to test.
* - Because no authentication is required, this is vulnerable to CSRF attacks
* by design. Hence a REST resource should probably only allow for anonymous
* for safe (GET/HEAD) HTTP methods, and only with extreme care should unsafe
* (POST/PATCH/DELETE) HTTP methods be allowed for a REST resource that allows
* anonymous access.
*/
trait AnonResourceTestTrait {
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
throw new \LogicException('When testing for anonymous users, authentication cannot be missing.');
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use Psr\Http\Message\ResponseInterface;
/**
* Trait for ResourceTestBase subclasses testing $auth=basic_auth.
*
* Characteristics:
* - Every request must send an Authorization header.
* - When accessing a URI that requires authentication without being
* authenticated, a 401 response must be sent.
* - Because every request must send an authorization, there is no danger of
* CSRF attacks.
*/
trait BasicAuthResourceTestTrait {
/**
* {@inheritdoc}
*/
protected function getAuthenticationRequestOptions($method) {
return [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw),
],
];
}
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
if ($method !== 'GET') {
return $this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response);
}
$expected_page_cache_header_value = $method === 'GET' ? 'MISS' : FALSE;
$expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE))
// @see \Drupal\basic_auth\Authentication\Provider\BasicAuth::challengeException()
->addCacheableDependency($this->config('system.site'))
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheTags(['config:user.role.anonymous']);
// Only add the 'user.roles:anonymous' cache context if its parent cache
// context is not already present.
if (!in_array('user.roles', $expected_cacheability->getCacheContexts(), TRUE)) {
$expected_cacheability->addCacheContexts(['user.roles:anonymous']);
}
$this->assertResourceErrorResponse(401, 'No authentication credentials provided.', $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), $expected_page_cache_header_value, FALSE);
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Url;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Trait for ResourceTestBase subclasses testing $auth=cookie.
*
* Characteristics:
* - After performing a valid "log in" request, the server responds with a 2xx
* status code and a 'Set-Cookie' response header. This cookie is what
* continues to identify the user in subsequent requests.
* - When accessing a URI that requires authentication without being
* authenticated, a standard 403 response must be sent.
* - Because of the reliance on cookies, and the fact that user agents send
* cookies with every request, this is vulnerable to CSRF attacks. To mitigate
* this, the response for the "log in" request contains a CSRF token that must
* be sent with every unsafe (POST/PATCH/DELETE) HTTP request.
*/
trait CookieResourceTestTrait {
/**
* The session cookie.
*
* @see ::initAuthentication
*
* @var string
*/
protected $sessionCookie;
/**
* The CSRF token.
*
* @see ::initAuthentication
*
* @var string
*/
protected $csrfToken;
/**
* The logout token.
*
* @see ::initAuthentication
*
* @var string
*/
protected $logoutToken;
/**
* {@inheritdoc}
*/
protected function initAuthentication() {
$user_login_url = Url::fromRoute('user.login.http')
->setRouteParameter('_format', static::$format);
$request_body = [
'name' => $this->account->name->value,
'pass' => $this->account->passRaw,
];
$request_options[RequestOptions::BODY] = $this->serializer->encode($request_body, static::$format);
$request_options[RequestOptions::HEADERS] = [
'Content-Type' => static::$mimeType,
];
$response = $this->request('POST', $user_login_url, $request_options);
// Parse and store the session cookie.
$this->sessionCookie = explode(';', $response->getHeader('Set-Cookie')[0], 2)[0];
// Parse and store the CSRF token and logout token.
$data = $this->serializer->decode((string) $response->getBody(), static::$format);
$this->csrfToken = $data['csrf_token'];
$this->logoutToken = $data['logout_token'];
}
/**
* {@inheritdoc}
*/
protected function getAuthenticationRequestOptions($method) {
$request_options[RequestOptions::HEADERS]['Cookie'] = $this->sessionCookie;
// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
if (!in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
}
return $request_options;
}
/**
* {@inheritdoc}
*/
protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response) {
// Requests needing cookie authentication but missing it results in a 403
// response. The cookie authentication mechanism sets no response message.
// Hence, effectively, this is just the 403 response that one gets as the
// anonymous user trying to access a certain REST resource.
// @see \Drupal\user\Authentication\Provider\Cookie
// @todo https://www.drupal.org/node/2847623
if ($method === 'GET') {
$expected_cookie_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability()
// @see \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber::onRespond()
->addCacheableDependency($this->getExpectedUnauthorizedEntityAccessCacheability(FALSE));
// - \Drupal\Core\EventSubscriber\AnonymousUserResponseSubscriber applies
// to cacheable anonymous responses: it updates their cacheability.
// - A 403 response to a GET request is cacheable.
// Therefore we must update our cacheability expectations accordingly.
if (in_array('user.permissions', $expected_cookie_403_cacheability->getCacheContexts(), TRUE)) {
$expected_cookie_403_cacheability->addCacheTags(['config:user.role.anonymous']);
}
$this->assertResourceErrorResponse(403, FALSE, $response, $expected_cookie_403_cacheability->getCacheTags(), $expected_cookie_403_cacheability->getCacheContexts(), 'MISS', FALSE);
}
else {
$this->assertResourceErrorResponse(403, FALSE, $response);
}
}
/**
* {@inheritdoc}
*/
protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options) {
// X-CSRF-Token request header is unnecessary for safe and side effect-free
// HTTP methods. No need for additional assertions.
// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
if (in_array($method, ['HEAD', 'GET', 'OPTIONS', 'TRACE'])) {
return;
}
unset($request_options[RequestOptions::HEADERS]['X-CSRF-Token']);
// DX: 403 when missing X-CSRF-Token request header.
$response = $this->request($method, $url, $request_options);
$this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is missing', $response);
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = 'this-is-not-the-token-you-are-looking-for';
// DX: 403 when invalid X-CSRF-Token request header.
$response = $this->request($method, $url, $request_options);
$this->assertResourceErrorResponse(403, 'X-CSRF-Token request header is invalid', $response);
$request_options[RequestOptions::HEADERS]['X-CSRF-Token'] = $this->csrfToken;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource;
/**
* Resource test base class for config entities.
*
* @todo Remove this in https://www.drupal.org/node/2300677.
*/
abstract class ConfigEntityResourceTestBase extends EntityResourceTestBase {
/**
* A list of test methods to skip.
*
* @var array
*/
const SKIP_METHODS = ['testPost', 'testPatch', 'testDelete'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
if (in_array($this->name(), static::SKIP_METHODS, TRUE)) {
// Skip before installing Drupal to prevent unnecessary use of resources.
$this->markTestSkipped("Not yet supported for config entities.");
}
parent::setUp();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonAnonTest extends ModeratedNodeResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonBasicAuthTest extends ModeratedNodeResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class ModeratedNodeJsonCookieTest extends ModeratedNodeResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Core\Cache\Cache;
use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
use Drupal\Tests\node\Functional\Rest\NodeResourceTestBase;
/**
* Extend the Node resource test base and apply moderation to the entity.
*/
abstract class ModeratedNodeResourceTestBase extends NodeResourceTestBase {
use ContentModerationTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['content_moderation'];
/**
* The test editorial workflow.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $workflow;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
parent::setUpAuthorization($method);
switch ($method) {
case 'POST':
case 'PATCH':
case 'DELETE':
$this->grantPermissionsToTestedRole(['use editorial transition publish', 'use editorial transition create_new_draft']);
break;
}
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$entity = parent::createEntity();
if (!$this->workflow) {
$this->workflow = $this->createEditorialWorkflow();
}
$this->workflow->getTypePlugin()->addEntityTypeAndBundle($entity->getEntityTypeId(), $entity->bundle());
$this->workflow->save();
return $entity;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return array_merge(parent::getExpectedNormalizedEntity(), [
'moderation_state' => [
[
'value' => 'published',
],
],
'vid' => [
[
'value' => (int) $this->entity->getRevisionId(),
],
],
]);
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheTags() {
return Cache::mergeTags(parent::getExpectedCacheTags(), ['config:workflows.workflow.editorial']);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlAnonTest extends ModeratedNodeResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlBasicAuthTest extends ModeratedNodeResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource\ModeratedNode;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class ModeratedNodeXmlCookieTest extends ModeratedNodeResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
public function testPatchPath(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\EntityResource;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
use Drupal\Core\Field\Plugin\Field\FieldType\ChangedItem;
use Drupal\Core\Field\Plugin\Field\FieldType\CreatedItem;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
use Drupal\Core\Field\Plugin\Field\FieldType\IntegerItem;
use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
use Drupal\file\Plugin\Field\FieldType\FileItem;
use Drupal\image\Plugin\Field\FieldType\ImageItem;
use Drupal\options\Plugin\Field\FieldType\ListIntegerItem;
use Drupal\path\Plugin\Field\FieldType\PathItem;
use Drupal\Tests\rest\Functional\XmlNormalizationQuirksTrait;
use Drupal\user\StatusItem;
/**
* Trait for EntityResourceTestBase subclasses testing $format='xml'.
*/
trait XmlEntityNormalizationQuirksTrait {
use XmlNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
$default_normalization = parent::getExpectedNormalizedEntity();
if ($this->entity instanceof FieldableEntityInterface) {
$normalization = $this->applyXmlFieldDecodingQuirks($default_normalization);
}
else {
$normalization = $this->applyXmlConfigEntityDecodingQuirks($default_normalization);
}
$normalization = $this->applyXmlDecodingQuirks($normalization);
return $normalization;
}
/**
* Applies the XML entity field encoding quirks that remain after decoding.
*
* The XML encoding:
* - loses type data (int and bool become string)
*
* @param array $normalization
* An entity normalization.
*
* @return array
* The updated fieldable entity normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlFieldDecodingQuirks(array $normalization) {
foreach ($this->entity->getFields(TRUE) as $field_name => $field) {
// Not every field is accessible.
if (!isset($normalization[$field_name])) {
continue;
}
for ($i = 0; $i < count($normalization[$field_name]); $i++) {
switch ($field->getItemDefinition()->getClass()) {
case BooleanItem::class:
case StatusItem::class:
// @todo Remove the StatusItem case in
// https://www.drupal.org/project/drupal/issues/2936864.
$value = &$normalization[$field_name][$i]['value'];
$value = $value === TRUE ? '1' : '0';
break;
case IntegerItem::class:
case ListIntegerItem::class:
$value = &$normalization[$field_name][$i]['value'];
$value = (string) $value;
break;
case PathItem::class:
$pid = &$normalization[$field_name][$i]['pid'];
$pid = (string) $pid;
break;
case EntityReferenceItem::class:
case FileItem::class:
$target_id = &$normalization[$field_name][$i]['target_id'];
$target_id = (string) $target_id;
break;
case ChangedItem::class:
case CreatedItem::class:
case TimestampItem::class:
$value = &$normalization[$field_name][$i]['value'];
if (is_numeric($value)) {
$value = (string) $value;
}
break;
case ImageItem::class:
$height = &$normalization[$field_name][$i]['height'];
$height = (string) $height;
$width = &$normalization[$field_name][$i]['width'];
$width = (string) $width;
$target_id = &$normalization[$field_name][$i]['target_id'];
$target_id = (string) $target_id;
break;
}
}
if (count($normalization[$field_name]) === 1) {
$normalization[$field_name] = $normalization[$field_name][0];
}
}
return $normalization;
}
/**
* Applies the XML config entity encoding quirks that remain after decoding.
*
* The XML encoding:
* - loses type data (int and bool become string)
* - converts single-item arrays into single items (non-arrays)
*
* @param array $normalization
* An entity normalization.
*
* @return array
* The updated config entity normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlConfigEntityDecodingQuirks(array $normalization) {
$normalization = static::castToString($normalization);
// When a single dependency is listed, it's not decoded into an array.
if (isset($normalization['dependencies'])) {
foreach ($normalization['dependencies'] as $dependency_type => $dependency_list) {
if (count($dependency_list) === 1) {
$normalization['dependencies'][$dependency_type] = $dependency_list[0];
}
}
}
return $normalization;
}
/**
* {@inheritdoc}
*/
public function testPost(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
/**
* {@inheritdoc}
*/
public function testPatch(): void {
// Deserialization of the XML format is not supported.
$this->markTestSkipped();
}
}

View File

@@ -0,0 +1,832 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\Core\Url;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\user\Entity\User;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
// cspell:ignore èxample msword
/**
* Tests binary data file upload route.
*/
abstract class FileUploadResourceTestBase extends ResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest_test', 'entity_test', 'file', 'user'];
/**
* {@inheritdoc}
*/
protected static $resourceConfigId = 'file.upload';
/**
* The POST URI.
*
* @var string
*/
protected static $postUri = 'file/upload/entity_test/entity_test/field_rest_file_test';
/**
* Test file data.
*
* @var string
*/
protected $testFileData = 'Hares sit on chairs, and mules sit on stools.';
/**
* The test field storage config.
*
* @var \Drupal\field\Entity\FieldStorageConfig
*/
protected $fieldStorage;
/**
* The field config.
*
* @var \Drupal\field\Entity\FieldConfig
*/
protected $field;
/**
* The parent entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* Created file entity.
*
* @var \Drupal\file\Entity\File
*/
protected $file;
/**
* An authenticated user.
*
* @var \Drupal\user\UserInterface
*/
protected $user;
/**
* The entity storage for the 'file' entity type.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $fileStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->fileStorage = $this->container->get('entity_type.manager')
->getStorage('file');
// Add a file field.
$this->fieldStorage = FieldStorageConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_rest_file_test',
'type' => 'file',
'settings' => [
'uri_scheme' => 'public',
],
])
->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
$this->fieldStorage->save();
$this->field = FieldConfig::create([
'entity_type' => 'entity_test',
'field_name' => 'field_rest_file_test',
'bundle' => 'entity_test',
'settings' => [
'file_directory' => 'foobar',
'file_extensions' => 'txt',
'max_filesize' => '',
],
])
->setLabel('Test file field')
->setTranslatable(FALSE);
$this->field->save();
// Create an entity that a file can be attached to.
$this->entity = EntityTest::create([
'name' => 'Llama',
'type' => 'entity_test',
]);
$this->entity->setOwnerId(isset($this->account) ? $this->account->id() : 0);
$this->entity->save();
// Provision entity_test resource.
$this->resourceConfigStorage->create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['POST'],
'formats' => [static::$format],
'authentication' => [static::$auth],
],
'status' => TRUE,
])->save();
// Provisioning the file upload REST resource without the File REST resource
// does not make sense.
$this->resourceConfigStorage->create([
'id' => 'entity.file',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET'],
'formats' => [static::$format],
'authentication' => isset(static::$auth) ? [static::$auth] : [],
],
'status' => TRUE,
])->save();
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Tests using the file upload POST route.
*/
public function testPostFileUpload(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$uri = Url::fromUri('base:' . static::$postUri);
// DX: 403 when unauthorized.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(403, $this->getExpectedUnauthorizedAccessMessage('POST'), $response);
$this->setUpAuthorization('POST');
// 404 when the field name is invalid.
$invalid_uri = Url::fromUri('base:file/upload/entity_test/entity_test/field_rest_file_test_invalid');
$response = $this->fileRequest($invalid_uri, $this->testFileData);
$this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist', $response);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
// Test the file again but using 'filename' in the Content-Disposition
// header with no 'file' prefix.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
$this->assertTrue($this->fileStorage->loadUnchanged(1)->isTemporary());
// Verify that we can create an entity that references the uploaded file.
$entity_test_post_url = Url::fromRoute('rest.entity.entity_test.POST')
->setOption('query', ['_format' => static::$format]);
$request_options = [];
$request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType;
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
$request_options[RequestOptions::BODY] = $this->serializer->encode($this->getNormalizedPostEntity(), static::$format);
$response = $this->request('POST', $entity_test_post_url, $request_options);
$this->assertResourceResponse(201, FALSE, $response);
$this->assertTrue($this->fileStorage->loadUnchanged(1)->isPermanent());
$this->assertSame([
[
'target_id' => '1',
'display' => NULL,
'description' => "The most fascinating file ever!",
],
], EntityTest::load(2)->get('field_rest_file_test')->getValue());
}
/**
* Returns the normalized POST entity referencing the uploaded file.
*
* @return array
*
* @see ::testPostFileUpload()
* @see \Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase::getNormalizedPostEntity()
*/
protected function getNormalizedPostEntity() {
return [
'type' => [
[
'value' => 'entity_test',
],
],
'name' => [
[
'value' => 'Drama llama',
],
],
'field_rest_file_test' => [
[
'target_id' => 1,
'description' => 'The most fascinating file ever!',
],
],
];
}
/**
* Tests using the file upload POST route with invalid headers.
*/
public function testPostFileUploadInvalidHeaders(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// The wrong content type header should return a 415 code.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Type' => static::$mimeType]);
$this->assertResourceErrorResponse(415, sprintf('No route found that matches "Content-Type: %s"', static::$mimeType), $response);
// An empty Content-Disposition header should return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => FALSE]);
$this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.', $response);
// An empty filename with a context in the Content-Disposition header should
// return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename=""']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// An empty filename without a context in the Content-Disposition header
// should return a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename=""']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// An invalid key-value pair in the Content-Disposition header should return
// a 400.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'not_a_filename="example.txt"']);
$this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.', $response);
// Using filename* extended format is not currently supported.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename*="UTF-8 \' \' example.txt"']);
$this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header.', $response);
}
/**
* Tests using the file upload POST route with a duplicate file name.
*
* A new file should be created with a suffixed name.
*/
public function testPostFileUploadDuplicateFile(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Make the same request again. The file should be saved as a new file
// entity that has the same file name but a suffixed file URI.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Loading expected normalized data for file 2, the duplicate file.
$expected = $this->getExpectedNormalizedEntity(2, 'example_0.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_0.txt'));
}
/**
* Tests using the file upload POST route twice, simulating a race condition.
*
* A validation error should occur when the filenames are not unique.
*/
public function testPostFileUploadDuplicateFileRaceCondition(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// This request will have the default 'application/octet-stream' content
// type header.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertSame(201, $response->getStatusCode());
// Simulate a race condition where two files are uploaded at almost the same
// time, by removing the first uploaded file from disk (leaving the entry in
// the file_managed table) before trying to upload another file with the
// same name.
unlink(\Drupal::service('file_system')->realpath('public://foobar/example.txt'));
// Make the same request again. The upload should fail validation.
$response = $this->fileRequest($uri, $this->testFileData);
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file public://foobar/example.txt already exists. Enter a unique file URI."), $response);
}
/**
* Tests using the file upload route with any path prefixes being stripped.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Directives
*/
public function testFileUploadStrippedFilePath(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="directory/example.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example.txt'));
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="../../example_2.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(2, 'example_2.txt', TRUE);
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/example_2.txt'));
$this->assertFileDoesNotExist('../../example_2.txt');
// Check a path from the root. Extensions have to be empty to allow a file
// with no extension to pass validation.
$this->field->setSetting('file_extensions', '')
->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="/etc/passwd"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(3, 'passwd', TRUE);
// This mime will be guessed as there is no extension.
$expected['filemime'][0]['value'] = 'application/octet-stream';
$this->assertResponseData($expected, $response);
// Check the actual file data. It should have been written to the configured
// directory, not /foobar/directory/example.txt.
$this->assertSame($this->testFileData, file_get_contents('public://foobar/passwd'));
}
/**
* Tests using the file upload route with a unicode file name.
*/
public function testFileUploadUnicodeFilename(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// It is important that the filename starts with a unicode character. See
// https://bugs.php.net/bug.php?id=77239.
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'file; filename="Èxample-✓.txt"']);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity(1, 'Èxample-✓.txt', TRUE);
$this->assertResponseData($expected, $response);
$this->assertSame($this->testFileData, file_get_contents('public://foobar/Èxample-✓.txt'));
}
/**
* Tests using the file upload route with a zero byte file.
*/
public function testFileUploadZeroByteFile(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Test with a zero byte file.
$response = $this->fileRequest($uri, NULL);
$this->assertSame(201, $response->getStatusCode());
$expected = $this->getExpectedNormalizedEntity();
// Modify the default expected data to account for the 0 byte file.
$expected['filesize'][0]['value'] = 0;
$this->assertResponseData($expected, $response);
// Check the actual file data.
$this->assertSame('', file_get_contents('public://foobar/example.txt'));
}
/**
* Tests using the file upload route with an invalid file type.
*/
public function testFileUploadInvalidFileType(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Test with a JSON file.
$response = $this->fileRequest($uri, '{"test":123}', ['Content-Disposition' => 'filename="example.json"']);
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nOnly files with the following extensions are allowed: <em class=\"placeholder\">txt</em>."), $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileDoesNotExist('public://foobar/example.txt');
}
/**
* Tests using the file upload route with a file size larger than allowed.
*/
public function testFileUploadLargerFileSize(): void {
// Set a limit of 50 bytes.
$this->field->setSetting('max_filesize', 50)
->save();
$this->refreshTestStateAfterRestConfigChange();
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
// Generate a string larger than the 50 byte limit set.
$response = $this->fileRequest($uri, $this->randomString(100));
$this->assertResourceErrorResponse(422, PlainTextOutput::renderFromHtml("Unprocessable Entity: file validation failed.\nThe file is <em class=\"placeholder\">100 bytes</em> exceeding the maximum file size of <em class=\"placeholder\">50 bytes</em>."), $response);
// Make sure that no file was saved.
$this->assertEmpty(File::load(1));
$this->assertFileDoesNotExist('public://foobar/example.txt');
}
/**
* Tests using the file upload POST route with malicious extensions.
*/
public function testFileUploadMaliciousExtension(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
// Allow all file uploads but system.file::allow_insecure_uploads is set to
// FALSE.
$this->field->setSetting('file_extensions', '')->save();
$this->refreshTestStateAfterRestConfigChange();
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$php_string = '<?php print "Drupal"; ?>';
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example.php"']);
// The filename is not munged because .txt is added and it is a known
// extension to apache.
$expected = $this->getExpectedNormalizedEntity(1, 'example.php_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.php_.txt');
// Add .php and .txt as allowed extensions. Since 'allow_insecure_uploads'
// is FALSE, .php files should be renamed to have a .txt extension.
$this->field->setSetting('file_extensions', 'php txt')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_2.php"']);
$expected = $this->getExpectedNormalizedEntity(2, 'example_2.php_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_2.php_.txt');
$this->assertFileDoesNotExist('public://foobar/example_2.php');
// Allow .doc file uploads and ensure even a mis-configured apache will not
// fallback to php because the filename will be munged.
$this->field->setSetting('file_extensions', 'doc')->save();
$this->refreshTestStateAfterRestConfigChange();
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_3.php.doc"']);
// The filename is munged.
$expected = $this->getExpectedNormalizedEntity(3, 'example_3.php_.doc', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should be 'application/msword'.
$expected['filemime'][0]['value'] = 'application/msword';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_3.php_.doc');
$this->assertFileDoesNotExist('public://foobar/example_3.php.doc');
// Test that a dangerous extension such as .php is munged even if it is in
// the list of allowed extensions.
$this->field->setSetting('file_extensions', 'doc php')->save();
$this->refreshTestStateAfterRestConfigChange();
// Test using a masked exploit file.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_4.php.doc"']);
// The filename is munged.
$expected = $this->getExpectedNormalizedEntity(4, 'example_4.php_.doc', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should be 'application/msword'.
$expected['filemime'][0]['value'] = 'application/msword';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_4.php_.doc');
$this->assertFileDoesNotExist('public://foobar/example_4.php.doc');
// Dangerous extensions are munged even when all extensions are allowed.
$this->field->setSetting('file_extensions', '')->save();
$this->rebuildAll();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_5.php.png"']);
$expected = $this->getExpectedNormalizedEntity(5, 'example_5.php_.png', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should still see this as a PNG image.
$expected['filemime'][0]['value'] = 'image/png';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_5.php_.png');
// Dangerous extensions are munged if is renamed to end in .txt.
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_6.cgi.png.txt"']);
$expected = $this->getExpectedNormalizedEntity(6, 'example_6.cgi_.png_.txt', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should also now be text.
$expected['filemime'][0]['value'] = 'text/plain';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_6.cgi_.png_.txt');
// Add .php as an allowed extension without .txt. Since insecure uploads are
// not allowed, .php files will be rejected.
$this->field->setSetting('file_extensions', 'php')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_7.php"']);
$this->assertResourceErrorResponse(422, "Unprocessable Entity: file validation failed.\nFor security reasons, your upload has been rejected.", $response);
// Make sure that no file was saved.
$this->assertFileDoesNotExist('public://foobar/example_7.php');
$this->assertFileDoesNotExist('public://foobar/example_7.php.txt');
// Now allow insecure uploads.
\Drupal::configFactory()
->getEditable('system.file')
->set('allow_insecure_uploads', TRUE)
->save();
// Allow all file uploads. This is very insecure.
$this->field->setSetting('file_extensions', '')->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $php_string, ['Content-Disposition' => 'filename="example_7.php"']);
$expected = $this->getExpectedNormalizedEntity(7, 'example_7.php', TRUE);
// Override the expected filesize.
$expected['filesize'][0]['value'] = strlen($php_string);
// The file mime should also now be PHP.
$expected['filemime'][0]['value'] = 'application/x-httpd-php';
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example_7.php');
}
/**
* Tests using the file upload POST route no extension configured.
*/
public function testFileUploadNoExtensionSetting(): void {
$this->initAuthentication();
$this->provisionResource([static::$format], static::$auth ? [static::$auth] : [], ['POST']);
$this->setUpAuthorization('POST');
$uri = Url::fromUri('base:' . static::$postUri);
$this->field->setSetting('file_extensions', '')
->save();
$this->refreshTestStateAfterRestConfigChange();
$response = $this->fileRequest($uri, $this->testFileData, ['Content-Disposition' => 'filename="example.txt"']);
$expected = $this->getExpectedNormalizedEntity(1, 'example.txt', TRUE);
$this->assertResponseData($expected, $response);
$this->assertFileExists('public://foobar/example.txt');
}
/**
* {@inheritdoc}
*/
protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) {
// The file upload resource only accepts binary data, so there are no
// normalization edge cases to test, as there are no normalized entity
// representations incoming.
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
return "The following permissions are required: 'administer entity_test content' OR 'administer entity_test_with_bundle content' OR 'create entity_test entity_test_with_bundle entities'.";
}
/**
* Gets the expected file entity.
*
* @param int $fid
* The file ID to load and create normalized data for.
* @param string $expected_filename
* The expected filename for the stored file.
* @param bool $expected_as_filename
* Whether the expected filename should be the filename property too.
*
* @return array
* The expected normalized data array.
*/
protected function getExpectedNormalizedEntity($fid = 1, $expected_filename = 'example.txt', $expected_as_filename = FALSE) {
$author = User::load(static::$auth ? $this->account->id() : 0);
$file = File::load($fid);
$this->assertInstanceOf(FileInterface::class, $file);
$expected_normalization = [
'fid' => [
[
'value' => (int) $file->id(),
],
],
'uuid' => [
[
'value' => $file->uuid(),
],
],
'langcode' => [
[
'value' => 'en',
],
],
'uid' => [
[
'target_id' => (int) $author->id(),
'target_type' => 'user',
'target_uuid' => $author->uuid(),
'url' => base_path() . 'user/' . $author->id(),
],
],
'filename' => [
[
'value' => $expected_as_filename ? $expected_filename : 'example.txt',
],
],
'uri' => [
[
'value' => 'public://foobar/' . $expected_filename,
'url' => base_path() . $this->siteDirectory . '/files/foobar/' . rawurlencode($expected_filename),
],
],
'filemime' => [
[
'value' => 'text/plain',
],
],
'filesize' => [
[
'value' => strlen($this->testFileData),
],
],
'status' => [
[
'value' => FALSE,
],
],
'created' => [
[
'value' => (new \DateTime())->setTimestamp($file->getCreatedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
'changed' => [
[
'value' => (new \DateTime())->setTimestamp($file->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339),
'format' => \DateTime::RFC3339,
],
],
];
return $expected_normalization;
}
/**
* Performs a file upload request. Wraps the Guzzle HTTP client.
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param \Drupal\Core\Url $url
* URL to request.
* @param string $file_contents
* The file contents to send as the request body.
* @param array $headers
* Additional headers to send with the request. Defaults will be added for
* Content-Type and Content-Disposition. In order to remove the defaults set
* the header value to FALSE.
*
* @return \Psr\Http\Message\ResponseInterface
*/
protected function fileRequest(Url $url, $file_contents, array $headers = []) {
// Set the format for the response.
$url->setOption('query', ['_format' => static::$format]);
$request_options = [];
$headers = $headers + [
// Set the required (and only accepted) content type for the request.
'Content-Type' => 'application/octet-stream',
// Set the required Content-Disposition header for the file name.
'Content-Disposition' => 'file; filename="example.txt"',
];
$request_options[RequestOptions::HEADERS] = array_filter($headers, function ($value) {
return $value !== FALSE;
});
$request_options[RequestOptions::BODY] = $file_contents;
$request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST'));
return $this->request('POST', $url, $request_options);
}
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
switch ($method) {
case 'GET':
$this->grantPermissionsToTestedRole(['view test entity']);
break;
case 'POST':
$this->grantPermissionsToTestedRole(['create entity_test entity_test_with_bundle entities', 'access content']);
break;
}
}
/**
* Asserts expected normalized data matches response data.
*
* @param array $expected
* The expected data.
* @param \Psr\Http\Message\ResponseInterface $response
* The file upload response.
*/
protected function assertResponseData(array $expected, ResponseInterface $response) {
static::recursiveKSort($expected);
$actual = $this->serializer->decode((string) $response->getBody(), static::$format);
static::recursiveKSort($actual);
$this->assertSame($expected, $actual);
}
/**
* {@inheritdoc}
*/
protected function getExpectedUnauthorizedAccessCacheability() {
// There is cacheability metadata to check as file uploads only allows POST
// requests, which will not return cacheable responses.
return new CacheableMetadata();
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for rest.
*
* @group rest
*/
class GenericTest extends GenericModuleTestBase {}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Drupal\Core\Session\AccountInterface;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
/**
* Tests the structure of a REST resource.
*
* @group rest
* @group #slow
*/
class ResourceTest extends BrowserTestBase {
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['rest', 'entity_test', 'rest_test'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create an entity programmatic.
$this->entity = EntityTest::create([
'name' => $this->randomMachineName(),
'user_id' => 1,
'field_test_text' => [
0 => [
'value' => $this->randomString(),
'format' => 'plain_text',
],
],
]);
$this->entity->save();
Role::load(AccountInterface::ANONYMOUS_ROLE)
->grantPermission('view test entity')
->save();
}
/**
* Tests that a resource without formats cannot be enabled.
*/
public function testFormats(): void {
RestResourceConfig::create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => [
'basic_auth',
],
],
],
])->save();
// Verify that accessing the resource returns 406.
$this->drupalGet($this->entity->toUrl()->setRouteParameter('_format', 'json'));
// \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
// non-REST route a match, but a lower quality one: no format restrictions
// means there's always a match and hence when there is no matching REST
// route, the non-REST route is used, but can't render into
// application/json, so it returns a 406.
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests that a resource without authentication cannot be enabled.
*/
public function testAuthentication(): void {
RestResourceConfig::create([
'id' => 'entity.entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
],
],
])->save();
// Verify that accessing the resource returns 401.
$this->drupalGet($this->entity->toUrl()->setRouteParameter('_format', 'json'));
// \Drupal\Core\Routing\RequestFormatRouteFilter considers the canonical,
// non-REST route a match, but a lower quality one: no format restrictions
// means there's always a match and hence when there is no matching REST
// route, the non-REST route is used, but can't render into
// application/json, so it returns a 406.
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests that serialization_class is optional.
*/
public function testSerializationClassIsOptional(): void {
RestResourceConfig::create([
'id' => 'serialization_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'POST' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
])->save();
\Drupal::service('router.builder')->rebuildIfNeeded();
Role::load(RoleInterface::ANONYMOUS_ID)
->grantPermission('restful post serialization_test')
->save();
$serialized = $this->container->get('serializer')->serialize(['foo', 'bar'], 'json');
$request_options = [
RequestOptions::HEADERS => ['Content-Type' => 'application/json'],
RequestOptions::BODY => $serialized,
];
/** @var \GuzzleHttp\ClientInterface $client */
$client = $this->getSession()->getDriver()->getClient()->getClient();
$response = $client->request('POST', $this->buildUrl('serialization_test', ['query' => ['_format' => 'json']]), $request_options);
$this->assertSame(200, $response->getStatusCode());
$this->assertSame('["foo","bar"]', (string) $response->getBody());
}
/**
* Tests that resource URI paths are formatted properly.
*/
public function testUriPaths(): void {
/** @var \Drupal\rest\Plugin\Type\ResourcePluginManager $manager */
$manager = \Drupal::service('plugin.manager.rest');
foreach ($manager->getDefinitions() as $resource => $definition) {
foreach ($definition['uri_paths'] as $key => $uri_path) {
$this->assertStringNotContainsString('//', $uri_path, 'The resource URI path does not have duplicate slashes.');
}
}
}
}

View File

@@ -0,0 +1,506 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
use Behat\Mink\Driver\BrowserKitDriver;
use Drupal\Core\Url;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\BrowserTestBase;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\ResponseInterface;
/**
* Subclass this for every REST resource, every format and every auth provider.
*
* For more guidance see
* \Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase
* which has recommendations for testing the
* \Drupal\rest\Plugin\rest\resource\EntityResource REST resource for every
* format and every auth provider. It's a special case (because that single REST
* resource generates supports not just one thing, but many things  multiple
* entity types), but the same principles apply.
*/
abstract class ResourceTestBase extends BrowserTestBase {
/**
* The format to use in this test.
*
* A format is the combination of a certain normalizer and a certain
* serializer.
*
* @see https://www.drupal.org/developing/api/8/serialization
*
* (The default is 'json' because that doesn't depend on any module.)
*
* @var string
*/
protected static $format = 'json';
/**
* The MIME type that corresponds to $format.
*
* (Sadly this cannot be computed automatically yet.)
*
* @var string
*/
protected static $mimeType = 'application/json';
/**
* The authentication mechanism to use in this test.
*
* (The default is 'cookie' because that doesn't depend on any module.)
*
* @var string
*/
protected static $auth = FALSE;
/**
* The REST Resource Config entity ID under test (i.e. a resource type).
*
* The REST Resource plugin ID can be calculated from this.
*
* @var string
*
* @see \Drupal\rest\Entity\RestResourceConfig::__construct()
*/
protected static $resourceConfigId = NULL;
/**
* The account to use for authentication, if any.
*
* @var null|\Drupal\Core\Session\AccountInterface
*/
protected $account = NULL;
/**
* The REST resource config entity storage.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
*/
protected $resourceConfigStorage;
/**
* The serializer service.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = ['rest'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->serializer = $this->container->get('serializer');
// Ensure the anonymous user role has no permissions at all.
$user_role = Role::load(RoleInterface::ANONYMOUS_ID);
foreach ($user_role->getPermissions() as $permission) {
$user_role->revokePermission($permission);
}
$user_role->save();
assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.');
if (static::$auth !== FALSE) {
// Ensure the authenticated user role has no permissions at all.
$user_role = Role::load(RoleInterface::AUTHENTICATED_ID);
foreach ($user_role->getPermissions() as $permission) {
$user_role->revokePermission($permission);
}
$user_role->save();
assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.');
// Create an account.
$this->account = $this->createUser();
}
else {
// Otherwise, also create an account, so that any test involving User
// entities will have the same user IDs regardless of authentication.
$this->createUser();
}
$this->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());
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Provisions the REST resource under test.
*
* @param string[] $formats
* The allowed formats for this resource.
* @param string[] $authentication
* The allowed authentication providers for this resource.
* @param string[] $methods
* The allowed methods for this resource.
*/
protected function provisionResource($formats = [], $authentication = [], array $methods = ['GET', 'POST', 'PATCH', 'DELETE']) {
$this->resourceConfigStorage->create([
'id' => static::$resourceConfigId,
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => $methods,
'formats' => $formats,
'authentication' => $authentication,
],
'status' => TRUE,
])->save();
$this->refreshTestStateAfterRestConfigChange();
}
/**
* Refreshes the state of the tester to be in sync with the testee.
*
* Should be called after every change made to:
* - RestResourceConfig entities
*/
protected function refreshTestStateAfterRestConfigChange() {
// Ensure that the cache tags invalidator has its internal values reset.
// Otherwise the http_response cache tag invalidation won't work.
$this->refreshVariables();
// Tests using this base class may trigger route rebuilds due to changes to
// RestResourceConfig entities. Ensure the test generates routes using an
// up-to-date router.
\Drupal::service('router.builder')->rebuildIfNeeded();
}
/**
* Return the expected error message.
*
* @param string $method
* The HTTP method (GET, POST, PATCH, DELETE).
*
* @return string
* The error string.
*/
protected function getExpectedUnauthorizedAccessMessage($method) {
$resource_plugin_id = str_replace('.', ':', static::$resourceConfigId);
$permission = 'restful ' . strtolower($method) . ' ' . $resource_plugin_id;
return "The '$permission' permission is required.";
}
/**
* Sets up the necessary authorization.
*
* In case of a test verifying publicly accessible REST resources: grant
* permissions to the anonymous user role.
*
* In case of a test verifying behavior when using a particular authentication
* provider: create a user with a particular set of permissions.
*
* Because of the $method parameter, it's possible to first set up
* authentication for only GET, then add POST, et cetera. This then also
* allows for verifying a 403 in case of missing authorization.
*
* @param string $method
* The HTTP method for which to set up authentication.
*
* @see ::grantPermissionsToAnonymousRole()
* @see ::grantPermissionsToAuthenticatedRole()
*/
abstract protected function setUpAuthorization($method);
/**
* Verifies the error response in case of missing authentication.
*
* @param string $method
* HTTP method.
* @param \Psr\Http\Message\ResponseInterface $response
* The response to assert.
*/
abstract protected function assertResponseWhenMissingAuthentication($method, ResponseInterface $response);
/**
* Asserts normalization-specific edge cases.
*
* (Should be called before sending a well-formed request.)
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*/
abstract protected function assertNormalizationEdgeCases($method, Url $url, array $request_options);
/**
* Asserts authentication provider-specific edge cases.
*
* (Should be called before sending a well-formed request.)
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*/
abstract protected function assertAuthenticationEdgeCases($method, Url $url, array $request_options);
/**
* Returns the expected cacheability of an unauthorized access response.
*
* @return \Drupal\Core\Cache\RefinableCacheableDependencyInterface
* The expected cacheability.
*/
abstract protected function getExpectedUnauthorizedAccessCacheability();
/**
* Initializes authentication.
*
* E.g. for cookie authentication, we first need to get a cookie.
*/
protected function initAuthentication() {}
/**
* Returns Guzzle request options for authentication.
*
* @param string $method
* The HTTP method for this authenticated request.
*
* @return array
* Guzzle request options to use for authentication.
*
* @see \GuzzleHttp\ClientInterface::request()
*/
protected function getAuthenticationRequestOptions($method) {
return [];
}
/**
* Grants permissions to the anonymous role.
*
* @param string[] $permissions
* Permissions to grant.
*/
protected function grantPermissionsToAnonymousRole(array $permissions) {
$this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), $permissions);
}
/**
* Grants permissions to the authenticated role.
*
* @param string[] $permissions
* Permissions to grant.
*/
protected function grantPermissionsToAuthenticatedRole(array $permissions) {
$this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions);
}
/**
* Grants permissions to the tested role: anonymous or authenticated.
*
* @param string[] $permissions
* Permissions to grant.
*
* @see ::grantPermissionsToAuthenticatedRole()
* @see ::grantPermissionsToAnonymousRole()
*/
protected function grantPermissionsToTestedRole(array $permissions) {
if (static::$auth) {
$this->grantPermissionsToAuthenticatedRole($permissions);
}
else {
$this->grantPermissionsToAnonymousRole($permissions);
}
}
/**
* Performs a HTTP request. Wraps the Guzzle HTTP client.
*
* Why wrap the Guzzle HTTP client? Because we want to keep the actual test
* code as simple as possible, and hence not require them to specify the
* 'http_errors = FALSE' request option, nor do we want them to have to
* convert Drupal Url objects to strings.
*
* We also don't want to follow redirects automatically, to ensure these tests
* are able to detect when redirects are added or removed.
*
* @see \GuzzleHttp\ClientInterface::request()
*
* @param string $method
* HTTP method.
* @param \Drupal\Core\Url $url
* URL to request.
* @param array $request_options
* Request options to apply.
*
* @return \Psr\Http\Message\ResponseInterface
*/
protected function request($method, Url $url, array $request_options) {
$request_options[RequestOptions::HTTP_ERRORS] = FALSE;
$request_options[RequestOptions::ALLOW_REDIRECTS] = FALSE;
$request_options = $this->decorateWithXdebugCookie($request_options);
$client = $this->getHttpClient();
return $client->request($method, $url->setAbsolute(TRUE)->toString(), $request_options);
}
/**
* Asserts that a resource response has the given status code and body.
*
* @param int $expected_status_code
* The expected response status.
* @param string|false $expected_body
* The expected response body. FALSE in case this should not be asserted.
* @param \Psr\Http\Message\ResponseInterface $response
* The response to assert.
* @param string[]|false $expected_cache_tags
* (optional) The expected cache tags in the X-Drupal-Cache-Tags response
* header, or FALSE if that header should be absent. Defaults to FALSE.
* @param string[]|false $expected_cache_contexts
* (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
* response header, or FALSE if that header should be absent. Defaults to
* FALSE.
* @param string|false $expected_page_cache_header_value
* (optional) The expected X-Drupal-Cache response header value, or FALSE if
* that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
* to FALSE.
* @param string|false $expected_dynamic_page_cache_header_value
* (optional) The expected X-Drupal-Dynamic-Cache response header value, or
* FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
* Defaults to FALSE.
*/
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) {
$this->assertSame($expected_status_code, $response->getStatusCode());
if ($expected_status_code === 204) {
// DELETE responses should not include a Content-Type header. But Apache
// sets it to 'text/html' by default. We also cannot detect the presence
// of Apache either here in the CLI. For now having this documented here
// is all we can do.
// $this->assertFalse($response->hasHeader('Content-Type'));
$this->assertSame('', (string) $response->getBody());
}
else {
$this->assertSame([static::$mimeType], $response->getHeader('Content-Type'));
if ($expected_body !== FALSE) {
$this->assertSame($expected_body, (string) $response->getBody());
}
}
// Expected cache tags: X-Drupal-Cache-Tags header.
$this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags'));
if (is_array($expected_cache_tags)) {
$this->assertEqualsCanonicalizing($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0]));
}
// Expected cache contexts: X-Drupal-Cache-Contexts header.
$this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts'));
if (is_array($expected_cache_contexts)) {
$optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts);
$this->assertEqualsCanonicalizing($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0]));
}
// Expected Page Cache header value: X-Drupal-Cache header.
if ($expected_page_cache_header_value !== FALSE) {
$this->assertTrue($response->hasHeader('X-Drupal-Cache'));
$this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]);
}
else {
$this->assertFalse($response->hasHeader('X-Drupal-Cache'));
}
// Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header.
if ($expected_dynamic_page_cache_header_value !== FALSE) {
$this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache'));
$this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]);
}
else {
$this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache'));
}
}
/**
* Asserts that a resource error response has the given message.
*
* @param int $expected_status_code
* The expected response status.
* @param string $expected_message
* The expected error message.
* @param \Psr\Http\Message\ResponseInterface $response
* The error response to assert.
* @param string[]|false $expected_cache_tags
* (optional) The expected cache tags in the X-Drupal-Cache-Tags response
* header, or FALSE if that header should be absent. Defaults to FALSE.
* @param string[]|false $expected_cache_contexts
* (optional) The expected cache contexts in the X-Drupal-Cache-Contexts
* response header, or FALSE if that header should be absent. Defaults to
* FALSE.
* @param string|false $expected_page_cache_header_value
* (optional) The expected X-Drupal-Cache response header value, or FALSE if
* that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults
* to FALSE.
* @param string|false $expected_dynamic_page_cache_header_value
* (optional) The expected X-Drupal-Dynamic-Cache response header value, or
* FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'.
* Defaults to FALSE.
*/
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) {
$expected_body = ($expected_message !== FALSE) ? $this->serializer->encode(['message' => $expected_message], static::$format) : FALSE;
$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);
}
/**
* Adds the Xdebug cookie to the request options.
*
* @param array $request_options
* The request options.
*
* @return array
* Request options updated with the Xdebug cookie if present.
*/
protected function decorateWithXdebugCookie(array $request_options) {
$session = $this->getSession();
$driver = $session->getDriver();
if ($driver instanceof BrowserKitDriver) {
$client = $driver->getClient();
foreach ($client->getCookieJar()->all() as $cookie) {
if (isset($request_options[RequestOptions::HEADERS]['Cookie'])) {
$request_options[RequestOptions::HEADERS]['Cookie'] .= '; ' . $cookie->getName() . '=' . $cookie->getValue();
}
else {
$request_options[RequestOptions::HEADERS]['Cookie'] = $cookie->getName() . '=' . $cookie->getValue();
}
}
}
return $request_options;
}
/**
* Recursively sorts an array by key.
*
* @param array $array
* An array to sort.
*/
protected static function recursiveKSort(array &$array) {
// First, sort the main array.
ksort($array);
// Then check for child arrays.
foreach ($array as $key => &$value) {
if (is_array($value)) {
static::recursiveKSort($value);
}
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonAnonTest extends RestResourceConfigResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonBasicAuthTest extends RestResourceConfigResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class RestResourceConfigJsonCookieTest extends RestResourceConfigResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
use Drupal\rest\Entity\RestResourceConfig;
abstract class RestResourceConfigResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['dblog'];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'rest_resource_config';
/**
* @var \Drupal\rest\RestResourceConfigInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer rest resources']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$rest_resource_config = RestResourceConfig::create([
'id' => 'llama',
'plugin_id' => 'dblog',
'granularity' => 'method',
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
]);
$rest_resource_config->save();
return $rest_resource_config;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'uuid' => $this->entity->uuid(),
'langcode' => 'en',
'status' => TRUE,
'dependencies' => [
'module' => [
'dblog',
'serialization',
'user',
],
],
'id' => 'llama',
'plugin_id' => 'dblog',
'granularity' => 'method',
'configuration' => [
'GET' => [
'supported_formats' => [
'json',
],
'supported_auth' => [
'cookie',
],
],
],
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
/**
* {@inheritdoc}
*/
protected function getExpectedCacheContexts() {
return [
'user.permissions',
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlAnonTest extends RestResourceConfigResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlBasicAuthTest extends RestResourceConfigResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class RestResourceConfigXmlCookieTest extends RestResourceConfigResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\node\Entity\Node;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the display of an excluded field that is used as a token.
*
* @group rest
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class ExcludedFieldTokenTest extends ViewTestBase {
/**
* @var \Drupal\views\ViewExecutable
*/
protected $view;
/**
* The views that are used by this test.
*
* @var array
*/
public static $testViews = ['test_excluded_field_token_display'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The modules that need to be installed for this test.
*
* @var array
*/
protected static $modules = [
'entity_test',
'rest_test_views',
'node',
'field',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
// Create some test content.
for ($i = 1; $i <= 10; $i++) {
Node::create([
'type' => 'article',
'title' => 'Article test ' . $i,
])->save();
}
$this->enableViewsTestModule();
$this->view = Views::getView('test_excluded_field_token_display');
$this->view->setDisplay('rest_export_1');
}
/**
* Tests the display of an excluded title field when used as a token.
*/
public function testExcludedTitleTokenDisplay(): void {
$actual_json = $this->drupalGet($this->view->getPath(), ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$expected = [
['nothing' => 'Article test 10'],
['nothing' => 'Article test 9'],
['nothing' => 'Article test 8'],
['nothing' => 'Article test 7'],
['nothing' => 'Article test 6'],
['nothing' => 'Article test 5'],
['nothing' => 'Article test 4'],
['nothing' => 'Article test 3'],
['nothing' => 'Article test 2'],
['nothing' => 'Article test 1'],
];
$this->assertSame(json_encode($expected), $actual_json);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\node\Entity\Node;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the display of counter field.
*
* @group rest
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class FieldCounterTest extends ViewTestBase {
/**
* @var \Drupal\views\ViewExecutable
*/
protected $view;
/**
* The views that are used by this test.
*
* @var array
*/
public static $testViews = ['test_field_counter_display'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* The modules that need to be installed for this test.
*
* @var array
*/
protected static $modules = [
'entity_test',
'rest_test_views',
'node',
'field',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
// Create some test content.
for ($i = 1; $i <= 10; $i++) {
Node::create([
'type' => 'article',
'title' => 'Article test ' . $i,
])->save();
}
$this->enableViewsTestModule();
$this->view = Views::getView('test_field_counter_display');
$this->view->setDisplay('rest_export_1');
}
/**
* Tests the display of an excluded title field when used as a token.
*/
public function testExcludedTitleTokenDisplay(): void {
$actual_json = $this->drupalGet($this->view->getPath(), ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$expected = [
['counter' => '1'],
['counter' => '2'],
['counter' => '3'],
['counter' => '4'],
['counter' => '5'],
['counter' => '6'],
['counter' => '7'],
['counter' => '8'],
['counter' => '9'],
['counter' => '10'],
];
$this->assertSame(json_encode($expected), $actual_json);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Entity\View;
/**
* Tests authentication for REST display.
*
* @group rest
*/
class RestExportAuthTest extends ViewTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'views_ui', 'basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = []): void {
parent::setUp($import_test_views, $modules);
$this->drupalLogin($this->drupalCreateUser(['administer views']));
}
/**
* Checks that correct authentication providers are available for choosing.
*
* @link https://www.drupal.org/node/2825204
*/
public function testAuthProvidersOptions(): void {
$view_id = 'test_view_rest_export';
$view_label = 'Test view (REST export)';
$view_display = 'rest_export_1';
$view_rest_path = 'test-view/rest-export';
// Create new view.
$this->drupalGet('admin/structure/views/add');
$this->submitForm([
'id' => $view_id,
'label' => $view_label,
'show[wizard_key]' => 'users',
'rest_export[path]' => $view_rest_path,
'rest_export[create]' => TRUE,
], 'Save and edit');
$this->drupalGet("admin/structure/views/nojs/display/$view_id/$view_display/auth");
// The "basic_auth" will always be available since module,
// providing it, has the same name.
$this->assertSession()->fieldExists('edit-auth-basic-auth');
// The "cookie" authentication provider defined by "user" module.
$this->assertSession()->fieldExists('edit-auth-cookie');
// Wrong behavior in "getAuthOptions()" method makes this option available
// instead of "cookie".
// @see \Drupal\rest\Plugin\views\display\RestExport::getAuthOptions()
$this->assertSession()->fieldNotExists('edit-auth-user');
$this->submitForm(['auth[basic_auth]' => 1, 'auth[cookie]' => 1], 'Apply');
$this->submitForm([], 'Save');
$view = View::load($view_id);
$this->assertEquals(['basic_auth', 'cookie'], $view->getDisplay('rest_export_1')['display_options']['auth']);
}
}

View File

@@ -0,0 +1,589 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\Cache;
use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
use Drupal\entity_test\Entity\EntityTest;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Views;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
/**
* Tests the serializer style plugin.
*
* @group rest
* @group #slow
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\style\Serializer
* @see \Drupal\rest\Plugin\views\row\DataEntityRow
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class StyleSerializerEntityTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'views_ui',
'entity_test',
'rest_test_views',
'text',
'field',
'language',
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_display_entity_translated', 'test_serializer_node_display_field', 'test_serializer_node_exposed_filter', 'test_serializer_shared_path'];
/**
* A user with administrative privileges to look at test entity and configure views.
*/
protected $adminUser;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
$this->adminUser = $this->drupalCreateUser([
'administer views',
'administer entity_test content',
'access user profiles',
'view test entity',
]);
// Save some entity_test entities.
for ($i = 1; $i <= 10; $i++) {
EntityTest::create(['name' => 'test_' . $i, 'user_id' => $this->adminUser->id()])->save();
}
$this->enableViewsTestModule();
$this->renderer = \Drupal::service('renderer');
}
/**
* Checks the behavior of the Serializer callback paths and row plugins.
*/
public function testSerializerResponses(): void {
// Test the serialize callback.
$view = Views::getView('test_serializer_display_field');
$view->initDisplay();
$this->executeView($view);
$actual_json = $this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertCacheTags($view->getCacheTags());
$this->assertCacheContexts(['languages:language_interface', 'theme', 'request_format']);
// @todo Due to https://www.drupal.org/node/2352009 we can't yet test the
// propagation of cache max-age.
// Test the http Content-type.
$headers = $this->getSession()->getResponseHeaders();
$this->assertSame(['application/json'], $headers['Content-Type']);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertSame(json_encode($expected), $actual_json, 'The expected JSON output was found.');
// Test that the rendered output and the preview output are the same.
$view->destroy();
$view->setDisplay('rest_export_1');
// Mock the request content type by setting it on the display handler.
$view->display_handler->setContentType('json');
$output = $view->preview();
$this->assertSame((string) $this->renderer->renderRoot($output), $actual_json, 'The expected JSON preview output was found.');
// Test a 403 callback.
$this->drupalGet('test/serialize/denied', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(403);
// Test the entity rows.
$view = Views::getView('test_serializer_display_entity');
$view->initDisplay();
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = [];
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSame($expected, $actual_json, 'The expected JSON output was found.');
$expected_cache_tags = $view->getCacheTags();
$expected_cache_tags[] = 'entity_test_list';
/** @var \Drupal\Core\Entity\EntityInterface $entity */
foreach ($entities as $entity) {
$expected_cache_tags = Cache::mergeTags($expected_cache_tags, $entity->getCacheTags());
}
$this->assertCacheTags($expected_cache_tags);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Change the format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
],
],
]);
$view->save();
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->assertSame(trim($expected), $actual_xml);
$this->assertCacheContexts(['languages:language_interface', 'theme', 'entity_test_view_grants', 'request_format']);
// Allow multiple formats.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
'json' => 'json',
],
],
]);
$view->save();
$expected = $serializer->serialize($entities, 'json');
$actual_json = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
$this->assertSame($expected, $actual_json, 'The expected JSON output was found.');
$expected = $serializer->serialize($entities, 'xml');
$actual_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->assertSame(trim($expected), $actual_xml);
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests REST export with views render caching enabled.
*/
public function testRestRenderCaching(): void {
$this->drupalLogin($this->adminUser);
/** @var \Drupal\Core\Render\RenderCacheInterface $render_cache */
$render_cache = \Drupal::service('render_cache');
// Enable render caching for the views.
/** @var \Drupal\views\ViewEntityInterface $storage */
$storage = View::load('test_serializer_display_entity');
$options = &$storage->getDisplay('default');
$options['display_options']['cache'] = [
'type' => 'tag',
];
$storage->save();
$original = DisplayPluginBase::buildBasicRenderable('test_serializer_display_entity', 'rest_export_1');
// Ensure that there is no corresponding render cache item yet.
$original['#cache'] += ['contexts' => []];
$original['#cache']['contexts'] = Cache::mergeContexts($original['#cache']['contexts'], $this->container->getParameter('renderer.config')['required_cache_contexts']);
$cache_tags = [
'config:views.view.test_serializer_display_entity',
'entity_test:1',
'entity_test:10',
'entity_test:2',
'entity_test:3',
'entity_test:4',
'entity_test:5',
'entity_test:6',
'entity_test:7',
'entity_test:8',
'entity_test:9',
'entity_test_list',
];
$cache_contexts = [
'entity_test_view_grants',
'languages:language_interface',
'theme',
'request_format',
];
$this->assertFalse($render_cache->get($original));
// Request the page, once in XML and once in JSON to ensure that the caching
// varies by it.
$result1 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
$result_xml = $this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'xml']]);
$this->addRequestWithFormat('xml');
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
// Ensure that the XML output is different from the JSON one.
$this->assertNotEquals($result1, $result_xml);
// Ensure that the cached page works.
$result2 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertEquals($result1, $result2);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
// Create a new entity and ensure that the cache tags are taken over.
EntityTest::create(['name' => 'test_11', 'user_id' => $this->adminUser->id()])->save();
$result3 = Json::decode($this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]));
$this->addRequestWithFormat('json');
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertNotEquals($result2, $result3);
// Add the new entity cache tag and remove the first one, because we just
// show 10 items in total.
$cache_tags[] = 'entity_test:11';
unset($cache_tags[array_search('entity_test:1', $cache_tags)]);
$this->assertCacheContexts($cache_contexts);
$this->assertCacheTags($cache_tags);
$this->assertNotEmpty($render_cache->get($original));
}
/**
* Tests the response format configuration.
*/
public function testResponseFormatConfiguration(): void {
$this->drupalLogin($this->adminUser);
$style_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/style_options';
// Ensure a request with no format returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field');
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Select only 'xml' as an accepted format.
$this->drupalGet($style_options);
$this->submitForm(['style_options[formats][xml]' => 'xml'], 'Apply');
$this->submitForm([], 'Save');
// Ensure a request for JSON returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Add 'json' as an accepted format, so we have multiple.
$this->drupalGet($style_options);
$this->submitForm(['style_options[formats][json]' => 'json'], 'Apply');
$this->submitForm([], 'Save');
// Should return a 406. Emulates a sample Firefox header.
$this->drupalGet('test/serialize/field', [], ['Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8']);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for HTML returns 406 Not Acceptable.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'html']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
// Ensure a request for JSON returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(200);
// Ensure a request XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Now configure no format, so both serialization formats should be allowed.
$this->drupalGet($style_options);
$this->submitForm([
'style_options[formats][json]' => '0',
'style_options[formats][xml]' => '0',
], 'Apply');
// Ensure a request for JSON returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->assertSession()->statusCodeEquals(200);
// Ensure a request for XML returns 200 OK.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'xml']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
$this->assertSession()->statusCodeEquals(200);
// Should return a 406 for HTML still.
$this->drupalGet('test/serialize/field', ['query' => ['_format' => 'html']]);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->assertSession()->statusCodeEquals(406);
}
/**
* Tests the field ID alias functionality of the DataFieldRow plugin.
*/
public function testUIFieldAlias(): void {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertSession()->linkByHrefExists($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$this->drupalGet($row_options);
$this->submitForm(['row_options[field_options][name][alias]' => ''], 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$id] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertEquals($expected, Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])));
// Test a random aliases for fields, they should be replaced.
$alias_map = [
'name' => $this->randomMachineName(),
// Use # to produce an invalid character for the validation.
'nothing' => '#' . $this->randomMachineName(),
'created' => 'created',
];
$edit = ['row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']];
$this->drupalGet($row_options);
$this->submitForm($edit, 'Apply');
$this->assertSession()->pageTextContains('The machine-readable name must contain only letters, numbers, dashes and underscores.');
// Change the map alias value to a valid one.
$alias_map['nothing'] = $this->randomMachineName();
$edit = ['row_options[field_options][name][alias]' => $alias_map['name'], 'row_options[field_options][nothing][alias]' => $alias_map['nothing']];
$this->drupalGet($row_options);
$this->submitForm($edit, 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$expected = [];
foreach ($view->result as $row) {
$expected_row = [];
foreach ($view->field as $id => $field) {
$expected_row[$alias_map[$id]] = $field->render($row);
}
$expected[] = $expected_row;
}
$this->assertEquals($expected, Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])));
}
/**
* Tests the raw output options for row field rendering.
*/
public function testFieldRawOutput(): void {
$this->drupalLogin($this->adminUser);
// Test the UI settings for adding field ID aliases.
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$row_options = 'admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options';
$this->assertSession()->linkByHrefExists($row_options);
// Test an empty string for an alias, this should not be used. This also
// tests that the form can be submitted with no aliases.
$values = [
'row_options[field_options][created][raw_output]' => '1',
'row_options[field_options][name][raw_output]' => '1',
];
$this->drupalGet($row_options);
$this->submitForm($values, 'Apply');
$this->submitForm([], 'Save');
$view = Views::getView('test_serializer_display_field');
$view->setDisplay('rest_export_1');
$this->executeView($view);
$storage = $this->container->get('entity_type.manager')->getStorage('entity_test');
// Update the name for each to include a script tag.
foreach ($storage->loadMultiple() as $entity_test) {
$name = $entity_test->name->value;
$entity_test->set('name', "<script>$name</script>");
$entity_test->save();
}
// Just test the raw 'created' value against each row.
foreach (Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])) as $index => $values) {
$this->assertSame($view->result[$index]->views_test_data_created, $values['created'], 'Expected raw created value found.');
$this->assertSame($view->result[$index]->views_test_data_name, $values['name'], 'Expected raw name value found.');
}
// Test result with an excluded field.
$view->setDisplay('rest_export_1');
$view->displayHandlers->get('rest_export_1')->overrideOption('fields', [
'name' => [
'id' => 'name',
'table' => 'views_test_data',
'field' => 'name',
'relationship' => 'none',
],
'created' => [
'id' => 'created',
'exclude' => TRUE,
'table' => 'views_test_data',
'field' => 'created',
'relationship' => 'none',
],
]);
$view->save();
$this->executeView($view);
foreach (Json::decode($this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']])) as $index => $values) {
$this->assertTrue(!isset($values['created']), 'Excluded value not found.');
}
// Test that the excluded field is not shown in the row options.
$this->drupalGet('admin/structure/views/nojs/display/test_serializer_display_field/rest_export_1/row_options');
$this->assertSession()->pageTextNotContains('created');
}
/**
* Tests the live preview output for json output.
*/
public function testLivePreview(): void {
// We set up a request so it looks like a request in the live preview.
$request = new Request();
$request->query->add([MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax']);
$request->setSession(new Session(new MockArraySessionStorage()));
/** @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */
$request_stack = \Drupal::service('request_stack');
$request_stack->push($request);
$view = Views::getView('test_serializer_display_entity');
$view->setDisplay('rest_export_1');
$this->executeView($view);
// Get the serializer service.
$serializer = $this->container->get('serializer');
$entities = [];
foreach ($view->result as $row) {
$entities[] = $row->_entity;
}
$expected = $serializer->serialize($entities, 'json');
$view->live_preview = TRUE;
$build = $view->preview();
$rendered_json = $build['#plain_text'];
$this->assertArrayNotHasKey('#markup', $build);
$this->assertSame($expected, $rendered_json, 'Ensure the previewed json is escaped.');
$view->destroy();
$expected = $serializer->serialize($entities, 'xml');
// Change the request format to xml.
$view->setDisplay('rest_export_1');
$view->getDisplay()->setOption('style', [
'type' => 'serializer',
'options' => [
'uses_fields' => FALSE,
'formats' => [
'xml' => 'xml',
],
],
]);
$this->executeView($view);
$build = $view->preview();
$rendered_xml = $build['#plain_text'];
$this->assertEquals($expected, $rendered_xml, 'Ensure we preview xml when we change the request format.');
}
/**
* Tests the views interface for REST export displays.
*/
public function testSerializerViewsUI(): void {
$this->drupalLogin($this->adminUser);
// Click the "Update preview button".
$this->drupalGet('admin/structure/views/view/test_serializer_display_field/edit/rest_export_1');
$this->submitForm($edit = [], 'Update preview');
$this->assertSession()->statusCodeEquals(200);
// Check if we receive the expected result.
$result = $this->assertSession()->elementExists('xpath', '//div[@id="views-live-preview"]/pre');
$json_preview = $result->getText();
$this->assertSame($json_preview, $this->drupalGet('test/serialize/field', ['query' => ['_format' => 'json']]), 'The expected JSON preview output was found.');
}
}

View File

@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional\Views;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
use Drupal\Tests\views\Functional\ViewTestBase;
use Drupal\views\Views;
/**
* Tests the serializer style plugin.
*
* @group rest
* @group #slow
* @see \Drupal\rest\Plugin\views\display\RestExport
* @see \Drupal\rest\Plugin\views\style\Serializer
* @see \Drupal\rest\Plugin\views\row\DataEntityRow
* @see \Drupal\rest\Plugin\views\row\DataFieldRow
*/
class StyleSerializerTest extends ViewTestBase {
use AssertPageCacheContextsAndTagsTrait;
/**
* Modules to install.
*
* @var array
*/
protected static $modules = [
'views_ui',
'entity_test',
'rest_test_views',
'node',
'text',
'field',
'language',
'basic_auth',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* Views used by this test.
*
* @var array
*/
public static $testViews = ['test_serializer_display_field', 'test_serializer_display_entity', 'test_serializer_display_entity_translated', 'test_serializer_node_display_field', 'test_serializer_node_exposed_filter', 'test_serializer_shared_path'];
/**
* A user with administrative privileges to look at test entity and configure views.
*/
protected $adminUser;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE, $modules = ['rest_test_views']): void {
parent::setUp($import_test_views, $modules);
$this->adminUser = $this->drupalCreateUser([
'administer views',
'administer entity_test content',
'access user profiles',
'view test entity',
]);
$this->enableViewsTestModule();
$this->renderer = \Drupal::service('renderer');
}
/**
* Checks that the auth options restricts access to a REST views display.
*/
public function testRestViewsAuthentication(): void {
// Assume the view is hidden behind a permission.
$this->drupalGet('test/serialize/auth_with_perm', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(401);
// Not even logging in would make it possible to see the view, because then
// we are denied based on authentication method (cookie).
$this->drupalLogin($this->adminUser);
$this->drupalGet('test/serialize/auth_with_perm', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(403);
$this->drupalLogout();
// But if we use the basic auth authentication strategy, we should be able
// to see the page.
$url = $this->buildUrl('test/serialize/auth_with_perm');
$response = \Drupal::httpClient()->get($url, [
'auth' => [$this->adminUser->getAccountName(), $this->adminUser->pass_raw],
'query' => [
'_format' => 'json',
],
]);
// Ensure that any changes to variables in the other thread are picked up.
$this->refreshVariables();
$this->assertSession()->statusCodeEquals(200);
}
/**
* Verifies REST export views work on the same path as a page display.
*/
public function testSharedPagePath(): void {
// Test with no format as well as html explicitly.
$this->drupalGet('test/serialize/shared');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'html']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/html; charset=UTF-8');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'json']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'application/json');
$this->drupalGet('test/serialize/shared', ['query' => ['_format' => 'xml']]);
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->responseHeaderEquals('content-type', 'text/xml; charset=UTF-8');
}
/**
* Verifies site maintenance mode functionality.
*/
public function testSiteMaintenance(): void {
$view = Views::getView('test_serializer_display_field');
$view->initDisplay();
$this->executeView($view);
// Set the site to maintenance mode.
$this->container->get('state')->set('system.maintenance_mode', TRUE);
$this->drupalGet('test/serialize/entity', ['query' => ['_format' => 'json']]);
// Verify that the endpoint is unavailable for anonymous users.
$this->assertSession()->statusCodeEquals(503);
}
/**
* Sets up a request on the request stack with a specified format.
*
* @param string $format
* The new request format.
*/
protected function addRequestWithFormat($format) {
$request = \Drupal::request();
$request = clone $request;
$request->setRequestFormat($format);
\Drupal::requestStack()->push($request);
}
/**
* Tests the "Grouped rows" functionality.
*/
public function testGroupRows(): void {
$this->drupalCreateContentType(['type' => 'page']);
// Create a text field with cardinality set to unlimited.
$field_name = 'field_group_rows';
$field_storage = FieldStorageConfig::create([
'field_name' => $field_name,
'entity_type' => 'node',
'type' => 'string',
'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED,
]);
$field_storage->save();
// Create an instance of the text field on the content type.
$field = FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'page',
]);
$field->save();
$grouped_field_values = ['a', 'b', 'c'];
$edit = [
'title' => $this->randomMachineName(),
$field_name => $grouped_field_values,
];
$this->drupalCreateNode($edit);
$view = Views::getView('test_serializer_node_display_field');
$view->setDisplay('rest_export_1');
// Override the view's fields to include the field_group_rows field, set the
// group_rows setting to true.
$fields = [
$field_name => [
'id' => $field_name,
'table' => 'node__' . $field_name,
'field' => $field_name,
'type' => 'string',
'group_rows' => TRUE,
],
];
$view->displayHandlers->get('default')->overrideOption('fields', $fields);
$build = $view->preview();
// Get the serializer service.
$serializer = $this->container->get('serializer');
// Check if the field_group_rows field is grouped.
$expected = [];
$expected[] = [$field_name => implode(', ', $grouped_field_values)];
$this->assertEquals($serializer->serialize($expected, 'json'), (string) $this->renderer->renderRoot($build));
// Set the group rows setting to false.
$view = Views::getView('test_serializer_node_display_field');
$view->setDisplay('rest_export_1');
$fields[$field_name]['group_rows'] = FALSE;
$view->displayHandlers->get('default')->overrideOption('fields', $fields);
$build = $view->preview();
// Check if the field_group_rows field is ungrouped and displayed per row.
$expected = [];
foreach ($grouped_field_values as $grouped_field_value) {
$expected[] = [$field_name => $grouped_field_value];
}
$this->assertEquals($serializer->serialize($expected, 'json'), (string) $this->renderer->renderRoot($build));
}
/**
* Tests the exposed filter works.
*
* There is an exposed filter on the title field which takes a title query
* parameter. This is set to filter nodes by those whose title starts with
* the value provided.
*/
public function testRestViewExposedFilter(): void {
$this->drupalCreateContentType(['type' => 'page']);
$node0 = $this->drupalCreateNode(['title' => 'Node 1']);
$node1 = $this->drupalCreateNode(['title' => 'Node 11']);
$node2 = $this->drupalCreateNode(['title' => 'Node 111']);
// Test that no filter brings back all three nodes.
$result = Json::decode($this->drupalGet('test/serialize/node-exposed-filter', ['query' => ['_format' => 'json']]));
$expected = [
0 => [
'nid' => $node0->id(),
'body' => (string) $node0->body->processed,
],
1 => [
'nid' => $node1->id(),
'body' => (string) $node1->body->processed,
],
2 => [
'nid' => $node2->id(),
'body' => (string) $node2->body->processed,
],
];
$this->assertSame($expected, $result, 'Querying a view with no exposed filter returns all nodes.');
// Test that title starts with 'Node 11' query finds 2 of the 3 nodes.
$result = Json::decode($this->drupalGet('test/serialize/node-exposed-filter', ['query' => ['_format' => 'json', 'title' => 'Node 11']]));
$expected = [
0 => [
'nid' => $node1->id(),
'body' => (string) $node1->body->processed,
],
1 => [
'nid' => $node2->id(),
'body' => (string) $node2->body->processed,
],
];
$cache_contexts = [
'languages:language_content',
'languages:language_interface',
'theme',
'request_format',
'user.node_grants:view',
'url',
];
$this->assertSame($expected, $result, 'Querying a view with a starts with exposed filter on the title returns nodes whose title starts with value provided.');
$this->assertCacheContexts($cache_contexts);
}
/**
* Tests multilingual entity rows.
*/
public function testMulEntityRows(): void {
// Create some languages.
ConfigurableLanguage::createFromLangcode('l1')->save();
ConfigurableLanguage::createFromLangcode('l2')->save();
// Create an entity with no translations.
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_mul');
$storage->create(['langcode' => 'l1', 'name' => 'mul-none'])->save();
// Create some entities with translations.
$entity = $storage->create(['langcode' => 'l1', 'name' => 'mul-l1-orig']);
$entity->save();
$entity->addTranslation('l2', ['name' => 'mul-l1-l2'])->save();
$entity = $storage->create(['langcode' => 'l2', 'name' => 'mul-l2-orig']);
$entity->save();
$entity->addTranslation('l1', ['name' => 'mul-l2-l1'])->save();
// Get the names of the output.
$json = $this->drupalGet('test/serialize/translated_entity', ['query' => ['_format' => 'json']]);
$decoded = $this->container->get('serializer')->decode($json, 'json');
$names = [];
foreach ($decoded as $item) {
$names[] = $item['name'][0]['value'];
}
sort($names);
// Check that the names are correct.
$expected = ['mul-l1-l2', 'mul-l1-orig', 'mul-l2-l1', 'mul-l2-orig', 'mul-none'];
$this->assertSame($expected, $names, 'The translated content was found in the JSON.');
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Functional;
/**
* Trait for ResourceTestBase subclasses testing $format='xml'.
*/
trait XmlNormalizationQuirksTrait {
/**
* Applies the XML encoding quirks that remain after decoding.
*
* The XML encoding:
* - maps empty arrays to the empty string
* - maps single-item arrays to just that single item
* - restructures multiple-item arrays that lives in a single-item array
*
* @param array $normalization
* A normalization.
*
* @return array
* The updated normalization.
*
* @see \Symfony\Component\Serializer\Encoder\XmlEncoder
*/
protected function applyXmlDecodingQuirks(array $normalization) {
foreach ($normalization as $key => $value) {
if ($value === [] || $value === NULL) {
$normalization[$key] = '';
}
elseif (is_array($value)) {
// Collapse single-item numeric arrays to just the single item.
if (count($value) === 1 && is_numeric(array_keys($value)[0]) && is_scalar($value[0])) {
$value = $value[0];
}
// Restructure multiple-item arrays inside a single-item numeric array.
// @see \Symfony\Component\Serializer\Encoder\XmlEncoder::buildXml()
elseif (count($value) === 1 && is_numeric(array_keys($value)[0]) && is_array(reset($value))) {
$rewritten_value = [];
foreach ($value[0] as $child_key => $child_value) {
if (is_numeric(array_keys(reset($value))[0])) {
$rewritten_value[$child_key] = ['@key' => $child_key] + $child_value;
}
else {
$rewritten_value[$child_key] = $child_value;
}
}
$value = $rewritten_value;
}
// If the post-quirk value is still an array after the above, recurse.
if (is_array($value)) {
$value = $this->applyXmlDecodingQuirks($value);
}
// Store post-quirk value.
$normalization[$key] = $value;
}
}
return $normalization;
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\ConfigDependencies;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\Entity\ConfigDependencies
*
* @group rest
*/
class ConfigDependenciesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'entity_test', 'serialization'];
/**
* @covers ::calculateDependencies
*
* @dataProvider providerBasicDependencies
*/
public function testCalculateDependencies(array $configuration): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$result = $config_dependencies->calculateDependencies($rest_config);
$this->assertEquals([
'module' => ['basic_auth', 'serialization'],
], $result);
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForMethodGranularity
* @covers ::onDependencyRemovalForResourceGranularity
*
* @dataProvider providerBasicDependencies
*/
public function testOnDependencyRemovalRemoveUnrelatedDependency(array $configuration): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$this->assertFalse($config_dependencies->onDependencyRemoval($rest_config, ['module' => ['node']]));
$this->assertEquals($configuration['configuration'], $rest_config->get('configuration'));
}
/**
* @return array
* An array with numerical keys:
* 0. The original REST resource configuration.
*/
public static function providerBasicDependencies() {
return [
'method' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['xml'],
],
],
],
],
'resource' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json'],
'authentication' => ['cookie', 'basic_auth'],
],
],
],
];
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForMethodGranularity
*/
public function testOnDependencyRemovalRemoveAuth(): void {
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
],
]);
$this->assertTrue($config_dependencies->onDependencyRemoval($rest_config, ['module' => ['basic_auth']]));
$this->assertEquals(['cookie'], $rest_config->getAuthenticationProviders('GET'));
$this->assertEquals([], $rest_config->getAuthenticationProviders('POST'));
$this->assertEquals([
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_formats' => ['json'],
],
], $rest_config->get('configuration'));
}
/**
* @covers ::onDependencyRemoval
* @covers ::onDependencyRemovalForResourceGranularity
*
* @dataProvider providerOnDependencyRemovalForResourceGranularity
*/
public function testOnDependencyRemovalForResourceGranularity(array $configuration, $module, $expected_configuration): void {
assert(is_string($module));
assert($expected_configuration === FALSE || is_array($expected_configuration));
$config_dependencies = new ConfigDependencies(['json' => 'serialization'], ['basic_auth' => 'basic_auth']);
$rest_config = RestResourceConfig::create($configuration);
$this->assertSame(!empty($expected_configuration), $config_dependencies->onDependencyRemoval($rest_config, ['module' => [$module]]));
if (!empty($expected_configuration)) {
$this->assertEquals($expected_configuration, $rest_config->get('configuration'));
}
}
/**
* @return array
* An array with numerical keys:
* 0. The original REST resource configuration.
* 1. The module to uninstall (the dependency that is about to be removed).
* 2. The expected configuration after uninstalling this module.
*/
public static function providerOnDependencyRemovalForResourceGranularity() {
return [
'resource with multiple formats' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['xml', 'json'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'serialization',
[
'methods' => ['GET', 'POST'],
'formats' => ['xml'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'resource with multiple authentication providers' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['cookie', 'basic_auth'],
],
],
'basic_auth',
[
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['cookie'],
],
],
'resource with only basic_auth authentication' => [
[
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
'configuration' => [
'methods' => ['GET', 'POST'],
'formats' => ['json', 'xml'],
'authentication' => ['basic_auth'],
],
],
'basic_auth',
FALSE,
],
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\RestPermissions
*
* @group rest
*/
class RestPermissionsTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest',
'dblog',
'serialization',
'basic_auth',
'user',
];
/**
* @covers ::permissions
*/
public function testPermissions(): void {
RestResourceConfig::create([
'id' => 'dblog',
'plugin_id' => 'dblog',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
],
])->save();
$permissions = $this->container->get('user.permissions')->getPermissions();
$this->assertArrayHasKey('restful get dblog', $permissions);
$this->assertSame(['config' => ['rest.resource.dblog']], $permissions['restful get dblog']['dependencies']);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* @coversDefaultClass \Drupal\rest\Entity\RestResourceConfig
*
* @group rest
*/
class RestResourceConfigTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest',
'entity_test',
'serialization',
'basic_auth',
'user',
];
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies(): void {
$rest_config = RestResourceConfig::create([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [
'GET' => [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
],
'POST' => [
'supported_auth' => ['basic_auth'],
'supported_formats' => ['json'],
],
],
]);
$rest_config->calculateDependencies();
$this->assertEquals(['module' => ['basic_auth', 'entity_test', 'serialization', 'user']], $rest_config->getDependencies());
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Entity;
use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
/**
* Tests validation of rest_resource_config entities.
*
* @group rest
*/
class RestResourceConfigValidationTest extends ConfigEntityValidationTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['rest', 'serialization'];
/**
* {@inheritdoc}
*/
protected bool $hasLabel = FALSE;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->entity = RestResourceConfig::create([
'id' => 'test',
'plugin_id' => 'entity:date_format',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => [],
]);
$this->entity->save();
}
/**
* Tests that the resource plugin ID is validated.
*/
public function testInvalidPluginId(): void {
$this->entity->set('plugin_id', 'non_existent');
$this->assertValidationErrors([
'plugin_id' => "The 'non_existent' plugin does not exist.",
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\EntityResource;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Extension\ExtensionLifecycle;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
/**
* Checks that all core content/config entity types have REST test coverage.
*
* Every entity type must have test coverage for:
* - every format in core (json + xml)
* - every authentication provider in core (anon, cookie, basic_auth)
*
* Additionally, every entity type must have the correct parent test class.
*
* @group rest
*/
class EntityResourceRestTestCoverageTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['system', 'user'];
/**
* Entity definitions array.
*
* @var array
*/
protected $definitions;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$all_modules = $this->container->get('extension.list.module')->getList();
$stable_core_modules = array_filter($all_modules, function ($module) {
// Filter out contrib, hidden, testing, deprecated and experimental
// modules. We also don't need to enable modules that are already enabled.
return $module->origin === 'core' &&
empty($module->info['hidden']) &&
$module->status == FALSE &&
$module->info['package'] !== 'Testing' &&
$module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] !== ExtensionLifecycle::DEPRECATED &&
$module->info[ExtensionLifecycle::LIFECYCLE_IDENTIFIER] !== ExtensionLifecycle::EXPERIMENTAL;
});
$this->container->get('module_installer')->install(array_keys($stable_core_modules));
$this->definitions = $this->container->get('entity_type.manager')->getDefinitions();
// Entity types marked as "internal" are not exposed by the entity REST
// resource plugin and hence also don't need test coverage.
$this->definitions = array_filter($this->definitions, function (EntityTypeInterface $entity_type) {
return !$entity_type->isInternal();
});
}
/**
* Tests that all core content/config entity types have REST test coverage.
*/
public function testEntityTypeRestTestCoverage(): void {
$tests = [
// Test coverage for formats provided by the 'serialization' module.
'serialization' => [
'path' => '\Drupal\Tests\PROVIDER\Functional\Rest\CLASS',
'class suffix' => [
'JsonAnonTest',
'JsonBasicAuthTest',
'JsonCookieTest',
'XmlAnonTest',
'XmlBasicAuthTest',
'XmlCookieTest',
],
],
];
$problems = [];
foreach ($this->definitions as $entity_type_id => $info) {
$class_name_full = $info->getClass();
$parts = explode('\\', $class_name_full);
$class_name = end($parts);
$module_name = $parts[1];
foreach ($tests as $module => $info) {
$path = $info['path'];
$missing_tests = [];
foreach ($info['class suffix'] as $postfix) {
$class = str_replace(['PROVIDER', 'CLASS'], [$module_name, $class_name], $path . $postfix);
$class_alternative = str_replace("\\Drupal\\Tests\\$module_name\\Functional", '\Drupal\FunctionalTests', $class);
// For entities defined in the system module with Jsonapi tests in
// another module.
$class_entity_in_system_alternative = str_replace(['PROVIDER', 'CLASS'], [$entity_type_id, $class_name], $path . $postfix);
if (class_exists($class) || class_exists($class_alternative) || class_exists($class_entity_in_system_alternative)) {
continue;
}
$missing_tests[] = $postfix;
}
if (!empty($missing_tests)) {
$missing_tests_list = implode(', ', array_map(function ($missing_test) use ($class_name) {
return $class_name . $missing_test;
}, $missing_tests));
$which_normalization = $module === 'serialization' ? 'default' : $module;
$problems[] = "$entity_type_id: $class_name ($class_name_full), $which_normalization normalization (expected tests: $missing_tests_list)";
}
}
$config_entity = is_subclass_of($class_name_full, ConfigEntityInterface::class);
$config_test = is_subclass_of($class, ConfigEntityResourceTestBase::class)
|| is_subclass_of($class_alternative, ConfigEntityResourceTestBase::class)
|| is_subclass_of($class_entity_in_system_alternative, ConfigEntityResourceTestBase::class);
if ($config_entity && !$config_test) {
$problems[] = "$entity_type_id: $class_name is a config entity, but the test is for content entities.";
}
elseif (!$config_entity && $config_test) {
$problems[] = "$entity_type_id: $class_name is a content entity, but the test is for config entities.";
}
}
$this->assertSame([], $problems);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Routing\RouteMatch;
use Drupal\KernelTests\KernelTestBase;
use Drupal\rest\Plugin\ResourceBase;
use Drupal\rest\RequestHandler;
use Drupal\rest\ResourceResponse;
use Drupal\rest\RestResourceConfigInterface;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Route;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Test REST RequestHandler controller logic.
*
* @group rest
* @coversDefaultClass \Drupal\rest\RequestHandler
*/
class RequestHandlerTest extends KernelTestBase {
/**
* @var \Drupal\rest\RequestHandler
*/
protected $requestHandler;
protected static $modules = ['serialization', 'rest'];
/**
* The entity storage.
*
* @var \Prophecy\Prophecy\ObjectProphecy
*/
protected $entityStorage;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$serializer = $this->prophesize(SerializerInterface::class);
$serializer->willImplement(DecoderInterface::class);
$serializer->decode(Json::encode(['this is an array']), 'json', Argument::type('array'))
->willReturn(['this is an array']);
$this->requestHandler = new RequestHandler($serializer->reveal());
}
/**
* @covers ::handle
*/
public function testHandle(): void {
$request = new Request([], [], [], [], [], ['CONTENT_TYPE' => 'application/json'], Json::encode(['this is an array']));
$route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin', 'example' => ''], ['_format' => 'json']))->setMethods(['GET']));
$resource = $this->prophesize(StubRequestHandlerResourcePlugin::class);
$resource->get('', $request)
->shouldBeCalled();
$resource->getPluginDefinition()
->willReturn([])
->shouldBeCalled();
// Setup the configuration.
$config = $this->prophesize(RestResourceConfigInterface::class);
$config->getResourcePlugin()->willReturn($resource->reveal());
$config->getCacheContexts()->willReturn([]);
$config->getCacheTags()->willReturn([]);
$config->getCacheMaxAge()->willReturn(12);
// Response returns NULL this time because response from plugin is not
// a ResourceResponse so it is passed through directly.
$response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals(NULL, $response);
// Response will return a ResourceResponse this time.
$response = new ResourceResponse([]);
$resource->get(NULL, $request)
->willReturn($response);
$handler_response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals($response, $handler_response);
// We will call the patch method this time.
$route_match = new RouteMatch('test', (new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin', 'example_original' => ''], ['_content_type_format' => 'json']))->setMethods(['PATCH']));
$request->setMethod('PATCH');
$response = new ResourceResponse([]);
$resource->patch(['this is an array'], $request)
->shouldBeCalledTimes(1)
->willReturn($response);
$handler_response = $this->requestHandler->handle($route_match, $request, $config->reveal());
$this->assertEquals($response, $handler_response);
}
}
/**
* Stub class where we can prophesize methods.
*/
class StubRequestHandlerResourcePlugin extends ResourceBase {
public function get($example = NULL, ?Request $request = NULL) {}
public function post() {}
public function patch($data, Request $request) {}
public function delete() {}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Views;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Tests\ViewTestData;
/**
* Tests the REST export view display plugin.
*
* @coversDefaultClass \Drupal\rest\Plugin\views\display\RestExport
*
* @group rest
*/
class RestExportTest extends ViewsKernelTestBase {
/**
* {@inheritdoc}
*/
public static $testViews = ['test_serializer_display_entity'];
/**
* {@inheritdoc}
*/
protected static $modules = [
'rest_test_views',
'serialization',
'rest',
'entity_test',
];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE): void {
parent::setUp($import_test_views);
ViewTestData::createTestViews(static::class, ['rest_test_views']);
$this->installEntitySchema('entity_test');
}
/**
* @covers ::buildResponse
*/
public function testBuildResponse(): void {
/** @var \Drupal\views\Entity\View $view */
$view = View::load('test_serializer_display_entity');
$display = &$view->getDisplay('rest_export_1');
$display['display_options']['defaults']['style'] = FALSE;
$display['display_options']['style']['type'] = 'serializer';
$display['display_options']['style']['options']['formats'] = ['json', 'xml'];
$view->save();
// No custom header should be set yet.
$response = RestExport::buildResponse('test_serializer_display_entity', 'rest_export_1', []);
$this->assertEmpty($response->headers->get('Custom-Header'));
// Clear render cache.
/** @var \Drupal\Core\Cache\MemoryBackend $render_cache */
$render_cache = $this->container->get('cache_factory')->get('render');
$render_cache->deleteAll();
// A custom header should now be added.
// @see rest_test_views_views_post_execute()
$header = $this->randomString();
$this->container->get('state')->set('rest_test_views_set_header', $header);
$response = RestExport::buildResponse('test_serializer_display_entity', 'rest_export_1', []);
$this->assertEquals($header, $response->headers->get('Custom-Header'));
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Kernel\Views;
use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
use Drupal\views\Entity\View;
use Drupal\views\Tests\ViewTestData;
/**
* @coversDefaultClass \Drupal\rest\Plugin\views\style\Serializer
* @group views
*/
class StyleSerializerKernelTest extends ViewsKernelTestBase {
/**
* {@inheritdoc}
*/
public static $testViews = ['test_serializer_display_entity'];
/**
* {@inheritdoc}
*/
protected static $modules = ['rest_test_views', 'serialization', 'rest'];
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE): void {
parent::setUp($import_test_views);
ViewTestData::createTestViews(static::class, ['rest_test_views']);
}
/**
* @covers ::calculateDependencies
*/
public function testCalculateDependencies(): void {
/** @var \Drupal\views\Entity\View $view */
$view = View::load('test_serializer_display_entity');
$display = &$view->getDisplay('rest_export_1');
$display['display_options']['defaults']['style'] = FALSE;
$display['display_options']['style']['type'] = 'serializer';
$display['display_options']['style']['options']['formats'] = ['json', 'xml'];
$view->save();
$view->calculateDependencies();
$this->assertEquals(['module' => ['rest', 'serialization', 'user']], $view->getDependencies());
}
}

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\views\Entity\View;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
/**
* Tests the REST export view plugin.
*
* @group rest
*/
class CollectRoutesTest extends UnitTestCase {
/**
* The REST export instance.
*
* @var \Drupal\rest\Plugin\views\display\RestExport
*/
protected $restExport;
/**
* The RouteCollection.
*/
protected $routes;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$container = new ContainerBuilder();
$request = $this->getMockBuilder('\Symfony\Component\HttpFoundation\Request')
->disableOriginalConstructor()
->getMock();
$view = new View(['id' => 'test_view'], 'view');
$view_executable = $this->getMockBuilder('\Drupal\views\ViewExecutable')
->onlyMethods(['initHandlers', 'getTitle'])
->disableOriginalConstructor()
->getMock();
$view_executable->expects($this->any())
->method('getTitle')
->willReturn('View title');
$view_executable->storage = $view;
$view_executable->argument = [];
$display_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.display', $display_manager);
$access_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.access', $access_manager);
$route_provider = $this->getMockBuilder('\Drupal\Core\Routing\RouteProviderInterface')
->disableOriginalConstructor()
->getMock();
$container->set('router.route_provider', $route_provider);
$container->setParameter('authentication_providers', ['basic_auth' => 'basic_auth']);
$state = $this->createMock('\Drupal\Core\State\StateInterface');
$container->set('state', $state);
$style_manager = $this->getMockBuilder('\Drupal\views\Plugin\ViewsPluginManager')
->disableOriginalConstructor()
->getMock();
$container->set('plugin.manager.views.style', $style_manager);
$container->set('renderer', $this->createMock('Drupal\Core\Render\RendererInterface'));
$authentication_collector = $this->createMock('\Drupal\Core\Authentication\AuthenticationCollectorInterface');
$container->set('authentication_collector', $authentication_collector);
$authentication_collector->expects($this->any())
->method('getSortedProviders')
->willReturn(['basic_auth' => 'data', 'cookie' => 'data']);
$container->setParameter('serializer.format_providers', ['json']);
\Drupal::setContainer($container);
$this->restExport = RestExport::create($container, [], "test_routes", []);
$this->restExport->view = $view_executable;
// Initialize a display.
$this->restExport->display = ['id' => 'page_1'];
// Set the style option.
$this->restExport->setOption('style', ['type' => 'serializer']);
// Set the auth option.
$this->restExport->setOption('auth', ['basic_auth']);
$display_manager->expects($this->once())
->method('getDefinition')
->willReturn(['id' => 'test', 'provider' => 'test']);
$none = $this->getMockBuilder('\Drupal\views\Plugin\views\access\None')
->disableOriginalConstructor()
->getMock();
$access_manager->expects($this->once())
->method('createInstance')
->willReturn($none);
$style_plugin = $this->getMockBuilder('\Drupal\rest\Plugin\views\style\Serializer')
->onlyMethods(['getFormats', 'init'])
->disableOriginalConstructor()
->getMock();
$style_plugin->expects($this->once())
->method('getFormats')
->willReturn(['json']);
$style_plugin->expects($this->once())
->method('init')
->with($view_executable)
->willReturn(TRUE);
$style_manager->expects($this->once())
->method('createInstance')
->willReturn($style_plugin);
$this->routes = new RouteCollection();
$this->routes->add('test_1', new Route('/test/1'));
$this->routes->add('view.test_view.page_1', new Route('/test/2'));
$view->addDisplay('page', NULL, 'page_1');
}
/**
* Tests if adding a requirement to a route only modify one route.
*/
public function testRoutesRequirements(): void {
$this->restExport->collectRoutes($this->routes);
$requirements_1 = $this->routes->get('test_1')->getRequirements();
$requirements_2 = $this->routes->get('view.test_view.page_1')->getRequirements();
$this->assertCount(0, $requirements_1, 'First route has no requirement.');
$this->assertCount(1, $requirements_2, 'Views route with rest export had the format requirement added.');
// Check auth options.
$auth = $this->routes->get('view.test_view.page_1')->getOption('_auth');
$this->assertCount(1, $auth, 'View route with rest export has an auth option added');
$this->assertEquals('basic_auth', $auth[0], 'View route with rest export has the correct auth option added');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\Entity;
use Drupal\rest\Entity\RestResourceConfig;
use Drupal\rest\RestResourceConfigInterface;
use Drupal\Tests\UnitTestCase;
/**
* @coversDefaultClass \Drupal\rest\Entity\RestResourceConfig
*
* @group rest
*/
class RestResourceConfigTest extends UnitTestCase {
/**
* Asserts that rest methods are normalized to upper case.
*
* This also tests that no exceptions are thrown during that method so that
* alternate methods such as OPTIONS and PUT are supported.
*/
public function testNormalizeRestMethod(): void {
$expected = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'FOO'];
$methods = ['get', 'put', 'post', 'patch', 'delete', 'options', 'foo'];
$configuration = [];
foreach ($methods as $method) {
$configuration[$method] = [
'supported_auth' => ['cookie'],
'supported_formats' => ['json'],
];
}
$entity = new RestResourceConfig([
'plugin_id' => 'entity:entity_test',
'granularity' => RestResourceConfigInterface::METHOD_GRANULARITY,
'configuration' => $configuration,
], 'rest_resource_config');
$this->assertEquals($expected, $entity->getMethods());
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit;
use Drupal\Core\Entity\EntityConstraintViolationList;
use Drupal\node\Entity\Node;
use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait;
use Drupal\Tests\UnitTestCase;
use Drupal\user\Entity\User;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\Validator\ConstraintViolationInterface;
/**
* @group rest
* @coversDefaultClass \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait
*/
class EntityResourceValidationTraitTest extends UnitTestCase {
/**
* @covers ::validate
*/
public function testValidate(): void {
$trait = new EntityResourceValidationTraitTestClass();
$method = new \ReflectionMethod($trait, 'validate');
$violations = $this->prophesize(EntityConstraintViolationList::class);
$violations->filterByFieldAccess()->shouldBeCalled()->willReturn([]);
$violations->count()->shouldBeCalled()->willReturn(0);
$entity = $this->prophesize(Node::class);
$entity->validate()->shouldBeCalled()->willReturn($violations->reveal());
$method->invoke($trait, $entity->reveal());
}
/**
* @covers ::validate
*/
public function testFailedValidate(): void {
$violation1 = $this->prophesize(ConstraintViolationInterface::class);
$violation1->getPropertyPath()->willReturn('property_path');
$violation1->getMessage()->willReturn('message');
$violation2 = $this->prophesize(ConstraintViolationInterface::class);
$violation2->getPropertyPath()->willReturn('property_path');
$violation2->getMessage()->willReturn('message');
$entity = $this->prophesize(User::class);
$violations = $this->getMockBuilder(EntityConstraintViolationList::class)
->setConstructorArgs([$entity->reveal(), [$violation1->reveal(), $violation2->reveal()]])
->onlyMethods(['filterByFieldAccess'])
->getMock();
$violations->expects($this->once())
->method('filterByFieldAccess')
->willReturn([]);
$entity->validate()->willReturn($violations);
$trait = new EntityResourceValidationTraitTestClass();
$method = new \ReflectionMethod($trait, 'validate');
$this->expectException(UnprocessableEntityHttpException::class);
$method->invoke($trait, $entity->reveal());
}
}
/**
* A test class to use to test EntityResourceValidationTrait.
*
* Because the mock doesn't use the \Drupal namespace, the Symfony 4+ class
* loader will throw a deprecation error.
*/
class EntityResourceValidationTraitTestClass {
use EntityResourceValidationTrait;
}

View File

@@ -0,0 +1,424 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\EventSubscriber;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\rest\EventSubscriber\ResourceResponseSubscriber;
use Drupal\rest\ModifiedResourceResponse;
use Drupal\rest\ResourceResponse;
use Drupal\rest\ResourceResponseInterface;
use Drupal\serialization\Encoder\JsonEncoder;
use Drupal\serialization\Encoder\XmlEncoder;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @coversDefaultClass \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
* @group rest
*/
class ResourceResponseSubscriberTest extends UnitTestCase {
/**
* @covers ::onResponse
* @dataProvider providerTestSerialization
*/
public function testSerialization($data, $expected_response = FALSE): void {
$request = new Request();
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => 'rest_plugin'], ['_format' => 'json']));
$handler_response = new ResourceResponse($data);
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
// Content is a serialized version of the data we provided.
$this->assertEquals($expected_response !== FALSE ? $expected_response : Json::encode($data), $event->getResponse()->getContent());
}
public static function providerTestSerialization() {
return [
// The default data for \Drupal\rest\ResourceResponse.
'default' => [NULL, ''],
'empty string' => [''],
'simple string' => ['string'],
// cSpell:disable-next-line
'complex string' => ['Complex \ string $%^&@ with unicode ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΣὨ'],
'empty array' => [[]],
'numeric array' => [['test']],
'associative array' => [['test' => 'foobar']],
'boolean true' => [TRUE],
'boolean false' => [FALSE],
];
}
/**
* @covers ::getResponseFormat
*
* Note this does *not* need to test formats being requested that are not
* accepted by the server, because the routing system would have already
* prevented those from reaching the controller.
*
* @dataProvider providerTestResponseFormat
*/
public function testResponseFormat($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
$resource_response_subscriber = new ResourceResponseSubscriber(
$this->prophesize(SerializerInterface::class)->reveal(),
$this->prophesize(RendererInterface::class)->reveal(),
$route_match
);
$this->assertSame($expected_response_format, $resource_response_subscriber->getResponseFormat($route_match, $request));
}
}
/**
* @covers ::onResponse
* @covers ::getResponseFormat
* @covers ::renderResponseBody
* @covers ::flattenResponse
*
* @dataProvider providerTestResponseFormat
*/
public function testOnResponseWithCacheableResponse($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
// The RequestHandler must return a ResourceResponseInterface object.
$handler_response = new ResourceResponse(['REST' => 'Drupal']);
$this->assertInstanceOf(ResourceResponseInterface::class, $handler_response);
$this->assertInstanceOf(CacheableResponseInterface::class, $handler_response);
// The ResourceResponseSubscriber must then generate a response body and
// transform it to a plain CacheableResponse object.
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
$final_response = $event->getResponse();
$this->assertNotInstanceOf(ResourceResponseInterface::class, $final_response);
$this->assertInstanceOf(CacheableResponseInterface::class, $final_response);
$this->assertSame($expected_response_content_type, $final_response->headers->get('Content-Type'));
$this->assertEquals($expected_response_content, $final_response->getContent());
}
}
/**
* @covers ::onResponse
* @covers ::getResponseFormat
* @covers ::renderResponseBody
* @covers ::flattenResponse
*
* @dataProvider providerTestResponseFormat
*/
public function testOnResponseWithUncacheableResponse($methods, array $supported_response_formats, array $supported_request_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content): void {
foreach ($request_headers as $key => $value) {
unset($request_headers[$key]);
$key = strtoupper(str_replace('-', '_', $key));
$request_headers[$key] = $value;
}
foreach ($methods as $method) {
$request = Request::create('/rest/test', $method, [], [], [], $request_headers, $request_body);
// \Drupal\Core\StackMiddleware\NegotiationMiddleware normally takes care
// of this so we'll hard code it here.
if ($request_format) {
$request->setRequestFormat($request_format);
}
$route_requirements = $this->generateRouteRequirements($supported_response_formats, $supported_request_formats);
$route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements));
// The RequestHandler must return a ResourceResponseInterface object.
$handler_response = new ModifiedResourceResponse(['REST' => 'Drupal']);
$this->assertInstanceOf(ResourceResponseInterface::class, $handler_response);
$this->assertNotInstanceOf(CacheableResponseInterface::class, $handler_response);
// The ResourceResponseSubscriber must then generate a response body and
// transform it to a plain Response object.
$resource_response_subscriber = $this->getFunctioningResourceResponseSubscriber($route_match);
$event = new ResponseEvent(
$this->prophesize(HttpKernelInterface::class)->reveal(),
$request,
HttpKernelInterface::MAIN_REQUEST,
$handler_response
);
$resource_response_subscriber->onResponse($event);
$final_response = $event->getResponse();
$this->assertNotInstanceOf(ResourceResponseInterface::class, $final_response);
$this->assertNotInstanceOf(CacheableResponseInterface::class, $final_response);
$this->assertSame($expected_response_content_type, $final_response->headers->get('Content-Type'));
$this->assertEquals($expected_response_content, $final_response->getContent());
}
}
/**
* @return array
* 0. methods to test
* 1. supported formats for route requirements
* 2. request format
* 3. request headers
* 4. request body
* 5. expected response format
* 6. expected response content type
* 7. expected response body
*/
public static function providerTestResponseFormat() {
$json_encoded = Json::encode(['REST' => 'Drupal']);
$xml_encoded = "<?xml version=\"1.0\"?>\n<response><REST>Drupal</REST></response>\n";
$safe_method_test_cases = [
'safe methods: client requested format (JSON)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
'json',
[],
NULL,
'json',
'application/json',
$json_encoded,
],
'safe methods: client requested format (XML)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
'xml',
[],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'safe methods: client requested no format: response should use the first configured format (JSON)' => [
['GET', 'HEAD'],
['json', 'xml'],
[],
FALSE,
[],
NULL,
'json',
'application/json',
$json_encoded,
],
'safe methods: client requested no format: response should use the first configured format (XML)' => [
['GET', 'HEAD'],
['xml', 'json'],
[],
FALSE,
[],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
];
$unsafe_method_bodied_test_cases = [
'unsafe methods with response (POST, PATCH): client requested no format, response should use request body format (JSON)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'application/json'],
$json_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested no format, response should use request body format (XML)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'text/xml'],
$xml_encoded,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format (JSON): response format should use requested format (XML)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
'xml',
['Content-Type' => 'application/json'],
$json_encoded,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format (XML), but is allowed for the request body (JSON)' => [
['POST', 'PATCH'],
['xml', 'json'],
['xml', 'json'],
'json',
['Content-Type' => 'text/xml'],
$xml_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format when only XML is allowed as a content type format' => [
['POST', 'PATCH'],
['xml'],
['json'],
'json',
['Content-Type' => 'text/xml'],
$xml_encoded,
'json',
'application/json',
$json_encoded,
],
'unsafe methods with response (POST, PATCH): client requested format other than request body format when only JSON is allowed as a content type format' => [
['POST', 'PATCH'],
['json'],
['xml'],
'xml',
['Content-Type' => 'application/json'],
$json_encoded,
'xml',
'text/xml',
$xml_encoded,
],
];
$unsafe_method_bodyless_test_cases = [
'unsafe methods without request bodies (DELETE): client requested no format, response should have the first acceptable format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
FALSE,
['Content-Type' => 'application/json'],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods without request bodies (DELETE): client requested format (XML), response should have xml format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
'xml',
['Content-Type' => 'application/json'],
NULL,
'xml',
'text/xml',
$xml_encoded,
],
'unsafe methods without request bodies (DELETE): client requested format (JSON), response should have json format' => [
['DELETE'],
['xml', 'json'],
['xml', 'json'],
'json',
['Content-Type' => 'application/json'],
NULL,
'json',
'application/json',
$json_encoded,
],
];
return $safe_method_test_cases + $unsafe_method_bodied_test_cases + $unsafe_method_bodyless_test_cases;
}
/**
* @return \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
*/
protected function getFunctioningResourceResponseSubscriber(RouteMatchInterface $route_match) {
// Create a dummy of the renderer service.
$renderer = $this->prophesize(RendererInterface::class);
$renderer->executeInRenderContext(Argument::type(RenderContext::class), Argument::type('callable'))
->will(function ($args) {
$callable = $args[1];
return $callable();
});
// Instantiate the ResourceResponseSubscriber we will test.
$resource_response_subscriber = new ResourceResponseSubscriber(
new Serializer([], [new JsonEncoder(), new XmlEncoder()]),
$renderer->reveal(),
$route_match
);
return $resource_response_subscriber;
}
/**
* Generates route requirements based on supported formats.
*
* @param array $supported_response_formats
* The supported response formats to add to the route requirements.
* @param array $supported_request_formats
* The supported request formats to add to the route requirements.
*
* @return array
* An array of route requirements.
*/
protected function generateRouteRequirements(array $supported_response_formats, array $supported_request_formats) {
$route_requirements = [
'_format' => implode('|', $supported_response_formats),
];
if (!empty($supported_request_formats)) {
$route_requirements['_content_type_format'] = implode('|', $supported_request_formats);
}
return $route_requirements;
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\rest\Unit\Plugin\views\style;
use Drupal\rest\Plugin\views\display\RestExport;
use Drupal\rest\Plugin\views\style\Serializer;
use Drupal\Tests\UnitTestCase;
use Drupal\views\ViewExecutable;
use Prophecy\Argument;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @coversDefaultClass \Drupal\rest\Plugin\views\style\Serializer
* @group rest
*/
class SerializerTest extends UnitTestCase {
/**
* The View instance.
*
* @var \Drupal\views\ViewExecutable|\PHPUnit\Framework\MockObject\MockObject
*/
protected $view;
/**
* The RestExport display handler.
*
* @var \Drupal\rest\Plugin\views\display\RestExport|\PHPUnit\Framework\MockObject\MockObject
*/
protected $displayHandler;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->view = $this->getMockBuilder(ViewExecutable::class)
->disableOriginalConstructor()
->getMock();
// Make the view result empty so we don't have to mock the row plugin render
// call.
$this->view->result = [];
$this->displayHandler = $this->getMockBuilder(RestExport::class)
->disableOriginalConstructor()
->getMock();
$this->displayHandler->expects($this->any())
->method('getContentType')
->willReturn('json');
}
/**
* Tests that the symfony serializer receives style plugin from the render() method.
*
* @covers ::render
*/
public function testSerializerReceivesOptions(): void {
$mock_serializer = $this->prophesize(SerializerInterface::class);
// This is the main expectation of the test. We want to make sure the
// serializer options are passed to the SerializerInterface object.
$mock_serializer->serialize([], 'json', Argument::that(function ($argument) {
return isset($argument['views_style_plugin']) && $argument['views_style_plugin'] instanceof Serializer;
}))
->willReturn('')
->shouldBeCalled();
$view_serializer_style = new Serializer([], 'dummy_serializer', [], $mock_serializer->reveal(), ['json', 'xml'], ['json' => 'serialization', 'xml' => 'serialization']);
$view_serializer_style->options = ['formats' => ['xml', 'json']];
$view_serializer_style->view = $this->view;
$view_serializer_style->displayHandler = $this->displayHandler;
$view_serializer_style->render();
}
}