From 2884c795e7b4f7604ba84f63583adcc4e4a62f75 Mon Sep 17 00:00:00 2001 From: Coleman Watts Date: Wed, 9 Nov 2022 16:26:30 -0500 Subject: [PATCH] Afform - Server-side validation of autocomplete field values --- .../core/Civi/Api4/Action/Afform/Submit.php | 64 ++++++ .../Subscriber/AutocompleteSubscriber.php | 17 +- ext/afform/core/afform.php | 3 +- ext/afform/core/ang/af/afField.component.js | 4 + .../api/v4/AfformAutocompleteUsageTest.php | 188 +++++++++++++++++- 5 files changed, 266 insertions(+), 10 deletions(-) diff --git a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php index f9c301cb1b..c502c51026 100644 --- a/ext/afform/core/Civi/Api4/Action/Afform/Submit.php +++ b/ext/afform/core/Civi/Api4/Action/Afform/Submit.php @@ -153,6 +153,36 @@ class Submit extends AbstractProcessor { } } + /** + * Validate all fields of type "EntityRef" contain values that are allowed by filters + * + * @param \Civi\Afform\Event\AfformValidateEvent $event + */ + public static function validateEntityRefFields(AfformValidateEvent $event): void { + $formName = $event->getAfform()['name']; + foreach ($event->getFormDataModel()->getEntities() as $entityName => $entity) { + $entityValues = $event->getEntityValues()[$entityName] ?? []; + foreach ($entityValues as $values) { + foreach ($entity['fields'] as $fieldName => $attributes) { + $error = self::getEntityRefError($formName, $entityName, $entity['type'], $fieldName, $attributes, $values['fields'][$fieldName] ?? NULL); + if ($error) { + $event->setError($error); + } + } + foreach ($entity['joins'] as $joinEntity => $join) { + foreach ($values['joins'][$joinEntity] ?? [] as $joinIndex => $joinValues) { + foreach ($join['fields'] ?? [] as $fieldName => $attributes) { + $error = self::getEntityRefError($formName, $entityName . '+' . $joinEntity, $joinEntity, $fieldName, $attributes, $joinValues[$fieldName] ?? NULL); + if ($error) { + $event->setError($error); + } + } + } + } + } + } + } + /** * If a required field is missing a value, return an error message * @@ -180,6 +210,40 @@ class Submit extends AbstractProcessor { return NULL; } + /** + * Return an error if an EntityRef field is submitted with a value outside the range of its savedSearch filters + * + * @param string $formName + * @param string $entityName + * @param string $apiEntity + * @param string $fieldName + * @param array $attributes + * @param mixed $value + * @return string|null + */ + private static function getEntityRefError(string $formName, string $entityName, string $apiEntity, string $fieldName, $attributes, $value) { + $values = array_filter((array) $value); + // If we have no values, continue + if (!$values) { + return NULL; + } + $fullDefn = FormDataModel::getField($apiEntity, $fieldName, 'create'); + $fieldType = $attributes['defn']['input_type'] ?? $fullDefn['input_type']; + $fkEntity = $attributes['defn']['fk_entity'] ?? $fullDefn['fk_entity'] ?? $apiEntity; + if ($fieldType === 'EntityRef') { + $result = (array) civicrm_api4($fkEntity, 'autocomplete', [ + 'ids' => $values, + 'formName' => "afform:$formName", + 'fieldName' => "$entityName:$fieldName", + ]); + if (count($result) < count($values) || array_diff($values, array_column($result, 'id'))) { + $label = $attributes['defn']['label'] ?? FormDataModel::getField($apiEntity, $fieldName, 'create')['label']; + return E::ts('Illegal value for %1.', [1 => $label]); + } + } + return NULL; + } + /** * Replace Entity reference fields with the id of the referenced entity. * @param string $entityName diff --git a/ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php b/ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php index 88af00a26f..33db7766d4 100644 --- a/ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php +++ b/ext/afform/core/Civi/Api4/Subscriber/AutocompleteSubscriber.php @@ -73,13 +73,24 @@ class AutocompleteSubscriber implements EventSubscriberInterface { return; } $formDataModel = new FormDataModel($afform['layout']); + [$entityName, $joinEntity] = array_pad(explode('+', $entityName), 2, NULL); $entity = $formDataModel->getEntity($entityName); - $isId = $fieldName === CoreUtil::getIdFieldName($entity['type']); - $fieldSpec = civicrm_api4($entity['type'], 'getFields', [ + + // If using a join (e.g. Contact -> Email) + if ($joinEntity) { + $apiEntity = $joinEntity; + $isId = FALSE; + $formField = $entity['joins'][$joinEntity]['fields'][$fieldName]['defn'] ?? []; + } + else { + $apiEntity = $entity['type']; + $isId = $fieldName === CoreUtil::getIdFieldName($apiEntity); + $formField = $entity['fields'][$fieldName]['defn'] ?? []; + } + $fieldSpec = civicrm_api4($apiEntity, 'getFields', [ 'checkPermissions' => FALSE, 'where' => [['name', '=', $fieldName]], ])->first(); - $formField = $entity['fields'][$fieldName]['defn'] ?? []; // Auto-add filters defined in schema foreach ($fieldSpec['input_attrs']['filter'] ?? [] as $key => $value) { diff --git a/ext/afform/core/afform.php b/ext/afform/core/afform.php index 5041b7509d..da9d220a7c 100644 --- a/ext/afform/core/afform.php +++ b/ext/afform/core/afform.php @@ -49,7 +49,8 @@ function afform_civicrm_config(&$config) { Civi::$statics[__FUNCTION__] = 1; $dispatcher = Civi::dispatcher(); - $dispatcher->addListener('civi.afform.validate', ['\Civi\Api4\Action\Afform\Submit', 'validateRequiredFields'], 10); + $dispatcher->addListener('civi.afform.validate', ['\Civi\Api4\Action\Afform\Submit', 'validateRequiredFields'], 50); + $dispatcher->addListener('civi.afform.validate', ['\Civi\Api4\Action\Afform\Submit', 'validateEntityRefFields'], 45); $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'processGenericEntity'], 0); $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'preprocessContact'], 10); $dispatcher->addListener('civi.afform.submit', ['\Civi\Api4\Action\Afform\Submit', 'processRelationships'], 1); diff --git a/ext/afform/core/ang/af/afField.component.js b/ext/afform/core/ang/af/afField.component.js index 6bdece3888..4de910d9ff 100644 --- a/ext/afform/core/ang/af/afField.component.js +++ b/ext/afform/core/ang/af/afField.component.js @@ -205,6 +205,10 @@ }; }; + ctrl.getAutocompleteFieldName = function() { + return ctrl.afFieldset.modelName + (ctrl.afJoin ? ('+' + ctrl.afJoin.entity) : '') + ':' + ctrl.fieldName; + }; + $scope.getOptions = function () { return chainSelectOptions || ctrl.defn.options || (ctrl.fieldName === 'is_primary' && ctrl.defn.input_type === 'Radio' ? noOptions : boolOptions); }; diff --git a/ext/afform/mock/tests/phpunit/api/v4/AfformAutocompleteUsageTest.php b/ext/afform/mock/tests/phpunit/api/v4/AfformAutocompleteUsageTest.php index 00dab56e67..43b88fcf5a 100644 --- a/ext/afform/mock/tests/phpunit/api/v4/AfformAutocompleteUsageTest.php +++ b/ext/afform/mock/tests/phpunit/api/v4/AfformAutocompleteUsageTest.php @@ -11,7 +11,7 @@ use Civi\Api4\GroupContact; class api_v4_AfformAutocompleteUsageTest extends api_v4_AfformUsageTestCase { /** - * Tests creating a relationship between multiple contacts + * Ensure that Afform restricts autocomplete results when it's set to use a SavedSearch */ public function testAutocompleteWithSavedSearchFilter(): void { $layout = << 'Yes', 'first_name' => 'A'], ['source' => 'No', 'first_name' => 'C'], ]; - Contact::save(FALSE) + $contacts = Contact::save(FALSE) ->setRecords($sampleContacts) ->addDefault('last_name', $lastName) - ->execute(); + ->execute()->indexBy('first_name')->column('id'); $result = Contact::autocomplete() ->setFormName('afform:' . $this->formName) @@ -70,8 +70,48 @@ EOHTML; $this->assertCount(2, $result); $this->assertEquals('A ' . $lastName, $result[0]['label']); $this->assertEquals('B ' . $lastName, $result[1]['label']); + + // Ensure form validates submission, restricting it to contacts A & B + $values = [ + 'Individual1' => [ + [ + 'fields' => [ + 'first_name' => 'Changed', + // Not allowed because contact C doesn't meet filter criteria + 'id' => $contacts['C'], + ], + ], + ], + ]; + try { + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + $this->fail(); + } + catch (CRM_Core_Exception $e) { + $this->assertEquals('Validation Error', $e->getMessage()); + } + + // Submit with a valid ID, it should work + $values['Individual1'][0]['fields']['id'] = $contacts['B']; + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + + // Verify one contact was changed + $check = Contact::get(FALSE) + ->addWhere('first_name', '=', 'Changed') + ->addWhere('last_name', '=', $lastName) + ->selectRowCount()->execute(); + $this->assertCount(1, $check); } + /** + * Ensure Afform enforces group filter set on a custom contact reference field + */ public function testCustomContactRefFieldWithGroupsFilter(): void { $lastName = uniqid(__FUNCTION__); @@ -83,13 +123,14 @@ EOHTML; $contacts = Contact::save(FALSE) ->setRecords($sampleData) - ->execute(); + ->execute()->indexBy('first_name')->column('id'); + // Place contacts A & B in the group, but not contact C $group = \Civi\Api4\Group::create(FALSE) ->addValue('name', $lastName) ->addValue('title', $lastName) - ->addChain('A', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts[0]['id'])) - ->addChain('B', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts[1]['id'])) + ->addChain('A', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts['A'])) + ->addChain('B', GroupContact::create()->addValue('group_id', '$id')->addValue('contact_id', $contacts['B'])) ->execute()->single(); \Civi\Api4\CustomGroup::create(FALSE) @@ -108,6 +149,7 @@ EOHTML;
+
@@ -128,6 +170,140 @@ EOHTML; $this->assertCount(2, $result); $this->assertEquals('A ' . $lastName, $result[0]['label']); $this->assertEquals('B ' . $lastName, $result[1]['label']); + + // Ensure form validates submission, restricting it to contacts A & B + $values = [ + 'Individual1' => [ + [ + 'fields' => [ + 'first_name' => 'Testy', + // Not allowed because contact C doesn't meet filter criteria + 'test_af_fields.contact_ref' => $contacts['C'], + ], + ], + ], + ]; + try { + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + $this->fail(); + } + catch (CRM_Core_Exception $e) { + $this->assertEquals('Validation Error', $e->getMessage()); + } + + // Submit with a valid ID, it should work + $values['Individual1'][0]['fields']['test_af_fields.contact_ref'] = $contacts['B']; + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + + // Verify contact was saved with custom value + $check = Contact::get(FALSE) + ->addWhere('test_af_fields.contact_ref', '=', $contacts['B']) + ->selectRowCount()->execute(); + $this->assertCount(1, $check); + } + + /** + * Ensure autocomplete contact reference fields work on a join entity + */ + public function testCustomContactRefFieldOnJoinEntity(): void { + $lastName = uniqid(__FUNCTION__); + + $sampleData = [ + ['last_name' => $lastName, 'first_name' => 'A', 'source' => 'in'], + ['last_name' => $lastName, 'first_name' => 'B', 'source' => 'out'], + ['last_name' => $lastName, 'first_name' => 'C', 'source' => 'in'], + ]; + + $contacts = Contact::save(FALSE) + ->setRecords($sampleData) + ->execute()->indexBy('first_name')->column('id'); + + \Civi\Api4\CustomGroup::create(FALSE) + ->addValue('title', 'test_address_fields') + ->addValue('extends', 'Address') + ->addChain('fields', \Civi\Api4\CustomField::save() + ->addDefault('custom_group_id', '$id') + ->setRecords([ + ['label' => 'contact_ref', 'data_type' => 'ContactReference', 'html_type' => 'Autocomplete', 'filter' => 'action=get&source=in'], + ]) + ) + ->execute(); + + $layout = << + +
+
+ +
+ + +
+
+
+ +EOHTML; + + $this->useValues([ + 'layout' => $layout, + 'permission' => CRM_Core_Permission::ALWAYS_ALLOW_PERMISSION, + ]); + + $result = Contact::autocomplete() + ->setFormName('afform:' . $this->formName) + ->setFieldName('Individual1+Address:test_address_fields.contact_ref') + ->setInput($lastName) + ->execute(); + + $this->assertCount(2, $result); + $this->assertEquals('A ' . $lastName, $result[0]['label']); + $this->assertEquals('C ' . $lastName, $result[1]['label']); + + // Ensure form validates submission, restricting it to contacts A & C + $values = [ + 'Individual1' => [ + [ + 'fields' => [ + 'first_name' => 'Testy', + ], + 'joins' => [ + 'Address' => [ + // Not allowed because contact B doesn't meet filter criteria + ['test_address_fields.contact_ref' => $contacts['B']], + ], + ], + ], + ], + ]; + try { + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + $this->fail(); + } + catch (CRM_Core_Exception $e) { + $this->assertEquals('Validation Error', $e->getMessage()); + } + + // Submit with a valid ID, it should work + $values['Individual1'][0]['joins']['Address'][0]['test_address_fields.contact_ref'] = $contacts['A']; + Civi\Api4\Afform::submit() + ->setName($this->formName) + ->setValues($values) + ->execute(); + + // Verify contact was saved with custom value + $check = Contact::get(FALSE) + ->addWhere('address_primary.test_address_fields.contact_ref', '=', $contacts['A']) + ->selectRowCount()->execute(); + $this->assertCount(1, $check); } } -- 2.25.1