Repository navigation
PRE-3750: Add cardholder name and in-field network selector to the Hosted Fields card form - #339
Conversation
8993b8d to
72d116c
Compare
ilajili
left a comment
There was a problem hiding this comment.
Review of the Hosted Fields rework: no blocking finding, CI is green. Inline notes below are one Medium (Sonar S107) and a few Low points.
72d116c to
c3ee196
Compare
hdelaforce-payplug
left a comment
There was a problem hiding this comment.
Review
Nothing blocking: nothing here can lose or duplicate money, no admin action is exposed, the server-side checks add protection, and the capture path only changes where fullName comes from. Approve once a JSON 400 for headless shops (Low 2) and the wording fix (Low 1) are in.
Checks run on c3ee196 (PHP 8.3):
- PHPStan (level max): no errors
- ECS on
srcandtests: clean - PHPUnit, touched tests (
PaymentProcessing,PostPaymentSelectEventSubscriberTest,CaptureHostedPaymentRequestHandlerTest): 114 tests OK - PHPUnit, full suite: 796 tests, 1 failure in
UnifiedAuthenticationControllerTest(OAuth, not touched by this PR). It comes from my localvendor/, not from this branch. - Not run: PHPMD, Behat, browser checks
| Area | Rating |
|---|---|
| Security | Good |
| Payment correctness | Good |
| Sylius / architecture | Good |
| General correctness | Some concerns (low) |
| Non-PHP files | Good |
| Performance | Good |
| Maintainability | Good |
Low
-
"Nothing is written" is not quite true. The PR description and the CHANGELOG say the 400 comes "before anything is written on the payment". The guard (
PostPaymentSelectEventSubscriber::buildHostedFieldsCaptureData()) runs onsylius.order.post_payment. By then,ResourceController::updateAction()has already saved the selected payment method and the order'sselect_paymentstep. What is true is that this subscriber writes nothing: no details, nocomplete. A crafted POST leaves the order atpayment_selected, which is harmless.
Suggestion: change the wording to "before the Hosted Fields details are stored or the checkout is completed". -
Raw 400 page. The
BadRequestHttpExceptionshows Symfony's error page. A legitimate shopper only gets there in two cases: a checkout page rendered before the deploy (old JS, no name field), or a theme override. A headless/SPA front end gets HTML rather than JSON, which goes against the repo's headless-compliance rule. That rule targets controllers, so this is borderline.
Suggestion: either accept it, since the CHANGELOG already warns theme owners, or answer headless shops with a JSON 400 carrying an error field. -
The browser and the server apply the same rule only in practice. JS
trim()(holderName()) strips Unicode spaces such as the non-breaking space; PHP\trim()strips ASCII spaces only. The two agree because the JS posts the value already trimmed. A name typed in decomposed form ("É" stored as two code points) counts 2 on both sides, and we don't know whether the Unified API's 45 limit counts the same way.
Suggestion (optional):.normalize('NFC')inholderName(). -
The typed name stays in the payment details (
hosted_fields_card_holder_name, set byNullHostedFieldsPaymentProcessor) indefinitely. The billing name is already stored, so this adds little exposure. Worth knowing if paymentdetailsare exposed through the API. -
The fallback name is not checked against 5–45. For payments started before the upgrade,
CaptureHostedPaymentRequestHandler::buildHostedFieldDto()still sends the billing or customer name with no length check. A billing name under 5 characters could be refused by the API. This existed before the PR and only matters during the upgrade window.
Non-PHP files
- Twig:
- Values are escaped with
e('js')ande('html_attr'). json_encode|rawis applied only to a PHP constant.%min%/%max%are filled.- The hidden input is present.
- The holder input has no
name, which avoids triggering the checkout's live component. - The
aria-*wiring is consistent.
- Values are escaped with
- Translations (en/fr/it): the same 4 keys are added and the same 2 removed in all three. Placeholders match, and no other file still uses
network_selection.*. - JS:
- No console leftovers.
- Observers are disconnected in
disconnect(). - The label positioning ignores its own style writes.
- Enter in the name field goes through the pay button's path.
- A theme without the name field is blocked, which is the documented breaking change.
- CSS: the old
#info-containerhiding rules are replaced by the new placement logic, and the holder font matches the iframe fonts. - CHANGELOG: entries sit under the right headings, with the breaking-changes table and a warning for theme owners. Wording fix in Low 1.
Positives
- Moving the brand list to a single
HostedFieldsCaptureData::ALLOWED_BRANDS, now shared byPayplugCardPersisterand the template, removes a list that could drift. Before, a crafted POST could send any brand to the Unified API. - The rejection log names the field and never logs the value, and tests check that the 400 path never calls
setDetails,process,applyorflush. whenVisiblefixes a real broken-iframe bug on preselected methods.- A very thorough PR description: the 18 end-to-end checks, the "not done" list, and the
fullName-without-saveFutureUsagequestion answered on staging.
hdelaforce-payplug
left a comment
There was a problem hiding this comment.
Inline comments for the review above. That summary was written against an earlier version of this branch: points 2 (raw 400 page) and 3 (PHP trim() vs JS trim()) are already handled in c3ee196 (redirect back to the payment step with a flash, Unicode-aware trimming). The remaining points are below, all low or nits. Nothing blocking.
dda22f7 to
c20a113
Compare
…sted Fields card form
c20a113 to
167ca49
Compare
Description
The Hosted Fields (UHF) card form at checkout is reworked to match the mockup attached to the ticket (Dalenys Hosted Fields lib v2.2.0): a cardholder name field, the network selector inside the card number field, vertically centred text, and per-field valid/invalid states.
autocomplete="cc-name") is added above the card number. It is required and must be 5 to 45 characters after trimming, counted in characters (mb_strlen), valid UTF-8 and free of control characters. Both sides compose it (NFC) before trimming it like the browser'sString.prototype.trim()(Unicode spaces such as the no-break space and the BOM included), so both count the characters the shopper sees.hostedfields_card_holder_name, stored in the payment details ashosted_fields_card_holder_name, and sent to the Unified API aspaymentMethod.details.fullName.HostedFieldsCaptureData.selectedBrandis trimmed and upper-cased, and must be one ofCB,VISA,MASTERCARD(HostedFieldsCaptureData::ALLOWED_BRANDS). It used to be checked only in the browser, so a crafted POST could send any string to the Unified API.InvalidHostedFieldsSubmissionException.PostPaymentSelectEventSubscriberlogs a warning that names the field but never the value, then sends the shopper back to the payment step with an error flash (en/fr/it), before the Hosted Fields details are stored or the checkout is completed (the payment method the shopper chose is already saved byResourceController::updateAction(), which is harmless). A non-HTML request (headless front end) gets a 400 instead, since the controller answers it with its REST view before reading the event response. Sylius renders no flashes on that step, so the plugin adds@SyliusShop/shared/flashes.html.twigto thesylius_shop.checkout.select_payment.contenttwig hook.brandfield is overlaid on the right of the card number field, withuseInlineSelection: true. It stays empty until a card is typed.#info-containerto<body>and positions it once, without reserving space.MutationObservernow places it under the card field and reserves the space while it is shown.disconnect()removes it, so a re-rendered form does not leave a stale second label on screen.line-heightof 46px, so the text is vertically centred.font-familycontains quotes.aria-live="polite", and the holder input hasaria-describedbyandaria-invalid.ResizeObserver). Before, a preselected method, or one chosen before its wrapper was shown, mounted the cross-origin iframes inside adisplay:nonecontainer.card_holder.label,card_holder.placeholder,error.card_holder_required,error.card_holder_length(with%min%/%max%).network_selection.labelandnetwork_selection.tooltip.Motivation: the current form has no cardholder name field, although the Unified API needs
details.fullNameto save a card. The network logos are not tied to the detected card, the co-branded selector is rendered outside the card field, and the typed text is stuck to the top of the fields in a different font.Related issue(s): PRE-3750. Follows PRE-3709 (co-branded network selector fix).
Type of Change
Checklist
Code Quality
Testing
sonarcloudCI jobSecurity & Ops
Notes for reviewer
Breaking changes. All are documented in the CHANGELOG
2.0.0"Breaking changes for anyone extending the plugin" table. 2.0.0 is still unreleased.HostedFieldsCaptureData::__construct()is now($hfToken, $selectedBrand, $saveCard, HostedFieldsCardDetails $card, $cardHolderName): the card metadata moved to the newHostedFieldsCardDetailsvalue object ($captureData->card->last4, etc.), which keeps the constructor under Sonarphp:S107, and the cardholder name is a new required argument. It now validates the brand and the name.PostPaymentSelectEventSubscriber::__construct()now also takes aLoggerInterface, which is autowired.ui.hosted_fields.network_selection.labeland.tooltipare removed.shop/hosted_fields/index.html.twigmust add the cardholder input and the hiddenhostedfields_card_holder_nameinput. Otherwise the checkout is sent back to the payment step with an error message.selectedBrandused to pass and is now refused.fullNamewithoutsaveFutureUsage. The ticket context saysfullNameis never sent withoutsaveFutureUsage, while its expected results say the typed name is used fordetails.fullName. The plugin already sentfullNamewhenever it had one, and this PR keeps that behaviour, with the typed name now taking priority over the billing name. Switching to "only withsaveFutureUsage" is a one-line change inCaptureHostedPaymentRequestHandler::buildHostedFieldDto(). The staging Unified API acceptsfullNamewithoutsaveFutureUsage(see the tests below).SDK options checked against the v2.2.0 bundle source.
localeis the option the lib reads. The mockup'slocationis ignored.useInlineSelectiondefaults tofalse, so it is set explicitly.stylewhitelist is: color, font-family, font-size, font-style, font-variant, font-weight, font-smooth, line-height, letter-spacing, text-decoration, text-shadow, text-transform, background-color.Tests. 52 PHPUnit tests are added:
HostedFieldsCaptureDataTest(new): bounds 4/5/45/46, NFC composition, trimming (Unicode spaces and BOM included), control characters, multibyte names, invalid UTF-8, unknown, empty and lowercase brands, the card details value object, and messages never containing the value.PostPaymentSelectEventSubscriberTest: each invalid submission redirects back to the payment step with the error flash (also without a session), a non-HTML request gets a 400 without a flash, with nosetDetails,process,applyorflush, and no value in the log; a padded lowercase brand is processed normalised.NullHostedFieldsPaymentProcessorTest: the name is stored and never logged.CaptureHostedPaymentRequestHandlerTest: the typed name wins, the billing name is the fallback, andsaveFutureUsageis sent with a typed name even when the billing name is empty.Local results (PHP 8.2):
developwith PRE-3677). This needs UPC 1.2.1 as pinned incomposer.lock; my localvendor/was stale on UPC 1.1.0.There is no JS test harness in the repo, so the controller and CSS are not covered by automated tests.
Browser and end-to-end tests performed. All tests ran in headless Chromium (Playwright) against the local Docker Sylius shop.
staging-internal-payment.gcp.dlns.io);PluginTestUHFMultideviseCredorax, paid in USD;4234600948280000co-branded CB/Visa,4022050523250000Visa,5186629195300000Mastercard.selectedBrand=VISAis postedHelvetica, Arial, sans-serif, 14px, line-height 46px in the card, expiry and CVV iframes, identical to the holder input." Jo "each show a message under the field, a red border andaria-invalid="true", with no tokenize call. 5 characters goes through.AMEX,"Jo B", a name with\n, NBSP +abcddetails: {"fullName":"Jean Dupont","selectedBrand":"VISA"}, API 200, order paid" Élodie Müller-Ñúñez "fullNameis trimmed and the accents are intact, both in the payload and in the payment details (valid UTF-8); API 200, order paidselectedBrand: "MASTERCARD", order paid"details":{"fullName":…,"selectedBrand":"VISA"},"saveFutureUsage":true; 3DS challenge passed; order paid; card saved as VISA ****0000payplug_card_choice=other)."capture":falsewithfullName; orderauthorized, thenpaidafter "Capturer" on the admin order screenNBSP + "Zoé Müller" + NBSPhostedfields_card_holder_name="Zoé Müller", order paid; tests 4 and 5 re-run at 1280 and 320px, all OKE+ combining accent)Zoé Mülleris posted composed (10 code points) and the VISA payment goes throughfullNamewithoutsaveFutureUsage(Q1) is answered. The staging Unified API accepts it (tests 13 to 15).Not done / known.
display:noneto visible in the same tick as their text, so some screen readers may not announce them.patternregex inside the expiry iframe;Cannot read properties of undefined (reading 'toggleClasses')when leaving the page.last4/exp_month/exp_year/country: still posted empty, becausecreateToken()does not return them under the field names the controller reads. This predates this PR, and the saved card still gets its last4 and expiry from the API.fullNameis not checked against 5–45. This predates the PR; tracked in PRE-3773.PAYPLUG_HOSTED_FIELDS_SDK_URLis set. Against the staging Unified API, a prod-SDK token is refused with "HFTOKEN has not been found or is expired", so staging setups must set that variable.fullNamewhenever the card was saved. Nothing in it is masked; masking it is tracked in PRE-3772.