4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
16 * @copyright CiviCRM LLC https://civicrm.org/licensing
19 namespace api\v
4\Entity
;
21 use api\v
4\Service\TestCreationParameterProvider
;
22 use api\v
4\Traits\CheckAccessTrait
;
23 use api\v
4\Traits\OptionCleanupTrait
;
24 use api\v
4\Traits\TableDropperTrait
;
25 use Civi\API\Exception\UnauthorizedException
;
26 use Civi\Api4\CustomField
;
27 use Civi\Api4\CustomGroup
;
29 use api\v
4\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
;
42 class ConformanceTest
extends UnitTestCase
implements HookInterface
{
45 use TableDropperTrait
;
46 use OptionCleanupTrait
{
47 setUp
as setUpOptionCleanup
;
51 * @var \api\v4\Service\TestCreationParameterProvider
53 protected $creationParamProvider;
56 * Set up baseline for testing
58 * @throws \CRM_Core_Exception
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);
69 $this->resetCheckAccess();
73 * @throws \API_Exception
74 * @throws \Civi\API\Exception\UnauthorizedException
76 public function tearDown(): void
{
77 CustomField
::delete()->addWhere('id', '>', 0)->execute();
78 CustomGroup
::delete()->addWhere('id', '>', 0)->execute();
83 'civicrm_participant',
87 $this->cleanup(['tablesToTruncate' => $tablesToTruncate]);
92 * Get entities to test.
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.
100 * @throws \API_Exception
101 * @throws \CRM_Core_Exception
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);
108 return $this->toDataProviderArray(Entity
::get(FALSE)->execute()->column('name'));
112 * Get entities to test.
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.
120 public function getEntitiesLotech(): array {
122 $manual['remove'] = ['CustomValue'];
123 $manual['transform'] = ['CiviCase' => 'Case'];
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;
133 array_unique(array_merge($scanned, $manual['add'])),
137 return $this->toDataProviderArray($names);
141 * Ensure that "getEntitiesLotech()" (which is the 'dataProvider') is up to date
142 * with "getEntitiesHitech()" (which is a live feed available entities).
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().");
149 * @param string $entity
152 * @dataProvider getEntitiesLotech
154 * @throws \API_Exception
156 public function testConformance(string $entity): void
{
157 $entityClass = CoreUtil
::getApiClass($entity);
159 $this->checkEntityInfo($entityClass);
160 $actions = $this->checkActions($entityClass);
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");
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);
182 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
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']);
202 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
203 * @param string $entity
205 * @throws \API_Exception
207 protected function checkFields($entityClass, $entity) {
208 $fields = $entityClass::getFields(FALSE)
209 ->addWhere('type', '=', 'Field')
213 $errMsg = sprintf('%s is missing required ID field', $entity);
214 $subset = ['data_type' => 'Integer'];
216 $this->assertArrayHasKey('data_type', $fields['id'], $errMsg);
217 $this->assertEquals('Integer', $fields['id']['data_type']);
219 // Ensure that the getFields (FieldSpec) format is generally consistent.
220 foreach ($fields as $field) {
221 $isNotNull = function($v) {
224 $class = empty($field['custom_field_id']) ? FieldSpec
::class : CustomFieldSpec
::class;
225 $spec = (new $class($field['name'], $field['entity']))->loadArray($field, TRUE);
227 array_filter($field, $isNotNull),
228 array_filter($spec->toArray(), $isNotNull)
234 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
238 * @throws \API_Exception
240 protected function checkActions($entityClass): array {
241 $actions = $entityClass::getActions(FALSE)
245 $this->assertNotEmpty($actions);
246 return (array) $actions;
250 * @param string $entity
251 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
255 protected function checkCreation($entity, $entityClass) {
256 $isReadOnly = $this->isReadOnly($entityClass);
259 $onValidate = function(ValidateValuesEvent
$e) use (&$hookLog) {
260 $hookLog[$e->getEntityName()][$e->getActionName()] = 1 +
($hookLog[$e->getEntityName()][$e->getActionName()] ??
0);
262 \Civi
::dispatcher()->addListener('civi.api4.validate', $onValidate);
263 \Civi
::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate);
265 $this->setCheckAccessGrants(["{$entity}::create" => TRUE]);
266 $this->assertEquals(0, $this->checkAccessCounts
["{$entity}::create"]);
268 $requiredParams = $this->creationParamProvider
->getRequired($entity);
269 $createResult = $entityClass::create()
270 ->setValues($requiredParams)
271 ->setCheckPermissions(!$isReadOnly)
275 $this->assertArrayHasKey('id', $createResult, "create missing ID");
276 $id = $createResult['id'];
277 $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive");
279 $this->assertEquals(1, $this->checkAccessCounts
["{$entity}::create"]);
281 $this->resetCheckAccess();
283 $this->assertEquals(2, $hookLog[$entity]['create']);
284 \Civi
::dispatcher()->removeListener('civi.api4.validate', $onValidate);
285 \Civi
::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate);
291 * @param string $entity
292 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
294 protected function checkCreationDenied(string $entity, $entityClass): void
{
295 $this->setCheckAccessGrants(["{$entity}::create" => FALSE]);
296 $this->assertEquals(0, $this->checkAccessCounts
["{$entity}::create"]);
298 $requiredParams = $this->creationParamProvider
->getRequired($entity);
301 $entityClass::create()
302 ->setValues($requiredParams)
303 ->setCheckPermissions(TRUE)
306 $this->fail("{$entityClass}::create() should throw an authorization failure.");
308 catch (UnauthorizedException
$e) {
309 // OK, expected exception
311 if (!$this->isReadOnly($entityClass)) {
312 $this->assertEquals(1, $this->checkAccessCounts
["{$entity}::create"]);
314 $this->resetCheckAccess();
318 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
321 protected function checkUpdateFailsFromCreate($entityClass, int $id): void
{
322 $exceptionThrown = '';
324 $entityClass::create(FALSE)
325 ->addValue('id', $id)
328 catch (\API_Exception
$e) {
329 $exceptionThrown = $e->getMessage();
331 $this->assertStringContainsString('id', $exceptionThrown);
335 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
337 * @param string $entity
339 protected function checkGet($entityClass, int $id, string $entity): void
{
340 $getResult = $entityClass::get(FALSE)
341 ->addWhere('id', '=', $id)
344 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
345 $this->assertEquals($id, $getResult->first()['id'], $errMsg);
346 $this->assertEquals(1, $getResult->count(), $errMsg);
350 * Use a permissioned request for `get()`, with access grnted
351 * via checkAccess event.
353 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
355 * @param string $entity
357 protected function checkGetAllowed($entityClass, $id, $entity) {
358 $this->setCheckAccessGrants(["{$entity}::get" => TRUE]);
359 $getResult = $entityClass::get()
360 ->addWhere('id', '=', $id)
363 $errMsg = sprintf('Failed to fetch a %s after creation', $entity);
364 $this->assertEquals($id, $getResult->first()['id'], $errMsg);
365 $this->assertEquals(1, $getResult->count(), $errMsg);
366 $this->resetCheckAccess();
370 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
372 * @param string $entity
374 protected function checkGetCount($entityClass, $id, $entity): void
{
375 $getResult = $entityClass::get(FALSE)
376 ->addWhere('id', '=', $id)
379 $errMsg = sprintf('%s getCount failed', $entity);
380 $this->assertEquals(1, $getResult->count(), $errMsg);
382 $getResult = $entityClass::get(FALSE)
385 $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg);
389 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
391 protected function checkDeleteWithNoId($entityClass) {
393 $entityClass::delete()
395 $this->fail("$entityClass should require ID to delete.");
397 catch (\API_Exception
$e) {
403 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
405 protected function checkWrongParamType($entityClass) {
406 $exceptionThrown = '';
409 ->setDebug('not a bool')
412 catch (\API_Exception
$e) {
413 $exceptionThrown = $e->getMessage();
415 $this->assertStringContainsString('debug', $exceptionThrown);
416 $this->assertStringContainsString('type', $exceptionThrown);
420 * Delete an entity - while having a targeted grant (hook_civirm_checkAccess).
422 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
424 * @param string $entity
426 protected function checkDeletionAllowed($entityClass, $id, $entity) {
427 $this->setCheckAccessGrants(["{$entity}::delete" => TRUE]);
428 $this->assertEquals(0, $this->checkAccessCounts
["{$entity}::delete"]);
429 $isReadOnly = $this->isReadOnly($entityClass);
431 $deleteAction = $entityClass::delete()
432 ->setCheckPermissions(!$isReadOnly)
433 ->addWhere('id', '=', $id);
435 if (property_exists($deleteAction, 'useTrash')) {
436 $deleteAction->setUseTrash(FALSE);
439 $log = $this->withPrePostLogging(function() use (&$deleteAction, &$deleteResult) {
440 $deleteResult = $deleteAction->execute();
443 // We should have emitted an event.
444 $hookEntity = ($entity === 'Contact') ?
'Individual' : $entity; /* ooph */
445 $this->assertContains("pre.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_pre() for deletions");
446 $this->assertContains("post.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_post() for deletions");
448 // should get back an array of deleted id
449 $this->assertEquals([['id' => $id]], (array) $deleteResult);
451 $this->assertEquals(1, $this->checkAccessCounts
["{$entity}::delete"]);
453 $this->resetCheckAccess();
457 * Attempt to delete an entity while having explicitly denied permission (hook_civicrm_checkAccess).
459 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
461 * @param string $entity
463 protected function checkDeletionDenied($entityClass, $id, $entity) {
464 $this->setCheckAccessGrants(["{$entity}::delete" => FALSE]);
465 $this->assertEquals(0, $this->checkAccessCounts
["{$entity}::delete"]);
468 $entityClass::delete()
469 ->addWhere('id', '=', $id)
471 $this->fail("{$entity}::delete should throw an authorization failure.");
473 catch (UnauthorizedException
$e) {
477 if (!$this->isReadOnly($entityClass)) {
478 $this->assertEquals(1, $this->checkAccessCounts
["{$entity}::delete"]);
480 $this->resetCheckAccess();
484 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
486 * @param string $entity
488 protected function checkPostDelete($entityClass, $id, $entity) {
489 $getDeletedResult = $entityClass::get(FALSE)
490 ->addWhere('id', '=', $id)
493 $errMsg = sprintf('Entity "%s" was not deleted', $entity);
494 $this->assertEquals(0, count($getDeletedResult), $errMsg);
498 * @param array $names
499 * List of entity names.
502 * List of data-provider arguments, one for each entity-name.
503 * Ex: ['Foo' => ['Foo'], 'Bar' => ['Bar']]
505 protected function toDataProviderArray($names) {
509 foreach ($names as $name) {
510 $result[$name] = [$name];
516 * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass
519 protected function isReadOnly($entityClass) {
520 return in_array('ReadOnly', $entityClass::getInfo()['type'], TRUE);
524 * Temporarily enable logging for `hook_civicrm_pre` and `hook_civicrm_post`.
526 * @param callable $callable
527 * Run this function. Create a log while running this function.
529 * Log; list of times the hooks were called.
530 * Ex: ['pre.Event.delete', 'post.Event.delete']
532 protected function withPrePostLogging($callable): array {
535 $listen = function ($e) use (&$log) {
536 if ($e instanceof PreEvent
) {
537 $log[] = "pre.{$e->entity}.{$e->action}";
539 elseif ($e instanceof PostEvent
) {
540 $log[] = "post.{$e->entity}.{$e->action}";
545 \Civi
::dispatcher()->addListener('hook_civicrm_pre', $listen);
546 \Civi
::dispatcher()->addListener('hook_civicrm_post', $listen);
550 \Civi
::dispatcher()->removeListener('hook_civicrm_pre', $listen);
551 \Civi
::dispatcher()->removeListener('hook_civicrm_post', $listen);