From c8577a33f144c3e26685aa7718c2ad3c47de49ab Mon Sep 17 00:00:00 2001 From: Richard Baugh Date: Fri, 7 Aug 2026 10:22:31 -0500 Subject: [PATCH 1/2] adds af-if logic to PFV items where visibility_id is admin to limit showing in form normalize field --- .../Contribute/Service/CreateContribution.php | 15 ++ .../PriceOptionAvailabilityPublisher.php | 123 ++++++++++++++ .../Service/PriceOptionDefnInjector.php | 160 ++++++++++++++++++ .../Civi/Contribute/Utils/PriceFieldUtils.php | 42 +++++ 4 files changed, 340 insertions(+) create mode 100644 ext/civi_contribute/Civi/Contribute/Service/PriceOptionAvailabilityPublisher.php create mode 100644 ext/civi_contribute/Civi/Contribute/Service/PriceOptionDefnInjector.php diff --git a/ext/civi_contribute/Civi/Contribute/Service/CreateContribution.php b/ext/civi_contribute/Civi/Contribute/Service/CreateContribution.php index e83d20214fab..a32269b09c87 100644 --- a/ext/civi_contribute/Civi/Contribute/Service/CreateContribution.php +++ b/ext/civi_contribute/Civi/Contribute/Service/CreateContribution.php @@ -211,6 +211,14 @@ public function saveNewContribution(AfformSubmitEvent $event) { private function getLineItemsForRecord(string $entityType, array $values, array $priceFields): array { $lineItems = []; + // Authoritative gate for admin-visibility (non-public) price options. + // These stay in the option list (see PriceFieldUtils::fetchPriceFieldSpecs) + // and are hidden client-side by an af-if, but the client is not trusted: + // reject a restricted option submitted by a user who may not select it. + // Mirrors CRM_Contribute_Form_Contribution_Main::buildPriceSet(). + $restrictedOptionIds = PriceFieldUtils::getRestrictedPriceFieldValueIds(); + $mayUseRestricted = !$restrictedOptionIds || \CRM_Core_Permission::check('edit contributions'); + foreach ($values as $key => $fieldValue) { $priceField = array_find($priceFields, fn ($priceField) => $priceField['name'] === $key); if (!$priceField) { @@ -218,6 +226,13 @@ private function getLineItemsForRecord(string $entityType, array $values, array } // $fieldValue can be scalar or array foreach ((array) $fieldValue as $singleFieldValue) { + // Only guard genuine option selections (a PFV id present in this + // field's option list) - never a quantity/amount entered on a + // qty or Default Contribution Amount field. + $isOption = isset($priceField['options']) && \array_key_exists($singleFieldValue, $priceField['options']); + if ($isOption && !$mayUseRestricted && \in_array((int) $singleFieldValue, $restrictedOptionIds, TRUE)) { + throw new \CRM_Core_Exception(E::ts('You are not permitted to select one of the chosen options.')); + } $lineItems[] = PriceFieldUtils::getLineItemForPriceFieldValue($entityType, $values['id'] ?? NULL, $priceField, $singleFieldValue); } } diff --git a/ext/civi_contribute/Civi/Contribute/Service/PriceOptionAvailabilityPublisher.php b/ext/civi_contribute/Civi/Contribute/Service/PriceOptionAvailabilityPublisher.php new file mode 100644 index 000000000000..f2ef108dfae0 --- /dev/null +++ b/ext/civi_contribute/Civi/Contribute/Service/PriceOptionAvailabilityPublisher.php @@ -0,0 +1,123 @@ + ['onAfformPrefill', -10], + 'civi.api.respond' => ['onApiRespond', 0], + ]; + } + + public function onAfformPrefill(AfformPrefillEvent $event): void { + // Nothing to reveal if there are no admin-visibility options anywhere. + if (!PriceFieldUtils::getRestrictedPriceFieldValueIds()) { + return; + } + // Only price-bearing entities carry these options. + if (!in_array($event->getEntityType(), PriceFieldUtils::getEnabledEntities(), TRUE)) { + return; + } + + $requestId = spl_object_id($event->getApiRequest()); + $this->factsByRequest[$requestId][$event->getEntityName()] = [ + self::FLAG => \CRM_Core_Permission::check('edit contributions'), + ]; + } + + public function onApiRespond($event): void { + $apiRequest = $event->getApiRequest(); + if (!($apiRequest instanceof Prefill)) { + return; + } + $requestId = spl_object_id($apiRequest); + $facts = $this->factsByRequest[$requestId] ?? NULL; + unset($this->factsByRequest[$requestId]); + + if (!$facts) { + return; + } + $response = $event->getResponse(); + if (!$response instanceof Result) { + return; + } + + // The Result is an ArrayObject - getArrayCopy + exchangeArray is the + // supported mutation pattern. + $values = $response->getArrayCopy(); + + // Index existing response entries by entity name for quick lookup. + // Entries are missing from the response when no record was loaded for + // that entity (e.g. a fresh "create" form has no autofill). + $indexByName = []; + foreach ($values as $i => $entry) { + if (isset($entry['name'])) { + $indexByName[$entry['name']] = $i; + } + } + + foreach ($facts as $entityName => $entityFacts) { + if (!isset($indexByName[$entityName])) { + // Entity has no response entry - append one so the flag is present + // on the first (created-from-scratch) record. + $values[] = [ + 'name' => $entityName, + 'values' => [['fields' => $entityFacts, 'joins' => []]], + ]; + continue; + } + + $i = $indexByName[$entityName]; + if (empty($values[$i]['values'])) { + $values[$i]['values'] = [['fields' => [], 'joins' => []]]; + } + foreach ($values[$i]['values'] as $idx => $record) { + if (!isset($values[$i]['values'][$idx]['fields'])) { + $values[$i]['values'][$idx]['fields'] = []; + } + foreach ($entityFacts as $field => $value) { + $values[$i]['values'][$idx]['fields'][$field] = $value; + } + } + } + $response->exchangeArray($values); + } + +} diff --git a/ext/civi_contribute/Civi/Contribute/Service/PriceOptionDefnInjector.php b/ext/civi_contribute/Civi/Contribute/Service/PriceOptionDefnInjector.php new file mode 100644 index 000000000000..33850d2dcf37 --- /dev/null +++ b/ext/civi_contribute/Civi/Contribute/Service/PriceOptionDefnInjector.php @@ -0,0 +1,160 @@ + defn.options, so they are hidden client-side from users who may + * not see them. + * + * Admin price options stay in the option list (see + * PriceFieldUtils::fetchPriceFieldSpecs) because they are real, selectable + * options for privileged users. The baked afform markup is shared across all + * users (it is cached), so we cannot decide visibility here; instead we tag + * each restricted option with: + * + * if: [['[0][fields][has_all_price_options]', 'IS NOT EMPTY']] + * + * The `has_all_price_options` flag is published per-user at prefill time by + * PriceOptionAvailabilityPublisher. When it is absent/empty (the default, and + * the case for every user without 'edit contributions') the option is hidden. + * + * Client-side hiding is a UX affordance only: the authoritative gate is + * CreateContribution::getLineItemsForRecord, which re-checks the permission + * and rejects a restricted value regardless of what the browser submits. + * + * Runs at priority -100 so it executes AFTER AfformMetadataInjector (default + * priority 0) has populated defn.options - lower priority = later in Symfony + * EventDispatcher. Mirrors civicrm-payflowpro's OptionDefnInjector. + * + * @service civi.contribute.price_option_defn_injector + */ +class PriceOptionDefnInjector extends AutoService implements EventSubscriberInterface { + + public static function getSubscribedEvents(): array { + return [ + 'hook_civicrm_alterAngular' => ['preprocess', -100], + ]; + } + + /** + * @param \Civi\Core\Event\GenericHookEvent $e + * + * @see CRM_Utils_Hook::alterAngular() + */ + public function preprocess(\Civi\Core\Event\GenericHookEvent $e): void { + $restrictedIds = PriceFieldUtils::getRestrictedPriceFieldValueIds(); + if (!$restrictedIds) { + return; + } + // Restrict the walk to fields that are actually price fields, so we never + // mistake an unrelated field's option id for a PriceFieldValue id. + $priceFieldNames = self::getPriceFieldNames(); + if (!$priceFieldNames) { + return; + } + // Fast lookup sets. + $restricted = array_fill_keys($restrictedIds, TRUE); + + $changeSet = \Civi\Angular\ChangeSet::create('priceOptionConditionals') + ->alterHtml(';\\.aff\\.html$;', function ($doc, $path) use ($priceFieldNames, $restricted) { + foreach (pq('af-field', $doc) as $afField) { + /** @var \DOMElement $afField */ + $name = $afField->getAttribute('name'); + if ($name === '') { + continue; + } + // The af-field name may carry a ':name' pseudoconstant suffix or a + // ','-joined range; the price-field spec name has neither. Normalise + // the same way core does (strip the comma-join, then the suffix) + // before matching. + $baseName = explode(':', explode(',', $name)[0])[0]; + if (!isset($priceFieldNames[$baseName])) { + continue; + } + $this->amendField($afField, $restricted); + } + }); + $e->angular->add($changeSet); + } + + /** + * Read the field's defn, attach the visibility `if:` to any option whose id + * is a restricted (admin) PriceFieldValue, write defn back. No-op if the + * field has no options or none are restricted. + * + * @param \DOMElement $afField + * @param array $restricted + * Restricted PriceFieldValue ids as a lookup set. + * + * @throws \Exception + */ + protected function amendField(\DOMElement $afField, array $restricted): void { + $existing = trim(pq($afField)->attr('defn') ?: ''); + // If the markup author wrote a non-object defn, leave it alone - same + // posture as AfformMetadataInjector::setFieldMetadata. + if ($existing && $existing[0] !== '{') { + return; + } + + $rawDefn = $existing ? \CRM_Utils_JS::getRawProps($existing) : []; + if (empty($rawDefn['options'])) { + return; + } + + $options = \CRM_Utils_JS::decode($rawDefn['options']); + if (!is_array($options)) { + return; + } + + // Resolve the containing entity name so the af-if can read the flag off + // the right record (e.g. Contribution1[0][fields][has_all_price_options]). + $entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset'); + if (!$entityName) { + return; + } + $lhs = $entityName . '[0][fields][' . PriceFieldUtils::RESTRICTED_OPTIONS_FLAG . ']'; + + $changed = FALSE; + foreach ($options as &$opt) { + // Options here are the verbose [{id, label, ...}] form (afform loads + // them with loadOptions => [id, label, ...]). + if (!isset($opt['id']) || !empty($opt['if'])) { + continue; + } + if (isset($restricted[(int) $opt['id']])) { + $opt['if'] = [[$lhs, 'IS NOT EMPTY']]; + $changed = TRUE; + } + } + unset($opt); + + if (!$changed) { + return; + } + $rawDefn['options'] = \CRM_Utils_JS::encode($options); + pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($rawDefn), ENT_COMPAT)); + } + + /** + * All price-field full names (across every price-bearing entity), as a + * lookup set keyed by name. These are the only names whose + * option ids are PriceFieldValue ids. + * + * @return array + */ + protected static function getPriceFieldNames(): array { + $names = []; + foreach (PriceFieldUtils::getPriceFieldSpecs() as $entitySpecs) { + foreach (array_keys($entitySpecs) as $fullName) { + $names[$fullName] = TRUE; + } + } + return $names; + } + +} diff --git a/ext/civi_contribute/Civi/Contribute/Utils/PriceFieldUtils.php b/ext/civi_contribute/Civi/Contribute/Utils/PriceFieldUtils.php index 69a3cfec0c24..f0d62bdf7976 100644 --- a/ext/civi_contribute/Civi/Contribute/Utils/PriceFieldUtils.php +++ b/ext/civi_contribute/Civi/Contribute/Utils/PriceFieldUtils.php @@ -4,6 +4,14 @@ class PriceFieldUtils { + /** + * Synthetic afform field that carries whether the current user may see + * admin-visibility (non-public) price options. Published per price-bearing + * entity by PriceOptionAvailabilityPublisher and referenced by the af-if + * that PriceOptionDefnInjector attaches to restricted options. + */ + const RESTRICTED_OPTIONS_FLAG = 'has_all_price_options'; + /** * @return string[] entities for which payments are enabled */ @@ -42,6 +50,36 @@ public static function getPriceFieldsForEntity(string $entity): array { return self::getPriceFieldSpecs()[$entity] ?? []; } + /** + * IDs of active PriceFieldValues whose visibility is "admin". + * + * These are legitimate, selectable options that must only be offered to - + * and accepted from - users who may see admin price options. This mirrors + * the option-level gate core QuickForm applies in + * CRM_Contribute_Form_Contribution_Main::buildPriceSet() (which drops admin + * options for users lacking 'edit contributions'). + * + * This is the authoritative server-side allow-list, used both to inject the + * client-side af-if that hides these options and to reject a crafted + * submission from an unprivileged user. + * + * @return int[] + * @throws \CRM_Core_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + public static function getRestrictedPriceFieldValueIds(): array { + $cacheKey = __CLASS__ . '::restrictedPriceFieldValueIds'; + if (!isset(\Civi::$statics[$cacheKey])) { + \Civi::$statics[$cacheKey] = array_map('intval', (array) \Civi\Api4\PriceFieldValue::get(FALSE) + ->addSelect('id') + ->addWhere('is_active', '=', TRUE) + ->addWhere('visibility_id:name', '=', 'admin') + ->execute() + ->column('id')); + } + return \Civi::$statics[$cacheKey]; + } + public static function getPriceFieldSpecs(): array { if (!isset(\Civi::$statics[__CLASS__])) { \Civi::$statics[__CLASS__] = self::fetchPriceFieldSpecs(); @@ -49,6 +87,10 @@ public static function getPriceFieldSpecs(): array { return \Civi::$statics[__CLASS__]; } + /** + * @throws \CRM_Core_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ protected static function fetchPriceFieldSpecs(): array { $priceFields = (array) \Civi\Api4\PriceField::get(FALSE) ->addSelect('id', 'name', 'label', 'html_type', 'is_enter_qty', 'is_display_amounts') From f73d30bed37e7b6139f2b84e1883fd46fc6e58e5 Mon Sep 17 00:00:00 2001 From: Richard Baugh Date: Fri, 7 Aug 2026 11:37:38 -0500 Subject: [PATCH 2/2] add unittest --- .../Contribute/AfformContributionTest.php | 205 +++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/ext/civi_contribute/tests/phpunit/Civi/Contribute/AfformContributionTest.php b/ext/civi_contribute/tests/phpunit/Civi/Contribute/AfformContributionTest.php index 1f1a6fc9d2a1..ecb0437020b0 100644 --- a/ext/civi_contribute/tests/phpunit/Civi/Contribute/AfformContributionTest.php +++ b/ext/civi_contribute/tests/phpunit/Civi/Contribute/AfformContributionTest.php @@ -105,6 +105,7 @@ public function setUp(): void { // reset the price field cache // TODO: this should probably be included in post hook unset(\Civi::$statics[PriceFieldUtils::class]); + unset(\Civi::$statics[PriceFieldUtils::class . '::restrictedPriceFieldValueIds']); $layout = << @@ -148,7 +149,7 @@ public function setUp(): void { public function tearDown(): void { \Civi\Api4\PriceFieldValue::delete(FALSE) - ->addWhere('name', 'IN', ['in_person', 'online']) + ->addWhere('name', 'IN', ['in_person', 'online', 'admin_only']) //->addWhere('id', '>', 0) ->execute(); @@ -322,4 +323,206 @@ public function testEntityDependencyOrdering() { $this->assertEquals($expected, $sorted); } + /** + * An active "admin" visibility PriceFieldValue is a restricted option: it + * stays in the option list (unlike an inactive value) but is reported by + * getRestrictedPriceFieldValueIds(); public values are not. + * + * @throws \CRM_Core_Exception + */ + public function testGetRestrictedPriceFieldValueIds(): void { + $adminPfvId = $this->addAdminTicketOption(); + + $restricted = PriceFieldUtils::getRestrictedPriceFieldValueIds(); + $this->assertContains($adminPfvId, $restricted); + // The public "In person" value must NOT be treated as restricted. + $this->assertNotContains($this->inPersonPriceFieldValueId, $restricted); + } + + /** + * A user without 'edit contributions' may not submit an admin-visibility + * price option, even though it is present in the option list. + * + * @throws \CRM_Core_Exception + */ + public function testAdminPriceOptionRejectedWithoutPermission(): void { + $adminPfvId = $this->addAdminTicketOption(); + + $permission = \CRM_Core_Config::singleton()->userPermissionClass; + $backup = $permission->permissions; + // Grant enough to run the form, but NOT 'edit contributions'. + $permission->permissions = ['access CiviCRM', 'access CiviContribute', 'access CiviEvent']; + try { + Afform::submit(FALSE) + ->setName('testAfformContribution') + ->setValues([ + 'Individual1' => [['fields' => ['first_name' => 'Test', 'last_name' => 'Contact']]], + 'Participant1' => [ + [ + 'fields' => [ + 'event_id' => $this->eventId, + 'participant_fields.ticket_option' => $adminPfvId, + ], + ], + ], + 'Contribution1' => [['fields' => ['source' => 'restricted']]], + ]) + ->execute(); + $this->fail('Afform::submit should have rejected the restricted price option'); + } + catch (\CRM_Core_Exception $e) { + $this->assertStringContainsString('not permitted', $e->getMessage()); + } + finally { + $permission->permissions = $backup; + } + } + + /** + * A user with 'edit contributions' may submit an admin-visibility price + * option and it produces a line item. + * + * @throws \CRM_Core_Exception + */ + public function testAdminPriceOptionAllowedWithPermission(): void { + $adminPfvId = $this->addAdminTicketOption(20); + + // Default headless permissions (NULL) grant everything, including + // 'edit contributions'. + $response = Afform::submit(FALSE) + ->setName('testAfformContribution') + ->setValues([ + 'Individual1' => [['fields' => ['first_name' => 'Admin', 'last_name' => 'User']]], + 'Participant1' => [ + [ + 'fields' => [ + 'event_id' => $this->eventId, + 'participant_fields.ticket_option' => $adminPfvId, + ], + ], + ], + 'Contribution1' => [['fields' => ['source' => 'adminAllowed']]], + ]) + ->execute(); + + $contributionId = $response->single()['Contribution1'][0]['id']; + $this->assertGreaterThan(0, $contributionId); + + $contribution = \Civi\Api4\Contribution::get(FALSE) + ->addWhere('id', '=', $contributionId) + ->execute() + ->single(); + // Only the admin ticket option (amount 20) contributes to the total. + $this->assertEquals(20, $contribution['total_amount']); + } + + /** + * The prefill response carries `has_all_price_options` on each price-bearing + * entity, TRUE only when the current user holds 'edit contributions'. This is + * what the client-side af-if reads to decide whether to show admin options. + * + * @throws \CRM_Core_Exception + */ + public function testPrefillPublishesRestrictedFlag(): void { + $this->addAdminTicketOption(); + $flag = PriceFieldUtils::RESTRICTED_OPTIONS_FLAG; + + // Default headless permissions (NULL) grant everything => flag TRUE. + $prefill = Afform::prefill(FALSE) + ->setName('testAfformContribution') + ->execute() + ->indexBy('name'); + $this->assertTrue((bool) ($prefill['Participant1']['values'][0]['fields'][$flag] ?? NULL)); + $this->assertTrue((bool) ($prefill['Contribution1']['values'][0]['fields'][$flag] ?? NULL)); + + // Without 'edit contributions' => flag FALSE. + $permission = \CRM_Core_Config::singleton()->userPermissionClass; + $backup = $permission->permissions; + $permission->permissions = ['access CiviCRM', 'access CiviContribute', 'access CiviEvent']; + try { + $prefill = Afform::prefill(FALSE) + ->setName('testAfformContribution') + ->execute() + ->indexBy('name'); + $this->assertFalse((bool) ($prefill['Participant1']['values'][0]['fields'][$flag] ?? NULL)); + } + finally { + $permission->permissions = $backup; + } + } + + /** + * The alterAngular injector tags an admin option with an `if:` bound to the + * flag - even when the price field is referenced with a ':name' suffix (the + * real-world markup form). This is the regression guard for suffix matching. + * + * @throws \CRM_Core_Exception + */ + public function testInjectorTagsAdminOptionWithNameSuffix(): void { + $adminPfvId = $this->addAdminTicketOption(); + + // Reference the price field WITH a ':name' suffix, as real forms do. + $layout = << + + +
+ + +
+ + HTML; + Afform::save(FALSE) + ->addRecord(['name' => 'testAfformPriceSuffix', 'layout' => $layout]) + ->setLayoutFormat('html') + ->execute(); + + $moduleName = Afform::get(FALSE) + ->addWhere('name', '=', 'testAfformPriceSuffix') + ->addSelect('module_name') + ->execute() + ->single()['module_name']; + + // Fresh Manager so alterAngular (and thus our injector) recomputes against + // the admin PriceFieldValue just created, rather than a cached change set. + $manager = new \Civi\Angular\Manager(\CRM_Core_Resources::singleton()); + $html = implode("\n", $manager->getPartials($moduleName)); + + // The suffixed field was matched and the admin option carries the if:. + $this->assertStringContainsString(PriceFieldUtils::RESTRICTED_OPTIONS_FLAG, $html); + $this->assertStringContainsString('IS NOT EMPTY', $html); + // The admin PFV id must appear in the baked options (it is not filtered out). + $this->assertStringContainsString((string) $adminPfvId, $html); + } + + /** + * Add an active admin-visibility PriceFieldValue to the participant + * ticket_option field and refresh the PriceFieldUtils caches. + * + * @throws \CRM_Core_Exception + */ + private function addAdminTicketOption(float $amount = 20): int { + $ticketFieldId = \Civi\Api4\PriceField::get(FALSE) + ->addWhere('name', '=', 'ticket_option') + ->execute() + ->single()['id']; + + $pfv = \Civi\Api4\PriceFieldValue::create(FALSE) + ->addValue('name', 'admin_only') + ->addValue('label', 'Admin only') + ->addValue('amount', $amount) + ->addValue('price_field_id', $ticketFieldId) + ->addValue('financial_type_id:name', 'Event Fee') + ->addValue('visibility_id:name', 'admin') + ->execute() + ->single(); + + // The specs + restricted-id lists are statically cached; refresh both so + // the new option is visible to this request. + unset(\Civi::$statics[PriceFieldUtils::class]); + unset(\Civi::$statics[PriceFieldUtils::class . '::restrictedPriceFieldValueIds']); + + return (int) $pfv['id']; + } + }