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