setUpOptionCleanup(); $this->loadDataSet('CaseType'); $this->loadDataSet('ConformanceTest'); $gatherer = new SpecGatherer(); $this->creationParamProvider = new TestCreationParameterProvider($gatherer); parent::setUp(); $this->resetCheckAccess(); } /** * @throws \API_Exception * @throws \Civi\API\Exception\UnauthorizedException */ public function tearDown(): void { CustomField::delete()->addWhere('id', '>', 0)->execute(); CustomGroup::delete()->addWhere('id', '>', 0)->execute(); $tablesToTruncate = [ 'civicrm_case_type', 'civicrm_group', 'civicrm_event', 'civicrm_participant', 'civicrm_batch', 'civicrm_product', ]; $this->cleanup(['tablesToTruncate' => $tablesToTruncate]); parent::tearDown(); } /** * Get entities to test. * * This is the hi-tech list as generated via Civi's runtime services. It * is canonical, but relies on services that may not be available during * early parts of PHPUnit lifecycle. * * @return array * * @throws \API_Exception * @throws \CRM_Core_Exception */ public function getEntitiesHitech(): array { // Ensure all components are enabled so their entities show up foreach (array_keys(\CRM_Core_Component::getComponents()) as $component) { \CRM_Core_BAO_ConfigSetting::enableComponent($component); } return $this->toDataProviderArray(Entity::get(FALSE)->execute()->column('name')); } /** * Get entities to test. * * This is the low-tech list as generated by manual-overrides and direct inspection. * It may be summoned at any time during PHPUnit lifecycle, but it may require * occasional twiddling to give correct results. * * @return array */ public function getEntitiesLotech(): array { $manual['add'] = []; $manual['remove'] = ['CustomValue']; $manual['transform'] = ['CiviCase' => 'Case']; $scanned = []; $srcDir = dirname(__DIR__, 5); foreach ((array) glob("$srcDir/Civi/Api4/*.php") as $name) { $fileName = basename($name, '.php'); $scanned[] = $manual['transform'][$fileName] ?? $fileName; } $names = array_diff( array_unique(array_merge($scanned, $manual['add'])), $manual['remove'] ); return $this->toDataProviderArray($names); } /** * Ensure that "getEntitiesLotech()" (which is the 'dataProvider') is up to date * with "getEntitiesHitech()" (which is a live feed available entities). */ public function testEntitiesProvider(): void { $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()."); } /** * @param string $entity * Ex: 'Contact' * * @dataProvider getEntitiesLotech * * @throws \API_Exception */ public function testConformance(string $entity): void { $entityClass = CoreUtil::getApiClass($entity); $this->checkEntityInfo($entityClass); $actions = $this->checkActions($entityClass); // Go no further if it's not a CRUD entity if (array_diff(['get', 'create', 'update', 'delete'], array_keys($actions))) { $this->markTestSkipped("The API \"$entity\" does not implement CRUD actions"); } $this->checkFields($entityClass, $entity); $this->checkCreationDenied($entity, $entityClass); $id = $this->checkCreation($entity, $entityClass); $this->checkGet($entityClass, $id, $entity); $this->checkGetAllowed($entityClass, $id, $entity); $this->checkGetCount($entityClass, $id, $entity); $this->checkUpdateFailsFromCreate($entityClass, $id); $this->checkWrongParamType($entityClass); $this->checkDeleteWithNoId($entityClass); $this->checkDeletionDenied($entityClass, $id, $entity); $this->checkDeletionAllowed($entityClass, $id, $entity); $this->checkPostDelete($entityClass, $id, $entity); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass */ protected function checkEntityInfo($entityClass): void { $info = $entityClass::getInfo(); $this->assertNotEmpty($info['name']); $this->assertNotEmpty($info['title']); $this->assertNotEmpty($info['title_plural']); $this->assertNotEmpty($info['type']); $this->assertNotEmpty($info['description']); $this->assertIsArray($info['primary_key']); $this->assertNotEmpty($info['primary_key']); $this->assertRegExp(';^\d\.\d+$;', $info['since']); $this->assertContains($info['searchable'], ['primary', 'secondary', 'bridge', 'none']); // Bridge must be between exactly 2 entities if (in_array('EntityBridge', $info['type'], TRUE)) { $this->assertCount(2, $info['bridge']); } } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param string $entity * * @throws \API_Exception */ protected function checkFields($entityClass, $entity) { $fields = $entityClass::getFields(FALSE) ->addWhere('type', '=', 'Field') ->execute() ->indexBy('name'); $errMsg = sprintf('%s is missing required ID field', $entity); $subset = ['data_type' => 'Integer']; $this->assertArrayHasKey('data_type', $fields['id'], $errMsg); $this->assertEquals('Integer', $fields['id']['data_type']); // Ensure that the getFields (FieldSpec) format is generally consistent. foreach ($fields as $field) { $isNotNull = function($v) { return $v !== NULL; }; $class = empty($field['custom_field_id']) ? FieldSpec::class : CustomFieldSpec::class; $spec = (new $class($field['name'], $field['entity']))->loadArray($field, TRUE); $this->assertEquals( array_filter($field, $isNotNull), array_filter($spec->toArray(), $isNotNull) ); } } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * * @return array * * @throws \API_Exception */ protected function checkActions($entityClass): array { $actions = $entityClass::getActions(FALSE) ->execute() ->indexBy('name'); $this->assertNotEmpty($actions); return (array) $actions; } /** * @param string $entity * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * * @return mixed */ protected function checkCreation($entity, $entityClass) { $isReadOnly = $this->isReadOnly($entityClass); $hookLog = []; $onValidate = function(ValidateValuesEvent $e) use (&$hookLog) { $hookLog[$e->getEntityName()][$e->getActionName()] = 1 + ($hookLog[$e->getEntityName()][$e->getActionName()] ?? 0); }; \Civi::dispatcher()->addListener('civi.api4.validate', $onValidate); \Civi::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate); $this->setCheckAccessGrants(["{$entity}::create" => TRUE]); $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]); $requiredParams = $this->creationParamProvider->getRequired($entity); $createResult = $entityClass::create() ->setValues($requiredParams) ->setCheckPermissions(!$isReadOnly) ->execute() ->first(); $this->assertArrayHasKey('id', $createResult, "create missing ID"); $id = $createResult['id']; $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive"); if (!$isReadOnly) { $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]); } $this->resetCheckAccess(); $this->assertEquals(2, $hookLog[$entity]['create']); \Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate); \Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate); return $id; } /** * @param string $entity * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass */ protected function checkCreationDenied(string $entity, $entityClass): void { $this->setCheckAccessGrants(["{$entity}::create" => FALSE]); $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]); $requiredParams = $this->creationParamProvider->getRequired($entity); try { $entityClass::create() ->setValues($requiredParams) ->setCheckPermissions(TRUE) ->execute() ->first(); $this->fail("{$entityClass}::create() should throw an authorization failure."); } catch (UnauthorizedException $e) { // OK, expected exception } if (!$this->isReadOnly($entityClass)) { $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]); } $this->resetCheckAccess(); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id */ protected function checkUpdateFailsFromCreate($entityClass, int $id): void { $exceptionThrown = ''; try { $entityClass::create(FALSE) ->addValue('id', $id) ->execute(); } catch (\API_Exception $e) { $exceptionThrown = $e->getMessage(); } $this->assertStringContainsString('id', $exceptionThrown); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkGet($entityClass, int $id, string $entity): void { $getResult = $entityClass::get(FALSE) ->addWhere('id', '=', $id) ->execute(); $errMsg = sprintf('Failed to fetch a %s after creation', $entity); $this->assertEquals($id, $getResult->first()['id'], $errMsg); $this->assertEquals(1, $getResult->count(), $errMsg); } /** * Use a permissioned request for `get()`, with access grnted * via checkAccess event. * * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkGetAllowed($entityClass, $id, $entity) { $this->setCheckAccessGrants(["{$entity}::get" => TRUE]); $getResult = $entityClass::get() ->addWhere('id', '=', $id) ->execute(); $errMsg = sprintf('Failed to fetch a %s after creation', $entity); $this->assertEquals($id, $getResult->first()['id'], $errMsg); $this->assertEquals(1, $getResult->count(), $errMsg); $this->resetCheckAccess(); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkGetCount($entityClass, $id, $entity): void { $getResult = $entityClass::get(FALSE) ->addWhere('id', '=', $id) ->selectRowCount() ->execute(); $errMsg = sprintf('%s getCount failed', $entity); $this->assertEquals(1, $getResult->count(), $errMsg); $getResult = $entityClass::get(FALSE) ->selectRowCount() ->execute(); $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass */ protected function checkDeleteWithNoId($entityClass) { try { $entityClass::delete() ->execute(); $this->fail("$entityClass should require ID to delete."); } catch (\API_Exception $e) { // OK } } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass */ protected function checkWrongParamType($entityClass) { $exceptionThrown = ''; try { $entityClass::get() ->setDebug('not a bool') ->execute(); } catch (\API_Exception $e) { $exceptionThrown = $e->getMessage(); } $this->assertStringContainsString('debug', $exceptionThrown); $this->assertStringContainsString('type', $exceptionThrown); } /** * Delete an entity - while having a targeted grant (hook_civirm_checkAccess). * * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkDeletionAllowed($entityClass, $id, $entity) { $this->setCheckAccessGrants(["{$entity}::delete" => TRUE]); $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]); $isReadOnly = $this->isReadOnly($entityClass); $deleteAction = $entityClass::delete() ->setCheckPermissions(!$isReadOnly) ->addWhere('id', '=', $id); if (property_exists($deleteAction, 'useTrash')) { $deleteAction->setUseTrash(FALSE); } $log = $this->withPrePostLogging(function() use (&$deleteAction, &$deleteResult) { $deleteResult = $deleteAction->execute(); }); // We should have emitted an event. $hookEntity = ($entity === 'Contact') ? 'Individual' : $entity; /* ooph */ $this->assertContains("pre.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_pre() for deletions"); $this->assertContains("post.{$hookEntity}.delete", $log, "$entity should emit hook_civicrm_post() for deletions"); // should get back an array of deleted id $this->assertEquals([['id' => $id]], (array) $deleteResult); if (!$isReadOnly) { $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]); } $this->resetCheckAccess(); } /** * Attempt to delete an entity while having explicitly denied permission (hook_civicrm_checkAccess). * * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkDeletionDenied($entityClass, $id, $entity) { $this->setCheckAccessGrants(["{$entity}::delete" => FALSE]); $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]); try { $entityClass::delete() ->addWhere('id', '=', $id) ->execute(); $this->fail("{$entity}::delete should throw an authorization failure."); } catch (UnauthorizedException $e) { // OK } if (!$this->isReadOnly($entityClass)) { $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]); } $this->resetCheckAccess(); } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @param int $id * @param string $entity */ protected function checkPostDelete($entityClass, $id, $entity) { $getDeletedResult = $entityClass::get(FALSE) ->addWhere('id', '=', $id) ->execute(); $errMsg = sprintf('Entity "%s" was not deleted', $entity); $this->assertEquals(0, count($getDeletedResult), $errMsg); } /** * @param array $names * List of entity names. * Ex: ['Foo', 'Bar'] * @return array * List of data-provider arguments, one for each entity-name. * Ex: ['Foo' => ['Foo'], 'Bar' => ['Bar']] */ protected function toDataProviderArray($names) { sort($names); $result = []; foreach ($names as $name) { $result[$name] = [$name]; } return $result; } /** * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass * @return bool */ protected function isReadOnly($entityClass) { return in_array('ReadOnly', $entityClass::getInfo()['type'], TRUE); } /** * Temporarily enable logging for `hook_civicrm_pre` and `hook_civicrm_post`. * * @param callable $callable * Run this function. Create a log while running this function. * @return array * Log; list of times the hooks were called. * Ex: ['pre.Event.delete', 'post.Event.delete'] */ protected function withPrePostLogging($callable): array { $log = []; $listen = function ($e) use (&$log) { if ($e instanceof PreEvent) { $log[] = "pre.{$e->entity}.{$e->action}"; } elseif ($e instanceof PostEvent) { $log[] = "post.{$e->entity}.{$e->action}"; } }; try { \Civi::dispatcher()->addListener('hook_civicrm_pre', $listen); \Civi::dispatcher()->addListener('hook_civicrm_post', $listen); $callable(); } finally { \Civi::dispatcher()->removeListener('hook_civicrm_pre', $listen); \Civi::dispatcher()->removeListener('hook_civicrm_post', $listen); } return $log; } }