diff --git a/packages/apps/braspag/README.md b/packages/apps/braspag/README.md index ee9ba4bc2..da03b064a 100644 --- a/packages/apps/braspag/README.md +++ b/packages/apps/braspag/README.md @@ -26,6 +26,20 @@ To test unreleased code on a store, set `BRASPAG_CARD_CLIENT_URI` to the file served from GitHub, e.g. `https://cdn.jsdelivr.net/gh/ecomplus/cloud-commerce@/packages/apps/braspag/assets/braspag-card-client.js`. +## Silent Order Post + +The card goes from the browser straight to the SOP API, which answers a +`PaymentToken` for the transaction. + +- **Cielo merchants:** the SOP script posts to Braspag unless `provider` is + `cielo`, and Braspag answers 500 to a Cielo merchant. With `is_cielo` in + production, `list_payments` loads the Cielo SOP script, and the card client + sets `provider: 'cielo'` when that script is on the page. +- **Single-use token:** each SOP access token tokenizes one card; reused, the + API answers 500 without CORS headers. Every `list_payments` gets a new token + (no cache), except on the checkout confirmation, where the card was already + tokenized. A second attempt on the same page asks the buyer to reload. + ## 3DS (Cielo MPI) Credit card authentication with the Cielo 3DS script (MPI V2), configured on @@ -39,6 +53,12 @@ Credit card authentication with the Cielo 3DS script (MPI V2), configured on | `timeout` | Seconds for the cardholder to finish the challenge (30–900; default 300 when required, 30 otherwise) | | `fraud_analysis` | Keep ClearSale fraud analysis on authenticated transactions (default: authenticated ones are captured without it, as before) | +In production the MPI refuses the whole authentication with `102` "Invalid +field value" when a buyer field is out of format, empty ones included. The card +client sends only valid values: phone as `55` + area code + number, zip code +with 8 digits, CPF/CNPJ digits, and `bpmpi_merchant_url` from the page origin +when `storefront.settings` is empty (v3 checkout). + Accepted ECI (Cielo table): Visa, Elo and Amex `05`/`06`, Mastercard `02`/`01`. The result goes to the transaction `custom_fields` (`3ds`, `3ds_eci`, `3ds_versao`, `3ds_referencia`), shown on the order. diff --git a/packages/apps/braspag/assets/braspag-card-client.js b/packages/apps/braspag/assets/braspag-card-client.js index dac524971..87342f6de 100644 --- a/packages/apps/braspag/assets/braspag-card-client.js +++ b/packages/apps/braspag/assets/braspag-card-client.js @@ -74,7 +74,9 @@ */ // Keep in sync with `REFUSAL_MESSAGE` on `lib-mjs/lib/braspag/3ds/policy.mjs` const refusal3dsMessage = 'O banco não autenticou esta compra no cartão (3DS). ' - + 'Tente novamente ou pague com Pix ou boleto.'; + + 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; + + const onlyDigits = (value) => (value == null ? '' : String(value).replace(/\D/g, '')); // Keep in sync with `parse3dsResult` on `lib-mjs/lib/braspag/3ds/policy.mjs`: // ECI with liability shift (Visa/Elo/Amex 05/06, Mastercard 02/01), CAVV @@ -105,7 +107,9 @@ const form3ds = document.createElement('form'); form3ds.id = 'braspag3ds'; form3ds.style.display = 'none'; - const shippingAddress = customer.addresses?.[0] || {}; + const address = customer.addresses?.find((addr) => addr.default) + || customer.addresses?.[0] + || {}; const formatDate = (date) => { if (!date) return undefined; const d = typeof date === 'string' ? new Date(date) : date; @@ -113,7 +117,20 @@ + `${String(d.getDate()).padStart(2, '0')}`; }; const ip64 = await getIp64(); - // https://docs.cielo.com.br/gateway/docs/2-mapeando-as-classes + const name = (customer.fullname || cardClient.name || '').slice(0, 120); + const docNumber = onlyDigits(customer.doc_number); + const phone = customer.phones?.[0]; + let phoneNumber = onlyDigits(phone?.number); + if (phoneNumber.length === 10 || phoneNumber.length === 11) { + phoneNumber = `${phone.country_code || 55}${phoneNumber}`; + } + const zip = onlyDigits(address.zip); + /* + * https://docs.cielo.com.br/gateway/docs/2-mapeando-as-classes + * In production the MPI refuses the whole authentication with 102 + * "Invalid field value" when a field is out of format, and an empty + * field counts: values that don't fit are left out (`undefined`) + */ const fields = { bpmpi_auth: true, bpmpi_auth_notifyonly: false, @@ -127,25 +144,36 @@ bpmpi_cardexpirationmonth: cardClient.month.toString(), bpmpi_cardexpirationyear: `20${cardClient.year.toString()}`, bpmpi_default_card: true, - bpmpi_billto_customerid: customer.doc_number, - bpmpi_merchant_newcustomer: customer.orders?.length > 1, - bpmpi_billto_contactname: customer.fullname || cardClient.name, - bpmpi_billto_name: customer.fullname || cardClient.name, - bpmpi_billto_phonenumber: customer.phones?.[0]?.number, + bpmpi_billto_customerid: docNumber.length === 11 || docNumber.length === 14 + ? docNumber + : undefined, + // New to the store: no previous orders + bpmpi_merchant_newcustomer: Array.isArray(customer.orders) + ? customer.orders.length === 0 + : undefined, + bpmpi_billto_contactname: name, + bpmpi_billto_name: name, + // Country code, area code and number, e.g. 5511999999999 + bpmpi_billto_phonenumber: /^\d{12,15}$/.test(phoneNumber) ? phoneNumber : undefined, bpmpi_billto_email: customer.main_email, - bpmpi_billto_street1: shippingAddress.street || shippingAddress.line_address, - bpmpi_billto_street2: shippingAddress.number, - bpmpi_billto_city: shippingAddress.city, - bpmpi_billto_state: shippingAddress.province_code, - bpmpi_billto_country: shippingAddress.country_code || 'BR', - bpmpi_billto_zipcode: shippingAddress.zip, + bpmpi_billto_street1: [address.street || address.line_address, address.number] + .filter(Boolean).join(', ').slice(0, 60), + bpmpi_billto_street2: [address.complement, address.borough] + .filter(Boolean).join(' - ').slice(0, 60), + bpmpi_billto_city: address.city, + bpmpi_billto_state: address.province_code, + bpmpi_billto_country: address.country_code || 'BR', + bpmpi_billto_zipcode: zip.length === 8 ? zip : undefined, bpmpi_shipto_sameasbillto: true, bpmpi_device_ipaddress: ip64, - bpmpi_device_1_fingerprint: cardClient.fingerPrintId, - bpmpi_device_1_provider: 'clearsale', + // No `bpmpi_device_1_*`: the ClearSale session ID is for the antifraud, + // and the 3DS device providers are only cardinal, inauth and threatmetrix bpmpi_device_channel: 'Browser', bpmpi_transaction_mode: 'S', - bpmpi_merchant_url: settings.domain && `https://${settings.domain}`, + // `storefront.settings` is empty on the v3 checkout (/app/) + bpmpi_merchant_url: settings.domain + ? `https://${settings.domain}` + : window.location.origin, bpmpi_order_recurrence: false, bpmpi_order_productcode: 'PHY', bpmpi_order_marketingoptin: customer.accepts_marketing, @@ -165,10 +193,12 @@ fields[`bpmpi_cart_${nItems}_unitprice`] = Math.round(price * 100); }); Object.keys(fields).forEach((className) => { + const value = fields[className]; + if (value === undefined || value === null || value === '') return; const input = document.createElement('input'); input.type = 'hidden'; input.className = className; - input.value = fields[className] == null ? '' : fields[className]; + input.value = value; form3ds.appendChild(input); }); document.body.appendChild(form3ds); @@ -231,20 +261,33 @@ check(); }); + /* + * The SOP script posts the card to Braspag unless `provider` is `cielo`, + * and Braspag answers 500 to Cielo merchants. The server loads the Cielo + * SOP script only for Cielo in production (`is_cielo`), so that script is + * the signal, with no new `window` variable. + */ + const getSopProvider = () => (document.querySelector( + 'script[src^="https://transaction.cieloecommerce.cielo.com.br/"]', + ) ? 'cielo' : 'braspag'); + + // A SOP access token tokenizes a single card: reused, the SOP API answers 500 + // without CORS headers. A new one only comes with a new `list_payments`, + // which the checkout requests on reload + const usedAccessTokens = []; + const reloadMessage = 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; + const hashCard = (cardClient, fingerPrintId) => { const isSandbox = window._braspagIsSandbox; const accessToken = window._braspagAccessToken; + if (usedAccessTokens.indexOf(accessToken) > -1) { + const error = new Error('Braspag SOP access token already used'); + error.userMsg = ` ${reloadMessage}`; + return Promise.reject(error); + } console.log('Session ID captured:', fingerPrintId); injectClearSaleScript(fingerprintApp, fingerPrintId); - const elementsForm = ` - - - - - - `; - // The SOP script reads the first matching input it finds: a leftover form // from a previous attempt would tokenize the old card while the 3DS // authenticates the new one @@ -253,13 +296,27 @@ const newForm = document.createElement('form'); newForm.setAttribute('id', 'formBraspag'); - newForm.innerHTML = elementsForm; + const sopFields = { + 'bp-sop-cardtype': 'creditCard', + 'bp-sop-cardnumber': cardClient.number, + 'bp-sop-cardexpirationdate': `${cardClient.month.toString()}/20${cardClient.year.toString()}`, + 'bp-sop-cardholdername': cardClient.name, + 'bp-sop-cardcvv': cardClient.cvc, + }; + Object.keys(sopFields).forEach((className) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.className = className; + input.value = sopFields[className] == null ? '' : sopFields[className]; + newForm.appendChild(input); + }); document.body.appendChild(newForm); return new Promise((resolve, reject) => { const options = { accessToken, onSuccess(response) { + usedAccessTokens.push(accessToken); if (response.PaymentToken) { const data = { token: response.PaymentToken, fingerPrintId }; const sendHash = () => resolve(window.btoa(JSON.stringify(data))); @@ -276,7 +333,7 @@ sendHash(); return; } - const card3ds = { ...cardClient, fingerPrintId }; + const card3ds = { ...cardClient }; delete card3ds.cvc; load3ds(card3ds).then(({ status, data: out3ds }) => { data.status3ds = status; @@ -299,12 +356,18 @@ } }, onError(response) { - reject(response); + // The request reached the API (`onInvalid` doesn't): token spent + usedAccessTokens.push(accessToken); + const error = new Error(`Braspag SOP error ${response?.Code}`); + error.response = response; + error.userMsg = ` ${reloadMessage}`; + reject(error); }, onInvalid(validationResults) { reject(validationResults); }, environment: isSandbox ? 'sandbox' : 'production', + provider: getSopProvider(), language: 'PT', enableBinQuery: false, enableVerifyCard: true, diff --git a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs index 20d776dd8..e2eea56cd 100644 --- a/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-create-transaction.mjs @@ -32,7 +32,6 @@ const createTransaction = async ({ params, application }) => { const transaction = { amount: amount.total }; const timeout = 28000; const db = getFirestore(); - const docSOP = db.doc(`braspagAdmin/sop_${process.env.BRASPAG_CLIENT_ID}`); const orderId = params.order_id; if (appData.merchant_id) { process.env.BRASPAG_MERCHANT_ID = appData.merchant_id; @@ -70,11 +69,6 @@ const createTransaction = async ({ params, application }) => { const status = parseStatus[payment.Status]; if (methodPayment === 'credit_card') { - // delete docSop can only be used once - if (docSOP) { - await docSOP.delete() - .catch(logger.error); - } intermediator.transaction_id = payment.PaymentId; intermediator.transaction_reference = payment.ProofOfSale; intermediator.transaction_code = payment.AcquirerTransactionId; @@ -162,10 +156,6 @@ const createTransaction = async ({ params, application }) => { transaction, }; } catch (error) { - if (docSOP) { - // delete docSop can only be used once - await docSOP.delete().catch(logger.error); - } if (error.name === 'Required3dsError') { logger.info(`3DS required, refused ${orderId}`, { result: error.result }); return { diff --git a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs index a05b29cb3..b90475e94 100644 --- a/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs +++ b/packages/apps/braspag/lib-mjs/braspag-list-payments.mjs @@ -46,8 +46,11 @@ const listPayments = async ({ params, application }) => { // below is not awaited): a slow MPI must not sum with the SOP time, Pix and // billet wait for this response on every requote const options3ds = get3dsOptions(appData); + // The checkout confirmation only matches the listed gateway (discount, + // label): the card was already tokenized with the token from the quote + const isConfirmation = Boolean(params.is_checkout_confirmation); let sopTokenPromise = null; - if (process.env.BRASPAG_CLIENT_ID) { + if (process.env.BRASPAG_CLIENT_ID && !isConfirmation) { const getTokenSOPBraspag = new TokenSOPBraspag(isSandbox); sopTokenPromise = getTokenSOPBraspag.preparing .then(() => getTokenSOPBraspag.accessToken); @@ -71,12 +74,12 @@ const listPayments = async ({ params, application }) => { let accessTokenSOP; if (sopResult.status === 'fulfilled') { accessTokenSOP = sopResult.value; - if (accessTokenSOP) { - listPaymentMethod.push('credit_card'); - } } else { logger.error(sopResult.reason); } + if (accessTokenSOP || (isConfirmation && process.env.BRASPAG_CLIENT_ID)) { + listPaymentMethod.push('credit_card'); + } let token3ds; if (token3dsResult.status === 'fulfilled') { token3ds = token3dsResult.value || undefined; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs index 7bc94fe84..af34b374b 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/3ds/policy.mjs @@ -12,9 +12,11 @@ */ export const AUTHENTICATED_ECI = ['01', '02', '05', '06']; -// Keep in sync with `refusal3dsMessage` on `assets/braspag-card-client.js` +// Keep in sync with `refusal3dsMessage` on `assets/braspag-card-client.js`. +// The card was already sent with this page's SOP token, which is single use: +// trying again needs a reload export const REFUSAL_MESSAGE = 'O banco não autenticou esta compra no cartão (3DS). ' - + 'Tente novamente ou pague com Pix ou boleto.'; + + 'Recarregue a página para tentar de novo, ou pague com Pix ou boleto.'; /** * Status set by the browser script (`status3ds` on the card hash). diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs index 53de934af..fb6607f9c 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/admin/create-auth.mjs @@ -1,10 +1,11 @@ import axios from 'axios'; import qs from 'qs'; +import requestWithRetry, { TOKEN_TIMEOUT } from '../request-with-retry.mjs'; -const createAuth = (hashLogin, isSandbox) => new Promise((resolve, reject) => { +const createAuth = async (hashLogin, isSandbox) => { const headers = { 'content-type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${hashLogin}`, + 'Authorization': `Basic ${hashLogin}`, }; /* @@ -15,18 +16,11 @@ const createAuth = (hashLogin, isSandbox) => new Promise((resolve, reject) => { const uri = `https://auth${isSandbox ? 'sandbox' : ''}.braspag.com.br/oauth2/token`; const body = qs.stringify({ grant_type: 'client_credentials' }); - - const request = async (isRetry) => { - axios.post(uri, body, { headers }) - .then(({ data }) => resolve(data)) - .catch((err) => { - if (!isRetry && err.response && err.response.status >= 429) { - setTimeout(() => request(true), 7000); - } - reject(err); - }); - }; - request(); -}); + const { data } = await requestWithRetry(() => axios.post(uri, body, { + headers, + timeout: TOKEN_TIMEOUT, + })); + return data; +}; export default createAuth; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs new file mode 100644 index 000000000..fd6b4714b --- /dev/null +++ b/packages/apps/braspag/lib-mjs/lib/braspag/request-with-retry.mjs @@ -0,0 +1,18 @@ +export const TOKEN_TIMEOUT = 4000; + +/** + * Runs `request` once more after `delayMs` when Braspag answers 429. + * Other errors are not retried: `list_payments` awaits these tokens, and Pix + * and boleto would wait along with the card while Braspag is down. + */ +const requestWithRetry = async (request, delayMs = 2000) => { + try { + return await request(); + } catch (err) { + if (err.response?.status !== 429) throw err; + await new Promise((resolve) => { setTimeout(resolve, delayMs); }); + return request(); + } +}; + +export default requestWithRetry; diff --git a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs index e897925ab..f70ccee2d 100644 --- a/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs +++ b/packages/apps/braspag/lib-mjs/lib/braspag/sop/get-access-token.mjs @@ -1,91 +1,34 @@ import { logger } from '@cloudcommerce/firebase/lib/config'; -import { getFirestore } from 'firebase-admin/firestore'; import axios from 'axios'; import OAuth2AdminBraspag from '../admin/get-auth.mjs'; +import requestWithRetry, { TOKEN_TIMEOUT } from '../request-with-retry.mjs'; -const auth = async (isSandbox) => new Promise((resolve, reject) => { +const createAccessToken = async (isSandbox) => { const oAuth2AdminBraspag = new OAuth2AdminBraspag(isSandbox); - - const request = async (isRetry) => { - oAuth2AdminBraspag.preparing - .then(() => { - const { accessToken } = oAuth2AdminBraspag; - const urlAuthSOP = `https://transaction${isSandbox ? 'sandbox' : ''}` - + '.pagador.com.br/post/api/public/v2/accesstoken'; - const headers = { - MerchantId: process.env.BRASPAG_MERCHANT_ID, - Authorization: `Bearer ${accessToken}`, - 'content-type': 'application/json', - }; - return axios.post(urlAuthSOP, {}, { headers }); - }) - .then(({ data }) => { - let expire = new Date(); - if (data.ExpiresIn) { - expire = new Date(`${data.ExpiresIn.replace('T', ' ')} UTC-3`); - } - resolve({ - accessToken: data.AccessToken, - expiresIn: expire.getTime(), - }); - }) - .catch((err) => { - if (!isRetry && err.response && err.response.status >= 429) { - setTimeout(() => request(true), 7000); - } - reject(err); - }); + await oAuth2AdminBraspag.preparing; + const urlAuthSOP = `https://transaction${isSandbox ? 'sandbox' : ''}` + + '.pagador.com.br/post/api/public/v2/accesstoken'; + const headers = { + 'MerchantId': process.env.BRASPAG_MERCHANT_ID, + 'Authorization': `Bearer ${oAuth2AdminBraspag.accessToken}`, + 'content-type': 'application/json', }; - request(); -}); + const { data } = await requestWithRetry(() => axios.post(urlAuthSOP, {}, { + headers, + timeout: TOKEN_TIMEOUT, + })); + return data.AccessToken; +}; +/** + * A SOP access token tokenizes a single card, so every `list_payments` gets a + * new one (the OAuth token behind it stays cached on `admin/get-auth.mjs`). + */ export default function getAccessToken(isSandbox = false) { const { BRASPAG_CLIENT_ID: clientId } = process.env; - const self = this; - const documentRef = getFirestore().doc(`braspagAdmin/sop_${clientId}`); - - this.preparing = new Promise((resolve, reject) => { - const authenticate = (accessToken) => { - self.accessToken = accessToken; - resolve(self); - }; - - const handleAuth = () => { - logger.info(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); - auth(isSandbox) - .then((data) => { - logger.info('> Braspag SOP Token Update'); - authenticate(data.accessToken); - const expiresInToString = new Date(data.expiresIn).toISOString(); - if (documentRef) { - documentRef.set({ - accessToken: data.accessToken, - expiresIn: Date.now() + (2 * 60 * 1000), - // ...data, - expiresInToString, - updated_at: new Date().toISOString(), - isSandbox, - }) - .catch(logger.error); - } - }) - .catch(reject); - }; - - if (documentRef) { - documentRef.get() - .then((documentSnapshot) => { - if (documentSnapshot.exists - && Date.now() < documentSnapshot.get('expiresIn') - ) { - authenticate(documentSnapshot.get('accessToken')); - } else { - handleAuth(); - } - }) - .catch(logger.error); - } else { - handleAuth(); - } + logger.debug(`> Braspag SOP ${clientId} ${isSandbox ? 'SandBox' : ''}`); + this.preparing = createAccessToken(isSandbox).then((accessToken) => { + this.accessToken = accessToken; + return this; }); } diff --git a/packages/apps/braspag/tests-unit/card-client.test.mjs b/packages/apps/braspag/tests-unit/card-client.test.mjs new file mode 100644 index 000000000..c85f15a14 --- /dev/null +++ b/packages/apps/braspag/tests-unit/card-client.test.mjs @@ -0,0 +1,256 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import test, { describe } from 'node:test'; +import vm from 'node:vm'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { minify } from 'uglify-js'; +import { REFUSAL_MESSAGE } from '../lib-mjs/lib/braspag/3ds/policy.mjs'; + +const readFile = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8'); +// Same minification as the build: the CDN serves the minified file +const cardClientScript = minify(readFile('../assets/braspag-card-client.js')).code; + +const CIELO_SOP_SCRIPT = 'https://transaction.cieloecommerce.cielo.com.br/post/scripts/silentorderpost-1.0.min.js'; +const BRASPAG_SOP_SCRIPT = 'https://www.pagador.com.br/post/scripts/silentorderpost-1.0.min.js'; + +const card = { + name: 'FULANO DE TAL', number: '4024007197692931', cvc: '123', month: '12', year: '30', brand: 'visa', +}; +const authenticated = { + Cavv: 'AAABBBCCC=', Xid: null, Eci: '05', Version: '2.2.0', ReferenceId: 'ref-1', +}; + +// Just the DOM the card client touches +const createDocument = (onAppend) => { + const createElement = (tagName) => { + const element = { + tagName: tagName.toUpperCase(), + id: '', + className: '', + value: '', + src: '', + style: {}, + children: [], + parentNode: null, + setAttribute(name, value) { + element[name] = value; + }, + appendChild(child) { + child.parentNode = element; + element.children.push(child); + onAppend(child); + return child; + }, + remove() { + if (!element.parentNode) return; + const siblings = element.parentNode.children; + siblings.splice(siblings.indexOf(element), 1); + element.parentNode = null; + }, + }; + return element; + }; + const head = createElement('head'); + const body = createElement('body'); + const allElements = () => { + const list = []; + const walk = (element) => element.children.forEach((child) => { + list.push(child); + walk(child); + }); + walk(head); + walk(body); + return list; + }; + return { + head, + body, + createElement, + getElementById: (id) => allElements().find((element) => element.id === id) || null, + querySelector(selector) { + const [, srcPrefix] = selector.match(/^script\[src\^="(.+)"\]$/) || []; + if (!srcPrefix) throw new Error(`Selector not supported by the test DOM: ${selector}`); + return allElements().find((element) => { + return element.tagName === 'SCRIPT' && element.src.startsWith(srcPrefix); + }) || null; + }, + }; +}; + +const formValues = (form) => Object.fromEntries(form.children.map((input) => { + return [input.className, String(input.value)]; +})); + +/** + * Runs the card client as the checkout does: SOP script on the page, `window` + * variables from the onload expression and `storefrontApp` from the checkout. + * `sopResponse` and `mpiResult` play the Braspag SOP and Cielo MPI scripts. + */ +const loadCardClient = ({ + sopScript = BRASPAG_SOP_SCRIPT, + vars = {}, + customer = {}, + settings = {}, + sopResponse = (options) => options.onSuccess({ PaymentToken: 'payment-token' }), + mpiResult = (config) => config.onSuccess(authenticated), +} = {}) => { + const sopCalls = []; + const window = { + console: { log() {} }, + setTimeout, + btoa, + fetch: async () => ({ ok: true, text: async () => '200.100.50.25' }), + location: { origin: 'https://loja.example.com' }, + storefront: { settings }, + storefrontApp: { amount: { total: 1500 }, customer }, + _braspagAccessToken: 'sop-token-1', + _braspagIsSandbox: false, + ...vars, + bpSop_silentOrderPost(options) { + sopCalls.push(options); + sopResponse(options, sopCalls.length); + }, + }; + window.window = window; + window.document = createDocument((element) => { + if (element.tagName === 'SCRIPT' && element.src.includes('BP.Mpi')) { + setTimeout(() => mpiResult(window.bpmpi_config()), 0); + } + }); + if (sopScript) { + const script = window.document.createElement('script'); + script.src = sopScript; + window.document.head.appendChild(script); + } + vm.runInNewContext(cardClientScript, window); + // ClearSale fills it on a real page + window.document.getElementById('mySessionId').value = 'session-id'; + const hashCard = () => window._braspagCardClient({ ...card }); + return { window, sopCalls, hashCard }; +}; + +const required3ds = { + _braspag3dsToken: '3ds-token', + _braspag3dsRequired: true, + _braspag3dsTimeout: 500, +}; + +describe('Card client', () => { + test('card goes to Cielo only when the Cielo SOP script is on the page', async () => { + const cielo = loadCardClient({ sopScript: CIELO_SOP_SCRIPT }); + await cielo.hashCard(); + assert.strictEqual(cielo.sopCalls[0].provider, 'cielo'); + + const braspag = loadCardClient({ sopScript: BRASPAG_SOP_SCRIPT }); + await braspag.hashCard(); + assert.strictEqual(braspag.sopCalls[0].provider, 'braspag'); + }); + + test('SOP form has the CVV on the class the SOP script reads', async () => { + const { window, hashCard } = loadCardClient(); + await hashCard(); + const sopForm = formValues(window.document.getElementById('formBraspag')); + assert.strictEqual(sopForm['bp-sop-cardcvv'], '123'); + assert.strictEqual(sopForm['bp-sop-cardexpirationdate'], '12/2030'); + assert.strictEqual(sopForm['bp-sop-cardnumber'], card.number); + }); + + test('3DS fields in the documented format, empty ones left out', async () => { + const { window, hashCard } = loadCardClient({ + vars: required3ds, + customer: { + fullname: 'Fulano de Tal', + main_email: 'f@example.com', + doc_number: '123.456.789-09', + phones: [{ country_code: 55, number: '(11) 98765-4321' }], + addresses: [ + { street: 'Rua B', number: 1, zip: '22222-222' }, + { + default: true, + street: 'Rua A', + number: 10, + borough: 'Centro', + city: '', + province_code: 'SP', + country_code: 'BR', + zip: '01310-100', + }, + ], + orders: [{ _id: 'order-1' }], + }, + }); + const hash = JSON.parse(Buffer.from(await hashCard(), 'base64').toString()); + assert.strictEqual(hash.token, 'payment-token'); + assert.strictEqual(hash.status3ds, 'authenticated'); + + const fields = formValues(window.document.getElementById('braspag3ds')); + assert.strictEqual(fields.bpmpi_billto_phonenumber, '5511987654321'); + assert.strictEqual(fields.bpmpi_billto_zipcode, '01310100'); + assert.strictEqual(fields.bpmpi_billto_customerid, '12345678909'); + assert.strictEqual(fields.bpmpi_billto_street1, 'Rua A, 10'); + assert.strictEqual(fields.bpmpi_billto_street2, 'Centro'); + assert.strictEqual(fields.bpmpi_merchant_url, 'https://loja.example.com'); + assert.strictEqual(fields.bpmpi_merchant_newcustomer, 'false'); + assert.ok(!('bpmpi_billto_city' in fields), 'empty city left out'); + assert.ok(!Object.keys(fields).some((key) => key.startsWith('bpmpi_device_1_'))); + assert.ok(!Object.values(fields).includes(''), 'no empty field'); + }); + + test('3DS fields out of format are left out, not sent', async () => { + const { window, hashCard } = loadCardClient({ + vars: required3ds, + settings: { domain: 'www.loja.com.br' }, + customer: { + fullname: 'Fulano de Tal', + doc_number: '123456789012', + phones: [{ number: '98765-4321' }], + addresses: [{ street: 'Rua A', zip: '1310-100' }], + orders: [], + }, + }); + await hashCard(); + const fields = formValues(window.document.getElementById('braspag3ds')); + assert.ok(!('bpmpi_billto_phonenumber' in fields), 'phone without area code'); + assert.ok(!('bpmpi_billto_customerid' in fields), 'neither CPF nor CNPJ'); + assert.ok(!('bpmpi_billto_zipcode' in fields), 'zip code without 8 digits'); + assert.strictEqual(fields.bpmpi_merchant_url, 'https://www.loja.com.br'); + assert.strictEqual(fields.bpmpi_merchant_newcustomer, 'true'); + }); + + test('a spent SOP token is never sent again', async () => { + const { sopCalls, hashCard } = loadCardClient(); + await hashCard(); + await assert.rejects(hashCard(), (err) => /Recarregue a página/.test(err.userMsg)); + assert.strictEqual(sopCalls.length, 1); + }); + + test('a SOP API error spends the token too', async () => { + const { sopCalls, hashCard } = loadCardClient({ + sopResponse: (options) => options.onError({ Code: 0, Text: '' }), + }); + await assert.rejects(hashCard(), (err) => /Recarregue a página/.test(err.userMsg)); + await assert.rejects(hashCard(), /already used/); + assert.strictEqual(sopCalls.length, 1); + }); + + test('card refused by the SOP script before sending keeps the token', async () => { + const { sopCalls, hashCard } = loadCardClient({ + sopResponse: (options, call) => (call === 1 + ? options.onInvalid([{ Field: 'bp-sop-cardnumber' }]) + : options.onSuccess({ PaymentToken: 'payment-token' })), + }); + await assert.rejects(hashCard()); + assert.ok(await hashCard()); + assert.strictEqual(sopCalls.length, 2); + }); + + test('required 3DS not authenticated: same refusal message as the server', async () => { + const { hashCard } = loadCardClient({ + vars: required3ds, + mpiResult: (config) => config.onFailure({ Eci: '07' }), + }); + await assert.rejects(hashCard(), (err) => { + return err.message === REFUSAL_MESSAGE && err.userMsg === ` ${REFUSAL_MESSAGE}`; + }); + }); +}); diff --git a/packages/apps/braspag/tests-unit/request-with-retry.test.mjs b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs new file mode 100644 index 000000000..f7c2e6735 --- /dev/null +++ b/packages/apps/braspag/tests-unit/request-with-retry.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import requestWithRetry from '../lib-mjs/lib/braspag/request-with-retry.mjs'; + +const httpError = (status) => Object.assign(new Error(`HTTP ${status}`), { + response: { status }, +}); + +// Fake request answering the given results in order +const sequence = (...results) => { + let calls = 0; + const request = async () => { + const result = results[calls]; + calls += 1; + if (result instanceof Error) throw result; + return result; + }; + return { request, calls: () => calls }; +}; + +describe('Braspag token requests', () => { + test('429 then 200: resolves with the retry', async () => { + const { request, calls } = sequence(httpError(429), { data: 'ok' }); + assert.deepStrictEqual(await requestWithRetry(request, 1), { data: 'ok' }); + assert.strictEqual(calls(), 2); + }); + + test('429 twice: rejects after a single retry', async () => { + const { request, calls } = sequence(httpError(429), httpError(429), { data: 'ok' }); + await assert.rejects(requestWithRetry(request, 1), /HTTP 429/); + assert.strictEqual(calls(), 2); + }); + + test('5xx and network errors are not retried', async () => { + const unavailable = sequence(httpError(503), { data: 'ok' }); + await assert.rejects(requestWithRetry(unavailable.request, 1), /HTTP 503/); + assert.strictEqual(unavailable.calls(), 1); + const network = sequence(new Error('ECONNRESET'), { data: 'ok' }); + await assert.rejects(requestWithRetry(network.request, 1), /ECONNRESET/); + assert.strictEqual(network.calls(), 1); + }); +});