Merge pull request #22967 from eileenmcnaughton/ret2
[civicrm-core.git] / tests / phpunit / api / v4 / Entity / ConformanceTest.php
1 <?php
2
3 /*
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
6 | |
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
10 +--------------------------------------------------------------------+
11 */
12
13 /**
14 *
15 * @package CRM
16 * @copyright CiviCRM LLC https://civicrm.org/licensing
17 */
18
19 namespace api\v4\Entity;
20
21 use api\v4\Service\TestCreationParameterProvider;
22 use api\v4\Traits\CheckAccessTrait;
23 use api\v4\Traits\OptionCleanupTrait;
24 use api\v4\Traits\TableDropperTrait;
25 use Civi\API\Exception\UnauthorizedException;
26 use Civi\Api4\CustomField;
27 use Civi\Api4\CustomGroup;
28 use Civi\Api4\Entity;
29 use api\v4\UnitTestCase;
30 use Civi\Api4\Event\ValidateValuesEvent;
31 use Civi\Api4\Service\Spec\CustomFieldSpec;
32 use Civi\Api4\Service\Spec\FieldSpec;
33 use Civi\Api4\Service\Spec\SpecGatherer;
34 use Civi\Api4\Utils\CoreUtil;
35 use Civi\Core\Event\PostEvent;
36 use Civi\Core\Event\PreEvent;
37 use Civi\Test\HookInterface;
38
39 /**
40 * @group headless
41 */
42 class ConformanceTest extends UnitTestCase implements HookInterface {
43
44 use CheckAccessTrait;
45 use TableDropperTrait;
46 use OptionCleanupTrait {
47 setUp as setUpOptionCleanup;
48 }
49
50 /**
51 * @var \api\v4\Service\TestCreationParameterProvider
52 */
53 protected $creationParamProvider;
54
55 /**
56 * Set up baseline for testing
57 *
58 * @throws \CRM_Core_Exception
59 */
60 public function setUp(): void {
61 // Enable all components
62 \CRM_Core_BAO_ConfigSetting::enableAllComponents();
63 $this->setUpOptionCleanup();
64 $this->loadDataSet('CaseType');
65 $this->loadDataSet('ConformanceTest');
66 $gatherer = new SpecGatherer();
67 $this->creationParamProvider = new TestCreationParameterProvider($gatherer);
68 parent::setUp();
69 $this->resetCheckAccess();
70 }
71
72 /**
73 * @throws \API_Exception
74 * @throws \Civi\API\Exception\UnauthorizedException
75 */
76 public function tearDown(): void {
77 CustomField::delete()->addWhere('id', '>', 0)->execute();
78 CustomGroup::delete()->addWhere('id', '>', 0)->execute();
79 $tablesToTruncate = [
80 'civicrm_case_type',
81 'civicrm_group',
82 'civicrm_event',
83 'civicrm_participant',
84 'civicrm_batch',
85 'civicrm_product',
86 ];
87 $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
88 parent::tearDown();
89 }
90
91 /**
92 * Get entities to test.
93 *
94 * This is the hi-tech list as generated via Civi's runtime services. It
95 * is canonical, but relies on services that may not be available during
96 * early parts of PHPUnit lifecycle.
97 *
98 * @return array
99 *
100 * @throws \API_Exception
101 * @throws \CRM_Core_Exception
102 */
103 public function getEntitiesHitech(): array {
104 // Ensure all components are enabled so their entities show up
105 foreach (array_keys(\CRM_Core_Component::getComponents()) as $component) {
106 \CRM_Core_BAO_ConfigSetting::enableComponent($component);
107 }
108 return $this->toDataProviderArray(Entity::get(FALSE)->execute()->column('name'));
109 }
110
111 /**
112 * Get entities to test.
113 *
114 * This is the low-tech list as generated by manual-overrides and direct inspection.
115 * It may be summoned at any time during PHPUnit lifecycle, but it may require
116 * occasional twiddling to give correct results.
117 *
118 * @return array
119 */
120 public function getEntitiesLotech(): array {
121 $manual['add'] = [];
122 $manual['remove'] = ['CustomValue'];
123 $manual['transform'] = ['CiviCase' => 'Case'];
124
125 $scanned = [];
126 $srcDir = dirname(__DIR__, 5);
127 foreach ((array) glob("$srcDir/Civi/Api4/*.php") as $name) {
128 $fileName = basename($name, '.php');
129 $scanned[] = $manual['transform'][$fileName] ?? $fileName;
130 }
131
132 $names = array_diff(
133 array_unique(array_merge($scanned, $manual['add'])),
134 $manual['remove']
135 );
136
137 return $this->toDataProviderArray($names);
138 }
139
140 /**
141 * Ensure that "getEntitiesLotech()" (which is the 'dataProvider') is up to date
142 * with "getEntitiesHitech()" (which is a live feed available entities).
143 */
144 public function testEntitiesProvider(): void {
145 $this->assertEquals($this->getEntitiesHitech(), $this->getEntitiesLotech(), "The lo-tech list of entities does not match the hi-tech list. You probably need to update getEntitiesLotech().");
146 }
147
148 /**
149 * @param string $entity
150 * Ex: 'Contact'
151 *
152 * @dataProvider getEntitiesLotech
153 *
154 * @throws \API_Exception
155 */
156 public function testConformance(string $entity): void {
157 $entityClass = CoreUtil::getApiClass($entity);
158
159 $this->checkEntityInfo($entityClass);
160 $actions = $this->checkActions($entityClass);
161
162 // Go no further if it's not a CRUD entity
163 if (array_diff(['get', 'create', 'update', 'delete'], array_keys($actions))) {
164 $this->markTestSkipped("The API \"$entity\" does not implement CRUD actions");
165 }
166
167 $this->checkFields($entityClass, $entity);
168 $this->checkCreationDenied($entity, $entityClass);
169 $id = $this->checkCreation($entity, $entityClass);
170 $this->checkGet($entityClass, $id, $entity);
171 $this->checkGetAllowed($entityClass, $id, $entity);
172 $this->checkGetCount($entityClass, $id, $entity);
173 $this->checkUpdateFailsFromCreate($entityClass, $id);
174 $this->checkWrongParamType($entityClass);
175 $this->checkDeleteWithNoId($entityClass);
176 $this->checkDeletionDenied($entityClass, $id, $entity);
177 $this->checkDeletionAllowed($entityClass, $id, $entity);
178 $this->checkPostDelete($entityClass, $id, $entity);
179 }
180
181 /**
182 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
183 */
184 protected function checkEntityInfo($entityClass): void {
185 $info = $entityClass::getInfo();
186 $this->assertNotEmpty($info['name']);
187 $this->assertNotEmpty($info['title']);
188 $this->assertNotEmpty($info['title_plural']);
189 $this->assertNotEmpty($info['type']);
190 $this->assertNotEmpty($info['description']);
191 $this->assertIsArray($info['primary_key']);
192 $this->assertNotEmpty($info['primary_key']);
193 $this->assertRegExp(';^\d\.\d+$;', $info['since']);
194 $this->assertContains($info['searchable'], ['primary', 'secondary', 'bridge', 'none']);
195 // Bridge must be between exactly 2 entities
196 if (in_array('EntityBridge', $info['type'], TRUE)) {
197 $this->assertCount(2, $info['bridge']);
198 }
199 }
200
201 /**
202 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
203 * @param string $entity
204 *
205 * @throws \API_Exception
206 */
207 protected function checkFields($entityClass, $entity) {
208 $fields = $entityClass::getFields(FALSE)
209 ->addWhere('type', '=', 'Field')
210 ->execute()
211 ->indexBy('name');
212
213 $idField = CoreUtil::getIdFieldName($entity);
214
215 $errMsg = sprintf('%s getfields is missing primary key field', $entity);
216
217 $this->assertArrayHasKey($idField, $fields, $errMsg);
218 $this->assertEquals('Integer', $fields[$idField]['data_type']);
219
220 // Ensure that the getFields (FieldSpec) format is generally consistent.
221 foreach ($fields as $field) {
222 $isNotNull = function($v) {
223 return $v !== NULL;
224 };
225 $class = empty($field['custom_field_id']) ? FieldSpec::class : CustomFieldSpec::class;
226 $spec = (new $class($field['name'], $field['entity']))->loadArray($field, TRUE);
227 $this->assertEquals(
228 array_filter($field, $isNotNull),
229 array_filter($spec->toArray(), $isNotNull)
230 );
231 }
232 }
233
234 /**
235 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
236 *
237 * @return array
238 *
239 * @throws \API_Exception
240 */
241 protected function checkActions($entityClass): array {
242 $actions = $entityClass::getActions(FALSE)
243 ->execute()
244 ->indexBy('name');
245
246 $this->assertNotEmpty($actions);
247 return (array) $actions;
248 }
249
250 /**
251 * @param string $entity
252 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
253 *
254 * @return mixed
255 */
256 protected function checkCreation($entity, $entityClass) {
257 $isReadOnly = $this->isReadOnly($entityClass);
258
259 $hookLog = [];
260 $onValidate = function(ValidateValuesEvent $e) use (&$hookLog) {
261 $hookLog[$e->getEntityName()][$e->getActionName()] = 1 + ($hookLog[$e->getEntityName()][$e->getActionName()] ?? 0);
262 };
263 \Civi::dispatcher()->addListener('civi.api4.validate', $onValidate);
264 \Civi::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate);
265
266 $this->setCheckAccessGrants(["{$entity}::create" => TRUE]);
267 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]);
268
269 $requiredParams = $this->creationParamProvider->getRequired($entity);
270 $createResult = $entityClass::create()
271 ->setValues($requiredParams)
272 ->setCheckPermissions(!$isReadOnly)
273 ->execute()
274 ->first();
275
276 $idField = CoreUtil::getIdFieldName($entity);
277
278 $this->assertArrayHasKey($idField, $createResult, "create missing ID");
279 $id = $createResult[$idField];
280 $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
281 if (!$isReadOnly) {
282 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]);
283 }
284 $this->resetCheckAccess();
285
286 $this->assertEquals(2, $hookLog[$entity]['create']);
287 \Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate);
288 \Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate);
289
290 return $id;
291 }
292
293 /**
294 * @param string $entity
295 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
296 */
297 protected function checkCreationDenied(string $entity, $entityClass): void {
298 $this->setCheckAccessGrants(["{$entity}::create" => FALSE]);
299 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]);
300
301 $requiredParams = $this->creationParamProvider->getRequired($entity);
302
303 try {
304 $entityClass::create()
305 ->setValues($requiredParams)
306 ->setCheckPermissions(TRUE)
307 ->execute()
308 ->first();
309 $this->fail("{$entityClass}::create() should throw an authorization failure.");
310 }
311 catch (UnauthorizedException $e) {
312 // OK, expected exception
313 }
314 if (!$this->isReadOnly($entityClass)) {
315 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]);
316 }
317 $this->resetCheckAccess();
318 }
319
320 /**
321 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
322 * @param int $id
323 */
324 protected function checkUpdateFailsFromCreate($entityClass, int $id): void {
325 $exceptionThrown = '';
326 try {
327 $entityClass::create(FALSE)
328 ->addValue('id', $id)
329 ->execute();
330 }
331 catch (\API_Exception $e) {
332 $exceptionThrown = $e->getMessage();
333 }
334 $this->assertStringContainsString('id', $exceptionThrown);
335 }
336
337 /**
338 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
339 * @param int $id
340 * @param string $entity
341 */
342 protected function checkGet($entityClass, int $id, string $entity): void {
343 $getResult = $entityClass::get(FALSE)
344 ->addWhere('id', '=', $id)
345 ->execute();
346
347 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
348 $idField = CoreUtil::getIdFieldName($entity);
349 $this->assertEquals($id, $getResult->first()[$idField], $errMsg);
350 $this->assertEquals(1, $getResult->count(), $errMsg);
351 }
352
353 /**
354 * Use a permissioned request for `get()`, with access grnted
355 * via checkAccess event.
356 *
357 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
358 * @param int $id
359 * @param string $entity
360 */
361 protected function checkGetAllowed($entityClass, $id, $entity) {
362 $this->setCheckAccessGrants(["{$entity}::get" => TRUE]);
363 $getResult = $entityClass::get()
364 ->addWhere('id', '=', $id)
365 ->execute();
366
367 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
368 $idField = CoreUtil::getIdFieldName($entity);
369 $this->assertEquals($id, $getResult->first()[$idField], $errMsg);
370 $this->assertEquals(1, $getResult->count(), $errMsg);
371 $this->resetCheckAccess();
372 }
373
374 /**
375 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
376 * @param int $id
377 * @param string $entity
378 */
379 protected function checkGetCount($entityClass, $id, $entity): void {
380 $idField = CoreUtil::getIdFieldName($entity);
381 $getResult = $entityClass::get(FALSE)
382 ->addWhere($idField, '=', $id)
383 ->selectRowCount()
384 ->execute();
385 $errMsg = sprintf('%s getCount failed', $entity);
386 $this->assertEquals(1, $getResult->count(), $errMsg);
387
388 $getResult = $entityClass::get(FALSE)
389 ->selectRowCount()
390 ->execute();
391 $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg);
392 }
393
394 /**
395 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
396 */
397 protected function checkDeleteWithNoId($entityClass) {
398 try {
399 $entityClass::delete()
400 ->execute();
401 $this->fail("$entityClass should require ID to delete.");
402 }
403 catch (\API_Exception $e) {
404 // OK
405 }
406 }
407
408 /**
409 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
410 */
411 protected function checkWrongParamType($entityClass) {
412 $exceptionThrown = '';
413 try {
414 $entityClass::get()
415 ->setDebug('not a bool')
416 ->execute();
417 }
418 catch (\API_Exception $e) {
419 $exceptionThrown = $e->getMessage();
420 }
421 $this->assertStringContainsString('debug', $exceptionThrown);
422 $this->assertStringContainsString('type', $exceptionThrown);
423 }
424
425 /**
426 * Delete an entity - while having a targeted grant (hook_civirm_checkAccess).
427 *
428 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
429 * @param int $id
430 * @param string $entity
431 */
432 protected function checkDeletionAllowed($entityClass, $id, $entity) {
433 $this->setCheckAccessGrants(["{$entity}::delete" => TRUE]);
434 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]);
435 $isReadOnly = $this->isReadOnly($entityClass);
436
437 $idField = CoreUtil::getIdFieldName($entity);
438 $deleteAction = $entityClass::delete()
439 ->setCheckPermissions(!$isReadOnly)
440 ->addWhere($idField, '=', $id);
441
442 if (property_exists($deleteAction, 'useTrash')) {
443 $deleteAction->setUseTrash(FALSE);
444 }
445
446 $log = $this->withPrePostLogging(function() use (&$deleteAction, &$deleteResult) {
447 $deleteResult = $deleteAction->execute();
448 });
449
450 if (in_array('DAOEntity', CoreUtil::getInfoItem($entity, 'type'))) {
451 // We should have emitted an event.
452 $hookEntity = ($entity === 'Contact') ? 'Individual' : $entity;/* ooph */
453 $this->assertContains("pre.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_pre() for deletions");
454 $this->assertContains("post.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_post() for deletions");
455
456 // should get back an array of deleted id
457 $this->assertEquals([['id' => $id]], (array) $deleteResult);
458 if (!$isReadOnly) {
459 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
460 }
461 }
462 $this->resetCheckAccess();
463 }
464
465 /**
466 * Attempt to delete an entity while having explicitly denied permission (hook_civicrm_checkAccess).
467 *
468 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
469 * @param int $id
470 * @param string $entity
471 */
472 protected function checkDeletionDenied($entityClass, $id, $entity) {
473 $this->setCheckAccessGrants(["{$entity}::delete" => FALSE]);
474 $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]);
475
476 try {
477 $entityClass::delete()
478 ->addWhere('id', '=', $id)
479 ->execute();
480 $this->fail("{$entity}::delete should throw an authorization failure.");
481 }
482 catch (UnauthorizedException $e) {
483 // OK
484 }
485
486 if (!$this->isReadOnly($entityClass)) {
487 $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]);
488 }
489 $this->resetCheckAccess();
490 }
491
492 /**
493 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
494 * @param int $id
495 * @param string $entity
496 */
497 protected function checkPostDelete($entityClass, $id, $entity) {
498 $getDeletedResult = $entityClass::get(FALSE)
499 ->addWhere('id', '=', $id)
500 ->execute();
501
502 $errMsg = sprintf('Entity "%s" was not deleted', $entity);
503 $this->assertEquals(0, count($getDeletedResult), $errMsg);
504 }
505
506 /**
507 * @param array $names
508 * List of entity names.
509 * Ex: ['Foo', 'Bar']
510 * @return array
511 * List of data-provider arguments, one for each entity-name.
512 * Ex: ['Foo' => ['Foo'], 'Bar' => ['Bar']]
513 */
514 protected function toDataProviderArray($names) {
515 sort($names);
516
517 $result = [];
518 foreach ($names as $name) {
519 $result[$name] = [$name];
520 }
521 return $result;
522 }
523
524 /**
525 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
526 * @return bool
527 */
528 protected function isReadOnly($entityClass) {
529 return in_array('ReadOnlyEntity', $entityClass::getInfo()['type'], TRUE);
530 }
531
532 /**
533 * Temporarily enable logging for `hook_civicrm_pre` and `hook_civicrm_post`.
534 *
535 * @param callable $callable
536 * Run this function. Create a log while running this function.
537 * @return array
538 * Log; list of times the hooks were called.
539 * Ex: ['pre.Event.delete', 'post.Event.delete']
540 */
541 protected function withPrePostLogging($callable): array {
542 $log = [];
543
544 $listen = function ($e) use (&$log) {
545 if ($e instanceof PreEvent) {
546 $log[] = "pre.{$e->entity}.{$e->action}";
547 }
548 elseif ($e instanceof PostEvent) {
549 $log[] = "post.{$e->entity}.{$e->action}";
550 }
551 };
552
553 try {
554 \Civi::dispatcher()->addListener('hook_civicrm_pre', $listen);
555 \Civi::dispatcher()->addListener('hook_civicrm_post', $listen);
556 $callable();
557 }
558 finally {
559 \Civi::dispatcher()->removeListener('hook_civicrm_pre', $listen);
560 \Civi::dispatcher()->removeListener('hook_civicrm_post', $listen);
561 }
562
563 return $log;
564 }
565
566 }