Commit | Line | Data |
---|---|---|
19b53e5b C |
1 | <?php |
2 | ||
380f3545 TO |
3 | /* |
4 | +--------------------------------------------------------------------+ | |
7d61e75f | 5 | | Copyright CiviCRM LLC. All rights reserved. | |
380f3545 | 6 | | | |
7d61e75f TO |
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 | | |
380f3545 TO |
10 | +--------------------------------------------------------------------+ |
11 | */ | |
12 | ||
13 | /** | |
14 | * | |
15 | * @package CRM | |
ca5cec67 | 16 | * @copyright CiviCRM LLC https://civicrm.org/licensing |
380f3545 TO |
17 | */ |
18 | ||
19 | ||
19b53e5b C |
20 | namespace api\v4\Entity; |
21 | ||
63ee6673 | 22 | use Civi\API\Exception\UnauthorizedException; |
19b53e5b | 23 | use Civi\Api4\Entity; |
19b53e5b | 24 | use api\v4\UnitTestCase; |
4bf92107 | 25 | use Civi\Api4\Event\ValidateValuesEvent; |
eb378b8a | 26 | use Civi\Api4\Utils\CoreUtil; |
63ee6673 | 27 | use Civi\Test\HookInterface; |
19b53e5b C |
28 | |
29 | /** | |
30 | * @group headless | |
31 | */ | |
63ee6673 | 32 | class ConformanceTest extends UnitTestCase implements HookInterface { |
19b53e5b | 33 | |
63ee6673 | 34 | use \api\v4\Traits\CheckAccessTrait; |
30755fcb | 35 | use \api\v4\Traits\TableDropperTrait; |
19b53e5b C |
36 | use \api\v4\Traits\OptionCleanupTrait { |
37 | setUp as setUpOptionCleanup; | |
38 | } | |
39 | ||
40 | /** | |
41 | * @var \api\v4\Service\TestCreationParameterProvider | |
42 | */ | |
43 | protected $creationParamProvider; | |
44 | ||
45 | /** | |
46 | * Set up baseline for testing | |
47 | */ | |
0b49aa04 | 48 | public function setUp(): void { |
19b53e5b | 49 | $tablesToTruncate = [ |
96f09dda | 50 | 'civicrm_case_type', |
19b53e5b C |
51 | 'civicrm_custom_group', |
52 | 'civicrm_custom_field', | |
53 | 'civicrm_group', | |
54 | 'civicrm_event', | |
55 | 'civicrm_participant', | |
56 | ]; | |
57 | $this->dropByPrefix('civicrm_value_myfavorite'); | |
58 | $this->cleanup(['tablesToTruncate' => $tablesToTruncate]); | |
59 | $this->setUpOptionCleanup(); | |
baf63a69 | 60 | $this->loadDataSet('CaseType'); |
19b53e5b C |
61 | $this->loadDataSet('ConformanceTest'); |
62 | $this->creationParamProvider = \Civi::container()->get('test.param_provider'); | |
63 | parent::setUp(); | |
63ee6673 | 64 | $this->resetCheckAccess(); |
19b53e5b C |
65 | } |
66 | ||
5acc6183 | 67 | /** |
68 | * Get entities to test. | |
69 | * | |
8868b7fc TO |
70 | * This is the hi-tech list as generated via Civi's runtime services. It |
71 | * is canonical, but relies on services that may not be available during | |
72 | * early parts of PHPUnit lifecycle. | |
73 | * | |
5acc6183 | 74 | * @return array |
75 | * | |
76 | * @throws \API_Exception | |
77 | * @throws \Civi\API\Exception\UnauthorizedException | |
78 | */ | |
8868b7fc | 79 | public function getEntitiesHitech() { |
d31fb4e3 | 80 | // Ensure all components are enabled so their entities show up |
a62d97f3 CW |
81 | foreach (array_keys(\CRM_Core_Component::getComponents()) as $component) { |
82 | \CRM_Core_BAO_ConfigSetting::enableComponent($component); | |
83 | } | |
fe806431 | 84 | return $this->toDataProviderArray(Entity::get(FALSE)->execute()->column('name')); |
19b53e5b C |
85 | } |
86 | ||
87 | /** | |
8868b7fc TO |
88 | * Get entities to test. |
89 | * | |
90 | * This is the low-tech list as generated by manual-overrides and direct inspection. | |
91 | * It may be summoned at any time during PHPUnit lifecycle, but it may require | |
92 | * occasional twiddling to give correct results. | |
93 | * | |
94 | * @return array | |
19b53e5b | 95 | */ |
8868b7fc TO |
96 | public function getEntitiesLotech() { |
97 | $manual['add'] = []; | |
98 | $manual['remove'] = ['CustomValue']; | |
a62d97f3 | 99 | $manual['transform'] = ['CiviCase' => 'Case']; |
8868b7fc TO |
100 | |
101 | $scanned = []; | |
155ea29a | 102 | $srcDir = dirname(__DIR__, 5); |
8868b7fc | 103 | foreach ((array) glob("$srcDir/Civi/Api4/*.php") as $name) { |
a62d97f3 CW |
104 | $fileName = basename($name, '.php'); |
105 | $scanned[] = $manual['transform'][$fileName] ?? $fileName; | |
19b53e5b | 106 | } |
8868b7fc TO |
107 | |
108 | $names = array_diff( | |
109 | array_unique(array_merge($scanned, $manual['add'])), | |
110 | $manual['remove'] | |
111 | ); | |
112 | ||
113 | return $this->toDataProviderArray($names); | |
114 | } | |
115 | ||
116 | /** | |
117 | * Ensure that "getEntitiesLotech()" (which is the 'dataProvider') is up to date | |
118 | * with "getEntitiesHitech()" (which is a live feed available entities). | |
119 | */ | |
120 | public function testEntitiesProvider() { | |
121 | $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()."); | |
122 | } | |
123 | ||
124 | /** | |
125 | * @param string $entity | |
126 | * Ex: 'Contact' | |
155ea29a | 127 | * |
8868b7fc | 128 | * @dataProvider getEntitiesLotech |
155ea29a | 129 | * |
130 | * @throws \API_Exception | |
8868b7fc | 131 | */ |
155ea29a | 132 | public function testConformance($entity): void { |
eb378b8a | 133 | $entityClass = CoreUtil::getApiClass($entity); |
8868b7fc | 134 | |
30755fcb | 135 | $this->checkEntityInfo($entityClass); |
8868b7fc TO |
136 | $actions = $this->checkActions($entityClass); |
137 | ||
138 | // Go no further if it's not a CRUD entity | |
139 | if (array_diff(['get', 'create', 'update', 'delete'], array_keys($actions))) { | |
30755fcb | 140 | $this->markTestSkipped("The API \"$entity\" does not implement CRUD actions"); |
8868b7fc TO |
141 | } |
142 | ||
143 | $this->checkFields($entityClass, $entity); | |
63ee6673 | 144 | $this->checkCreationDenied($entity, $entityClass); |
8868b7fc TO |
145 | $id = $this->checkCreation($entity, $entityClass); |
146 | $this->checkGet($entityClass, $id, $entity); | |
63ee6673 | 147 | $this->checkGetAllowed($entityClass, $id, $entity); |
8868b7fc TO |
148 | $this->checkGetCount($entityClass, $id, $entity); |
149 | $this->checkUpdateFailsFromCreate($entityClass, $id); | |
150 | $this->checkWrongParamType($entityClass); | |
151 | $this->checkDeleteWithNoId($entityClass); | |
63ee6673 TO |
152 | $this->checkDeletionDenied($entityClass, $id, $entity); |
153 | $this->checkDeletionAllowed($entityClass, $id, $entity); | |
8868b7fc | 154 | $this->checkPostDelete($entityClass, $id, $entity); |
19b53e5b C |
155 | } |
156 | ||
157 | /** | |
30755fcb CW |
158 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass |
159 | */ | |
155ea29a | 160 | protected function checkEntityInfo($entityClass): void { |
30755fcb CW |
161 | $info = $entityClass::getInfo(); |
162 | $this->assertNotEmpty($info['name']); | |
163 | $this->assertNotEmpty($info['title']); | |
9813ae79 | 164 | $this->assertNotEmpty($info['title_plural']); |
30755fcb CW |
165 | $this->assertNotEmpty($info['type']); |
166 | $this->assertNotEmpty($info['description']); | |
d44cc3cb | 167 | $this->assertRegExp(';^\d\.\d\d$;', $info['since']); |
c5076889 | 168 | $this->assertContains($info['searchable'], ['primary', 'secondary', 'bridge', 'none']); |
27d31a0f CW |
169 | // Bridge must be between exactly 2 entities |
170 | if (in_array('EntityBridge', $info['type'], TRUE)) { | |
171 | $this->assertCount(2, $info['bridge']); | |
172 | } | |
30755fcb CW |
173 | } |
174 | ||
175 | /** | |
176 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
5acc6183 | 177 | * @param string $entity |
155ea29a | 178 | * |
179 | * @throws \API_Exception | |
19b53e5b C |
180 | */ |
181 | protected function checkFields($entityClass, $entity) { | |
fe806431 | 182 | $fields = $entityClass::getFields(FALSE) |
a1415a02 | 183 | ->addWhere('type', '=', 'Field') |
19b53e5b C |
184 | ->execute() |
185 | ->indexBy('name'); | |
186 | ||
187 | $errMsg = sprintf('%s is missing required ID field', $entity); | |
188 | $subset = ['data_type' => 'Integer']; | |
189 | ||
df347a8c SL |
190 | $this->assertArrayHasKey('data_type', $fields['id'], $errMsg); |
191 | $this->assertEquals('Integer', $fields['id']['data_type']); | |
19b53e5b C |
192 | } |
193 | ||
194 | /** | |
30755fcb | 195 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass |
5acc6183 | 196 | * |
197 | * @return array | |
155ea29a | 198 | * |
199 | * @throws \API_Exception | |
19b53e5b | 200 | */ |
155ea29a | 201 | protected function checkActions($entityClass): array { |
fe806431 | 202 | $actions = $entityClass::getActions(FALSE) |
19b53e5b C |
203 | ->execute() |
204 | ->indexBy('name'); | |
205 | ||
206 | $this->assertNotEmpty($actions); | |
207 | return (array) $actions; | |
208 | } | |
209 | ||
210 | /** | |
211 | * @param string $entity | |
212 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
213 | * | |
214 | * @return mixed | |
215 | */ | |
216 | protected function checkCreation($entity, $entityClass) { | |
f818ece6 | 217 | $isReadOnly = $this->isReadOnly($entityClass); |
cb72c80e | 218 | |
4bf92107 TO |
219 | $hookLog = []; |
220 | $onValidate = function(ValidateValuesEvent $e) use (&$hookLog) { | |
63ee6673 | 221 | $hookLog[$e->getEntityName()][$e->getActionName()] = 1 + ($hookLog[$e->getEntityName()][$e->getActionName()] ?? 0); |
4bf92107 TO |
222 | }; |
223 | \Civi::dispatcher()->addListener('civi.api4.validate', $onValidate); | |
224 | \Civi::dispatcher()->addListener('civi.api4.validate::' . $entity, $onValidate); | |
225 | ||
63ee6673 TO |
226 | $this->setCheckAccessGrants(["{$entity}::create" => TRUE]); |
227 | $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]); | |
228 | ||
19b53e5b C |
229 | $requiredParams = $this->creationParamProvider->getRequired($entity); |
230 | $createResult = $entityClass::create() | |
231 | ->setValues($requiredParams) | |
cb72c80e | 232 | ->setCheckPermissions(!$isReadOnly) |
19b53e5b C |
233 | ->execute() |
234 | ->first(); | |
235 | ||
236 | $this->assertArrayHasKey('id', $createResult, "create missing ID"); | |
237 | $id = $createResult['id']; | |
19b53e5b | 238 | $this->assertGreaterThanOrEqual(1, $id, "$entity ID not positive"); |
cb72c80e TO |
239 | if (!$isReadOnly) { |
240 | $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]); | |
241 | } | |
63ee6673 | 242 | $this->resetCheckAccess(); |
19b53e5b | 243 | |
4bf92107 TO |
244 | $this->assertEquals(2, $hookLog[$entity]['create']); |
245 | \Civi::dispatcher()->removeListener('civi.api4.validate', $onValidate); | |
246 | \Civi::dispatcher()->removeListener('civi.api4.validate::' . $entity, $onValidate); | |
19b53e5b C |
247 | |
248 | return $id; | |
249 | } | |
250 | ||
63ee6673 TO |
251 | /** |
252 | * @param string $entity | |
253 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
254 | * | |
255 | * @return mixed | |
256 | */ | |
257 | protected function checkCreationDenied($entity, $entityClass) { | |
258 | $this->setCheckAccessGrants(["{$entity}::create" => FALSE]); | |
259 | $this->assertEquals(0, $this->checkAccessCounts["{$entity}::create"]); | |
260 | ||
261 | $requiredParams = $this->creationParamProvider->getRequired($entity); | |
262 | ||
263 | try { | |
264 | $entityClass::create() | |
265 | ->setValues($requiredParams) | |
266 | ->setCheckPermissions(TRUE) | |
267 | ->execute() | |
268 | ->first(); | |
269 | $this->fail("{$entityClass}::create() should throw an authorization failure."); | |
270 | } | |
271 | catch (UnauthorizedException $e) { | |
272 | // OK, expected exception | |
273 | } | |
f818ece6 | 274 | if (!$this->isReadOnly($entityClass)) { |
cb72c80e TO |
275 | $this->assertEquals(1, $this->checkAccessCounts["{$entity}::create"]); |
276 | } | |
63ee6673 TO |
277 | $this->resetCheckAccess(); |
278 | } | |
279 | ||
19b53e5b C |
280 | /** |
281 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
282 | * @param int $id | |
283 | */ | |
155ea29a | 284 | protected function checkUpdateFailsFromCreate($entityClass, $id): void { |
19b53e5b C |
285 | $exceptionThrown = ''; |
286 | try { | |
fe806431 | 287 | $entityClass::create(FALSE) |
19b53e5b C |
288 | ->addValue('id', $id) |
289 | ->execute(); | |
290 | } | |
291 | catch (\API_Exception $e) { | |
292 | $exceptionThrown = $e->getMessage(); | |
293 | } | |
df347a8c | 294 | $this->assertStringContainsString('id', $exceptionThrown); |
19b53e5b C |
295 | } |
296 | ||
297 | /** | |
298 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
299 | * @param int $id | |
300 | * @param string $entity | |
301 | */ | |
302 | protected function checkGet($entityClass, $id, $entity) { | |
fe806431 | 303 | $getResult = $entityClass::get(FALSE) |
19b53e5b C |
304 | ->addWhere('id', '=', $id) |
305 | ->execute(); | |
306 | ||
307 | $errMsg = sprintf('Failed to fetch a %s after creation', $entity); | |
308 | $this->assertEquals($id, $getResult->first()['id'], $errMsg); | |
309 | $this->assertEquals(1, $getResult->count(), $errMsg); | |
310 | } | |
311 | ||
63ee6673 TO |
312 | /** |
313 | * Use a permissioned request for `get()`, with access grnted | |
314 | * via checkAccess event. | |
315 | * | |
316 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
317 | * @param int $id | |
318 | * @param string $entity | |
319 | */ | |
320 | protected function checkGetAllowed($entityClass, $id, $entity) { | |
321 | $this->setCheckAccessGrants(["{$entity}::get" => TRUE]); | |
322 | $getResult = $entityClass::get() | |
323 | ->addWhere('id', '=', $id) | |
324 | ->execute(); | |
325 | ||
326 | $errMsg = sprintf('Failed to fetch a %s after creation', $entity); | |
327 | $this->assertEquals($id, $getResult->first()['id'], $errMsg); | |
328 | $this->assertEquals(1, $getResult->count(), $errMsg); | |
329 | $this->resetCheckAccess(); | |
330 | } | |
331 | ||
19b53e5b C |
332 | /** |
333 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
334 | * @param int $id | |
335 | * @param string $entity | |
336 | */ | |
155ea29a | 337 | protected function checkGetCount($entityClass, $id, $entity): void { |
fe806431 | 338 | $getResult = $entityClass::get(FALSE) |
19b53e5b C |
339 | ->addWhere('id', '=', $id) |
340 | ->selectRowCount() | |
341 | ->execute(); | |
342 | $errMsg = sprintf('%s getCount failed', $entity); | |
343 | $this->assertEquals(1, $getResult->count(), $errMsg); | |
344 | ||
fe806431 | 345 | $getResult = $entityClass::get(FALSE) |
19b53e5b C |
346 | ->selectRowCount() |
347 | ->execute(); | |
19b53e5b C |
348 | $this->assertGreaterThanOrEqual(1, $getResult->count(), $errMsg); |
349 | } | |
350 | ||
351 | /** | |
352 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
353 | */ | |
354 | protected function checkDeleteWithNoId($entityClass) { | |
19b53e5b C |
355 | try { |
356 | $entityClass::delete() | |
357 | ->execute(); | |
f818ece6 | 358 | $this->fail("$entityClass should require ID to delete."); |
19b53e5b C |
359 | } |
360 | catch (\API_Exception $e) { | |
f818ece6 | 361 | // OK |
19b53e5b | 362 | } |
19b53e5b C |
363 | } |
364 | ||
365 | /** | |
366 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
367 | */ | |
368 | protected function checkWrongParamType($entityClass) { | |
369 | $exceptionThrown = ''; | |
370 | try { | |
371 | $entityClass::get() | |
6764a9d3 | 372 | ->setDebug('not a bool') |
19b53e5b C |
373 | ->execute(); |
374 | } | |
375 | catch (\API_Exception $e) { | |
376 | $exceptionThrown = $e->getMessage(); | |
377 | } | |
df347a8c SL |
378 | $this->assertStringContainsString('debug', $exceptionThrown); |
379 | $this->assertStringContainsString('type', $exceptionThrown); | |
19b53e5b C |
380 | } |
381 | ||
382 | /** | |
63ee6673 TO |
383 | * Delete an entity - while having a targeted grant (hook_civirm_checkAccess). |
384 | * | |
19b53e5b C |
385 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass |
386 | * @param int $id | |
63ee6673 | 387 | * @param string $entity |
19b53e5b | 388 | */ |
63ee6673 TO |
389 | protected function checkDeletionAllowed($entityClass, $id, $entity) { |
390 | $this->setCheckAccessGrants(["{$entity}::delete" => TRUE]); | |
391 | $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]); | |
f818ece6 | 392 | $isReadOnly = $this->isReadOnly($entityClass); |
63ee6673 TO |
393 | |
394 | $deleteResult = $entityClass::delete() | |
f818ece6 | 395 | ->setCheckPermissions(!$isReadOnly) |
19b53e5b C |
396 | ->addWhere('id', '=', $id) |
397 | ->execute(); | |
398 | ||
399 | // should get back an array of deleted id | |
400 | $this->assertEquals([['id' => $id]], (array) $deleteResult); | |
f818ece6 CW |
401 | if (!$isReadOnly) { |
402 | $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]); | |
403 | } | |
63ee6673 TO |
404 | $this->resetCheckAccess(); |
405 | } | |
406 | ||
407 | /** | |
408 | * Attempt to delete an entity while having explicitly denied permission (hook_civicrm_checkAccess). | |
409 | * | |
410 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
411 | * @param int $id | |
412 | * @param string $entity | |
413 | */ | |
414 | protected function checkDeletionDenied($entityClass, $id, $entity) { | |
415 | $this->setCheckAccessGrants(["{$entity}::delete" => FALSE]); | |
416 | $this->assertEquals(0, $this->checkAccessCounts["{$entity}::delete"]); | |
417 | ||
418 | try { | |
419 | $entityClass::delete() | |
420 | ->addWhere('id', '=', $id) | |
421 | ->execute(); | |
422 | $this->fail("{$entity}::delete should throw an authorization failure."); | |
423 | } | |
424 | catch (UnauthorizedException $e) { | |
425 | // OK | |
426 | } | |
427 | ||
f818ece6 CW |
428 | if (!$this->isReadOnly($entityClass)) { |
429 | $this->assertEquals(1, $this->checkAccessCounts["{$entity}::delete"]); | |
430 | } | |
63ee6673 | 431 | $this->resetCheckAccess(); |
19b53e5b C |
432 | } |
433 | ||
434 | /** | |
435 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
436 | * @param int $id | |
437 | * @param string $entity | |
438 | */ | |
439 | protected function checkPostDelete($entityClass, $id, $entity) { | |
fe806431 | 440 | $getDeletedResult = $entityClass::get(FALSE) |
19b53e5b C |
441 | ->addWhere('id', '=', $id) |
442 | ->execute(); | |
443 | ||
444 | $errMsg = sprintf('Entity "%s" was not deleted', $entity); | |
445 | $this->assertEquals(0, count($getDeletedResult), $errMsg); | |
446 | } | |
447 | ||
8868b7fc TO |
448 | /** |
449 | * @param array $names | |
450 | * List of entity names. | |
451 | * Ex: ['Foo', 'Bar'] | |
452 | * @return array | |
453 | * List of data-provider arguments, one for each entity-name. | |
454 | * Ex: ['Foo' => ['Foo'], 'Bar' => ['Bar']] | |
455 | */ | |
456 | protected function toDataProviderArray($names) { | |
457 | sort($names); | |
458 | ||
459 | $result = []; | |
460 | foreach ($names as $name) { | |
461 | $result[$name] = [$name]; | |
462 | } | |
463 | return $result; | |
464 | } | |
465 | ||
f818ece6 CW |
466 | /** |
467 | * @param \Civi\Api4\Generic\AbstractEntity|string $entityClass | |
468 | * @return bool | |
469 | */ | |
470 | protected function isReadOnly($entityClass) { | |
471 | return in_array('ReadOnly', $entityClass::getInfo()['type'], TRUE); | |
472 | } | |
473 | ||
19b53e5b | 474 | } |