Mercurial > hg > cmmr2012-drupal-site
comparison core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php @ 5:12f9dff5fda9 tip
Update to Drupal core 8.7.1
author | Chris Cannam |
---|---|
date | Thu, 09 May 2019 15:34:47 +0100 |
parents | |
children |
comparison
equal
deleted
inserted
replaced
4:a9cd425dd02b | 5:12f9dff5fda9 |
---|---|
1 <?php | |
2 | |
3 namespace Drupal\Tests\jsonapi\Functional; | |
4 | |
5 use Drupal\Component\Serialization\Json; | |
6 use Drupal\Component\Utility\NestedArray; | |
7 use Drupal\Component\Utility\Random; | |
8 use Drupal\Core\Access\AccessResult; | |
9 use Drupal\Core\Access\AccessResultReasonInterface; | |
10 use Drupal\Core\Cache\Cache; | |
11 use Drupal\Core\Cache\CacheableMetadata; | |
12 use Drupal\Core\Cache\CacheableResponseInterface; | |
13 use Drupal\Core\Config\Entity\ConfigEntityInterface; | |
14 use Drupal\Core\Entity\ContentEntityInterface; | |
15 use Drupal\Core\Entity\ContentEntityNullStorage; | |
16 use Drupal\Core\Entity\EntityInterface; | |
17 use Drupal\Core\Entity\EntityPublishedInterface; | |
18 use Drupal\Core\Entity\FieldableEntityInterface; | |
19 use Drupal\Core\Entity\RevisionableInterface; | |
20 use Drupal\Core\Entity\RevisionLogInterface; | |
21 use Drupal\Core\Field\FieldDefinitionInterface; | |
22 use Drupal\Core\Field\FieldStorageDefinitionInterface; | |
23 use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem; | |
24 use Drupal\Core\Session\AccountInterface; | |
25 use Drupal\Core\TypedData\DataReferenceTargetDefinition; | |
26 use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper; | |
27 use Drupal\Core\Url; | |
28 use Drupal\field\Entity\FieldConfig; | |
29 use Drupal\field\Entity\FieldStorageConfig; | |
30 use Drupal\jsonapi\JsonApiResource\LinkCollection; | |
31 use Drupal\jsonapi\JsonApiResource\NullIncludedData; | |
32 use Drupal\jsonapi\JsonApiResource\Link; | |
33 use Drupal\jsonapi\JsonApiResource\ResourceObject; | |
34 use Drupal\jsonapi\JsonApiResource\ResourceObjectData; | |
35 use Drupal\jsonapi\Normalizer\HttpExceptionNormalizer; | |
36 use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel; | |
37 use Drupal\jsonapi\ResourceResponse; | |
38 use Drupal\path\Plugin\Field\FieldType\PathItem; | |
39 use Drupal\Tests\BrowserTestBase; | |
40 use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; | |
41 use Drupal\user\Entity\Role; | |
42 use Drupal\user\EntityOwnerInterface; | |
43 use Drupal\user\RoleInterface; | |
44 use Drupal\user\UserInterface; | |
45 use GuzzleHttp\RequestOptions; | |
46 use Psr\Http\Message\ResponseInterface; | |
47 use Symfony\Component\HttpFoundation\Response; | |
48 | |
49 /** | |
50 * Subclass this for every JSON:API resource type. | |
51 */ | |
52 abstract class ResourceTestBase extends BrowserTestBase { | |
53 | |
54 use ResourceResponseTestTrait; | |
55 use ContentModerationTestTrait; | |
56 use JsonApiRequestTestTrait; | |
57 | |
58 /** | |
59 * {@inheritdoc} | |
60 */ | |
61 protected static $modules = [ | |
62 'jsonapi', | |
63 'basic_auth', | |
64 'rest_test', | |
65 'jsonapi_test_field_access', | |
66 'text', | |
67 ]; | |
68 | |
69 /** | |
70 * The tested entity type. | |
71 * | |
72 * @var string | |
73 */ | |
74 protected static $entityTypeId = NULL; | |
75 | |
76 /** | |
77 * The name of the tested JSON:API resource type. | |
78 * | |
79 * @var string | |
80 */ | |
81 protected static $resourceTypeName = NULL; | |
82 | |
83 /** | |
84 * Whether the tested JSON:API resource is versionable. | |
85 * | |
86 * @var bool | |
87 */ | |
88 protected static $resourceTypeIsVersionable = FALSE; | |
89 | |
90 /** | |
91 * The JSON:API resource type for the tested entity type plus bundle. | |
92 * | |
93 * Necessary for looking up public (alias) or internal (actual) field names. | |
94 * | |
95 * @var \Drupal\jsonapi\ResourceType\ResourceType | |
96 */ | |
97 protected $resourceType; | |
98 | |
99 /** | |
100 * The fields that are protected against modification during PATCH requests. | |
101 * | |
102 * @var string[] | |
103 */ | |
104 protected static $patchProtectedFieldNames; | |
105 | |
106 /** | |
107 * Fields that need unique values. | |
108 * | |
109 * @var string[] | |
110 * | |
111 * @see ::testPostIndividual() | |
112 * @see ::getModifiedEntityForPostTesting() | |
113 */ | |
114 protected static $uniqueFieldNames = []; | |
115 | |
116 /** | |
117 * The entity ID for the first created entity in testPost(). | |
118 * | |
119 * The default value of 2 should work for most content entities. | |
120 * | |
121 * @var string|int | |
122 * | |
123 * @see ::testPostIndividual() | |
124 */ | |
125 protected static $firstCreatedEntityId = 2; | |
126 | |
127 /** | |
128 * The entity ID for the second created entity in testPost(). | |
129 * | |
130 * The default value of 3 should work for most content entities. | |
131 * | |
132 * @var string|int | |
133 * | |
134 * @see ::testPostIndividual() | |
135 */ | |
136 protected static $secondCreatedEntityId = 3; | |
137 | |
138 /** | |
139 * Optionally specify which field is the 'label' field. | |
140 * | |
141 * Some entities specify a 'label_callback', but not a 'label' entity key. | |
142 * For example: User. | |
143 * | |
144 * @var string|null | |
145 * | |
146 * @see ::getInvalidNormalizedEntityToCreate() | |
147 */ | |
148 protected static $labelFieldName = NULL; | |
149 | |
150 /** | |
151 * Whether new revisions of updated entities should be created by default. | |
152 * | |
153 * @var bool | |
154 */ | |
155 protected static $newRevisionsShouldBeAutomatic = FALSE; | |
156 | |
157 /** | |
158 * Whether anonymous users can view labels of this resource type. | |
159 * | |
160 * @var bool | |
161 */ | |
162 protected static $anonymousUsersCanViewLabels = FALSE; | |
163 | |
164 /** | |
165 * The standard `jsonapi` top-level document member. | |
166 * | |
167 * @var array | |
168 */ | |
169 protected static $jsonApiMember = [ | |
170 'version' => '1.0', | |
171 'meta' => [ | |
172 'links' => ['self' => ['href' => 'http://jsonapi.org/format/1.0/']], | |
173 ], | |
174 ]; | |
175 | |
176 /** | |
177 * The entity being tested. | |
178 * | |
179 * @var \Drupal\Core\Entity\EntityInterface | |
180 */ | |
181 protected $entity; | |
182 | |
183 /** | |
184 * Another entity of the same type used for testing. | |
185 * | |
186 * @var \Drupal\Core\Entity\EntityInterface | |
187 */ | |
188 protected $anotherEntity; | |
189 | |
190 /** | |
191 * The account to use for authentication. | |
192 * | |
193 * @var null|\Drupal\Core\Session\AccountInterface | |
194 */ | |
195 protected $account; | |
196 | |
197 /** | |
198 * The entity storage. | |
199 * | |
200 * @var \Drupal\Core\Entity\EntityStorageInterface | |
201 */ | |
202 protected $entityStorage; | |
203 | |
204 /** | |
205 * The UUID key. | |
206 * | |
207 * @var string | |
208 */ | |
209 protected $uuidKey; | |
210 | |
211 /** | |
212 * The serializer service. | |
213 * | |
214 * @var \Symfony\Component\Serializer\Serializer | |
215 */ | |
216 protected $serializer; | |
217 | |
218 /** | |
219 * {@inheritdoc} | |
220 */ | |
221 public function setUp() { | |
222 parent::setUp(); | |
223 | |
224 $this->serializer = $this->container->get('jsonapi.serializer'); | |
225 | |
226 // Ensure the anonymous user role has no permissions at all. | |
227 $user_role = Role::load(RoleInterface::ANONYMOUS_ID); | |
228 foreach ($user_role->getPermissions() as $permission) { | |
229 $user_role->revokePermission($permission); | |
230 } | |
231 $user_role->save(); | |
232 assert([] === $user_role->getPermissions(), 'The anonymous user role has no permissions at all.'); | |
233 | |
234 // Ensure the authenticated user role has no permissions at all. | |
235 $user_role = Role::load(RoleInterface::AUTHENTICATED_ID); | |
236 foreach ($user_role->getPermissions() as $permission) { | |
237 $user_role->revokePermission($permission); | |
238 } | |
239 $user_role->save(); | |
240 assert([] === $user_role->getPermissions(), 'The authenticated user role has no permissions at all.'); | |
241 | |
242 // Create an account, which tests will use. Also ensure the @current_user | |
243 // service this account, to ensure certain access check logic in tests works | |
244 // as expected. | |
245 $this->account = $this->createUser(); | |
246 $this->container->get('current_user')->setAccount($this->account); | |
247 | |
248 // Create an entity. | |
249 $entity_type_manager = $this->container->get('entity_type.manager'); | |
250 $this->entityStorage = $entity_type_manager->getStorage(static::$entityTypeId); | |
251 $this->uuidKey = $entity_type_manager->getDefinition(static::$entityTypeId) | |
252 ->getKey('uuid'); | |
253 $this->entity = $this->setUpFields($this->createEntity(), $this->account); | |
254 | |
255 $this->resourceType = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName); | |
256 } | |
257 | |
258 /** | |
259 * Sets up additional fields for testing. | |
260 * | |
261 * @param \Drupal\Core\Entity\EntityInterface $entity | |
262 * The primary test entity. | |
263 * @param \Drupal\user\UserInterface $account | |
264 * The primary test user account. | |
265 * | |
266 * @return \Drupal\Core\Entity\EntityInterface | |
267 * The reloaded entity with the new fields attached. | |
268 * | |
269 * @throws \Drupal\Core\Entity\EntityStorageException | |
270 */ | |
271 protected function setUpFields(EntityInterface $entity, UserInterface $account) { | |
272 if (!$entity instanceof FieldableEntityInterface) { | |
273 return $entity; | |
274 } | |
275 | |
276 $entity_bundle = $entity->bundle(); | |
277 $account_bundle = $account->bundle(); | |
278 | |
279 // Add access-protected field. | |
280 FieldStorageConfig::create([ | |
281 'entity_type' => static::$entityTypeId, | |
282 'field_name' => 'field_rest_test', | |
283 'type' => 'text', | |
284 ]) | |
285 ->setCardinality(1) | |
286 ->save(); | |
287 FieldConfig::create([ | |
288 'entity_type' => static::$entityTypeId, | |
289 'field_name' => 'field_rest_test', | |
290 'bundle' => $entity_bundle, | |
291 ]) | |
292 ->setLabel('Test field') | |
293 ->setTranslatable(FALSE) | |
294 ->save(); | |
295 | |
296 FieldStorageConfig::create([ | |
297 'entity_type' => static::$entityTypeId, | |
298 'field_name' => 'field_jsonapi_test_entity_ref', | |
299 'type' => 'entity_reference', | |
300 ]) | |
301 ->setSetting('target_type', 'user') | |
302 ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) | |
303 ->save(); | |
304 | |
305 FieldConfig::create([ | |
306 'entity_type' => static::$entityTypeId, | |
307 'field_name' => 'field_jsonapi_test_entity_ref', | |
308 'bundle' => $entity_bundle, | |
309 ]) | |
310 ->setTranslatable(FALSE) | |
311 ->setSetting('handler', 'default') | |
312 ->setSetting('handler_settings', [ | |
313 'target_bundles' => NULL, | |
314 ]) | |
315 ->save(); | |
316 | |
317 // Add multi-value field. | |
318 FieldStorageConfig::create([ | |
319 'entity_type' => static::$entityTypeId, | |
320 'field_name' => 'field_rest_test_multivalue', | |
321 'type' => 'string', | |
322 ]) | |
323 ->setCardinality(3) | |
324 ->save(); | |
325 FieldConfig::create([ | |
326 'entity_type' => static::$entityTypeId, | |
327 'field_name' => 'field_rest_test_multivalue', | |
328 'bundle' => $entity_bundle, | |
329 ]) | |
330 ->setLabel('Test field: multi-value') | |
331 ->setTranslatable(FALSE) | |
332 ->save(); | |
333 | |
334 \Drupal::service('router.builder')->rebuildIfNeeded(); | |
335 | |
336 // Reload entity so that it has the new field. | |
337 $reloaded_entity = $this->entityLoadUnchanged($entity->id()); | |
338 // Some entity types are not stored, hence they cannot be reloaded. | |
339 if ($reloaded_entity !== NULL) { | |
340 $entity = $reloaded_entity; | |
341 | |
342 // Set a default value on the fields. | |
343 $entity->set('field_rest_test', ['value' => 'All the faith he had had had had no effect on the outcome of his life.']); | |
344 $entity->set('field_jsonapi_test_entity_ref', ['user' => $account->id()]); | |
345 $entity->set('field_rest_test_multivalue', [['value' => 'One'], ['value' => 'Two']]); | |
346 $entity->save(); | |
347 } | |
348 | |
349 return $entity; | |
350 } | |
351 | |
352 /** | |
353 * Sets up a collection of entities of the same type for testing. | |
354 * | |
355 * @return \Drupal\Core\Entity\EntityInterface[] | |
356 * The collection of entities to test. | |
357 * | |
358 * @throws \Drupal\Core\Entity\EntityStorageException | |
359 */ | |
360 protected function getData() { | |
361 if ($this->entityStorage->getQuery()->count()->execute() < 2) { | |
362 $this->createAnotherEntity('two'); | |
363 } | |
364 $query = $this->entityStorage->getQuery()->sort($this->entity->getEntityType()->getKey('id')); | |
365 return $this->entityStorage->loadMultiple($query->execute()); | |
366 } | |
367 | |
368 /** | |
369 * Generates a JSON:API normalization for the given entity. | |
370 * | |
371 * @param \Drupal\Core\Entity\EntityInterface $entity | |
372 * The entity to generate a JSON:API normalization for. | |
373 * @param \Drupal\Core\Url $url | |
374 * The URL to use as the "self" link. | |
375 * | |
376 * @return array | |
377 * The JSON:API normalization for the given entity. | |
378 */ | |
379 protected function normalize(EntityInterface $entity, Url $url) { | |
380 $self_link = new Link(new CacheableMetadata(), $url, ['self']); | |
381 $resource_type = $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName); | |
382 $doc = new JsonApiDocumentTopLevel(new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1), new NullIncludedData(), new LinkCollection(['self' => $self_link])); | |
383 return $this->serializer->normalize($doc, 'api_json', [ | |
384 'resource_type' => $resource_type, | |
385 'account' => $this->account, | |
386 ])->getNormalization(); | |
387 } | |
388 | |
389 /** | |
390 * Creates the entity to be tested. | |
391 * | |
392 * @return \Drupal\Core\Entity\EntityInterface | |
393 * The entity to be tested. | |
394 */ | |
395 abstract protected function createEntity(); | |
396 | |
397 /** | |
398 * Creates another entity to be tested. | |
399 * | |
400 * @param mixed $key | |
401 * A unique key to be used for the ID and/or label of the duplicated entity. | |
402 * | |
403 * @return \Drupal\Core\Entity\EntityInterface | |
404 * Another entity based on $this->entity. | |
405 * | |
406 * @throws \Drupal\Core\Entity\EntityStorageException | |
407 */ | |
408 protected function createAnotherEntity($key) { | |
409 $duplicate = $this->getEntityDuplicate($this->entity, $key); | |
410 // Some entity types are not stored, hence they cannot be reloaded. | |
411 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { | |
412 $duplicate->set('field_rest_test', 'Second collection entity'); | |
413 } | |
414 $duplicate->save(); | |
415 return $duplicate; | |
416 } | |
417 | |
418 /** | |
419 * {@inheritdoc} | |
420 */ | |
421 protected function getEntityDuplicate(EntityInterface $original, $key) { | |
422 $duplicate = $original->createDuplicate(); | |
423 if ($label_key = $original->getEntityType()->getKey('label')) { | |
424 $duplicate->set($label_key, $original->label() . '_' . $key); | |
425 } | |
426 if ($duplicate instanceof ConfigEntityInterface && $id_key = $duplicate->getEntityType()->getKey('id')) { | |
427 $id = $original->id(); | |
428 $id_key = $duplicate->getEntityType()->getKey('id'); | |
429 $duplicate->set($id_key, $id . '_' . $key); | |
430 } | |
431 return $duplicate; | |
432 } | |
433 | |
434 /** | |
435 * Returns the expected JSON:API document for the entity. | |
436 * | |
437 * @see ::createEntity() | |
438 * | |
439 * @return array | |
440 * A JSON:API response document. | |
441 */ | |
442 abstract protected function getExpectedDocument(); | |
443 | |
444 /** | |
445 * Returns the JSON:API POST document. | |
446 * | |
447 * @see ::testPostIndividual() | |
448 * | |
449 * @return array | |
450 * A JSON:API request document. | |
451 */ | |
452 abstract protected function getPostDocument(); | |
453 | |
454 /** | |
455 * Returns the JSON:API PATCH document. | |
456 * | |
457 * By default, reuses ::getPostDocument(), which works fine for most entity | |
458 * types. A counter example: the 'comment' entity type. | |
459 * | |
460 * @see ::testPatchIndividual() | |
461 * | |
462 * @return array | |
463 * A JSON:API request document. | |
464 */ | |
465 protected function getPatchDocument() { | |
466 return NestedArray::mergeDeep(['data' => ['id' => $this->entity->uuid()]], $this->getPostDocument()); | |
467 } | |
468 | |
469 /** | |
470 * Returns the expected cacheability for an unauthorized response. | |
471 * | |
472 * @return \Drupal\Core\Cache\CacheableMetadata | |
473 * The expected cacheability. | |
474 */ | |
475 protected function getExpectedUnauthorizedAccessCacheability() { | |
476 return (new CacheableMetadata()) | |
477 ->setCacheTags(['4xx-response', 'http_response']) | |
478 ->setCacheContexts(['url.site', 'user.permissions']) | |
479 ->addCacheContexts($this->entity->getEntityType()->isRevisionable() | |
480 ? ['url.query_args:resourceVersion'] | |
481 : [] | |
482 ); | |
483 } | |
484 | |
485 /** | |
486 * The expected cache tags for the GET/HEAD response of the test entity. | |
487 * | |
488 * @param array|null $sparse_fieldset | |
489 * If a sparse fieldset is being requested, limit the expected cache tags | |
490 * for this entity's fields to just these fields. | |
491 * | |
492 * @return string[] | |
493 * A set of cache tags. | |
494 * | |
495 * @see ::testGetIndividual() | |
496 */ | |
497 protected function getExpectedCacheTags(array $sparse_fieldset = NULL) { | |
498 $expected_cache_tags = [ | |
499 'http_response', | |
500 ]; | |
501 return Cache::mergeTags($expected_cache_tags, $this->entity->getCacheTags()); | |
502 } | |
503 | |
504 /** | |
505 * The expected cache contexts for the GET/HEAD response of the test entity. | |
506 * | |
507 * @param array|null $sparse_fieldset | |
508 * If a sparse fieldset is being requested, limit the expected cache | |
509 * contexts for this entity's fields to just these fields. | |
510 * | |
511 * @return string[] | |
512 * A set of cache contexts. | |
513 * | |
514 * @see ::testGetIndividual() | |
515 */ | |
516 protected function getExpectedCacheContexts(array $sparse_fieldset = NULL) { | |
517 $cache_contexts = [ | |
518 // Cache contexts for JSON:API URL query parameters. | |
519 'url.query_args:fields', | |
520 'url.query_args:include', | |
521 // Drupal defaults. | |
522 'url.site', | |
523 'user.permissions', | |
524 ]; | |
525 $entity_type = $this->entity->getEntityType(); | |
526 return Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []); | |
527 } | |
528 | |
529 /** | |
530 * Computes the cacheability for a given entity collection. | |
531 * | |
532 * @param \Drupal\Core\Session\AccountInterface $account | |
533 * An account for which cacheability should be computed (cacheability is | |
534 * dependent on access). | |
535 * @param \Drupal\Core\Entity\EntityInterface[] $collection | |
536 * The entities for which cacheability should be computed. | |
537 * @param array $sparse_fieldset | |
538 * (optional) If a sparse fieldset is being requested, limit the expected | |
539 * cacheability for the collection entities' fields to just those in the | |
540 * fieldset. NULL means all fields. | |
541 * @param bool $filtered | |
542 * Whether the collection is filtered or not. | |
543 * | |
544 * @return \Drupal\Core\Cache\CacheableMetadata | |
545 * The expected cacheability for the given entity collection. | |
546 */ | |
547 protected static function getExpectedCollectionCacheability(AccountInterface $account, array $collection, array $sparse_fieldset = NULL, $filtered = FALSE) { | |
548 $cacheability = array_reduce($collection, function (CacheableMetadata $cacheability, EntityInterface $entity) use ($sparse_fieldset, $account) { | |
549 $access_result = static::entityAccess($entity, 'view', $account); | |
550 if (!$access_result->isAllowed()) { | |
551 $access_result = static::entityAccess($entity, 'view label', $account)->addCacheableDependency($access_result); | |
552 } | |
553 $cacheability->addCacheableDependency($access_result); | |
554 if ($access_result->isAllowed()) { | |
555 $cacheability->addCacheableDependency($entity); | |
556 if ($entity instanceof FieldableEntityInterface) { | |
557 foreach ($entity as $field_name => $field_item_list) { | |
558 /* @var \Drupal\Core\Field\FieldItemListInterface $field_item_list */ | |
559 if (is_null($sparse_fieldset) || in_array($field_name, $sparse_fieldset)) { | |
560 $field_access = static::entityFieldAccess($entity, $field_name, 'view', $account); | |
561 $cacheability->addCacheableDependency($field_access); | |
562 if ($field_access->isAllowed()) { | |
563 foreach ($field_item_list as $field_item) { | |
564 /* @var \Drupal\Core\Field\FieldItemInterface $field_item */ | |
565 foreach (TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item) as $property) { | |
566 $cacheability->addCacheableDependency(CacheableMetadata::createFromObject($property)); | |
567 } | |
568 } | |
569 } | |
570 } | |
571 } | |
572 } | |
573 } | |
574 return $cacheability; | |
575 }, new CacheableMetadata()); | |
576 $entity_type = reset($collection)->getEntityType(); | |
577 $cacheability->addCacheTags(['http_response']); | |
578 $cacheability->addCacheTags($entity_type->getListCacheTags()); | |
579 $cache_contexts = [ | |
580 // Cache contexts for JSON:API URL query parameters. | |
581 'url.query_args:fields', | |
582 'url.query_args:filter', | |
583 'url.query_args:include', | |
584 'url.query_args:page', | |
585 'url.query_args:sort', | |
586 // Drupal defaults. | |
587 'url.site', | |
588 ]; | |
589 // If the entity type is revisionable, add a resource version cache context. | |
590 $cache_contexts = Cache::mergeContexts($cache_contexts, $entity_type->isRevisionable() ? ['url.query_args:resourceVersion'] : []); | |
591 $cacheability->addCacheContexts($cache_contexts); | |
592 return $cacheability; | |
593 } | |
594 | |
595 /** | |
596 * Sets up the necessary authorization. | |
597 * | |
598 * In case of a test verifying publicly accessible REST resources: grant | |
599 * permissions to the anonymous user role. | |
600 * | |
601 * In case of a test verifying behavior when using a particular authentication | |
602 * provider: create a user with a particular set of permissions. | |
603 * | |
604 * Because of the $method parameter, it's possible to first set up | |
605 * authentication for only GET, then add POST, et cetera. This then also | |
606 * allows for verifying a 403 in case of missing authorization. | |
607 * | |
608 * @param string $method | |
609 * The HTTP method for which to set up authentication. | |
610 * | |
611 * @see ::grantPermissionsToAnonymousRole() | |
612 * @see ::grantPermissionsToAuthenticatedRole() | |
613 */ | |
614 abstract protected function setUpAuthorization($method); | |
615 | |
616 /** | |
617 * Sets up the necessary authorization for handling revisions. | |
618 * | |
619 * @param string $method | |
620 * The HTTP method for which to set up authentication. | |
621 * | |
622 * @see ::testRevisions() | |
623 */ | |
624 protected function setUpRevisionAuthorization($method) { | |
625 assert($method === 'GET', 'Only read operations on revisions are supported.'); | |
626 $this->setUpAuthorization($method); | |
627 } | |
628 | |
629 /** | |
630 * Return the expected error message. | |
631 * | |
632 * @param string $method | |
633 * The HTTP method (GET, POST, PATCH, DELETE). | |
634 * | |
635 * @return string | |
636 * The error string. | |
637 */ | |
638 protected function getExpectedUnauthorizedAccessMessage($method) { | |
639 $permission = $this->entity->getEntityType()->getAdminPermission(); | |
640 if ($permission !== FALSE) { | |
641 return "The '{$permission}' permission is required."; | |
642 } | |
643 | |
644 return NULL; | |
645 } | |
646 | |
647 /** | |
648 * Grants permissions to the authenticated role. | |
649 * | |
650 * @param string[] $permissions | |
651 * Permissions to grant. | |
652 */ | |
653 protected function grantPermissionsToTestedRole(array $permissions) { | |
654 $this->grantPermissions(Role::load(RoleInterface::AUTHENTICATED_ID), $permissions); | |
655 } | |
656 | |
657 /** | |
658 * Revokes permissions from the authenticated role. | |
659 * | |
660 * @param string[] $permissions | |
661 * Permissions to revoke. | |
662 */ | |
663 protected function revokePermissionsFromTestedRole(array $permissions) { | |
664 $role = Role::load(RoleInterface::AUTHENTICATED_ID); | |
665 foreach ($permissions as $permission) { | |
666 $role->revokePermission($permission); | |
667 } | |
668 $role->trustData()->save(); | |
669 } | |
670 | |
671 /** | |
672 * Asserts that a resource response has the given status code and body. | |
673 * | |
674 * @param int $expected_status_code | |
675 * The expected response status. | |
676 * @param array|null|false $expected_document | |
677 * The expected document or NULL if there should not be a response body. | |
678 * FALSE in case this should not be asserted. | |
679 * @param \Psr\Http\Message\ResponseInterface $response | |
680 * The response to assert. | |
681 * @param string[]|false $expected_cache_tags | |
682 * (optional) The expected cache tags in the X-Drupal-Cache-Tags response | |
683 * header, or FALSE if that header should be absent. Defaults to FALSE. | |
684 * @param string[]|false $expected_cache_contexts | |
685 * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts | |
686 * response header, or FALSE if that header should be absent. Defaults to | |
687 * FALSE. | |
688 * @param string|false $expected_page_cache_header_value | |
689 * (optional) The expected X-Drupal-Cache response header value, or FALSE if | |
690 * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults | |
691 * to FALSE. | |
692 * @param string|false $expected_dynamic_page_cache_header_value | |
693 * (optional) The expected X-Drupal-Dynamic-Cache response header value, or | |
694 * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. | |
695 * Defaults to FALSE. | |
696 */ | |
697 protected function assertResourceResponse($expected_status_code, $expected_document, ResponseInterface $response, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { | |
698 $this->assertSame($expected_status_code, $response->getStatusCode(), var_export(Json::decode((string) $response->getBody()), TRUE)); | |
699 if ($expected_status_code === 204) { | |
700 // DELETE responses should not include a Content-Type header. But Apache | |
701 // sets it to 'text/html' by default. We also cannot detect the presence | |
702 // of Apache either here in the CLI. For now having this documented here | |
703 // is all we can do. | |
704 /* $this->assertSame(FALSE, $response->hasHeader('Content-Type')); */ | |
705 $this->assertSame('', (string) $response->getBody()); | |
706 } | |
707 else { | |
708 $this->assertSame(['application/vnd.api+json'], $response->getHeader('Content-Type')); | |
709 if ($expected_document !== FALSE) { | |
710 $response_document = Json::decode((string) $response->getBody()); | |
711 if ($expected_document === NULL) { | |
712 $this->assertNull($response_document); | |
713 } | |
714 else { | |
715 $this->assertSameDocument($expected_document, $response_document); | |
716 } | |
717 } | |
718 } | |
719 | |
720 // Expected cache tags: X-Drupal-Cache-Tags header. | |
721 $this->assertSame($expected_cache_tags !== FALSE, $response->hasHeader('X-Drupal-Cache-Tags')); | |
722 if (is_array($expected_cache_tags)) { | |
723 $this->assertSame($expected_cache_tags, explode(' ', $response->getHeader('X-Drupal-Cache-Tags')[0])); | |
724 } | |
725 | |
726 // Expected cache contexts: X-Drupal-Cache-Contexts header. | |
727 $this->assertSame($expected_cache_contexts !== FALSE, $response->hasHeader('X-Drupal-Cache-Contexts')); | |
728 if (is_array($expected_cache_contexts)) { | |
729 $optimized_expected_cache_contexts = \Drupal::service('cache_contexts_manager')->optimizeTokens($expected_cache_contexts); | |
730 $this->assertSame($optimized_expected_cache_contexts, explode(' ', $response->getHeader('X-Drupal-Cache-Contexts')[0])); | |
731 } | |
732 | |
733 // Expected Page Cache header value: X-Drupal-Cache header. | |
734 if ($expected_page_cache_header_value !== FALSE) { | |
735 $this->assertTrue($response->hasHeader('X-Drupal-Cache')); | |
736 $this->assertSame($expected_page_cache_header_value, $response->getHeader('X-Drupal-Cache')[0]); | |
737 } | |
738 else { | |
739 $this->assertFalse($response->hasHeader('X-Drupal-Cache')); | |
740 } | |
741 | |
742 // Expected Dynamic Page Cache header value: X-Drupal-Dynamic-Cache header. | |
743 if ($expected_dynamic_page_cache_header_value !== FALSE) { | |
744 $this->assertTrue($response->hasHeader('X-Drupal-Dynamic-Cache')); | |
745 $this->assertSame($expected_dynamic_page_cache_header_value, $response->getHeader('X-Drupal-Dynamic-Cache')[0]); | |
746 } | |
747 else { | |
748 $this->assertFalse($response->hasHeader('X-Drupal-Dynamic-Cache')); | |
749 } | |
750 } | |
751 | |
752 /** | |
753 * Asserts that an expected document matches the response body. | |
754 * | |
755 * @param array $expected_document | |
756 * The expected JSON:API document. | |
757 * @param array $actual_document | |
758 * The actual response document to assert. | |
759 */ | |
760 protected function assertSameDocument(array $expected_document, array $actual_document) { | |
761 static::recursiveKsort($expected_document); | |
762 static::recursiveKsort($actual_document); | |
763 | |
764 if (!empty($expected_document['included'])) { | |
765 static::sortResourceCollection($expected_document['included']); | |
766 static::sortResourceCollection($actual_document['included']); | |
767 } | |
768 | |
769 if (isset($actual_document['meta']['omitted']) && isset($expected_document['meta']['omitted'])) { | |
770 $actual_omitted =& $actual_document['meta']['omitted']; | |
771 $expected_omitted =& $expected_document['meta']['omitted']; | |
772 static::sortOmittedLinks($actual_omitted); | |
773 static::sortOmittedLinks($expected_omitted); | |
774 static::resetOmittedLinkKeys($actual_omitted); | |
775 static::resetOmittedLinkKeys($expected_omitted); | |
776 } | |
777 | |
778 $expected_keys = array_keys($expected_document); | |
779 $actual_keys = array_keys($actual_document); | |
780 $missing_member_names = array_diff($expected_keys, $actual_keys); | |
781 $extra_member_names = array_diff($actual_keys, $expected_keys); | |
782 if (!empty($missing_member_names) || !empty($extra_member_names)) { | |
783 $message_format = "The document members did not match the expected values. Missing: [ %s ]. Unexpected: [ %s ]"; | |
784 $message = sprintf($message_format, implode(', ', $missing_member_names), implode(', ', $extra_member_names)); | |
785 $this->assertSame($expected_document, $actual_document, $message); | |
786 } | |
787 foreach ($expected_document as $member_name => $expected_member) { | |
788 $actual_member = $actual_document[$member_name]; | |
789 $this->assertSame($expected_member, $actual_member, "The '$member_name' member was not as expected."); | |
790 } | |
791 } | |
792 | |
793 /** | |
794 * Asserts that a resource error response has the given message. | |
795 * | |
796 * @param int $expected_status_code | |
797 * The expected response status. | |
798 * @param string $expected_message | |
799 * The expected error message. | |
800 * @param \Drupal\Core\Url|null $via_link | |
801 * The source URL for the errors of the response. NULL if the error occurs | |
802 * for example during entity creation. | |
803 * @param \Psr\Http\Message\ResponseInterface $response | |
804 * The error response to assert. | |
805 * @param string|false $pointer | |
806 * The expected JSON Pointer to the associated entity in the request | |
807 * document. See http://jsonapi.org/format/#error-objects. | |
808 * @param string[]|false $expected_cache_tags | |
809 * (optional) The expected cache tags in the X-Drupal-Cache-Tags response | |
810 * header, or FALSE if that header should be absent. Defaults to FALSE. | |
811 * @param string[]|false $expected_cache_contexts | |
812 * (optional) The expected cache contexts in the X-Drupal-Cache-Contexts | |
813 * response header, or FALSE if that header should be absent. Defaults to | |
814 * FALSE. | |
815 * @param string|false $expected_page_cache_header_value | |
816 * (optional) The expected X-Drupal-Cache response header value, or FALSE if | |
817 * that header should be absent. Possible strings: 'MISS', 'HIT'. Defaults | |
818 * to FALSE. | |
819 * @param string|false $expected_dynamic_page_cache_header_value | |
820 * (optional) The expected X-Drupal-Dynamic-Cache response header value, or | |
821 * FALSE if that header should be absent. Possible strings: 'MISS', 'HIT'. | |
822 * Defaults to FALSE. | |
823 */ | |
824 protected function assertResourceErrorResponse($expected_status_code, $expected_message, $via_link, ResponseInterface $response, $pointer = FALSE, $expected_cache_tags = FALSE, $expected_cache_contexts = FALSE, $expected_page_cache_header_value = FALSE, $expected_dynamic_page_cache_header_value = FALSE) { | |
825 assert(is_null($via_link) || $via_link instanceof Url); | |
826 $expected_error = []; | |
827 if (!empty(Response::$statusTexts[$expected_status_code])) { | |
828 $expected_error['title'] = Response::$statusTexts[$expected_status_code]; | |
829 } | |
830 $expected_error['status'] = (string) $expected_status_code; | |
831 $expected_error['detail'] = $expected_message; | |
832 if ($via_link) { | |
833 $expected_error['links']['via']['href'] = $via_link->setAbsolute()->toString(); | |
834 } | |
835 if ($info_url = HttpExceptionNormalizer::getInfoUrl($expected_status_code)) { | |
836 $expected_error['links']['info']['href'] = $info_url; | |
837 } | |
838 if ($pointer !== FALSE) { | |
839 $expected_error['source']['pointer'] = $pointer; | |
840 } | |
841 | |
842 $expected_document = [ | |
843 'jsonapi' => static::$jsonApiMember, | |
844 'errors' => [ | |
845 0 => $expected_error, | |
846 ], | |
847 ]; | |
848 $this->assertResourceResponse($expected_status_code, $expected_document, $response, $expected_cache_tags, $expected_cache_contexts, $expected_page_cache_header_value, $expected_dynamic_page_cache_header_value); | |
849 } | |
850 | |
851 /** | |
852 * Makes the JSON:API document violate the spec by omitting the resource type. | |
853 * | |
854 * @param array $document | |
855 * A JSON:API document. | |
856 * | |
857 * @return array | |
858 * The same JSON:API document, without its resource type. | |
859 */ | |
860 protected function removeResourceTypeFromDocument(array $document) { | |
861 unset($document['data']['type']); | |
862 return $document; | |
863 } | |
864 | |
865 /** | |
866 * Makes the given JSON:API document invalid. | |
867 * | |
868 * @param array $document | |
869 * A JSON:API document. | |
870 * @param string $entity_key | |
871 * The entity key whose normalization to make invalid. | |
872 * | |
873 * @return array | |
874 * The updated JSON:API document, now invalid. | |
875 */ | |
876 protected function makeNormalizationInvalid(array $document, $entity_key) { | |
877 $entity_type = $this->entity->getEntityType(); | |
878 switch ($entity_key) { | |
879 case 'label': | |
880 // Add a second label to this entity to make it invalid. | |
881 $label_field = $entity_type->hasKey('label') ? $entity_type->getKey('label') : static::$labelFieldName; | |
882 $document['data']['attributes'][$label_field] = [ | |
883 0 => $document['data']['attributes'][$label_field], | |
884 1 => 'Second Title', | |
885 ]; | |
886 break; | |
887 | |
888 case 'id': | |
889 $document['data']['attributes'][$entity_type->getKey('id')] = $this->anotherEntity->id(); | |
890 break; | |
891 | |
892 case 'uuid': | |
893 $document['data']['id'] = $this->anotherEntity->uuid(); | |
894 break; | |
895 } | |
896 | |
897 return $document; | |
898 } | |
899 | |
900 /** | |
901 * Tests GETting an individual resource, plus edge cases to ensure good DX. | |
902 */ | |
903 public function testGetIndividual() { | |
904 // The URL and Guzzle request options that will be used in this test. The | |
905 // request options will be modified/expanded throughout this test: | |
906 // - to first test all mistakes a developer might make, and assert that the | |
907 // error responses provide a good DX | |
908 // - to eventually result in a well-formed request that succeeds. | |
909 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
910 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); | |
911 /* $url = $this->entity->toUrl('jsonapi'); */ | |
912 $request_options = []; | |
913 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
914 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
915 | |
916 // DX: 403 when unauthorized, or 200 if the 'view label' operation is | |
917 // supported by the entity type. | |
918 $response = $this->request('GET', $url, $request_options); | |
919 if (!static::$anonymousUsersCanViewLabels) { | |
920 $expected_403_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); | |
921 $reason = $this->getExpectedUnauthorizedAccessMessage('GET'); | |
922 $message = trim("The current user is not allowed to GET the selected resource. $reason"); | |
923 $this->assertResourceErrorResponse(403, $message, $url, $response, '/data', $expected_403_cacheability->getCacheTags(), $expected_403_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
924 $this->assertArrayNotHasKey('Link', $response->getHeaders()); | |
925 } | |
926 else { | |
927 $expected_document = $this->getExpectedDocument(); | |
928 $label_field_name = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; | |
929 $expected_document['data']['attributes'] = array_intersect_key($expected_document['data']['attributes'], [$label_field_name => TRUE]); | |
930 unset($expected_document['data']['relationships']); | |
931 // MISS or UNCACHEABLE depends on data. It must not be HIT. | |
932 $dynamic_cache_label_only = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts([$label_field_name]))) ? 'UNCACHEABLE' : 'MISS'; | |
933 $this->assertResourceResponse(200, $expected_document, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts([$label_field_name]), FALSE, $dynamic_cache_label_only); | |
934 } | |
935 | |
936 $this->setUpAuthorization('GET'); | |
937 | |
938 // Set body despite that being nonsensical: should be ignored. | |
939 $request_options[RequestOptions::BODY] = Json::encode($this->getExpectedDocument()); | |
940 | |
941 // 400 for GET request with reserved custom query parameter. | |
942 $url_reserved_custom_query_parameter = clone $url; | |
943 $url_reserved_custom_query_parameter = $url_reserved_custom_query_parameter->setOption('query', ['foo' => 'bar']); | |
944 $response = $this->request('GET', $url_reserved_custom_query_parameter, $request_options); | |
945 $expected_document = [ | |
946 'jsonapi' => static::$jsonApiMember, | |
947 'errors' => [ | |
948 [ | |
949 'title' => 'Bad Request', | |
950 'status' => '400', | |
951 'detail' => "The following query parameters violate the JSON:API spec: 'foo'.", | |
952 'links' => [ | |
953 'info' => ['href' => 'http://jsonapi.org/format/#query-parameters'], | |
954 'via' => ['href' => $url_reserved_custom_query_parameter->toString()], | |
955 ], | |
956 ], | |
957 ], | |
958 ]; | |
959 $this->assertResourceResponse(400, $expected_document, $response, ['4xx-response', 'http_response'], ['url.query_args', 'url.site'], FALSE, 'MISS'); | |
960 | |
961 // 200 for well-formed HEAD request. | |
962 $response = $this->request('HEAD', $url, $request_options); | |
963 // MISS or UNCACHEABLE depends on data. It must not be HIT. | |
964 $dynamic_cache = !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; | |
965 $this->assertResourceResponse(200, NULL, $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache); | |
966 $head_headers = $response->getHeaders(); | |
967 | |
968 // 200 for well-formed GET request. Page Cache hit because of HEAD request. | |
969 // Same for Dynamic Page Cache hit. | |
970 $response = $this->request('GET', $url, $request_options); | |
971 | |
972 $this->assertResourceResponse(200, $this->getExpectedDocument(), $response, $this->getExpectedCacheTags(), $this->getExpectedCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE'); | |
973 // Assert that Dynamic Page Cache did not store a ResourceResponse object, | |
974 // which needs serialization after every cache hit. Instead, it should | |
975 // contain a flattened response. Otherwise performance suffers. | |
976 // @see \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber::flattenResponse() | |
977 $cache_items = $this->container->get('database') | |
978 ->query("SELECT cid, data FROM {cache_dynamic_page_cache} WHERE cid LIKE :pattern", [ | |
979 ':pattern' => '%[route]=jsonapi.%', | |
980 ]) | |
981 ->fetchAllAssoc('cid'); | |
982 $this->assertTrue(count($cache_items) >= 2); | |
983 $found_cache_redirect = FALSE; | |
984 $found_cached_200_response = FALSE; | |
985 $other_cached_responses_are_4xx = TRUE; | |
986 foreach ($cache_items as $cid => $cache_item) { | |
987 $cached_data = unserialize($cache_item->data); | |
988 if (!isset($cached_data['#cache_redirect'])) { | |
989 $cached_response = $cached_data['#response']; | |
990 if ($cached_response->getStatusCode() === 200) { | |
991 $found_cached_200_response = TRUE; | |
992 } | |
993 elseif (!$cached_response->isClientError()) { | |
994 $other_cached_responses_are_4xx = FALSE; | |
995 } | |
996 $this->assertNotInstanceOf(ResourceResponse::class, $cached_response); | |
997 $this->assertInstanceOf(CacheableResponseInterface::class, $cached_response); | |
998 } | |
999 else { | |
1000 $found_cache_redirect = TRUE; | |
1001 } | |
1002 } | |
1003 $this->assertTrue($found_cache_redirect); | |
1004 $this->assertSame($dynamic_cache !== 'UNCACHEABLE' || isset($dynamic_cache_label_only) && $dynamic_cache_label_only !== 'UNCACHEABLE', $found_cached_200_response); | |
1005 $this->assertTrue($other_cached_responses_are_4xx); | |
1006 | |
1007 // Not only assert the normalization, also assert deserialization of the | |
1008 // response results in the expected object. | |
1009 $unserialized = $this->serializer->deserialize((string) $response->getBody(), JsonApiDocumentTopLevel::class, 'api_json', [ | |
1010 'target_entity' => static::$entityTypeId, | |
1011 'resource_type' => $this->container->get('jsonapi.resource_type.repository')->getByTypeName(static::$resourceTypeName), | |
1012 ]); | |
1013 $this->assertSame($unserialized->uuid(), $this->entity->uuid()); | |
1014 $get_headers = $response->getHeaders(); | |
1015 | |
1016 // Verify that the GET and HEAD responses are the same. The only difference | |
1017 // is that there's no body. For this reason the 'Transfer-Encoding' and | |
1018 // 'Vary' headers are also added to the list of headers to ignore, as they | |
1019 // may be added to GET requests, depending on web server configuration. They | |
1020 // are usually 'Transfer-Encoding: chunked' and 'Vary: Accept-Encoding'. | |
1021 $ignored_headers = [ | |
1022 'Date', | |
1023 'Content-Length', | |
1024 'X-Drupal-Cache', | |
1025 'X-Drupal-Dynamic-Cache', | |
1026 'Transfer-Encoding', | |
1027 'Vary', | |
1028 ]; | |
1029 $header_cleaner = function ($headers) use ($ignored_headers) { | |
1030 foreach ($headers as $header => $value) { | |
1031 if (strpos($header, 'X-Drupal-Assertion-') === 0 || in_array($header, $ignored_headers)) { | |
1032 unset($headers[$header]); | |
1033 } | |
1034 } | |
1035 return $headers; | |
1036 }; | |
1037 $get_headers = $header_cleaner($get_headers); | |
1038 $head_headers = $header_cleaner($head_headers); | |
1039 $this->assertSame($get_headers, $head_headers); | |
1040 | |
1041 // Feature: Sparse fieldsets. | |
1042 $this->doTestSparseFieldSets($url, $request_options); | |
1043 // Feature: Included. | |
1044 $this->doTestIncluded($url, $request_options); | |
1045 | |
1046 // DX: 404 when GETting non-existing entity. | |
1047 $random_uuid = \Drupal::service('uuid')->generate(); | |
1048 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $random_uuid]); | |
1049 $response = $this->request('GET', $url, $request_options); | |
1050 $message_url = clone $url; | |
1051 $path = str_replace($random_uuid, '{entity}', $message_url->setAbsolute()->setOptions(['base_url' => '', 'query' => []])->toString()); | |
1052 $message = 'The "entity" parameter was not converted for the path "' . $path . '" (route name: "jsonapi.' . static::$resourceTypeName . '.individual")'; | |
1053 $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE'); | |
1054 | |
1055 // DX: when Accept request header is missing, still 404, same response. | |
1056 unset($request_options[RequestOptions::HEADERS]['Accept']); | |
1057 $response = $this->request('GET', $url, $request_options); | |
1058 $this->assertResourceErrorResponse(404, $message, $url, $response, FALSE, ['4xx-response', 'http_response'], ['url.site'], FALSE, 'UNCACHEABLE'); | |
1059 } | |
1060 | |
1061 /** | |
1062 * Tests GETting a collection of resources. | |
1063 */ | |
1064 public function testCollection() { | |
1065 $entity_collection = $this->getData(); | |
1066 assert(count($entity_collection) > 1, 'A collection must have more that one entity in it.'); | |
1067 | |
1068 $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(TRUE); | |
1069 $request_options = []; | |
1070 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
1071 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
1072 | |
1073 // This asserts that collections will work without a sort, added by default | |
1074 // below, without actually asserting the content of the response. | |
1075 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); | |
1076 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1077 $response = $this->request('HEAD', $collection_url, $request_options); | |
1078 // MISS or UNCACHEABLE depends on the collection data. It must not be HIT. | |
1079 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'; | |
1080 $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1081 | |
1082 // Different databases have different sort orders, so a sort is required so | |
1083 // test expectations do not need to vary per database. | |
1084 $default_sort = ['sort' => 'drupal_internal__' . $this->entity->getEntityType()->getKey('id')]; | |
1085 $collection_url->setOption('query', $default_sort); | |
1086 | |
1087 // 200 for collections, even when all entities are inaccessible. Access is | |
1088 // on a per-entity basis, which is handled by | |
1089 // self::getExpectedCollectionResponse(). | |
1090 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); | |
1091 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1092 $expected_document = $expected_response->getResponseData(); | |
1093 $response = $this->request('GET', $collection_url, $request_options); | |
1094 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1095 | |
1096 $this->setUpAuthorization('GET'); | |
1097 | |
1098 // 200 for well-formed HEAD request. | |
1099 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); | |
1100 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1101 $response = $this->request('HEAD', $collection_url, $request_options); | |
1102 $this->assertResourceResponse(200, NULL, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1103 | |
1104 // 200 for well-formed GET request. | |
1105 $expected_response = $this->getExpectedCollectionResponse($entity_collection, $collection_url->toString(), $request_options); | |
1106 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1107 $expected_document = $expected_response->getResponseData(); | |
1108 $response = $this->request('GET', $collection_url, $request_options); | |
1109 // Dynamic Page Cache HIT unless the HEAD request was UNCACHEABLE. | |
1110 $dynamic_cache = $dynamic_cache === 'UNCACHEABLE' ? 'UNCACHEABLE' : 'HIT'; | |
1111 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1112 | |
1113 if ($this->entity instanceof FieldableEntityInterface) { | |
1114 // 403 for filtering on an unauthorized field on the base resource type. | |
1115 $unauthorized_filter_url = clone $collection_url; | |
1116 $unauthorized_filter_url->setOption('query', [ | |
1117 'filter' => [ | |
1118 'related_author_id' => [ | |
1119 'operator' => '<>', | |
1120 'path' => 'field_jsonapi_test_entity_ref.status', | |
1121 'value' => 'doesnt@matter.com', | |
1122 ], | |
1123 ], | |
1124 ]); | |
1125 $response = $this->request('GET', $unauthorized_filter_url, $request_options); | |
1126 $expected_error_message = "The current user is not authorized to filter by the `field_jsonapi_test_entity_ref` field, given in the path `field_jsonapi_test_entity_ref`. The 'field_jsonapi_test_entity_ref view access' permission is required."; | |
1127 $expected_cache_tags = ['4xx-response', 'http_response']; | |
1128 $expected_cache_contexts = [ | |
1129 'url.query_args:filter', | |
1130 'url.query_args:sort', | |
1131 'url.site', | |
1132 'user.permissions', | |
1133 ]; | |
1134 $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
1135 | |
1136 $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']); | |
1137 | |
1138 // 403 for filtering on an unauthorized field on a related resource type. | |
1139 $response = $this->request('GET', $unauthorized_filter_url, $request_options); | |
1140 $expected_error_message = "The current user is not authorized to filter by the `status` field, given in the path `field_jsonapi_test_entity_ref.entity:user.status`."; | |
1141 $this->assertResourceErrorResponse(403, $expected_error_message, $unauthorized_filter_url, $response, FALSE, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
1142 } | |
1143 | |
1144 // Remove an entity from the collection, then filter it out. | |
1145 $filtered_entity_collection = $entity_collection; | |
1146 $removed = array_shift($filtered_entity_collection); | |
1147 $filtered_collection_url = clone $collection_url; | |
1148 $entity_collection_filter = [ | |
1149 'filter' => [ | |
1150 'ids' => [ | |
1151 'condition' => [ | |
1152 'operator' => '<>', | |
1153 'path' => 'id', | |
1154 'value' => $removed->uuid(), | |
1155 ], | |
1156 ], | |
1157 ], | |
1158 ]; | |
1159 $filtered_collection_url->setOption('query', $entity_collection_filter + $default_sort); | |
1160 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_url->toString(), $request_options, NULL, TRUE); | |
1161 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1162 $expected_document = $expected_response->getResponseData(); | |
1163 $response = $this->request('GET', $filtered_collection_url, $request_options); | |
1164 // MISS or UNCACHEABLE depends on the collection data. It must not be HIT. | |
1165 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; | |
1166 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1167 | |
1168 // Filtered collection with includes. | |
1169 $relationship_field_names = array_reduce($filtered_entity_collection, function ($relationship_field_names, $entity) { | |
1170 return array_unique(array_merge($relationship_field_names, $this->getRelationshipFieldNames($entity))); | |
1171 }, []); | |
1172 $include = ['include' => implode(',', $relationship_field_names)]; | |
1173 $filtered_collection_include_url = clone $collection_url; | |
1174 $filtered_collection_include_url->setOption('query', $entity_collection_filter + $include + $default_sort); | |
1175 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE); | |
1176 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1177 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); | |
1178 $expected_document = $expected_response->getResponseData(); | |
1179 $response = $this->request('GET', $filtered_collection_include_url, $request_options); | |
1180 // MISS or UNCACHEABLE depends on the included data. It must not be HIT. | |
1181 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; | |
1182 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1183 | |
1184 // If the response should vary by a user's authorizations, grant permissions | |
1185 // for the included resources and execute another request. | |
1186 $permission_related_cache_contexts = [ | |
1187 'user', | |
1188 'user.permissions', | |
1189 'user.roles', | |
1190 ]; | |
1191 if (!empty($relationship_field_names) && !empty(array_intersect($expected_cacheability->getCacheContexts(), $permission_related_cache_contexts))) { | |
1192 $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($relationship_field_names)); | |
1193 $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', [])); | |
1194 $this->grantPermissionsToTestedRole($flattened_permissions); | |
1195 $expected_response = $this->getExpectedCollectionResponse($filtered_entity_collection, $filtered_collection_include_url->toString(), $request_options, $relationship_field_names, TRUE); | |
1196 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1197 $expected_document = $expected_response->getResponseData(); | |
1198 $response = $this->request('GET', $filtered_collection_include_url, $request_options); | |
1199 $requires_include_only_permissions = !empty($flattened_permissions); | |
1200 $uncacheable = $expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())); | |
1201 $dynamic_cache = !$uncacheable ? $requires_include_only_permissions ? 'MISS' : 'HIT' : 'UNCACHEABLE'; | |
1202 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1203 } | |
1204 | |
1205 // Sorted collection with includes. | |
1206 $sorted_entity_collection = $entity_collection; | |
1207 uasort($sorted_entity_collection, function (EntityInterface $a, EntityInterface $b) { | |
1208 // Sort by ID in reverse order. | |
1209 return strcmp($b->uuid(), $a->uuid()); | |
1210 }); | |
1211 $sorted_collection_include_url = clone $collection_url; | |
1212 $sorted_collection_include_url->setOption('query', $include + ['sort' => "-id"]); | |
1213 $expected_response = $this->getExpectedCollectionResponse($sorted_entity_collection, $sorted_collection_include_url->toString(), $request_options, $relationship_field_names); | |
1214 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
1215 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); | |
1216 $expected_document = $expected_response->getResponseData(); | |
1217 $response = $this->request('GET', $sorted_collection_include_url, $request_options); | |
1218 // MISS or UNCACHEABLE depends on the included data. It must not be HIT. | |
1219 $dynamic_cache = $expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS'; | |
1220 $this->assertResourceResponse(200, $expected_document, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
1221 } | |
1222 | |
1223 /** | |
1224 * Returns a JSON:API collection document for the expected entities. | |
1225 * | |
1226 * @param \Drupal\Core\Entity\EntityInterface[] $collection | |
1227 * The entities for the collection. | |
1228 * @param string $self_link | |
1229 * The self link for the collection response document. | |
1230 * @param array $request_options | |
1231 * Request options to apply. | |
1232 * @param array|null $included_paths | |
1233 * (optional) Any include paths that should be appended to the expected | |
1234 * response. | |
1235 * @param bool $filtered | |
1236 * Whether the collection is filtered or not. | |
1237 * | |
1238 * @return \Drupal\jsonapi\ResourceResponse | |
1239 * A ResourceResponse for the expected entity collection. | |
1240 * | |
1241 * @see \GuzzleHttp\ClientInterface::request() | |
1242 */ | |
1243 protected function getExpectedCollectionResponse(array $collection, $self_link, array $request_options, array $included_paths = NULL, $filtered = FALSE) { | |
1244 $resource_identifiers = array_map([static::class, 'toResourceIdentifier'], $collection); | |
1245 $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options)); | |
1246 $merged_response = static::toCollectionResourceResponse($individual_responses, $self_link, TRUE); | |
1247 | |
1248 $merged_document = $merged_response->getResponseData(); | |
1249 if (!isset($merged_document['data'])) { | |
1250 $merged_document['data'] = []; | |
1251 } | |
1252 | |
1253 $cacheability = static::getExpectedCollectionCacheability($this->account, $collection, NULL, $filtered); | |
1254 $cacheability->setCacheMaxAge($merged_response->getCacheableMetadata()->getCacheMaxAge()); | |
1255 | |
1256 $collection_response = ResourceResponse::create($merged_document); | |
1257 $collection_response->addCacheableDependency($cacheability); | |
1258 | |
1259 if (is_null($included_paths)) { | |
1260 return $collection_response; | |
1261 } | |
1262 | |
1263 $related_responses = array_reduce($collection, function ($related_responses, EntityInterface $entity) use ($included_paths, $request_options, $self_link) { | |
1264 if (!$entity->access('view', $this->account) && !$entity->access('view label', $this->account)) { | |
1265 return $related_responses; | |
1266 } | |
1267 $expected_related_responses = $this->getExpectedRelatedResponses($included_paths, $request_options, $entity); | |
1268 if (empty($related_responses)) { | |
1269 return $expected_related_responses; | |
1270 } | |
1271 foreach ($included_paths as $included_path) { | |
1272 $both_responses = [$related_responses[$included_path], $expected_related_responses[$included_path]]; | |
1273 $related_responses[$included_path] = static::toCollectionResourceResponse($both_responses, $self_link, TRUE); | |
1274 } | |
1275 return $related_responses; | |
1276 }, []); | |
1277 | |
1278 return static::decorateExpectedResponseForIncludedFields($collection_response, $related_responses); | |
1279 } | |
1280 | |
1281 /** | |
1282 * Tests GETing related resource of an individual resource. | |
1283 * | |
1284 * Expected responses are built by making requests to 'relationship' routes. | |
1285 * Using the fetched resource identifiers, if any, all targeted resources are | |
1286 * fetched individually. These individual responses are then 'merged' into a | |
1287 * single expected ResourceResponse. This is repeated for every relationship | |
1288 * field of the resource type under test. | |
1289 */ | |
1290 public function testRelated() { | |
1291 $request_options = []; | |
1292 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
1293 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
1294 $this->doTestRelated($request_options); | |
1295 $this->setUpAuthorization('GET'); | |
1296 $this->doTestRelated($request_options); | |
1297 } | |
1298 | |
1299 /** | |
1300 * Tests CRUD of individual resource relationship data. | |
1301 * | |
1302 * Unlike the "related" routes, relationship routes only return information | |
1303 * about the "relationship" itself, not the targeted resources. For JSON:API | |
1304 * with Drupal, relationship routes are like looking at an entity reference | |
1305 * field without loading the entities. It only reveals the type of the | |
1306 * targeted resource and the target resource IDs. These type+ID combos are | |
1307 * referred to as "resource identifiers." | |
1308 */ | |
1309 public function testRelationships() { | |
1310 if ($this->entity instanceof ConfigEntityInterface) { | |
1311 $this->markTestSkipped('Configuration entities cannot have relationships.'); | |
1312 } | |
1313 | |
1314 $request_options = []; | |
1315 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
1316 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
1317 | |
1318 // Test GET. | |
1319 $this->doTestRelationshipGet($request_options); | |
1320 $this->setUpAuthorization('GET'); | |
1321 $this->doTestRelationshipGet($request_options); | |
1322 | |
1323 // Test POST. | |
1324 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
1325 $this->doTestRelationshipMutation($request_options); | |
1326 // Grant entity-level edit access. | |
1327 $this->setUpAuthorization('PATCH'); | |
1328 $this->doTestRelationshipMutation($request_options); | |
1329 // Field edit access is still forbidden, grant it. | |
1330 $this->grantPermissionsToTestedRole([ | |
1331 'field_jsonapi_test_entity_ref view access', | |
1332 'field_jsonapi_test_entity_ref edit access', | |
1333 'field_jsonapi_test_entity_ref update access', | |
1334 ]); | |
1335 $this->doTestRelationshipMutation($request_options); | |
1336 } | |
1337 | |
1338 /** | |
1339 * Performs one round of related route testing. | |
1340 * | |
1341 * By putting this behavior in its own method, authorization and other | |
1342 * variations can be done in the calling method around assertions. For | |
1343 * example, it can be run once with an authorized user and again without one. | |
1344 * | |
1345 * @param array $request_options | |
1346 * Request options to apply. | |
1347 * | |
1348 * @see \GuzzleHttp\ClientInterface::request() | |
1349 */ | |
1350 protected function doTestRelated(array $request_options) { | |
1351 $relationship_field_names = $this->getRelationshipFieldNames($this->entity); | |
1352 // If there are no relationship fields, we can't test related routes. | |
1353 if (empty($relationship_field_names)) { | |
1354 return; | |
1355 } | |
1356 // Builds an array of expected responses, keyed by relationship field name. | |
1357 $expected_relationship_responses = $this->getExpectedRelatedResponses($relationship_field_names, $request_options); | |
1358 // Fetches actual responses as an array keyed by relationship field name. | |
1359 $related_responses = $this->getRelatedResponses($relationship_field_names, $request_options); | |
1360 foreach ($relationship_field_names as $relationship_field_name) { | |
1361 /* @var \Drupal\jsonapi\ResourceResponse $expected_resource_response */ | |
1362 $expected_resource_response = $expected_relationship_responses[$relationship_field_name]; | |
1363 /* @var \Psr\Http\Message\ResponseInterface $actual_response */ | |
1364 $actual_response = $related_responses[$relationship_field_name]; | |
1365 // Dynamic Page Cache miss because cache should vary based on the | |
1366 // 'include' query param. | |
1367 $expected_cacheability = $expected_resource_response->getCacheableMetadata(); | |
1368 $this->assertResourceResponse( | |
1369 $expected_resource_response->getStatusCode(), | |
1370 $expected_resource_response->getResponseData(), | |
1371 $actual_response, | |
1372 $expected_cacheability->getCacheTags(), | |
1373 $expected_cacheability->getCacheContexts(), | |
1374 FALSE, | |
1375 $actual_response->getStatusCode() === 200 | |
1376 ? ($expected_cacheability->getCacheMaxAge() === 0 ? 'UNCACHEABLE' : 'MISS') | |
1377 : FALSE | |
1378 ); | |
1379 } | |
1380 } | |
1381 | |
1382 /** | |
1383 * Performs one round of relationship route testing. | |
1384 * | |
1385 * @param array $request_options | |
1386 * Request options to apply. | |
1387 * | |
1388 * @see \GuzzleHttp\ClientInterface::request() | |
1389 * @see ::testRelationships | |
1390 */ | |
1391 protected function doTestRelationshipGet(array $request_options) { | |
1392 $relationship_field_names = $this->getRelationshipFieldNames($this->entity); | |
1393 // If there are no relationship fields, we can't test relationship routes. | |
1394 if (empty($relationship_field_names)) { | |
1395 return; | |
1396 } | |
1397 | |
1398 // Test GET. | |
1399 $related_responses = $this->getRelationshipResponses($relationship_field_names, $request_options); | |
1400 foreach ($relationship_field_names as $relationship_field_name) { | |
1401 $expected_resource_response = $this->getExpectedGetRelationshipResponse($relationship_field_name); | |
1402 $expected_document = $expected_resource_response->getResponseData(); | |
1403 $expected_cacheability = $expected_resource_response->getCacheableMetadata(); | |
1404 $actual_response = $related_responses[$relationship_field_name]; | |
1405 $this->assertResourceResponse( | |
1406 $expected_resource_response->getStatusCode(), | |
1407 $expected_document, | |
1408 $actual_response, | |
1409 $expected_cacheability->getCacheTags(), | |
1410 $expected_cacheability->getCacheContexts(), | |
1411 FALSE, | |
1412 $expected_resource_response->isSuccessful() ? 'MISS' : FALSE | |
1413 ); | |
1414 } | |
1415 } | |
1416 | |
1417 /** | |
1418 * Performs one round of relationship POST, PATCH and DELETE route testing. | |
1419 * | |
1420 * @param array $request_options | |
1421 * Request options to apply. | |
1422 * | |
1423 * @see \GuzzleHttp\ClientInterface::request() | |
1424 * @see ::testRelationships | |
1425 */ | |
1426 protected function doTestRelationshipMutation(array $request_options) { | |
1427 /* @var \Drupal\Core\Entity\FieldableEntityInterface $resource */ | |
1428 $resource = $this->createAnotherEntity('dupe'); | |
1429 $resource->set('field_jsonapi_test_entity_ref', NULL); | |
1430 $violations = $resource->validate(); | |
1431 assert($violations->count() === 0, (string) $violations); | |
1432 $resource->save(); | |
1433 $target_resource = $this->createUser(); | |
1434 $violations = $target_resource->validate(); | |
1435 assert($violations->count() === 0, (string) $violations); | |
1436 $target_resource->save(); | |
1437 $target_identifier = static::toResourceIdentifier($target_resource); | |
1438 $resource_identifier = static::toResourceIdentifier($resource); | |
1439 $relationship_field_name = 'field_jsonapi_test_entity_ref'; | |
1440 /* @var \Drupal\Core\Access\AccessResultReasonInterface $update_access */ | |
1441 $update_access = static::entityAccess($resource, 'update', $this->account) | |
1442 ->andIf(static::entityFieldAccess($resource, $relationship_field_name, 'edit', $this->account)); | |
1443 $url = Url::fromRoute(sprintf("jsonapi.{$resource_identifier['type']}.{$relationship_field_name}.relationship.patch"), [ | |
1444 'entity' => $resource->uuid(), | |
1445 ]); | |
1446 | |
1447 // Test POST: missing content-type. | |
1448 $response = $this->request('POST', $url, $request_options); | |
1449 $this->assertSame(415, $response->getStatusCode()); | |
1450 | |
1451 // Set the JSON:API media type header for all subsequent requests. | |
1452 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
1453 | |
1454 if ($update_access->isAllowed()) { | |
1455 // Test POST: empty body. | |
1456 $response = $this->request('POST', $url, $request_options); | |
1457 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); | |
1458 // Test PATCH: empty body. | |
1459 $response = $this->request('PATCH', $url, $request_options); | |
1460 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); | |
1461 | |
1462 // Test POST: empty data. | |
1463 $request_options[RequestOptions::BODY] = Json::encode(['data' => []]); | |
1464 $response = $this->request('POST', $url, $request_options); | |
1465 $this->assertResourceResponse(204, NULL, $response); | |
1466 // Test PATCH: empty data. | |
1467 $request_options[RequestOptions::BODY] = Json::encode(['data' => []]); | |
1468 $response = $this->request('PATCH', $url, $request_options); | |
1469 $this->assertResourceResponse(204, NULL, $response); | |
1470 | |
1471 // Test POST: data as resource identifier, not array of identifiers. | |
1472 $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]); | |
1473 $response = $this->request('POST', $url, $request_options); | |
1474 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); | |
1475 // Test PATCH: data as resource identifier, not array of identifiers. | |
1476 $request_options[RequestOptions::BODY] = Json::encode(['data' => $target_identifier]); | |
1477 $response = $this->request('PATCH', $url, $request_options); | |
1478 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); | |
1479 | |
1480 // Test POST: missing the 'type' field. | |
1481 $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]); | |
1482 $response = $this->request('POST', $url, $request_options); | |
1483 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); | |
1484 // Test PATCH: missing the 'type' field. | |
1485 $request_options[RequestOptions::BODY] = Json::encode(['data' => array_intersect_key($target_identifier, ['id' => 'id'])]); | |
1486 $response = $this->request('PATCH', $url, $request_options); | |
1487 $this->assertResourceErrorResponse(400, 'Invalid body payload for the relationship.', $url, $response, FALSE); | |
1488 | |
1489 // If the base resource type is the same as that of the target's (as it | |
1490 // will be for `user--user`), then the validity error will not be | |
1491 // triggered, needlessly failing this assertion. | |
1492 if (static::$resourceTypeName !== $target_identifier['type']) { | |
1493 // Test POST: invalid target. | |
1494 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]); | |
1495 $response = $this->request('POST', $url, $request_options); | |
1496 $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE); | |
1497 // Test PATCH: invalid target. | |
1498 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$resource_identifier]]); | |
1499 $response = $this->request('POST', $url, $request_options); | |
1500 $this->assertResourceErrorResponse(400, sprintf('The provided type (%s) does not mach the destination resource types (%s).', $resource_identifier['type'], $target_identifier['type']), $url, $response, FALSE); | |
1501 } | |
1502 | |
1503 // Test POST: duplicate targets, no arity. | |
1504 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]); | |
1505 $response = $this->request('POST', $url, $request_options); | |
1506 $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE); | |
1507 | |
1508 // Test PATCH: duplicate targets, no arity. | |
1509 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier, $target_identifier]]); | |
1510 $response = $this->request('PATCH', $url, $request_options); | |
1511 $this->assertResourceErrorResponse(400, 'Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.', $url, $response, FALSE); | |
1512 | |
1513 // Test POST: success. | |
1514 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); | |
1515 $response = $this->request('POST', $url, $request_options); | |
1516 $this->assertResourceResponse(204, NULL, $response); | |
1517 | |
1518 // Test POST: success, relationship already exists, no arity. | |
1519 $response = $this->request('POST', $url, $request_options); | |
1520 $this->assertResourceResponse(204, NULL, $response); | |
1521 | |
1522 // Test POST: success, relationship already exists, new arity. | |
1523 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier + ['meta' => ['arity' => 1]]]]); | |
1524 $response = $this->request('POST', $url, $request_options); | |
1525 $resource->set($relationship_field_name, [$target_resource, $target_resource]); | |
1526 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1527 $expected_document['data'][0] += ['meta' => ['arity' => 0]]; | |
1528 $expected_document['data'][1] += ['meta' => ['arity' => 1]]; | |
1529 $this->assertResourceResponse(200, $expected_document, $response); | |
1530 | |
1531 // Test PATCH: success, new value is the same as given value. | |
1532 $request_options[RequestOptions::BODY] = Json::encode([ | |
1533 'data' => [ | |
1534 $target_identifier + ['meta' => ['arity' => 0]], | |
1535 $target_identifier + ['meta' => ['arity' => 1]], | |
1536 ], | |
1537 ]); | |
1538 $response = $this->request('PATCH', $url, $request_options); | |
1539 $this->assertResourceResponse(204, NULL, $response); | |
1540 | |
1541 // Test POST: success, relationship already exists, new arity. | |
1542 $request_options[RequestOptions::BODY] = Json::encode([ | |
1543 'data' => [ | |
1544 $target_identifier + ['meta' => ['arity' => 2]], | |
1545 ], | |
1546 ]); | |
1547 $response = $this->request('POST', $url, $request_options); | |
1548 $resource->set($relationship_field_name, [ | |
1549 $target_resource, | |
1550 $target_resource, | |
1551 $target_resource, | |
1552 ]); | |
1553 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1554 $expected_document['data'][0] += ['meta' => ['arity' => 0]]; | |
1555 $expected_document['data'][1] += ['meta' => ['arity' => 1]]; | |
1556 $expected_document['data'][2] += ['meta' => ['arity' => 2]]; | |
1557 // 200 with response body because the request did not include the | |
1558 // existing relationship resource identifier object. | |
1559 $this->assertResourceResponse(200, $expected_document, $response); | |
1560 | |
1561 // Test POST: success. | |
1562 $request_options[RequestOptions::BODY] = Json::encode([ | |
1563 'data' => [ | |
1564 $target_identifier + ['meta' => ['arity' => 0]], | |
1565 $target_identifier + ['meta' => ['arity' => 1]], | |
1566 ], | |
1567 ]); | |
1568 $response = $this->request('POST', $url, $request_options); | |
1569 // 200 with response body because the request did not include the | |
1570 // resource identifier with arity 2. | |
1571 $this->assertResourceResponse(200, $expected_document, $response); | |
1572 | |
1573 // Test PATCH: success. | |
1574 $request_options[RequestOptions::BODY] = Json::encode([ | |
1575 'data' => [ | |
1576 $target_identifier + ['meta' => ['arity' => 0]], | |
1577 $target_identifier + ['meta' => ['arity' => 1]], | |
1578 $target_identifier + ['meta' => ['arity' => 2]], | |
1579 ], | |
1580 ]); | |
1581 $response = $this->request('PATCH', $url, $request_options); | |
1582 // 204 no content. PATCH data matches existing data. | |
1583 $this->assertResourceResponse(204, NULL, $response); | |
1584 | |
1585 // Test DELETE: three existing relationships, two removed. | |
1586 $request_options[RequestOptions::BODY] = Json::encode([ | |
1587 'data' => [ | |
1588 $target_identifier + ['meta' => ['arity' => 0]], | |
1589 $target_identifier + ['meta' => ['arity' => 2]], | |
1590 ], | |
1591 ]); | |
1592 $response = $this->request('DELETE', $url, $request_options); | |
1593 $this->assertResourceResponse(204, NULL, $response); | |
1594 // Subsequent GET should return only one resource identifier, with no | |
1595 // arity. | |
1596 $resource->set($relationship_field_name, [$target_resource]); | |
1597 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1598 $response = $this->request('GET', $url, $request_options); | |
1599 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); | |
1600 | |
1601 // Test DELETE: one existing relationship, removed. | |
1602 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); | |
1603 $response = $this->request('DELETE', $url, $request_options); | |
1604 $resource->set($relationship_field_name, []); | |
1605 $this->assertResourceResponse(204, NULL, $response); | |
1606 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1607 $response = $this->request('GET', $url, $request_options); | |
1608 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); | |
1609 | |
1610 // Test DELETE: no existing relationships, no op, success. | |
1611 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); | |
1612 $response = $this->request('DELETE', $url, $request_options); | |
1613 $this->assertResourceResponse(204, NULL, $response); | |
1614 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1615 $response = $this->request('GET', $url, $request_options); | |
1616 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); | |
1617 | |
1618 // Test PATCH: success, new value is different than existing value. | |
1619 $request_options[RequestOptions::BODY] = Json::encode([ | |
1620 'data' => [ | |
1621 $target_identifier + ['meta' => ['arity' => 2]], | |
1622 $target_identifier + ['meta' => ['arity' => 3]], | |
1623 ], | |
1624 ]); | |
1625 $response = $this->request('PATCH', $url, $request_options); | |
1626 $resource->set($relationship_field_name, [$target_resource, $target_resource]); | |
1627 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1628 $expected_document['data'][0] += ['meta' => ['arity' => 0]]; | |
1629 $expected_document['data'][1] += ['meta' => ['arity' => 1]]; | |
1630 // 200 with response body because arity values are computed; that means | |
1631 // that the PATCH arity values 2 + 3 will become 0 + 1 if there are not | |
1632 // already resource identifiers with those arity values. | |
1633 $this->assertResourceResponse(200, $expected_document, $response); | |
1634 | |
1635 // Test DELETE: two existing relationships, both removed because no arity | |
1636 // was specified. | |
1637 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); | |
1638 $response = $this->request('DELETE', $url, $request_options); | |
1639 $resource->set($relationship_field_name, []); | |
1640 $this->assertResourceResponse(204, NULL, $response); | |
1641 $resource->set($relationship_field_name, []); | |
1642 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $resource); | |
1643 $response = $this->request('GET', $url, $request_options); | |
1644 $this->assertSameDocument($expected_document, Json::decode((string) $response->getBody())); | |
1645 } | |
1646 else { | |
1647 $request_options[RequestOptions::BODY] = Json::encode(['data' => [$target_identifier]]); | |
1648 $response = $this->request('POST', $url, $request_options); | |
1649 $message = 'The current user is not allowed to edit this relationship.'; | |
1650 $message .= ($reason = $update_access->getReason()) ? ' ' . $reason : ''; | |
1651 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); | |
1652 $response = $this->request('PATCH', $url, $request_options); | |
1653 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); | |
1654 $response = $this->request('DELETE', $url, $request_options); | |
1655 $this->assertResourceErrorResponse(403, $message, $url, $response, FALSE); | |
1656 } | |
1657 | |
1658 // Remove the test entities that were created. | |
1659 $resource->delete(); | |
1660 $target_resource->delete(); | |
1661 } | |
1662 | |
1663 /** | |
1664 * Gets an expected ResourceResponse for the given relationship. | |
1665 * | |
1666 * @param string $relationship_field_name | |
1667 * The relationship for which to get an expected response. | |
1668 * @param \Drupal\Core\Entity\EntityInterface|null $entity | |
1669 * (optional) The entity for which to get expected relationship response. | |
1670 * | |
1671 * @return \Drupal\jsonapi\ResourceResponse | |
1672 * The expected ResourceResponse. | |
1673 */ | |
1674 protected function getExpectedGetRelationshipResponse($relationship_field_name, EntityInterface $entity = NULL) { | |
1675 $entity = $entity ?: $this->entity; | |
1676 $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); | |
1677 $access = $access->orIf(static::entityFieldAccess($entity, $this->resourceType->getInternalName($relationship_field_name), 'view', $this->account)); | |
1678 if (!$access->isAllowed()) { | |
1679 $via_link = Url::fromRoute( | |
1680 sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, $relationship_field_name), | |
1681 ['entity' => $entity->uuid()] | |
1682 ); | |
1683 return static::getAccessDeniedResponse($this->entity, $access, $via_link, $relationship_field_name, 'The current user is not allowed to view this relationship.', FALSE); | |
1684 } | |
1685 $expected_document = $this->getExpectedGetRelationshipDocument($relationship_field_name, $entity); | |
1686 $expected_cacheability = (new CacheableMetadata()) | |
1687 ->addCacheTags(['http_response']) | |
1688 ->addCacheContexts([ | |
1689 'url.site', | |
1690 'url.query_args:include', | |
1691 'url.query_args:fields', | |
1692 ]) | |
1693 ->addCacheableDependency($entity) | |
1694 ->addCacheableDependency($access); | |
1695 $status_code = isset($expected_document['errors'][0]['status']) ? $expected_document['errors'][0]['status'] : 200; | |
1696 $resource_response = new ResourceResponse($expected_document, $status_code); | |
1697 $resource_response->addCacheableDependency($expected_cacheability); | |
1698 return $resource_response; | |
1699 } | |
1700 | |
1701 /** | |
1702 * Gets an expected document for the given relationship. | |
1703 * | |
1704 * @param string $relationship_field_name | |
1705 * The relationship for which to get an expected response. | |
1706 * @param \Drupal\Core\Entity\EntityInterface|null $entity | |
1707 * (optional) The entity for which to get expected relationship document. | |
1708 * | |
1709 * @return array | |
1710 * The expected document array. | |
1711 */ | |
1712 protected function getExpectedGetRelationshipDocument($relationship_field_name, EntityInterface $entity = NULL) { | |
1713 $entity = $entity ?: $this->entity; | |
1714 $entity_type_id = $entity->getEntityTypeId(); | |
1715 $bundle = $entity->bundle(); | |
1716 $id = $entity->uuid(); | |
1717 $self_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/relationships/$relationship_field_name")->setAbsolute(); | |
1718 $related_link = Url::fromUri("base:/jsonapi/$entity_type_id/$bundle/$id/$relationship_field_name")->setAbsolute(); | |
1719 if (static::$resourceTypeIsVersionable) { | |
1720 assert($entity instanceof RevisionableInterface); | |
1721 $version_query = ['resourceVersion' => 'id:' . $entity->getRevisionId()]; | |
1722 $self_link->setOption('query', $version_query); | |
1723 $related_link->setOption('query', $version_query); | |
1724 } | |
1725 $data = $this->getExpectedGetRelationshipDocumentData($relationship_field_name, $entity); | |
1726 return [ | |
1727 'data' => $data, | |
1728 'jsonapi' => static::$jsonApiMember, | |
1729 'links' => [ | |
1730 'self' => ['href' => $self_link->toString(TRUE)->getGeneratedUrl()], | |
1731 'related' => ['href' => $related_link->toString(TRUE)->getGeneratedUrl()], | |
1732 ], | |
1733 ]; | |
1734 } | |
1735 | |
1736 /** | |
1737 * Gets the expected document data for the given relationship. | |
1738 * | |
1739 * @param string $relationship_field_name | |
1740 * The relationship for which to get an expected response. | |
1741 * @param \Drupal\Core\Entity\EntityInterface|null $entity | |
1742 * (optional) The entity for which to get expected relationship data. | |
1743 * | |
1744 * @return mixed | |
1745 * The expected document data. | |
1746 */ | |
1747 protected function getExpectedGetRelationshipDocumentData($relationship_field_name, EntityInterface $entity = NULL) { | |
1748 $entity = $entity ?: $this->entity; | |
1749 $internal_field_name = $this->resourceType->getInternalName($relationship_field_name); | |
1750 /* @var \Drupal\Core\Field\FieldItemListInterface $field */ | |
1751 $field = $entity->{$internal_field_name}; | |
1752 $is_multiple = $field->getFieldDefinition()->getFieldStorageDefinition()->getCardinality() !== 1; | |
1753 if ($field->isEmpty()) { | |
1754 return $is_multiple ? [] : NULL; | |
1755 } | |
1756 if (!$is_multiple) { | |
1757 $target_entity = $field->entity; | |
1758 return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity); | |
1759 } | |
1760 else { | |
1761 return array_filter(array_map(function ($item) { | |
1762 $target_entity = $item->entity; | |
1763 return is_null($target_entity) ? NULL : static::toResourceIdentifier($target_entity); | |
1764 }, iterator_to_array($field))); | |
1765 } | |
1766 } | |
1767 | |
1768 /** | |
1769 * Builds an array of expected related ResourceResponses, keyed by field name. | |
1770 * | |
1771 * @param array $relationship_field_names | |
1772 * The relationship field names for which to build expected | |
1773 * ResourceResponses. | |
1774 * @param array $request_options | |
1775 * Request options to apply. | |
1776 * @param \Drupal\Core\Entity\EntityInterface|null $entity | |
1777 * (optional) The entity for which to get expected related resources. | |
1778 * | |
1779 * @return \Drupal\jsonapi\ResourceResponse[] | |
1780 * An array of expected ResourceResponses, keyed by their relationship field | |
1781 * name. | |
1782 * | |
1783 * @see \GuzzleHttp\ClientInterface::request() | |
1784 */ | |
1785 protected function getExpectedRelatedResponses(array $relationship_field_names, array $request_options, EntityInterface $entity = NULL) { | |
1786 $entity = $entity ?: $this->entity; | |
1787 return array_map(function ($relationship_field_name) use ($entity, $request_options) { | |
1788 return $this->getExpectedRelatedResponse($relationship_field_name, $request_options, $entity); | |
1789 }, array_combine($relationship_field_names, $relationship_field_names)); | |
1790 } | |
1791 | |
1792 /** | |
1793 * Builds an expected related ResourceResponse for the given field. | |
1794 * | |
1795 * @param string $relationship_field_name | |
1796 * The relationship field name for which to build an expected | |
1797 * ResourceResponse. | |
1798 * @param array $request_options | |
1799 * Request options to apply. | |
1800 * @param \Drupal\Core\Entity\EntityInterface $entity | |
1801 * The entity for which to get expected related resources. | |
1802 * | |
1803 * @return \Drupal\jsonapi\ResourceResponse | |
1804 * An expected ResourceResponse. | |
1805 * | |
1806 * @see \GuzzleHttp\ClientInterface::request() | |
1807 */ | |
1808 protected function getExpectedRelatedResponse($relationship_field_name, array $request_options, EntityInterface $entity) { | |
1809 // Get the relationships responses which contain resource identifiers for | |
1810 // every related resource. | |
1811 /* @var \Drupal\jsonapi\ResourceResponse[] $relationship_responses */ | |
1812 $base_resource_identifier = static::toResourceIdentifier($entity); | |
1813 $internal_name = $this->resourceType->getInternalName($relationship_field_name); | |
1814 $access = AccessResult::neutral()->addCacheContexts($entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); | |
1815 $access = $access->orIf(static::entityFieldAccess($entity, $internal_name, 'view', $this->account)); | |
1816 if (!$access->isAllowed()) { | |
1817 $detail = 'The current user is not allowed to view this relationship.'; | |
1818 if (!$entity->access('view') && $entity->access('view label') && $access instanceof AccessResultReasonInterface && empty($access->getReason())) { | |
1819 $access->setReason("The user only has authorization for the 'view label' operation."); | |
1820 } | |
1821 $via_link = Url::fromRoute( | |
1822 sprintf('jsonapi.%s.%s.related', $base_resource_identifier['type'], $relationship_field_name), | |
1823 ['entity' => $base_resource_identifier['id']] | |
1824 ); | |
1825 $related_response = static::getAccessDeniedResponse($entity, $access, $via_link, $relationship_field_name, $detail, FALSE); | |
1826 } | |
1827 else { | |
1828 $self_link = static::getRelatedLink($base_resource_identifier, $relationship_field_name); | |
1829 $relationship_response = $this->getExpectedGetRelationshipResponse($relationship_field_name, $entity); | |
1830 $relationship_document = $relationship_response->getResponseData(); | |
1831 // The relationships may be empty, in which case we shouldn't attempt to | |
1832 // fetch the individual identified resources. | |
1833 if (empty($relationship_document['data'])) { | |
1834 $cache_contexts = Cache::mergeContexts([ | |
1835 // Cache contexts for JSON:API URL query parameters. | |
1836 'url.query_args:fields', | |
1837 'url.query_args:include', | |
1838 // Drupal defaults. | |
1839 'url.site', | |
1840 ], $this->entity->getEntityType()->isRevisionable() ? ['url.query_args:resourceVersion'] : []); | |
1841 $cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts)->addCacheTags(['http_response']); | |
1842 $related_response = isset($relationship_document['errors']) | |
1843 ? $relationship_response | |
1844 : (new ResourceResponse(static::getEmptyCollectionResponse(!is_null($relationship_document['data']), $self_link)->getResponseData()))->addCacheableDependency($cacheability); | |
1845 } | |
1846 else { | |
1847 $is_to_one_relationship = static::isResourceIdentifier($relationship_document['data']); | |
1848 $resource_identifiers = $is_to_one_relationship | |
1849 ? [$relationship_document['data']] | |
1850 : $relationship_document['data']; | |
1851 // Remove any relationships to 'virtual' resources. | |
1852 $resource_identifiers = array_filter($resource_identifiers, function ($resource_identifier) { | |
1853 return $resource_identifier['id'] !== 'virtual'; | |
1854 }); | |
1855 if (!empty($resource_identifiers)) { | |
1856 $individual_responses = static::toResourceResponses($this->getResponses(static::getResourceLinks($resource_identifiers), $request_options)); | |
1857 $related_response = static::toCollectionResourceResponse($individual_responses, $self_link, !$is_to_one_relationship); | |
1858 } | |
1859 else { | |
1860 $related_response = static::getEmptyCollectionResponse(!$is_to_one_relationship, $self_link); | |
1861 } | |
1862 } | |
1863 $related_response->addCacheableDependency($relationship_response->getCacheableMetadata()); | |
1864 } | |
1865 return $related_response; | |
1866 } | |
1867 | |
1868 /** | |
1869 * Tests POSTing an individual resource, plus edge cases to ensure good DX. | |
1870 */ | |
1871 public function testPostIndividual() { | |
1872 // @todo Remove this in https://www.drupal.org/node/2300677. | |
1873 if ($this->entity instanceof ConfigEntityInterface) { | |
1874 $this->assertTrue(TRUE, 'POSTing config entities is not yet supported.'); | |
1875 return; | |
1876 } | |
1877 | |
1878 // Try with all of the following request bodies. | |
1879 $unparseable_request_body = '!{>}<'; | |
1880 $parseable_valid_request_body = Json::encode($this->getPostDocument()); | |
1881 /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPostEntity()); */ | |
1882 $parseable_invalid_request_body_missing_type = Json::encode($this->removeResourceTypeFromDocument($this->getPostDocument(), 'type')); | |
1883 $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPostDocument(), 'label')); | |
1884 $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['id' => $this->randomMachineName(129)]], $this->getPostDocument())); | |
1885 $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPostDocument())); | |
1886 $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPostDocument())); | |
1887 | |
1888 // The URL and Guzzle request options that will be used in this test. The | |
1889 // request options will be modified/expanded throughout this test: | |
1890 // - to first test all mistakes a developer might make, and assert that the | |
1891 // error responses provide a good DX | |
1892 // - to eventually result in a well-formed request that succeeds. | |
1893 $url = Url::fromRoute(sprintf('jsonapi.%s.collection.post', static::$resourceTypeName)); | |
1894 $request_options = []; | |
1895 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
1896 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
1897 | |
1898 // DX: 405 when read-only mode is enabled. | |
1899 $response = $this->request('POST', $url, $request_options); | |
1900 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); | |
1901 if ($this->resourceType->isLocatable()) { | |
1902 $this->assertSame(['GET'], $response->getHeader('Allow')); | |
1903 } | |
1904 else { | |
1905 $this->assertSame([''], $response->getHeader('Allow')); | |
1906 } | |
1907 | |
1908 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
1909 | |
1910 // DX: 415 when no Content-Type request header. | |
1911 $response = $this->request('POST', $url, $request_options); | |
1912 $this->assertSame(415, $response->getStatusCode()); | |
1913 | |
1914 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
1915 | |
1916 // DX: 403 when unauthorized. | |
1917 $response = $this->request('POST', $url, $request_options); | |
1918 $reason = $this->getExpectedUnauthorizedAccessMessage('POST'); | |
1919 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response); | |
1920 | |
1921 $this->setUpAuthorization('POST'); | |
1922 | |
1923 // DX: 400 when no request body. | |
1924 $response = $this->request('POST', $url, $request_options); | |
1925 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); | |
1926 | |
1927 $request_options[RequestOptions::BODY] = $unparseable_request_body; | |
1928 | |
1929 // DX: 400 when unparseable request body. | |
1930 $response = $this->request('POST', $url, $request_options); | |
1931 $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); | |
1932 | |
1933 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_missing_type; | |
1934 | |
1935 // DX: 400 when invalid JSON:API request body. | |
1936 $response = $this->request('POST', $url, $request_options); | |
1937 $this->assertResourceErrorResponse(400, 'Resource object must include a "type".', $url, $response, FALSE); | |
1938 | |
1939 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; | |
1940 | |
1941 // DX: 422 when invalid entity: multiple values sent for single-value field. | |
1942 $response = $this->request('POST', $url, $request_options); | |
1943 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; | |
1944 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); | |
1945 $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); | |
1946 | |
1947 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; | |
1948 | |
1949 // DX: 403 when invalid entity: UUID field too long. | |
1950 // @todo Fix this in https://www.drupal.org/project/drupal/issues/2149851. | |
1951 if ($this->entity->getEntityType()->hasKey('uuid')) { | |
1952 $response = $this->request('POST', $url, $request_options); | |
1953 $this->assertResourceErrorResponse(422, "IDs should be properly generated and formatted UUIDs as described in RFC 4122.", $url, $response); | |
1954 } | |
1955 | |
1956 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; | |
1957 | |
1958 // DX: 403 when entity contains field without 'edit' access. | |
1959 $response = $this->request('POST', $url, $request_options); | |
1960 $this->assertResourceErrorResponse(403, "The current user is not allowed to POST the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); | |
1961 | |
1962 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4; | |
1963 | |
1964 // DX: 422 when request document contains non-existent field. | |
1965 $response = $this->request('POST', $url, $request_options); | |
1966 $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE); | |
1967 | |
1968 $request_options[RequestOptions::BODY] = $parseable_valid_request_body; | |
1969 | |
1970 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; | |
1971 | |
1972 // DX: 415 when request body in existing but not allowed format. | |
1973 $response = $this->request('POST', $url, $request_options); | |
1974 $this->assertResourceErrorResponse(415, 'No route found that matches "Content-Type: text/xml"', $url, $response); | |
1975 | |
1976 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
1977 | |
1978 // 201 for well-formed request. | |
1979 $response = $this->request('POST', $url, $request_options); | |
1980 $this->assertResourceResponse(201, FALSE, $response); | |
1981 $this->assertFalse($response->hasHeader('X-Drupal-Cache')); | |
1982 // If the entity is stored, perform extra checks. | |
1983 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { | |
1984 $created_entity = $this->entityLoadUnchanged(static::$firstCreatedEntityId); | |
1985 $uuid = $created_entity->uuid(); | |
1986 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
1987 $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]); | |
1988 if (static::$resourceTypeIsVersionable) { | |
1989 assert($created_entity instanceof RevisionableInterface); | |
1990 $location->setOption('query', ['resourceVersion' => 'id:' . $created_entity->getRevisionId()]); | |
1991 } | |
1992 /* $location = $this->entityStorage->load(static::$firstCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */ | |
1993 $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location')); | |
1994 | |
1995 // Assert that the entity was indeed created, and that the response body | |
1996 // contains the serialized created entity. | |
1997 $created_entity_document = $this->normalize($created_entity, $url); | |
1998 $decoded_response_body = Json::decode((string) $response->getBody()); | |
1999 $this->assertSame($created_entity_document, $decoded_response_body); | |
2000 // Assert that the entity was indeed created using the POSTed values. | |
2001 foreach ($this->getPostDocument()['data']['attributes'] as $field_name => $field_normalization) { | |
2002 // If the value is an array of properties, only verify that the sent | |
2003 // properties are present, the server could be computing additional | |
2004 // properties. | |
2005 if (is_array($field_normalization)) { | |
2006 $this->assertArraySubset($field_normalization, $created_entity_document['data']['attributes'][$field_name]); | |
2007 } | |
2008 else { | |
2009 $this->assertSame($field_normalization, $created_entity_document['data']['attributes'][$field_name]); | |
2010 } | |
2011 } | |
2012 if (isset($this->getPostDocument()['data']['relationships'])) { | |
2013 foreach ($this->getPostDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) { | |
2014 // POSTing relationships: 'data' is required, 'links' is optional. | |
2015 static::recursiveKsort($relationship_field_normalization); | |
2016 static::recursiveKsort($created_entity_document['data']['relationships'][$field_name]); | |
2017 $this->assertSame($relationship_field_normalization, array_diff_key($created_entity_document['data']['relationships'][$field_name], ['links' => TRUE])); | |
2018 } | |
2019 } | |
2020 } | |
2021 else { | |
2022 $this->assertFalse($response->hasHeader('Location')); | |
2023 } | |
2024 | |
2025 // 201 for well-formed request that creates another entity. | |
2026 // If the entity is stored, delete the first created entity (in case there | |
2027 // is a uniqueness constraint). | |
2028 if (get_class($this->entityStorage) !== ContentEntityNullStorage::class) { | |
2029 $this->entityStorage->load(static::$firstCreatedEntityId)->delete(); | |
2030 } | |
2031 $response = $this->request('POST', $url, $request_options); | |
2032 $this->assertResourceResponse(201, FALSE, $response); | |
2033 $this->assertFalse($response->hasHeader('X-Drupal-Cache')); | |
2034 | |
2035 if ($this->entity->getEntityType()->getStorageClass() !== ContentEntityNullStorage::class && $this->entity->getEntityType()->hasKey('uuid')) { | |
2036 $second_created_entity = $this->entityStorage->load(static::$secondCreatedEntityId); | |
2037 $uuid = $second_created_entity->uuid(); | |
2038 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
2039 $location = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $uuid]); | |
2040 /* $location = $this->entityStorage->load(static::$secondCreatedEntityId)->toUrl('jsonapi')->setAbsolute(TRUE)->toString(); */ | |
2041 if (static::$resourceTypeIsVersionable) { | |
2042 assert($created_entity instanceof RevisionableInterface); | |
2043 $location->setOption('query', ['resourceVersion' => 'id:' . $second_created_entity->getRevisionId()]); | |
2044 } | |
2045 $this->assertSame([$location->setAbsolute()->toString()], $response->getHeader('Location')); | |
2046 | |
2047 // 500 when creating an entity with a duplicate UUID. | |
2048 $doc = $this->getModifiedEntityForPostTesting(); | |
2049 $doc['data']['id'] = $uuid; | |
2050 $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; | |
2051 $request_options[RequestOptions::BODY] = Json::encode($doc); | |
2052 | |
2053 $response = $this->request('POST', $url, $request_options); | |
2054 $this->assertResourceErrorResponse(409, 'Conflict: Entity already exists.', $url, $response, FALSE); | |
2055 | |
2056 // 201 when successfully creating an entity with a new UUID. | |
2057 $doc = $this->getModifiedEntityForPostTesting(); | |
2058 $new_uuid = \Drupal::service('uuid')->generate(); | |
2059 $doc['data']['id'] = $new_uuid; | |
2060 $doc['data']['attributes'][$label_field] = [['value' => $this->randomMachineName()]]; | |
2061 $request_options[RequestOptions::BODY] = Json::encode($doc); | |
2062 | |
2063 $response = $this->request('POST', $url, $request_options); | |
2064 $this->assertResourceResponse(201, FALSE, $response); | |
2065 $entities = $this->entityStorage->loadByProperties([$this->uuidKey => $new_uuid]); | |
2066 $new_entity = reset($entities); | |
2067 $this->assertNotNull($new_entity); | |
2068 $new_entity->delete(); | |
2069 } | |
2070 else { | |
2071 $this->assertFalse($response->hasHeader('Location')); | |
2072 } | |
2073 } | |
2074 | |
2075 /** | |
2076 * Tests PATCHing an individual resource, plus edge cases to ensure good DX. | |
2077 */ | |
2078 public function testPatchIndividual() { | |
2079 // @todo Remove this in https://www.drupal.org/node/2300677. | |
2080 if ($this->entity instanceof ConfigEntityInterface) { | |
2081 $this->assertTrue(TRUE, 'PATCHing config entities is not yet supported.'); | |
2082 return; | |
2083 } | |
2084 | |
2085 $prior_revision_id = (int) $this->entityLoadUnchanged($this->entity->id())->getRevisionId(); | |
2086 | |
2087 // Patch testing requires that another entity of the same type exists. | |
2088 $this->anotherEntity = $this->createAnotherEntity('dupe'); | |
2089 | |
2090 // Try with all of the following request bodies. | |
2091 $unparseable_request_body = '!{>}<'; | |
2092 $parseable_valid_request_body = Json::encode($this->getPatchDocument()); | |
2093 /* $parseable_valid_request_body_2 = Json::encode($this->getNormalizedPatchEntity()); */ | |
2094 $parseable_invalid_request_body = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'label')); | |
2095 $parseable_invalid_request_body_2 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->randomString()]]], $this->getPatchDocument())); | |
2096 // The 'field_rest_test' field does not allow 'view' access, so does not end | |
2097 // up in the JSON:API document. Even when we explicitly add it to the JSON | |
2098 // API document that we send in a PATCH request, it is considered invalid. | |
2099 $parseable_invalid_request_body_3 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_rest_test' => $this->entity->get('field_rest_test')->getValue()]]], $this->getPatchDocument())); | |
2100 $parseable_invalid_request_body_4 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_nonexistent' => $this->randomString()]]], $this->getPatchDocument())); | |
2101 // It is invalid to PATCH a relationship field under the attributes member. | |
2102 if ($this->entity instanceof FieldableEntityInterface && $this->entity->hasField('field_jsonapi_test_entity_ref')) { | |
2103 $parseable_invalid_request_body_5 = Json::encode(NestedArray::mergeDeep(['data' => ['attributes' => ['field_jsonapi_test_entity_ref' => ['target_id' => $this->randomString()]]]], $this->getPostDocument())); | |
2104 } | |
2105 | |
2106 // The URL and Guzzle request options that will be used in this test. The | |
2107 // request options will be modified/expanded throughout this test: | |
2108 // - to first test all mistakes a developer might make, and assert that the | |
2109 // error responses provide a good DX | |
2110 // - to eventually result in a well-formed request that succeeds. | |
2111 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
2112 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); | |
2113 /* $url = $this->entity->toUrl('jsonapi'); */ | |
2114 $request_options = []; | |
2115 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
2116 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
2117 | |
2118 // DX: 405 when read-only mode is enabled. | |
2119 $response = $this->request('PATCH', $url, $request_options); | |
2120 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); | |
2121 $this->assertSame(['GET'], $response->getHeader('Allow')); | |
2122 | |
2123 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
2124 | |
2125 // DX: 415 when no Content-Type request header. | |
2126 $response = $this->request('PATCH', $url, $request_options); | |
2127 $this->assertsame(415, $response->getStatusCode()); | |
2128 | |
2129 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
2130 | |
2131 // DX: 403 when unauthorized. | |
2132 $response = $this->request('PATCH', $url, $request_options); | |
2133 $reason = $this->getExpectedUnauthorizedAccessMessage('PATCH'); | |
2134 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response); | |
2135 | |
2136 $this->setUpAuthorization('PATCH'); | |
2137 | |
2138 // DX: 400 when no request body. | |
2139 $response = $this->request('PATCH', $url, $request_options); | |
2140 $this->assertResourceErrorResponse(400, 'Empty request body.', $url, $response, FALSE); | |
2141 | |
2142 $request_options[RequestOptions::BODY] = $unparseable_request_body; | |
2143 | |
2144 // DX: 400 when unparseable request body. | |
2145 $response = $this->request('PATCH', $url, $request_options); | |
2146 $this->assertResourceErrorResponse(400, 'Syntax error', $url, $response, FALSE); | |
2147 | |
2148 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body; | |
2149 | |
2150 // DX: 422 when invalid entity: multiple values sent for single-value field. | |
2151 $response = $this->request('PATCH', $url, $request_options); | |
2152 $label_field = $this->entity->getEntityType()->hasKey('label') ? $this->entity->getEntityType()->getKey('label') : static::$labelFieldName; | |
2153 $label_field_capitalized = $this->entity->getFieldDefinition($label_field)->getLabel(); | |
2154 $this->assertResourceErrorResponse(422, "$label_field: $label_field_capitalized: this field cannot hold more than 1 values.", NULL, $response, '/data/attributes/' . $label_field); | |
2155 | |
2156 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_2; | |
2157 | |
2158 // DX: 403 when entity contains field without 'edit' access. | |
2159 $response = $this->request('PATCH', $url, $request_options); | |
2160 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); | |
2161 | |
2162 // DX: 403 when entity trying to update an entity's ID field. | |
2163 $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'id')); | |
2164 $response = $this->request('PATCH', $url, $request_options); | |
2165 $id_field_name = $this->entity->getEntityType()->getKey('id'); | |
2166 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field ($id_field_name). The entity ID cannot be changed.", $url, $response, "/data/attributes/$id_field_name"); | |
2167 | |
2168 if ($this->entity->getEntityType()->hasKey('uuid')) { | |
2169 // DX: 400 when entity trying to update an entity's UUID field. | |
2170 $request_options[RequestOptions::BODY] = Json::encode($this->makeNormalizationInvalid($this->getPatchDocument(), 'uuid')); | |
2171 $response = $this->request('PATCH', $url, $request_options); | |
2172 $this->assertResourceErrorResponse(400, sprintf("The selected entity (%s) does not match the ID in the payload (%s).", $this->entity->uuid(), $this->anotherEntity->uuid()), $url, $response, FALSE); | |
2173 } | |
2174 | |
2175 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_3; | |
2176 | |
2177 // DX: 403 when entity contains field without 'edit' nor 'view' access, even | |
2178 // when the value for that field matches the current value. This is allowed | |
2179 // in principle, but leads to information disclosure. | |
2180 $response = $this->request('PATCH', $url, $request_options); | |
2181 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (field_rest_test).", $url, $response, '/data/attributes/field_rest_test'); | |
2182 | |
2183 // DX: 403 when sending PATCH request with updated read-only fields. | |
2184 list($modified_entity, $original_values) = static::getModifiedEntityForPatchTesting($this->entity); | |
2185 // Send PATCH request by serializing the modified entity, assert the error | |
2186 // response, change the modified entity field that caused the error response | |
2187 // back to its original value, repeat. | |
2188 foreach (static::$patchProtectedFieldNames as $patch_protected_field_name => $reason) { | |
2189 $request_options[RequestOptions::BODY] = Json::encode($this->normalize($modified_entity, $url)); | |
2190 $response = $this->request('PATCH', $url, $request_options); | |
2191 $this->assertResourceErrorResponse(403, "The current user is not allowed to PATCH the selected field (" . $patch_protected_field_name . ")." . ($reason !== NULL ? ' ' . $reason : ''), $url->setAbsolute(), $response, '/data/attributes/' . $patch_protected_field_name); | |
2192 $modified_entity->get($patch_protected_field_name)->setValue($original_values[$patch_protected_field_name]); | |
2193 } | |
2194 | |
2195 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_4; | |
2196 | |
2197 // DX: 422 when request document contains non-existent field. | |
2198 $response = $this->request('PATCH', $url, $request_options); | |
2199 $this->assertResourceErrorResponse(422, sprintf("The attribute field_nonexistent does not exist on the %s resource type.", static::$resourceTypeName), $url, $response, FALSE); | |
2200 | |
2201 // DX: 422 when updating a relationship field under attributes. | |
2202 if (isset($parseable_invalid_request_body_5)) { | |
2203 $request_options[RequestOptions::BODY] = $parseable_invalid_request_body_5; | |
2204 $response = $this->request('PATCH', $url, $request_options); | |
2205 $this->assertResourceErrorResponse(422, "The following relationship fields were provided as attributes: [ field_jsonapi_test_entity_ref ]", $url, $response, FALSE); | |
2206 } | |
2207 | |
2208 // 200 for well-formed PATCH request that sends all fields (even including | |
2209 // read-only ones, but with unchanged values). | |
2210 $valid_request_body = NestedArray::mergeDeep($this->normalize($this->entity, $url), $this->getPatchDocument()); | |
2211 $request_options[RequestOptions::BODY] = Json::encode($valid_request_body); | |
2212 $response = $this->request('PATCH', $url, $request_options); | |
2213 $this->assertResourceResponse(200, FALSE, $response); | |
2214 $updated_entity = $this->entityLoadUnchanged($this->entity->id()); | |
2215 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); | |
2216 $prior_revision_id = (int) $updated_entity->getRevisionId(); | |
2217 | |
2218 $request_options[RequestOptions::BODY] = $parseable_valid_request_body; | |
2219 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'text/xml'; | |
2220 | |
2221 // DX: 415 when request body in existing but not allowed format. | |
2222 $response = $this->request('PATCH', $url, $request_options); | |
2223 $this->assertSame(415, $response->getStatusCode()); | |
2224 | |
2225 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
2226 | |
2227 // 200 for well-formed request. | |
2228 $response = $this->request('PATCH', $url, $request_options); | |
2229 $this->assertResourceResponse(200, FALSE, $response); | |
2230 $this->assertFalse($response->hasHeader('X-Drupal-Cache')); | |
2231 // Assert that the entity was indeed updated, and that the response body | |
2232 // contains the serialized updated entity. | |
2233 $updated_entity = $this->entityLoadUnchanged($this->entity->id()); | |
2234 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); | |
2235 if ($this->entity instanceof RevisionLogInterface) { | |
2236 if (static::$newRevisionsShouldBeAutomatic) { | |
2237 $this->assertNotSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime()); | |
2238 } | |
2239 else { | |
2240 $this->assertSame((int) $this->entity->getRevisionCreationTime(), (int) $updated_entity->getRevisionCreationTime()); | |
2241 } | |
2242 } | |
2243 $updated_entity_document = $this->normalize($updated_entity, $url); | |
2244 $this->assertSame($updated_entity_document, Json::decode((string) $response->getBody())); | |
2245 $prior_revision_id = (int) $updated_entity->getRevisionId(); | |
2246 // Assert that the entity was indeed created using the PATCHed values. | |
2247 foreach ($this->getPatchDocument()['data']['attributes'] as $field_name => $field_normalization) { | |
2248 // If the value is an array of properties, only verify that the sent | |
2249 // properties are present, the server could be computing additional | |
2250 // properties. | |
2251 if (is_array($field_normalization)) { | |
2252 $this->assertArraySubset($field_normalization, $updated_entity_document['data']['attributes'][$field_name]); | |
2253 } | |
2254 else { | |
2255 $this->assertSame($field_normalization, $updated_entity_document['data']['attributes'][$field_name]); | |
2256 } | |
2257 } | |
2258 if (isset($this->getPatchDocument()['data']['relationships'])) { | |
2259 foreach ($this->getPatchDocument()['data']['relationships'] as $field_name => $relationship_field_normalization) { | |
2260 // POSTing relationships: 'data' is required, 'links' is optional. | |
2261 static::recursiveKsort($relationship_field_normalization); | |
2262 static::recursiveKsort($updated_entity_document['data']['relationships'][$field_name]); | |
2263 $this->assertSame($relationship_field_normalization, array_diff_key($updated_entity_document['data']['relationships'][$field_name], ['links' => TRUE])); | |
2264 } | |
2265 } | |
2266 | |
2267 // Ensure that fields do not get deleted if they're not present in the PATCH | |
2268 // request. Test this using the configurable field that we added, but which | |
2269 // is not sent in the PATCH request. | |
2270 $this->assertSame('All the faith he had had had had no effect on the outcome of his life.', $updated_entity->get('field_rest_test')->value); | |
2271 | |
2272 // Multi-value field: remove item 0. Then item 1 becomes item 0. | |
2273 $doc_multi_value_tests = $this->getPatchDocument(); | |
2274 $doc_multi_value_tests['data']['attributes']['field_rest_test_multivalue'] = $this->entity->get('field_rest_test_multivalue')->getValue(); | |
2275 $doc_remove_item = $doc_multi_value_tests; | |
2276 unset($doc_remove_item['data']['attributes']['field_rest_test_multivalue'][0]); | |
2277 $request_options[RequestOptions::BODY] = Json::encode($doc_remove_item, 'api_json'); | |
2278 $response = $this->request('PATCH', $url, $request_options); | |
2279 $this->assertResourceResponse(200, FALSE, $response); | |
2280 $updated_entity = $this->entityLoadUnchanged($this->entity->id()); | |
2281 $this->assertSame([0 => ['value' => 'Two']], $updated_entity->get('field_rest_test_multivalue')->getValue()); | |
2282 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); | |
2283 $prior_revision_id = (int) $updated_entity->getRevisionId(); | |
2284 | |
2285 // Multi-value field: add one item before the existing one, and one after. | |
2286 $doc_add_items = $doc_multi_value_tests; | |
2287 $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => 'Three']; | |
2288 $request_options[RequestOptions::BODY] = Json::encode($doc_add_items); | |
2289 $response = $this->request('PATCH', $url, $request_options); | |
2290 $this->assertResourceResponse(200, FALSE, $response); | |
2291 $expected_document = [ | |
2292 0 => ['value' => 'One'], | |
2293 1 => ['value' => 'Two'], | |
2294 2 => ['value' => 'Three'], | |
2295 ]; | |
2296 $updated_entity = $this->entityLoadUnchanged($this->entity->id()); | |
2297 $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue()); | |
2298 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); | |
2299 $prior_revision_id = (int) $updated_entity->getRevisionId(); | |
2300 | |
2301 // Finally, assert that when Content Moderation is installed, a new revision | |
2302 // is automatically created when PATCHing for entity types that have a | |
2303 // moderation handler. | |
2304 // @see \Drupal\content_moderation\Entity\Handler\ModerationHandler::onPresave() | |
2305 // @see \Drupal\content_moderation\EntityTypeInfo::$moderationHandlers | |
2306 if ($updated_entity instanceof EntityPublishedInterface) { | |
2307 $updated_entity->setPublished()->save(); | |
2308 } | |
2309 $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.'); | |
2310 | |
2311 if (!\Drupal::service('content_moderation.moderation_information')->canModerateEntitiesOfEntityType($this->entity->getEntityType())) { | |
2312 return; | |
2313 } | |
2314 | |
2315 $workflow = $this->createEditorialWorkflow(); | |
2316 $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle()); | |
2317 $workflow->save(); | |
2318 $this->grantPermissionsToTestedRole(['use editorial transition publish']); | |
2319 $doc_add_items['data']['attributes']['field_rest_test_multivalue'][2] = ['value' => '3']; | |
2320 $request_options[RequestOptions::BODY] = Json::encode($doc_add_items); | |
2321 $response = $this->request('PATCH', $url, $request_options); | |
2322 $this->assertResourceResponse(200, FALSE, $response); | |
2323 $expected_document = [ | |
2324 0 => ['value' => 'One'], | |
2325 1 => ['value' => 'Two'], | |
2326 2 => ['value' => '3'], | |
2327 ]; | |
2328 $updated_entity = $this->entityLoadUnchanged($this->entity->id()); | |
2329 $this->assertSame($expected_document, $updated_entity->get('field_rest_test_multivalue')->getValue()); | |
2330 if ($this->entity->getEntityType()->hasHandlerClass('moderation')) { | |
2331 $this->assertLessThan((int) $updated_entity->getRevisionId(), $prior_revision_id); | |
2332 } | |
2333 else { | |
2334 $this->assertSame(static::$newRevisionsShouldBeAutomatic, $prior_revision_id < (int) $updated_entity->getRevisionId()); | |
2335 } | |
2336 | |
2337 // Ensure that PATCHing an entity that is not the latest revision is | |
2338 // unsupported. | |
2339 if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) { | |
2340 return; | |
2341 } | |
2342 assert($this->entity instanceof RevisionableInterface); | |
2343 | |
2344 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
2345 $request_options[RequestOptions::BODY] = Json::encode([ | |
2346 'data' => [ | |
2347 'type' => static::$resourceTypeName, | |
2348 'id' => $this->entity->uuid(), | |
2349 ], | |
2350 ]); | |
2351 $this->setUpAuthorization('PATCH'); | |
2352 $this->grantPermissionsToTestedRole([ | |
2353 'use editorial transition create_new_draft', | |
2354 'use editorial transition archived_published', | |
2355 'use editorial transition published', | |
2356 ]); | |
2357 | |
2358 // Disallow PATCHing an entity that has a pending revision. | |
2359 $updated_entity->set('moderation_state', 'draft'); | |
2360 $updated_entity->setNewRevision(); | |
2361 $updated_entity->save(); | |
2362 $actual_response = $this->request('PATCH', $url, $request_options); | |
2363 $this->assertResourceErrorResponse(400, 'Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/jsonapi/issues/2795279.', $url, $actual_response); | |
2364 | |
2365 // Allow PATCHing an unpublished default revision. | |
2366 $updated_entity->set('moderation_state', 'archived'); | |
2367 $updated_entity->setNewRevision(); | |
2368 $updated_entity->save(); | |
2369 $actual_response = $this->request('PATCH', $url, $request_options); | |
2370 $this->assertSame(200, $actual_response->getStatusCode()); | |
2371 | |
2372 // Allow PATCHing an unpublished default revision. (An entity that | |
2373 // transitions from archived to draft remains an unpublished default | |
2374 // revision.) | |
2375 $updated_entity->set('moderation_state', 'draft'); | |
2376 $updated_entity->setNewRevision(); | |
2377 $updated_entity->save(); | |
2378 $actual_response = $this->request('PATCH', $url, $request_options); | |
2379 $this->assertSame(200, $actual_response->getStatusCode()); | |
2380 | |
2381 // Allow PATCHing a published default revision. | |
2382 $updated_entity->set('moderation_state', 'published'); | |
2383 $updated_entity->setNewRevision(); | |
2384 $updated_entity->save(); | |
2385 $actual_response = $this->request('PATCH', $url, $request_options); | |
2386 $this->assertSame(200, $actual_response->getStatusCode()); | |
2387 } | |
2388 | |
2389 /** | |
2390 * Tests DELETEing an individual resource, plus edge cases to ensure good DX. | |
2391 */ | |
2392 public function testDeleteIndividual() { | |
2393 // @todo Remove this in https://www.drupal.org/node/2300677. | |
2394 if ($this->entity instanceof ConfigEntityInterface) { | |
2395 $this->assertTrue(TRUE, 'DELETEing config entities is not yet supported.'); | |
2396 return; | |
2397 } | |
2398 | |
2399 // The URL and Guzzle request options that will be used in this test. The | |
2400 // request options will be modified/expanded throughout this test: | |
2401 // - to first test all mistakes a developer might make, and assert that the | |
2402 // error responses provide a good DX | |
2403 // - to eventually result in a well-formed request that succeeds. | |
2404 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
2405 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()]); | |
2406 /* $url = $this->entity->toUrl('jsonapi'); */ | |
2407 $request_options = []; | |
2408 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
2409 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
2410 | |
2411 // DX: 405 when read-only mode is enabled. | |
2412 $response = $this->request('DELETE', $url, $request_options); | |
2413 $this->assertResourceErrorResponse(405, sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromUri('base:/admin/config/services/jsonapi')->setAbsolute()->toString(TRUE)->getGeneratedUrl()), $url, $response); | |
2414 $this->assertSame(['GET'], $response->getHeader('Allow')); | |
2415 | |
2416 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
2417 | |
2418 // DX: 403 when unauthorized. | |
2419 $response = $this->request('DELETE', $url, $request_options); | |
2420 $reason = $this->getExpectedUnauthorizedAccessMessage('DELETE'); | |
2421 $this->assertResourceErrorResponse(403, (string) $reason, $url, $response, FALSE); | |
2422 | |
2423 $this->setUpAuthorization('DELETE'); | |
2424 | |
2425 // 204 for well-formed request. | |
2426 $response = $this->request('DELETE', $url, $request_options); | |
2427 $this->assertResourceResponse(204, NULL, $response); | |
2428 | |
2429 // DX: 404 when non-existent. | |
2430 $response = $this->request('DELETE', $url, $request_options); | |
2431 $this->assertSame(404, $response->getStatusCode()); | |
2432 } | |
2433 | |
2434 /** | |
2435 * Recursively sorts an array by key. | |
2436 * | |
2437 * @param array $array | |
2438 * An array to sort. | |
2439 */ | |
2440 protected static function recursiveKsort(array &$array) { | |
2441 // First, sort the main array. | |
2442 ksort($array); | |
2443 | |
2444 // Then check for child arrays. | |
2445 foreach ($array as $key => &$value) { | |
2446 if (is_array($value)) { | |
2447 static::recursiveKsort($value); | |
2448 } | |
2449 } | |
2450 } | |
2451 | |
2452 /** | |
2453 * Returns Guzzle request options for authentication. | |
2454 * | |
2455 * @return array | |
2456 * Guzzle request options to use for authentication. | |
2457 * | |
2458 * @see \GuzzleHttp\ClientInterface::request() | |
2459 */ | |
2460 protected function getAuthenticationRequestOptions() { | |
2461 return [ | |
2462 'headers' => [ | |
2463 'Authorization' => 'Basic ' . base64_encode($this->account->name->value . ':' . $this->account->passRaw), | |
2464 ], | |
2465 ]; | |
2466 } | |
2467 | |
2468 /** | |
2469 * Clones the given entity and modifies all PATCH-protected fields. | |
2470 * | |
2471 * @param \Drupal\Core\Entity\EntityInterface $entity | |
2472 * The entity being tested and to modify. | |
2473 * | |
2474 * @return array | |
2475 * Contains two items: | |
2476 * 1. The modified entity object. | |
2477 * 2. The original field values, keyed by field name. | |
2478 * | |
2479 * @internal | |
2480 */ | |
2481 protected static function getModifiedEntityForPatchTesting(EntityInterface $entity) { | |
2482 $modified_entity = clone $entity; | |
2483 $original_values = []; | |
2484 foreach (array_keys(static::$patchProtectedFieldNames) as $field_name) { | |
2485 $field = $modified_entity->get($field_name); | |
2486 $original_values[$field_name] = $field->getValue(); | |
2487 switch ($field->getItemDefinition()->getClass()) { | |
2488 case BooleanItem::class: | |
2489 // BooleanItem::generateSampleValue() picks either 0 or 1. So a 50% | |
2490 // chance of not picking a different value. | |
2491 $field->value = ((int) $field->value) === 1 ? '0' : '1'; | |
2492 break; | |
2493 | |
2494 case PathItem::class: | |
2495 // PathItem::generateSampleValue() doesn't set a PID, which causes | |
2496 // PathItem::postSave() to fail. Keep the PID (and other properties), | |
2497 // just modify the alias. | |
2498 $field->alias = str_replace(' ', '-', strtolower((new Random())->sentences(3))); | |
2499 break; | |
2500 | |
2501 default: | |
2502 $original_field = clone $field; | |
2503 while ($field->equals($original_field)) { | |
2504 $field->generateSampleItems(); | |
2505 } | |
2506 break; | |
2507 } | |
2508 } | |
2509 | |
2510 return [$modified_entity, $original_values]; | |
2511 } | |
2512 | |
2513 /** | |
2514 * Gets the normalized POST entity with random values for its unique fields. | |
2515 * | |
2516 * @see ::testPostIndividual | |
2517 * @see ::getPostDocument | |
2518 * | |
2519 * @return array | |
2520 * An array structure as returned by ::getNormalizedPostEntity(). | |
2521 */ | |
2522 protected function getModifiedEntityForPostTesting() { | |
2523 $document = $this->getPostDocument(); | |
2524 | |
2525 // Ensure that all the unique fields of the entity type get a new random | |
2526 // value. | |
2527 foreach (static::$uniqueFieldNames as $field_name) { | |
2528 $field_definition = $this->entity->getFieldDefinition($field_name); | |
2529 $field_type_class = $field_definition->getItemDefinition()->getClass(); | |
2530 $document['data']['attributes'][$field_name] = $field_type_class::generateSampleValue($field_definition); | |
2531 } | |
2532 | |
2533 return $document; | |
2534 } | |
2535 | |
2536 /** | |
2537 * Tests sparse field sets. | |
2538 * | |
2539 * @param \Drupal\Core\Url $url | |
2540 * The base URL with which to test includes. | |
2541 * @param array $request_options | |
2542 * Request options to apply. | |
2543 * | |
2544 * @see \GuzzleHttp\ClientInterface::request() | |
2545 */ | |
2546 protected function doTestSparseFieldSets(Url $url, array $request_options) { | |
2547 $field_sets = $this->getSparseFieldSets(); | |
2548 $expected_cacheability = new CacheableMetadata(); | |
2549 foreach ($field_sets as $type => $field_set) { | |
2550 if ($type === 'all') { | |
2551 assert($this->getExpectedCacheTags($field_set) === $this->getExpectedCacheTags()); | |
2552 assert($this->getExpectedCacheContexts($field_set) === $this->getExpectedCacheContexts()); | |
2553 } | |
2554 $query = ['fields[' . static::$resourceTypeName . ']' => implode(',', $field_set)]; | |
2555 $expected_document = $this->getExpectedDocument(); | |
2556 $expected_cacheability->setCacheTags($this->getExpectedCacheTags($field_set)); | |
2557 $expected_cacheability->setCacheContexts($this->getExpectedCacheContexts($field_set)); | |
2558 // This tests sparse field sets on included entities. | |
2559 if (strpos($type, 'nested') === 0) { | |
2560 $this->grantPermissionsToTestedRole(['access user profiles']); | |
2561 $query['fields[user--user]'] = implode(',', $field_set); | |
2562 $query['include'] = 'uid'; | |
2563 $owner = $this->entity->getOwner(); | |
2564 $owner_resource = static::toResourceIdentifier($owner); | |
2565 foreach ($field_set as $field_name) { | |
2566 $owner_resource['attributes'][$field_name] = $this->serializer->normalize($owner->get($field_name)[0]->get('value'), 'api_json'); | |
2567 } | |
2568 $owner_resource['links']['self']['href'] = static::getResourceLink($owner_resource); | |
2569 $expected_document['included'] = [$owner_resource]; | |
2570 $expected_cacheability->addCacheableDependency($owner); | |
2571 $expected_cacheability->addCacheableDependency(static::entityAccess($owner, 'view', $this->account)); | |
2572 } | |
2573 // Remove fields not in the sparse field set. | |
2574 foreach (['attributes', 'relationships'] as $member) { | |
2575 if (!empty($expected_document['data'][$member])) { | |
2576 $remaining = array_intersect_key( | |
2577 $expected_document['data'][$member], | |
2578 array_flip($field_set) | |
2579 ); | |
2580 if (empty($remaining)) { | |
2581 unset($expected_document['data'][$member]); | |
2582 } | |
2583 else { | |
2584 $expected_document['data'][$member] = $remaining; | |
2585 } | |
2586 } | |
2587 } | |
2588 $url->setOption('query', $query); | |
2589 // 'self' link should include the 'fields' query param. | |
2590 $expected_document['links']['self']['href'] = $url->setAbsolute()->toString(); | |
2591 | |
2592 $response = $this->request('GET', $url, $request_options); | |
2593 // Dynamic Page Cache MISS because cache should vary based on the 'field' | |
2594 // query param. (Or uncacheable if expensive cache context.) | |
2595 $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; | |
2596 $this->assertResourceResponse( | |
2597 200, | |
2598 $expected_document, | |
2599 $response, | |
2600 $expected_cacheability->getCacheTags(), | |
2601 $expected_cacheability->getCacheContexts(), | |
2602 FALSE, | |
2603 $dynamic_cache | |
2604 ); | |
2605 } | |
2606 // Test Dynamic Page Cache HIT for a query with the same field set (unless | |
2607 // expensive cache context is present). | |
2608 $response = $this->request('GET', $url, $request_options); | |
2609 $this->assertResourceResponse(200, FALSE, $response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache === 'MISS' ? 'HIT' : 'UNCACHEABLE'); | |
2610 } | |
2611 | |
2612 /** | |
2613 * Tests included resources. | |
2614 * | |
2615 * @param \Drupal\Core\Url $url | |
2616 * The base URL with which to test includes. | |
2617 * @param array $request_options | |
2618 * Request options to apply. | |
2619 * | |
2620 * @see \GuzzleHttp\ClientInterface::request() | |
2621 */ | |
2622 protected function doTestIncluded(Url $url, array $request_options) { | |
2623 $relationship_field_names = $this->getRelationshipFieldNames($this->entity); | |
2624 // If there are no relationship fields, we can't include anything. | |
2625 if (empty($relationship_field_names)) { | |
2626 return; | |
2627 } | |
2628 | |
2629 $field_sets = [ | |
2630 'empty' => [], | |
2631 'all' => $relationship_field_names, | |
2632 ]; | |
2633 if (count($relationship_field_names) > 1) { | |
2634 $about_half_the_fields = floor(count($relationship_field_names) / 2); | |
2635 $field_sets['some'] = array_slice($relationship_field_names, $about_half_the_fields); | |
2636 | |
2637 $nested_includes = $this->getNestedIncludePaths(); | |
2638 if (!empty($nested_includes) && !in_array($nested_includes, $field_sets)) { | |
2639 $field_sets['nested'] = $nested_includes; | |
2640 } | |
2641 } | |
2642 | |
2643 foreach ($field_sets as $type => $included_paths) { | |
2644 $this->grantIncludedPermissions($included_paths); | |
2645 $query = ['include' => implode(',', $included_paths)]; | |
2646 $url->setOption('query', $query); | |
2647 $actual_response = $this->request('GET', $url, $request_options); | |
2648 $expected_response = $this->getExpectedIncludedResourceResponse($included_paths, $request_options); | |
2649 $expected_document = $expected_response->getResponseData(); | |
2650 // Dynamic Page Cache miss because cache should vary based on the | |
2651 // 'include' query param. | |
2652 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
2653 // MISS or UNCACHEABLE depends on data. It must not be HIT. | |
2654 $dynamic_cache = ($expected_cacheability->getCacheMaxAge() === 0 || !empty(array_intersect(['user', 'session'], $this->getExpectedCacheContexts()))) ? 'UNCACHEABLE' : 'MISS'; | |
2655 $this->assertResourceResponse( | |
2656 200, | |
2657 $expected_document, | |
2658 $actual_response, | |
2659 $expected_cacheability->getCacheTags(), | |
2660 $expected_cacheability->getCacheContexts(), | |
2661 FALSE, | |
2662 $dynamic_cache | |
2663 ); | |
2664 } | |
2665 } | |
2666 | |
2667 /** | |
2668 * Tests individual and collection revisions. | |
2669 */ | |
2670 public function testRevisions() { | |
2671 if (!$this->entity->getEntityType()->isRevisionable() || !$this->entity instanceof FieldableEntityInterface) { | |
2672 return; | |
2673 } | |
2674 assert($this->entity instanceof RevisionableInterface); | |
2675 | |
2676 // JSON:API will only support node and media revisions until Drupal core has | |
2677 // a generic revision access API. | |
2678 if (!static::$resourceTypeIsVersionable) { | |
2679 $this->setUpRevisionAuthorization('GET'); | |
2680 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute(); | |
2681 $url->setOption('query', ['resourceVersion' => 'id:' . $this->entity->getRevisionId()]); | |
2682 $request_options = []; | |
2683 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
2684 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
2685 $response = $this->request('GET', $url, $request_options); | |
2686 $detail = 'JSON:API does not yet support resource versioning for this resource type.'; | |
2687 $detail .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.'; | |
2688 $detail .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.'; | |
2689 $expected_cache_contexts = [ | |
2690 'url.path', | |
2691 'url.query_args:resourceVersion', | |
2692 'url.site', | |
2693 ]; | |
2694 $this->assertResourceErrorResponse(501, $detail, $url, $response, FALSE, ['http_response'], $expected_cache_contexts); | |
2695 return; | |
2696 } | |
2697 | |
2698 // Add a field to modify in order to test revisions. | |
2699 FieldStorageConfig::create([ | |
2700 'entity_type' => static::$entityTypeId, | |
2701 'field_name' => 'field_revisionable_number', | |
2702 'type' => 'integer', | |
2703 ])->setCardinality(1)->save(); | |
2704 FieldConfig::create([ | |
2705 'entity_type' => static::$entityTypeId, | |
2706 'field_name' => 'field_revisionable_number', | |
2707 'bundle' => $this->entity->bundle(), | |
2708 ])->setLabel('Revisionable text field')->setTranslatable(FALSE)->save(); | |
2709 | |
2710 // Reload entity so that it has the new field. | |
2711 $entity = $this->entityLoadUnchanged($this->entity->id()); | |
2712 | |
2713 // Set up test data. | |
2714 /* @var \Drupal\Core\Entity\FieldableEntityInterface $entity */ | |
2715 $entity->set('field_revisionable_number', 42); | |
2716 $entity->save(); | |
2717 $original_revision_id = (int) $entity->getRevisionId(); | |
2718 | |
2719 $entity->set('field_revisionable_number', 99); | |
2720 $entity->setNewRevision(); | |
2721 $entity->save(); | |
2722 $latest_revision_id = (int) $entity->getRevisionId(); | |
2723 | |
2724 // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463. | |
2725 $url = Url::fromRoute(sprintf('jsonapi.%s.individual', static::$resourceTypeName), ['entity' => $this->entity->uuid()])->setAbsolute(); | |
2726 /* $url = $this->entity->toUrl('jsonapi'); */ | |
2727 $collection_url = Url::fromRoute(sprintf('jsonapi.%s.collection', static::$resourceTypeName))->setAbsolute(); | |
2728 $relationship_url = Url::fromRoute(sprintf('jsonapi.%s.%s.relationship.get', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute(); | |
2729 $related_url = Url::fromRoute(sprintf('jsonapi.%s.%s.related', static::$resourceTypeName, 'field_jsonapi_test_entity_ref'), ['entity' => $this->entity->uuid()])->setAbsolute(); | |
2730 $original_revision_id_url = clone $url; | |
2731 $original_revision_id_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); | |
2732 $original_revision_id_relationship_url = clone $relationship_url; | |
2733 $original_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); | |
2734 $original_revision_id_related_url = clone $related_url; | |
2735 $original_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$original_revision_id"]); | |
2736 $latest_revision_id_url = clone $url; | |
2737 $latest_revision_id_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); | |
2738 $latest_revision_id_relationship_url = clone $relationship_url; | |
2739 $latest_revision_id_relationship_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); | |
2740 $latest_revision_id_related_url = clone $related_url; | |
2741 $latest_revision_id_related_url->setOption('query', ['resourceVersion' => "id:$latest_revision_id"]); | |
2742 $rel_latest_version_url = clone $url; | |
2743 $rel_latest_version_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); | |
2744 $rel_latest_version_relationship_url = clone $relationship_url; | |
2745 $rel_latest_version_relationship_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); | |
2746 $rel_latest_version_related_url = clone $related_url; | |
2747 $rel_latest_version_related_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); | |
2748 $rel_latest_version_collection_url = clone $collection_url; | |
2749 $rel_latest_version_collection_url->setOption('query', ['resourceVersion' => 'rel:latest-version']); | |
2750 $rel_working_copy_url = clone $url; | |
2751 $rel_working_copy_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); | |
2752 $rel_working_copy_relationship_url = clone $relationship_url; | |
2753 $rel_working_copy_relationship_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); | |
2754 $rel_working_copy_related_url = clone $related_url; | |
2755 $rel_working_copy_related_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); | |
2756 $rel_working_copy_collection_url = clone $collection_url; | |
2757 $rel_working_copy_collection_url->setOption('query', ['resourceVersion' => 'rel:working-copy']); | |
2758 $rel_invalid_collection_url = clone $collection_url; | |
2759 $rel_invalid_collection_url->setOption('query', ['resourceVersion' => 'rel:invalid']); | |
2760 $revision_id_key = 'drupal_internal__' . $this->entity->getEntityType()->getKey('revision'); | |
2761 $published_key = $this->entity->getEntityType()->getKey('published'); | |
2762 $revision_translation_affected_key = $this->entity->getEntityType()->getKey('revision_translation_affected'); | |
2763 | |
2764 $amend_relationship_urls = function (array &$document, $revision_id) { | |
2765 if (!empty($document['data']['relationships'])) { | |
2766 foreach ($document['data']['relationships'] as &$relationship) { | |
2767 $pattern = '/resourceVersion=id%3A\d/'; | |
2768 $replacement = 'resourceVersion=' . urlencode("id:$revision_id"); | |
2769 $relationship['links']['self']['href'] = preg_replace($pattern, $replacement, $relationship['links']['self']['href']); | |
2770 $relationship['links']['related']['href'] = preg_replace($pattern, $replacement, $relationship['links']['related']['href']); | |
2771 } | |
2772 } | |
2773 }; | |
2774 | |
2775 $request_options = []; | |
2776 $request_options[RequestOptions::HEADERS]['Accept'] = 'application/vnd.api+json'; | |
2777 $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions()); | |
2778 | |
2779 // Ensure 403 forbidden on typical GET. | |
2780 $actual_response = $this->request('GET', $url, $request_options); | |
2781 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); | |
2782 $result = $entity->access('view', $this->account, TRUE); | |
2783 $detail = 'The current user is not allowed to GET the selected resource.'; | |
2784 if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) { | |
2785 $detail .= ' ' . $reason; | |
2786 } | |
2787 $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2788 | |
2789 // Ensure that targeting a revision does not bypass access. | |
2790 $actual_response = $this->request('GET', $original_revision_id_url, $request_options); | |
2791 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); | |
2792 $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.'; | |
2793 if ($result instanceof AccessResultReasonInterface && ($reason = $result->getReason()) && !empty($reason)) { | |
2794 $detail .= ' ' . $reason; | |
2795 } | |
2796 $this->assertResourceErrorResponse(403, $detail, $url, $actual_response, '/data', $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2797 | |
2798 $this->setUpRevisionAuthorization('GET'); | |
2799 | |
2800 // Ensure that the URL without a `resourceVersion` query parameter returns | |
2801 // the default revision. This is always the latest revision when | |
2802 // content_moderation is not installed. | |
2803 $actual_response = $this->request('GET', $url, $request_options); | |
2804 $expected_document = $this->getExpectedDocument(); | |
2805 // The resource object should always links to the specific revision it | |
2806 // represents. | |
2807 $expected_document['data']['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString(); | |
2808 $amend_relationship_urls($expected_document, $latest_revision_id); | |
2809 // Resource objects always link to their specific revision by revision ID. | |
2810 $expected_document['data']['attributes'][$revision_id_key] = $latest_revision_id; | |
2811 $expected_document['data']['attributes']['field_revisionable_number'] = 99; | |
2812 $expected_cache_tags = $this->getExpectedCacheTags(); | |
2813 $expected_cache_contexts = $this->getExpectedCacheContexts(); | |
2814 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2815 // Fetch the same revision using its revision ID. | |
2816 $actual_response = $this->request('GET', $latest_revision_id_url, $request_options); | |
2817 // The top-level document object's `self` link should always link to the | |
2818 // request URL. | |
2819 $expected_document['links']['self']['href'] = $latest_revision_id_url->setAbsolute()->toString(); | |
2820 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2821 // Ensure dynamic cache HIT on second request when using a version | |
2822 // negotiator. | |
2823 $actual_response = $this->request('GET', $latest_revision_id_url, $request_options); | |
2824 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'HIT'); | |
2825 // Fetch the same revision using the `latest-version` link relation type | |
2826 // negotiator. Without content_moderation, this is always the most recent | |
2827 // revision. | |
2828 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); | |
2829 $expected_document['links']['self']['href'] = $rel_latest_version_url->setAbsolute()->toString(); | |
2830 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2831 // Fetch the same revision using the `working-copy` link relation type | |
2832 // negotiator. Without content_moderation, this is always the most recent | |
2833 // revision. | |
2834 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); | |
2835 $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString(); | |
2836 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2837 | |
2838 // Fetch the prior revision. | |
2839 $actual_response = $this->request('GET', $original_revision_id_url, $request_options); | |
2840 $expected_document['data']['attributes'][$revision_id_key] = $original_revision_id; | |
2841 $expected_document['data']['attributes']['field_revisionable_number'] = 42; | |
2842 $expected_document['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString(); | |
2843 // The resource object should always links to the specific revision it | |
2844 // represents. | |
2845 $expected_document['data']['links']['self']['href'] = $original_revision_id_url->setAbsolute()->toString(); | |
2846 $amend_relationship_urls($expected_document, $original_revision_id); | |
2847 // When the resource object is not the latest version or the working copy, | |
2848 // a link should be provided that links to those versions. Therefore, the | |
2849 // presence or absence of these links communicates the state of the resource | |
2850 // object. | |
2851 $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString(); | |
2852 $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString(); | |
2853 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2854 | |
2855 // Install content_moderation module. | |
2856 $this->assertTrue($this->container->get('module_installer')->install(['content_moderation'], TRUE), 'Installed modules.'); | |
2857 | |
2858 // Set up an editorial workflow. | |
2859 $workflow = $this->createEditorialWorkflow(); | |
2860 $workflow->getTypePlugin()->addEntityTypeAndBundle(static::$entityTypeId, $this->entity->bundle()); | |
2861 $workflow->save(); | |
2862 | |
2863 // Ensure the test entity has content_moderation fields attached to it. | |
2864 /* @var \Drupal\Core\Entity\FieldableEntityInterface|\Drupal\Core\Entity\TranslatableRevisionableInterface $entity */ | |
2865 $entity = $this->entityStorage->load($entity->id()); | |
2866 | |
2867 // Set the published moderation state on the test entity. | |
2868 $entity->set('moderation_state', 'published'); | |
2869 $entity->setNewRevision(); | |
2870 $entity->save(); | |
2871 $default_revision_id = (int) $entity->getRevisionId(); | |
2872 | |
2873 // Fetch the published revision by using the `rel` version negotiator and | |
2874 // the `latest-version` version argument. With content_moderation, this is | |
2875 // now the most recent revision where the moderation state was the 'default' | |
2876 // one. | |
2877 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); | |
2878 $expected_document['data']['attributes'][$revision_id_key] = $default_revision_id; | |
2879 $expected_document['data']['attributes']['moderation_state'] = 'published'; | |
2880 $expected_document['data']['attributes'][$published_key] = TRUE; | |
2881 $expected_document['data']['attributes']['field_revisionable_number'] = 99; | |
2882 $expected_document['links']['self']['href'] = $rel_latest_version_url->toString(); | |
2883 $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected(); | |
2884 // The resource object now must link to the new revision. | |
2885 $default_revision_id_url = clone $url; | |
2886 $default_revision_id_url = $default_revision_id_url->setOption('query', ['resourceVersion' => "id:$default_revision_id"]); | |
2887 $expected_document['data']['links']['self']['href'] = $default_revision_id_url->setAbsolute()->toString(); | |
2888 $amend_relationship_urls($expected_document, $default_revision_id); | |
2889 // Since the requested version is the latest version and working copy, there | |
2890 // should be no links. | |
2891 unset($expected_document['data']['links']['latest-version']); | |
2892 unset($expected_document['data']['links']['working-copy']); | |
2893 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2894 // Fetch the collection URL using the `latest-version` version argument. | |
2895 $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options); | |
2896 $expected_response = $this->getExpectedCollectionResponse([$entity], $rel_latest_version_collection_url->toString(), $request_options); | |
2897 $expected_collection_document = $expected_response->getResponseData(); | |
2898 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
2899 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2900 // Fetch the published revision by using the `working-copy` version | |
2901 // argument. With content_moderation, this is always the most recent | |
2902 // revision regardless of moderation state. | |
2903 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); | |
2904 $expected_document['links']['self']['href'] = $rel_working_copy_url->toString(); | |
2905 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2906 // Fetch the collection URL using the `working-copy` version argument. | |
2907 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); | |
2908 $expected_collection_document['links']['self']['href'] = $rel_working_copy_collection_url->toString(); | |
2909 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2910 // @todo: remove the next assertion when Drupal core supports entity query access control on revisions. | |
2911 $rel_working_copy_collection_url_filtered = clone $rel_working_copy_collection_url; | |
2912 $rel_working_copy_collection_url_filtered->setOption('query', ['filter[foo]' => 'bar'] + $rel_working_copy_collection_url->getOption('query')); | |
2913 $actual_response = $this->request('GET', $rel_working_copy_collection_url_filtered, $request_options); | |
2914 $filtered_collection_expected_cache_contexts = [ | |
2915 'url.path', | |
2916 'url.query_args:filter', | |
2917 'url.query_args:resourceVersion', | |
2918 'url.site', | |
2919 ]; | |
2920 $this->assertResourceErrorResponse(501, 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.', $rel_working_copy_collection_url_filtered, $actual_response, FALSE, ['http_response'], $filtered_collection_expected_cache_contexts); | |
2921 // Fetch the collection URL using an invalid version identifier. | |
2922 $actual_response = $this->request('GET', $rel_invalid_collection_url, $request_options); | |
2923 $invalid_version_expected_cache_contexts = [ | |
2924 'url.path', | |
2925 'url.query_args:resourceVersion', | |
2926 'url.site', | |
2927 ]; | |
2928 $this->assertResourceErrorResponse(400, 'Collection resources only support the following resource version identifiers: rel:latest-version, rel:working-copy', $rel_invalid_collection_url, $actual_response, FALSE, ['4xx-response', 'http_response'], $invalid_version_expected_cache_contexts); | |
2929 | |
2930 // Move the entity to its draft moderation state. | |
2931 $entity->set('field_revisionable_number', 42); | |
2932 // Change a relationship field so revisions can be tested on related and | |
2933 // relationship routes. | |
2934 $new_user = $this->createUser(); | |
2935 $new_user->save(); | |
2936 $entity->set('field_jsonapi_test_entity_ref', ['target_id' => $new_user->id()]); | |
2937 $entity->set('moderation_state', 'draft'); | |
2938 $entity->setNewRevision(); | |
2939 $entity->save(); | |
2940 $forward_revision_id = (int) $entity->getRevisionId(); | |
2941 | |
2942 // The `latest-version` link should *still* reference the same revision | |
2943 // since a draft is not a default revision. | |
2944 $actual_response = $this->request('GET', $rel_latest_version_url, $request_options); | |
2945 $expected_document['links']['self']['href'] = $rel_latest_version_url->toString(); | |
2946 // Since the latest version is no longer also the working copy, a | |
2947 // `working-copy` link is required to indicate that there is a forward | |
2948 // revision available. | |
2949 $expected_document['data']['links']['working-copy']['href'] = $rel_working_copy_url->setAbsolute()->toString(); | |
2950 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2951 // And the same should be true for collections. | |
2952 $actual_response = $this->request('GET', $rel_latest_version_collection_url, $request_options); | |
2953 $expected_collection_document['data'][0] = $expected_document['data']; | |
2954 $expected_collection_document['links']['self']['href'] = $rel_latest_version_collection_url->toString(); | |
2955 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2956 // Ensure that the `latest-version` response is same as the default link, | |
2957 // aside from the document's `self` link. | |
2958 $actual_response = $this->request('GET', $url, $request_options); | |
2959 $expected_document['links']['self']['href'] = $url->toString(); | |
2960 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2961 // And the same should be true for collections. | |
2962 $actual_response = $this->request('GET', $collection_url, $request_options); | |
2963 $expected_collection_document['links']['self']['href'] = $collection_url->toString(); | |
2964 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2965 // Now, the `working-copy` link should reference the draft revision. This | |
2966 // is significant because without content_moderation, the two responses | |
2967 // would still been the same. | |
2968 // | |
2969 // Access is checked before any special permissions are granted. This | |
2970 // asserts a 403 forbidden if the user is not allowed to see unpublished | |
2971 // content. | |
2972 $result = $entity->access('view', $this->account, TRUE); | |
2973 if (!$result->isAllowed()) { | |
2974 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); | |
2975 $expected_cacheability = $this->getExpectedUnauthorizedAccessCacheability(); | |
2976 $expected_cache_tags = Cache::mergeTags($expected_cacheability->getCacheTags(), $entity->getCacheTags()); | |
2977 $expected_cache_contexts = $expected_cacheability->getCacheContexts(); | |
2978 $detail = 'The current user is not allowed to GET the selected resource. The user does not have access to the requested version.'; | |
2979 $message = $result instanceof AccessResultReasonInterface ? trim($detail . ' ' . $result->getReason()) : $detail; | |
2980 $this->assertResourceErrorResponse(403, $message, $url, $actual_response, '/data', $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
2981 // On the collection URL, we should expect to see the draft omitted from | |
2982 // the collection. | |
2983 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); | |
2984 $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options); | |
2985 $expected_collection_document = $expected_response->getResponseData(); | |
2986 $expected_collection_document['data'] = []; | |
2987 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
2988 $access_denied_response = static::getAccessDeniedResponse($entity, $result, $url, NULL, $detail)->getResponseData(); | |
2989 static::addOmittedObject($expected_collection_document, static::errorsToOmittedObject($access_denied_response['errors'])); | |
2990 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
2991 } | |
2992 | |
2993 // Since additional permissions are required to see 'draft' entities, | |
2994 // grant those permissions. | |
2995 $this->grantPermissionsToTestedRole($this->getEditorialPermissions()); | |
2996 | |
2997 // Now, the `working-copy` link should be latest revision and be accessible. | |
2998 $actual_response = $this->request('GET', $rel_working_copy_url, $request_options); | |
2999 $expected_document['data']['attributes'][$revision_id_key] = $forward_revision_id; | |
3000 $expected_document['data']['attributes']['moderation_state'] = 'draft'; | |
3001 $expected_document['data']['attributes'][$published_key] = FALSE; | |
3002 $expected_document['data']['attributes']['field_revisionable_number'] = 42; | |
3003 $expected_document['links']['self']['href'] = $rel_working_copy_url->setAbsolute()->toString(); | |
3004 $expected_document['data']['attributes'][$revision_translation_affected_key] = $entity->isRevisionTranslationAffected(); | |
3005 // The resource object now must link to the forward revision. | |
3006 $forward_revision_id_url = clone $url; | |
3007 $forward_revision_id_url = $forward_revision_id_url->setOption('query', ['resourceVersion' => "id:$forward_revision_id"]); | |
3008 $expected_document['data']['links']['self']['href'] = $forward_revision_id_url->setAbsolute()->toString(); | |
3009 $amend_relationship_urls($expected_document, $forward_revision_id); | |
3010 // Since the the working copy is not the default revision. A | |
3011 // `latest-version` link is required to indicate that the requested version | |
3012 // is not the default revision. | |
3013 unset($expected_document['data']['links']['working-copy']); | |
3014 $expected_document['data']['links']['latest-version']['href'] = $rel_latest_version_url->setAbsolute()->toString(); | |
3015 $expected_cache_tags = $this->getExpectedCacheTags(); | |
3016 $expected_cache_contexts = $this->getExpectedCacheContexts(); | |
3017 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cache_tags, $expected_cache_contexts, FALSE, 'MISS'); | |
3018 // And the collection response should also have the latest revision. | |
3019 $actual_response = $this->request('GET', $rel_working_copy_collection_url, $request_options); | |
3020 $expected_response = static::getExpectedCollectionResponse([$entity], $rel_working_copy_collection_url->toString(), $request_options); | |
3021 $expected_collection_document = $expected_response->getResponseData(); | |
3022 $expected_collection_document['data'] = [$expected_document['data']]; | |
3023 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3024 $this->assertResourceResponse(200, $expected_collection_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
3025 | |
3026 // Test relationship responses. | |
3027 // Fetch the prior revision's relationship URL. | |
3028 $test_relationship_urls = [ | |
3029 [ | |
3030 NULL, | |
3031 $relationship_url, | |
3032 $related_url, | |
3033 ], | |
3034 [ | |
3035 $original_revision_id, | |
3036 $original_revision_id_relationship_url, | |
3037 $original_revision_id_related_url, | |
3038 ], | |
3039 [ | |
3040 $latest_revision_id, | |
3041 $latest_revision_id_relationship_url, | |
3042 $latest_revision_id_related_url, | |
3043 ], | |
3044 [ | |
3045 $default_revision_id, | |
3046 $rel_latest_version_relationship_url, | |
3047 $rel_latest_version_related_url, | |
3048 ], | |
3049 [ | |
3050 $forward_revision_id, | |
3051 $rel_working_copy_relationship_url, | |
3052 $rel_working_copy_related_url, | |
3053 ], | |
3054 ]; | |
3055 foreach ($test_relationship_urls as $revision_case) { | |
3056 list($revision_id, $relationship_url, $related_url) = $revision_case; | |
3057 // Load the revision that will be requested. | |
3058 $this->entityStorage->resetCache([$entity->id()]); | |
3059 $revision = is_null($revision_id) | |
3060 ? $this->entityStorage->load($entity->id()) | |
3061 : $this->entityStorage->loadRevision($revision_id); | |
3062 // Request the relationship resource without access to the relationship | |
3063 // field. | |
3064 $actual_response = $this->request('GET', $relationship_url, $request_options); | |
3065 $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision); | |
3066 $expected_document = $expected_response->getResponseData(); | |
3067 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3068 $expected_document['errors'][0]['links']['via']['href'] = $relationship_url->toString(); | |
3069 $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts()); | |
3070 // Request the related route. | |
3071 $actual_response = $this->request('GET', $related_url, $request_options); | |
3072 // @todo: refactor self::getExpectedRelatedResponses() into a function which returns a single response. | |
3073 $expected_response = $this->getExpectedRelatedResponses(['field_jsonapi_test_entity_ref'], $request_options, $revision)['field_jsonapi_test_entity_ref']; | |
3074 $expected_document = $expected_response->getResponseData(); | |
3075 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3076 $expected_document['errors'][0]['links']['via']['href'] = $related_url->toString(); | |
3077 $this->assertResourceResponse(403, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts()); | |
3078 } | |
3079 $this->grantPermissionsToTestedRole(['field_jsonapi_test_entity_ref view access']); | |
3080 foreach ($test_relationship_urls as $revision_case) { | |
3081 list($revision_id, $relationship_url, $related_url) = $revision_case; | |
3082 // Load the revision that will be requested. | |
3083 $this->entityStorage->resetCache([$entity->id()]); | |
3084 $revision = is_null($revision_id) | |
3085 ? $this->entityStorage->load($entity->id()) | |
3086 : $this->entityStorage->loadRevision($revision_id); | |
3087 // Request the relationship resource after granting access to the | |
3088 // relationship field. | |
3089 $actual_response = $this->request('GET', $relationship_url, $request_options); | |
3090 $expected_response = $this->getExpectedGetRelationshipResponse('field_jsonapi_test_entity_ref', $revision); | |
3091 $expected_document = $expected_response->getResponseData(); | |
3092 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3093 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, 'MISS'); | |
3094 // Request the related route. | |
3095 $actual_response = $this->request('GET', $related_url, $request_options); | |
3096 $expected_response = $this->getExpectedRelatedResponse('field_jsonapi_test_entity_ref', $request_options, $revision); | |
3097 $expected_document = $expected_response->getResponseData(); | |
3098 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3099 $expected_document['links']['self']['href'] = $related_url->toString(); | |
3100 // MISS or UNCACHEABLE depends on data. It must not be HIT. | |
3101 $dynamic_cache = !empty(array_intersect(['user', 'session'], $expected_cacheability->getCacheContexts())) ? 'UNCACHEABLE' : 'MISS'; | |
3102 $this->assertResourceResponse(200, $expected_document, $actual_response, $expected_cacheability->getCacheTags(), $expected_cacheability->getCacheContexts(), FALSE, $dynamic_cache); | |
3103 } | |
3104 | |
3105 $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); | |
3106 | |
3107 // Ensures that PATCH and DELETE on individual resources with a | |
3108 // `resourceVersion` query parameter is not supported. | |
3109 $individual_urls = [ | |
3110 $original_revision_id_url, | |
3111 $latest_revision_id_url, | |
3112 $rel_latest_version_url, | |
3113 $rel_working_copy_url, | |
3114 ]; | |
3115 $request_options[RequestOptions::HEADERS]['Content-Type'] = 'application/vnd.api+json'; | |
3116 foreach ($individual_urls as $url) { | |
3117 foreach (['PATCH', 'DELETE'] as $method) { | |
3118 $actual_response = $this->request($method, $url, $request_options); | |
3119 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); | |
3120 } | |
3121 } | |
3122 | |
3123 // Ensures that PATCH, POST and DELETE on relationship resources with a | |
3124 // `resourceVersion` query parameter is not supported. | |
3125 $relationship_urls = [ | |
3126 $original_revision_id_relationship_url, | |
3127 $latest_revision_id_relationship_url, | |
3128 $rel_latest_version_relationship_url, | |
3129 $rel_working_copy_relationship_url, | |
3130 ]; | |
3131 foreach ($relationship_urls as $url) { | |
3132 foreach (['PATCH', 'POST', 'DELETE'] as $method) { | |
3133 $actual_response = $this->request($method, $url, $request_options); | |
3134 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); | |
3135 } | |
3136 } | |
3137 | |
3138 // Ensures that POST on collection resources with a `resourceVersion` query | |
3139 // parameter is not supported. | |
3140 $collection_urls = [ | |
3141 $rel_latest_version_collection_url, | |
3142 $rel_working_copy_collection_url, | |
3143 ]; | |
3144 foreach ($collection_urls as $url) { | |
3145 foreach (['POST'] as $method) { | |
3146 $actual_response = $this->request($method, $url, $request_options); | |
3147 $this->assertResourceErrorResponse(400, sprintf('%s requests with a `%s` query parameter are not supported.', $method, 'resourceVersion'), $url, $actual_response); | |
3148 } | |
3149 } | |
3150 } | |
3151 | |
3152 /** | |
3153 * Decorates the expected response with included data and cache metadata. | |
3154 * | |
3155 * This adds the expected includes to the expected document and also builds | |
3156 * the expected cacheability for those includes. It does so based of responses | |
3157 * from the related routes for individual relationships. | |
3158 * | |
3159 * @param \Drupal\jsonapi\ResourceResponse $expected_response | |
3160 * The expected ResourceResponse. | |
3161 * @param \Drupal\jsonapi\ResourceResponse[] $related_responses | |
3162 * The related ResourceResponses, keyed by relationship field names. | |
3163 * | |
3164 * @return \Drupal\jsonapi\ResourceResponse | |
3165 * The decorated ResourceResponse. | |
3166 */ | |
3167 protected static function decorateExpectedResponseForIncludedFields(ResourceResponse $expected_response, array $related_responses) { | |
3168 $expected_document = $expected_response->getResponseData(); | |
3169 $expected_cacheability = $expected_response->getCacheableMetadata(); | |
3170 foreach ($related_responses as $related_response) { | |
3171 $related_document = $related_response->getResponseData(); | |
3172 $expected_cacheability->addCacheableDependency($related_response->getCacheableMetadata()); | |
3173 $expected_cacheability->setCacheTags(array_values(array_diff($expected_cacheability->getCacheTags(), ['4xx-response']))); | |
3174 // If any of the related response documents had omitted items or errors, | |
3175 // we should later expect the document to have omitted items as well. | |
3176 if (!empty($related_document['errors'])) { | |
3177 static::addOmittedObject($expected_document, static::errorsToOmittedObject($related_document['errors'])); | |
3178 } | |
3179 if (!empty($related_document['meta']['omitted'])) { | |
3180 static::addOmittedObject($expected_document, $related_document['meta']['omitted']); | |
3181 } | |
3182 if (isset($related_document['data'])) { | |
3183 $related_data = $related_document['data']; | |
3184 $related_resources = (static::isResourceIdentifier($related_data)) | |
3185 ? [$related_data] | |
3186 : $related_data; | |
3187 foreach ($related_resources as $related_resource) { | |
3188 if (empty($expected_document['included']) || !static::collectionHasResourceIdentifier($related_resource, $expected_document['included'])) { | |
3189 $expected_document['included'][] = $related_resource; | |
3190 } | |
3191 } | |
3192 } | |
3193 } | |
3194 return (new ResourceResponse($expected_document))->addCacheableDependency($expected_cacheability); | |
3195 } | |
3196 | |
3197 /** | |
3198 * Gets the expected individual ResourceResponse for GET. | |
3199 * | |
3200 * @return \Drupal\jsonapi\ResourceResponse | |
3201 * The expected individual ResourceResponse. | |
3202 */ | |
3203 protected function getExpectedGetIndividualResourceResponse($status_code = 200) { | |
3204 $resource_response = new ResourceResponse($this->getExpectedDocument(), $status_code); | |
3205 $cacheability = new CacheableMetadata(); | |
3206 $cacheability->setCacheContexts($this->getExpectedCacheContexts()); | |
3207 $cacheability->setCacheTags($this->getExpectedCacheTags()); | |
3208 return $resource_response->addCacheableDependency($cacheability); | |
3209 } | |
3210 | |
3211 /** | |
3212 * Returns an array of sparse fields sets to test. | |
3213 * | |
3214 * @return array | |
3215 * An array of sparse field sets (an array of field names), keyed by a label | |
3216 * for the field set. | |
3217 */ | |
3218 protected function getSparseFieldSets() { | |
3219 $field_names = array_keys($this->entity->toArray()); | |
3220 $field_sets = [ | |
3221 'empty' => [], | |
3222 'some' => array_slice($field_names, floor(count($field_names) / 2)), | |
3223 'all' => $field_names, | |
3224 ]; | |
3225 if ($this->entity instanceof EntityOwnerInterface) { | |
3226 $field_sets['nested_empty_fieldset'] = $field_sets['empty']; | |
3227 $field_sets['nested_fieldset_with_owner_fieldset'] = ['name', 'created']; | |
3228 } | |
3229 return $field_sets; | |
3230 } | |
3231 | |
3232 /** | |
3233 * Gets a list of public relationship names for the resource type under test. | |
3234 * | |
3235 * @param \Drupal\Core\Entity\EntityInterface|null $entity | |
3236 * (optional) The entity for which to get relationship field names. | |
3237 * | |
3238 * @return array | |
3239 * An array of relationship field names. | |
3240 */ | |
3241 protected function getRelationshipFieldNames(EntityInterface $entity = NULL) { | |
3242 $entity = $entity ?: $this->entity; | |
3243 // Only content entity types can have relationships. | |
3244 $fields = $entity instanceof ContentEntityInterface | |
3245 ? iterator_to_array($entity) | |
3246 : []; | |
3247 return array_reduce($fields, function ($field_names, $field) { | |
3248 /* @var \Drupal\Core\Field\FieldItemListInterface $field */ | |
3249 if (static::isReferenceFieldDefinition($field->getFieldDefinition())) { | |
3250 $field_names[] = $this->resourceType->getPublicName($field->getName()); | |
3251 } | |
3252 return $field_names; | |
3253 }, []); | |
3254 } | |
3255 | |
3256 /** | |
3257 * Authorize the user under test with additional permissions to view includes. | |
3258 * | |
3259 * @return array | |
3260 * An array of special permissions to be granted for certain relationship | |
3261 * paths where the keys are relationships paths and values are an array of | |
3262 * permissions. | |
3263 */ | |
3264 protected static function getIncludePermissions() { | |
3265 return []; | |
3266 } | |
3267 | |
3268 /** | |
3269 * Gets an array of permissions required to view and update any tested entity. | |
3270 * | |
3271 * @return string[] | |
3272 * An array of permission names. | |
3273 */ | |
3274 protected function getEditorialPermissions() { | |
3275 return ['view latest version', "view any unpublished content"]; | |
3276 } | |
3277 | |
3278 /** | |
3279 * Checks access for the given operation on the given entity. | |
3280 * | |
3281 * @param \Drupal\Core\Entity\EntityInterface $entity | |
3282 * The entity for which to check field access. | |
3283 * @param string $operation | |
3284 * The operation for which to check access. | |
3285 * @param \Drupal\Core\Session\AccountInterface $account | |
3286 * The account for which to check access. | |
3287 * | |
3288 * @return \Drupal\Core\Access\AccessResultInterface | |
3289 * The AccessResult. | |
3290 */ | |
3291 protected static function entityAccess(EntityInterface $entity, $operation, AccountInterface $account) { | |
3292 // The default entity access control handler assumes that permissions do not | |
3293 // change during the lifetime of a request and caches access results. | |
3294 // However, we're changing permissions during a test run and need fresh | |
3295 // results, so reset the cache. | |
3296 \Drupal::entityTypeManager()->getAccessControlHandler($entity->getEntityTypeId())->resetCache(); | |
3297 return $entity->access($operation, $account, TRUE); | |
3298 } | |
3299 | |
3300 /** | |
3301 * Checks access for the given field operation on the given entity. | |
3302 * | |
3303 * @param \Drupal\Core\Entity\EntityInterface $entity | |
3304 * The entity for which to check field access. | |
3305 * @param string $field_name | |
3306 * The field for which to check access. | |
3307 * @param string $operation | |
3308 * The operation for which to check access. | |
3309 * @param \Drupal\Core\Session\AccountInterface $account | |
3310 * The account for which to check access. | |
3311 * | |
3312 * @return \Drupal\Core\Access\AccessResultInterface | |
3313 * The AccessResult. | |
3314 */ | |
3315 protected static function entityFieldAccess(EntityInterface $entity, $field_name, $operation, AccountInterface $account) { | |
3316 $entity_access = static::entityAccess($entity, $operation === 'edit' ? 'update' : 'view', $account); | |
3317 $field_access = $entity->{$field_name}->access($operation, $account, TRUE); | |
3318 return $entity_access->andIf($field_access); | |
3319 } | |
3320 | |
3321 /** | |
3322 * Gets an array of of all nested include paths to be tested. | |
3323 * | |
3324 * @param int $depth | |
3325 * (optional) The maximum depth to which included paths should be nested. | |
3326 * | |
3327 * @return array | |
3328 * An array of nested include paths. | |
3329 */ | |
3330 protected function getNestedIncludePaths($depth = 3) { | |
3331 $get_nested_relationship_field_names = function (EntityInterface $entity, $depth, $path = "") use (&$get_nested_relationship_field_names) { | |
3332 $relationship_field_names = $this->getRelationshipFieldNames($entity); | |
3333 if ($depth > 0) { | |
3334 $paths = []; | |
3335 foreach ($relationship_field_names as $field_name) { | |
3336 $next = ($path) ? "$path.$field_name" : $field_name; | |
3337 $internal_field_name = $this->resourceType->getInternalName($field_name); | |
3338 if ($target_entity = $entity->{$internal_field_name}->entity) { | |
3339 $deep = $get_nested_relationship_field_names($target_entity, $depth - 1, $next); | |
3340 $paths = array_merge($paths, $deep); | |
3341 } | |
3342 else { | |
3343 $paths[] = $next; | |
3344 } | |
3345 } | |
3346 return $paths; | |
3347 } | |
3348 return array_map(function ($target_name) use ($path) { | |
3349 return "$path.$target_name"; | |
3350 }, $relationship_field_names); | |
3351 }; | |
3352 return $get_nested_relationship_field_names($this->entity, $depth); | |
3353 } | |
3354 | |
3355 /** | |
3356 * Determines if a given field definition is a reference field. | |
3357 * | |
3358 * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition | |
3359 * The field definition to inspect. | |
3360 * | |
3361 * @return bool | |
3362 * TRUE if the field definition is found to be a reference field. FALSE | |
3363 * otherwise. | |
3364 */ | |
3365 protected static function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) { | |
3366 /* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */ | |
3367 $item_definition = $field_definition->getItemDefinition(); | |
3368 $main_property = $item_definition->getMainPropertyName(); | |
3369 $property_definition = $item_definition->getPropertyDefinition($main_property); | |
3370 return $property_definition instanceof DataReferenceTargetDefinition; | |
3371 } | |
3372 | |
3373 /** | |
3374 * Grants authorization to view includes. | |
3375 * | |
3376 * @param string[] $include_paths | |
3377 * An array of include paths for which to grant access. | |
3378 */ | |
3379 protected function grantIncludedPermissions(array $include_paths = []) { | |
3380 $applicable_permissions = array_intersect_key(static::getIncludePermissions(), array_flip($include_paths)); | |
3381 $flattened_permissions = array_unique(array_reduce($applicable_permissions, 'array_merge', [])); | |
3382 // Always grant access to 'view' the test entity reference field. | |
3383 $flattened_permissions[] = 'field_jsonapi_test_entity_ref view access'; | |
3384 $this->grantPermissionsToTestedRole($flattened_permissions); | |
3385 } | |
3386 | |
3387 /** | |
3388 * Loads an entity in the test container, ignoring the static cache. | |
3389 * | |
3390 * @param int $id | |
3391 * The entity ID. | |
3392 * | |
3393 * @return \Drupal\Core\Entity\EntityInterface|null | |
3394 * The loaded entity. | |
3395 * | |
3396 * @todo Remove this after https://www.drupal.org/project/drupal/issues/3038706 lands. | |
3397 */ | |
3398 protected function entityLoadUnchanged($id) { | |
3399 $this->entityStorage->resetCache(); | |
3400 return $this->entityStorage->loadUnchanged($id); | |
3401 } | |
3402 | |
3403 } |