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