payment-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinese<objective>
Payment flows fail in ways generic E2E tests miss: a card field in a cross-origin iframe
that `page.locator` silently never reaches, a renewal that won't trigger for a year, a
webhook handler that "works" until Stripe retries and fulfills an order twice, an order
marked paid on a redirect that the browser forged. This skill makes you test payments the
way they actually break — against PSP sandboxes, with the real test cards, the nested 3DS
challenge, server-side test clocks, signature-verified webhooks, and idempotent
fulfillment. Never a real PAN, never a live key.
</objective>
<objective>
通用端到端测试无法覆盖支付流程的各类故障场景:跨域iframe中的卡号输入框无法被`page.locator`定位、需等待一年才会触发的订阅续费、看似正常但在Stripe重试时会重复执行订单履约的Webhook处理器、浏览器伪造跳转后标记为已支付的订单。本技能指导你按照支付流程实际故障的方式进行测试——基于PSP沙箱,使用真实测试卡、嵌套3DS挑战、服务端测试时钟、签名验证的Webhook以及幂等履约机制。全程不使用真实银行卡号,不使用生产密钥。
</objective>
Quick Route
快速指引
| You need to test… | Go to | Reference |
|---|---|---|
| Success / decline / insufficient-funds outcomes | Test cards | |
| A 3DS/SCA challenge that pops a modal | 3DS challenge | |
| A subscription renewal months/years out | Test clocks | |
| Webhooks reaching localhost + signatures | Webhooks | |
| A failed renewal then a refund | Failed payments | |
| Fulfillment only after real payment | Reconciliation | |
| Adyen / PayPal / Braintree sandboxes | Multi-PSP | |
| 你需要测试… | 跳转至 | 参考文档 |
|---|---|---|
| 成功/拒付/余额不足场景 | 测试卡号 | |
| 弹出模态框的3DS/SCA挑战 | 3DS挑战 | |
| 数月/数年后的订阅续费 | 测试时钟 | |
| Webhook本地接收及签名验证 | Webhook | |
| 续费失败后退款流程 | 失败支付 | |
| 仅在真实支付完成后履约 | 对账 | |
| Adyen / PayPal / Braintree沙箱 | 多PSP适配 | |
Discovery Questions
调研问题
First, check in the project root and skip anything it
already answers (PSP, stack, test framework, existing fixtures). Then clarify:
.agents/qa-project-context.md- Which PSP, and is it Stripe? Stripe is the default here. Other PSPs share the pattern but have their own sandbox cards/accounts — see Multi-PSP.
- One-time payments, subscriptions, or both? Subscriptions pull in test clocks, dunning,
and the lifecycle; one-time payments don't.
invoice.* - Is SCA/3DS in play? EU/UK card flows almost always challenge. If yes you need the nested-iframe pattern, not plain locators.
- Do you fulfill on a webhook or on the redirect today? If fulfillment happens on
, that's the bug to test for — fulfillment must wait for the verified webhook.
return_url - Where does the webhook handler run in tests? Local () vs a deployed preview env changes how you deliver events.
stripe listen
首先查看项目根目录下的,跳过已明确的内容(PSP、技术栈、测试框架、现有夹具)。然后确认以下信息:
.agents/qa-project-context.md- 使用哪款PSP,是否为Stripe? 本技能默认以Stripe为主。其他PSP遵循相同测试模式,但有各自的沙箱卡号/账户——详见多PSP适配。
- 是一次性支付、订阅支付,还是两者都有? 订阅支付涉及测试时钟、催缴流程及生命周期;一次性支付则无需这些。
invoice.* - 是否启用SCA/3DS? 欧盟/英国的银行卡流程几乎都会触发挑战。若是,则需使用嵌套iframe定位方式,而非普通定位器。
- 当前是基于Webhook还是跳转链接完成履约?若履约依赖,这是需要测试的缺陷——履约必须等待经过签名验证的Webhook。
return_url - Webhook处理器在测试环境中运行于何处? 本地环境()与部署的预览环境会影响事件传递方式。
stripe listen
Core Principles
核心原则
-
Never touch a real card or a live key — and this is non-negotiable, not a preference. Real PANs in any environment violate Stripe's Services Agreement and drag your repo into PCI scope. Test mode (/
pk_test_) with Stripe's published test cards is the only correct answer. Masking or encrypting a real number does not fix it; removing it does.sk_test_ -
Money is confirmed server-side, never client-side. A redirect, ancallback, or a
onApprovequery param can be premature, replayed, or forged. Fulfill an order only after a signature-verified?status=successwebhook, re-confirmed with an APIpayment_intent.succeeded.retrieve -
Verify the signature before you parse the body. Parsing JSON first destroys the raw bytesneeds. The webhook route gets the raw body; everything else can parse JSON.
constructEvent -
Time is a server-side construct for billing. Stripe's billing engine runs on Stripe's servers. Faking the clock in your test process changes nothing. Use test clocks.
-
Assume every webhook is delivered more than once. Stripe retries. Idempotency keyed onin durable storage is mandatory; an in-memory set is not idempotency.
event.id
-
严禁使用真实银行卡或生产密钥——这是硬性要求,而非可选方案。 在任何环境中使用真实银行卡号都会违反Stripe服务协议,并使你的代码库落入PCI合规范围。唯一正确的方式是使用测试模式(/
pk_test_)及Stripe官方发布的测试卡号。对真实卡号进行掩码或加密无法解决问题,必须彻底移除。sk_test_ -
支付状态需通过服务端确认,而非客户端。 跳转链接、回调或
onApprove查询参数可能存在提前触发、重复执行或伪造的风险。仅在收到签名验证通过的?status=successWebhook,并通过APIpayment_intent.succeeded再次确认后,方可执行订单履约。retrieve -
验证签名后再解析请求体。 先解析JSON会破坏所需的原始字节数据。Webhook路由需获取原始请求体;其他路由可正常解析JSON。
constructEvent -
账单时间由服务端控制。 Stripe的账单引擎运行于Stripe服务器。在测试流程中伪造本地时钟不会对Stripe的账单引擎产生任何影响,必须使用测试时钟。
-
假设每个Webhook都会被多次传递。 Stripe会重试Webhook传递。必须基于在持久化存储中实现幂等性;内存集合无法实现真正的幂等性。
event.id
Stripe Test Cards and Outcomes
Stripe测试卡号及场景
Drive each outcome with the card that deterministically produces it. The four you need most:
| PAN | Outcome | Code |
|---|---|---|
| Succeeds | — |
| Declined | |
| Declined | |
| 3DS always challenges | — |
| Attaches, then fails on charge | |
Use test keys (/) in the app under test and assert that in setup.
Do not use — that is a generic Braintree/PayPal-era Luhn number,
not a Stripe test card, and it does not deterministically decline.
pk_test_…sk_test_…4111111111111111The card field is in a cross-origin Stripe iframe, so fill it through , never
directly. Assert outcomes on UI copy for a smoke test, or more robustly on
the server-side from . Full
Playwright tests for success / / :
.
frameLocatorpage.locatorlast_payment_error.decline_codepaymentIntents.retrievecard_declinedinsufficient_fundsreferences/stripe-test-cards.md使用特定卡号触发对应的测试场景。最常用的四类卡号如下:
| 卡号 | 场景 | 代码 |
|---|---|---|
| 支付成功 | — |
| 支付被拒 | |
| 余额不足 | |
| 始终触发3DS挑战 | — |
| 卡片绑定成功,但后续支付失败 | |
在被测应用中使用测试密钥(/),并在初始化阶段进行断言。请勿使用——这是Braintree/PayPal时代的通用Luhn校验卡号,并非Stripe测试卡号,无法稳定触发拒付场景。
pk_test_…sk_test_…4111111111111111卡号输入框位于跨域Stripe iframe中,需通过填充,不可直接使用。冒烟测试可通过UI文案断言结果,更可靠的方式是通过服务端调用获取进行断言。完整的Playwright测试用例(成功//)详见:。
frameLocatorpage.locatorpaymentIntents.retrievelast_payment_error.decline_codecard_declinedinsufficient_fundsreferences/stripe-test-cards.md3DS / SCA: The Nested-Iframe Challenge
3DS / SCA: 嵌套iframe挑战
This is the hardest part to get right. The Stripe 3DS challenge is a frame nested inside
the Stripe modal frame — a single cannot reach it. Chain
outer → inner, then click Complete authentication.
frameLocatorframeLocatorWhat fails, and why:
- → the input is cross-origin; the locator matches nothing.
page.locator('#card') - → frame index shifts when Stripe adds/reorders frames. Never select frames by index.
page.frames()[1] - → guessing the challenge duration. Wait on the element.
await page.waitForTimeout(5000)
The correct shape (full test, including the fail-authentication variant, in
):
references/playwright-3ds.mdts
// 3DS-required card so the challenge always appears.
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();
// Nested: outer Stripe challenge frame → inner ACS frame. One frameLocator is not enough.
const inner = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();
await expect(page).toHaveURL(/\/success/); // assert the succeeded state
await expect(page.getByText(/payment succeeded/i)).toBeVisible();4000002760003184这是最容易出错的环节。Stripe 3DS挑战是嵌套在Stripe模态框iframe中的子iframe——单个无法定位到目标元素。需链式调用(外层→内层),然后点击「完成验证」按钮。
frameLocatorframeLocator常见错误及原因:
- → 输入框处于跨域环境,定位器无法匹配到任何元素。
page.locator('#card') - → 当Stripe添加/重新排序iframe时,frame索引会发生变化。切勿通过索引选择iframe。
page.frames()[1] - → 猜测挑战加载时长。应等待元素加载完成,而非固定时长。
await page.waitForTimeout(5000)
正确的代码示例(完整测试用例,包括验证失败场景,详见):
references/playwright-3ds.mdts
// 使用需3DS验证的卡号,确保挑战弹窗触发
await card.getByPlaceholder('Card number').fill('4000000000003220');
await page.getByRole('button', { name: /pay/i }).click();
// 嵌套结构:外层Stripe挑战iframe → 内层ACS iframe。单个frameLocator无法满足需求
const inner = page
.frameLocator('iframe[name^="__privateStripeFrame"]')
.frameLocator('iframe#challengeFrame, iframe[name="acsFrame"]');
await inner.getByRole('button', { name: /complete authentication|complete|authorize/i }).click();
await expect(page).toHaveURL(/\/success/); // 断言支付成功状态
await expect(page.getByText(/payment succeeded/i)).toBeVisible();4000002760003184Test Clocks: Server-Side Time Travel
测试时钟: 服务端时间模拟
To test an annual renewal without waiting a year, use a Stripe test clock — a
server-side construct. Client-side fakes (, sinon, mocking ) do
nothing to Stripe's billing engine.
jest.useFakeTimersDateRules that bite if missed:
- Create the clock at a , then attach the customer at creation with
frozen_time. You cannot attach an existing customer to a clock afterward.test_clock: clock.id - moves time forward only — you cannot rewind. Advance at most two billing cycles per call.
testHelpers.testClocks.advance - After advancing, poll the clock to , then assert the renewal invoice and webhooks.
ready
ts
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …create subscription, then advance ~12 months forward:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });Full create/advance/assert flow: (section 4).
references/webhooks-and-clocks.md若要测试年度续费流程而无需等待一年,需使用Stripe测试时钟——这是服务端专属的时间模拟机制。客户端时间伪造(、sinon、mock)无法影响Stripe的账单引擎。
jest.useFakeTimersDate需注意的规则:
- 创建时钟时需指定,并在创建客户时关联时钟(
frozen_time)。无法为已存在的客户关联时钟。test_clock: clock.id - 仅能向前调整时间——无法回退。每次调用最多调整两个账单周期。
testHelpers.testClocks.advance - 调整时间后,需轮询时钟状态至,再断言续费发票及Webhook事件。
ready
ts
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), name: 'annual-renewal',
});
const customer = await stripe.customers.create({ test_clock: clock.id /* … */ });
// …创建订阅,然后将时间向前调整约12个月:
await stripe.testHelpers.testClocks.advance(clock.id, { frozen_time: oneYearLater });完整的创建/调整/断言流程详见:(第4节)。
references/webhooks-and-clocks.mdWebhooks: Local Delivery, Signatures, Idempotency
Webhook: 本地传递、签名验证、幂等性
Local delivery. Do not expose your endpoint with ngrok and do not poll the API for
status. tunnels test events to localhost natively; fires
them on demand:
stripe listenstripe triggerbash
stripe listen --forward-to localhost:3000/webhooks # prints whsec_… ONCE at startup
stripe trigger payment_intent.succeededCopy that into . It is the signing secret, a different
value from () — do not conflate them.
whsec_…STRIPE_WEBHOOK_SECRETSTRIPE_SECRET_KEYsk_test_…Signature verification. Mount on the webhook route before any global
, so gets the raw body. A forged or tampered event must be
rejected with 400; never hand-roll a string comparison.
express.rawexpress.json()constructEvent=== signaturets
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'] as string;
try {
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
return handleEvent(event, res);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
}
});
app.use(express.json()); // everything else, after the webhook routeIdempotency. Stripe retries delivery, so the same arrives twice. Request-side
idempotency keys (for outbound API calls) do not dedup inbound webhooks. Store
with a UNIQUE constraint and short-circuit on conflict; an in-memory set is lost on
restart and useless across instances. The handler returns 200 for a duplicate so Stripe
stops retrying, and fulfillment runs exactly once.
event.idevent.idts
const inserted = await db.query(
`INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id]);
if (inserted.rowCount === 0) return res.status(200).send('duplicate ignored');The signature test (valid accepted, forged → 400) and the "deliver the same event twice,
assert fulfilled once" idempotency test are in
(sections 2–3).
references/webhooks-and-clocks.md本地传递。无需使用ngrok暴露端点,也无需轮询API状态。可原生将测试事件转发至本地;可按需触发事件:
stripe listenstripe triggerbash
stripe listen --forward-to localhost:3000/webhooks # 启动时会打印唯一的whsec_…密钥
stripe trigger payment_intent.succeeded将打印的密钥复制到中。这是签名密钥,与()是不同的密钥——请勿混淆。
whsec_…STRIPE_WEBHOOK_SECRETSTRIPE_SECRET_KEYsk_test_…签名验证。在Webhook路由上挂载优先于全局,确保能获取原始请求体。伪造或篡改的事件需返回400状态码;切勿手动进行签名字符串对比。
express.rawexpress.json()constructEvent===ts
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'] as string;
try {
const event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
return handleEvent(event, res);
} catch (err) {
return res.status(400).send(`Webhook Error: ${(err as Error).message}`); // SignatureVerificationError
}
});
app.use(express.json()); // 其他路由需在Webhook路由之后挂载幂等性。Stripe会重试事件传递,因此同一可能多次到达。请求端的幂等密钥无法去重入站Webhook;内存集合会在重启后丢失,且无法跨实例生效。需将存储在带有唯一约束的持久化存储中,冲突时直接返回;重复事件需返回200状态码,确保Stripe停止重试,且履约流程仅执行一次。
event.idevent.idts
const inserted = await db.query(
`INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id]);
if (inserted.rowCount === 0) return res.status(200).send('duplicate ignored');签名测试(合法事件通过,伪造事件返回400)及“同一事件传递两次,仅履约一次”的幂等性测试详见:(第2-3节)。
references/webhooks-and-clocks.mdFailed Payments: Dunning and Refunds
失败支付: 催缴与退款
To test a failed recurring charge end to end, subscribe with (SDK token
) — it attaches to the customer but fails on the later
charge, which is what the renewal needs. Cards that decline at attach time can't be saved,
so they can't model a renewal failure.
4000000000000341pm_card_chargeCustomerFailDrive the lifecycle with a test clock:
- Subscribe the customer (on a test clock) with the attach-then-fail card.
- the clock past the renewal date → Stripe attempts the charge.
advance - The charge fails → Stripe emits and the subscription goes
invoice.payment_failed. Assert both.past_due - Resolve by issuing a refund with (fires
refunds.create) — do not "fix" it by deleting the subscription.charge.refunded
Full driver in (section 5).
references/webhooks-and-clocks.md若要端到端测试 recurring charge失败流程,需使用卡号(SDK令牌)订阅——该卡号可成功绑定客户,但后续支付会失败,正好模拟续费失败场景。绑定阶段即拒付的卡片无法保存,无法模拟续费失败。
4000000000000341pm_card_chargeCustomerFail使用测试时钟驱动整个生命周期:
- 在测试时钟上为客户绑定该卡片并创建订阅。
- 将时钟调整至续费日期之后 → Stripe尝试扣款。
- 扣款失败 → Stripe触发**事件,订阅状态变为
invoice.payment_failed**。需同时断言这两个结果。past_due - 通过调用****发起退款(触发
refunds.create事件)解决问题——请勿通过删除订阅“修复”该场景。charge.refunded
完整流程示例详见:(第5节)。
references/webhooks-and-clocks.mdReconciliation: Fulfill on the Webhook, Not the Redirect
对账: 基于Webhook而非跳转履约
Mark an order paid only after a signature-verified webhook,
re-confirmed against the API — never on the redirect or a client-side success
flag, and never by polling with a sleep.
payment_intent.succeededreturn_urlts
if (event.type === 'payment_intent.succeeded') {
const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
if (verified.status === 'succeeded' && verified.amount_received === expected) {
await markOrderPaid(verified.metadata.orderId); // fulfillment happens HERE
}
}The reconciliation test asserts the order is still after the redirect and only
after the verified webhook: (section 6).
pendingpaidreferences/webhooks-and-clocks.md仅在收到签名验证通过的Webhook,并通过API再次确认后,方可标记订单为已支付——切勿依赖跳转或客户端成功标记,也切勿通过轮询+等待的方式判断状态。
payment_intent.succeededreturn_urlts
if (event.type === 'payment_intent.succeeded') {
const verified = await stripe.paymentIntents.retrieve(event.data.object.id);
if (verified.status === 'succeeded' && verified.amount_received === expected) {
await markOrderPaid(verified.metadata.orderId); // 履约流程在此执行
}
}对账测试需断言:跳转后订单仍处于状态,仅在收到验证后的Webhook后才变为状态。详见:(第6节)。
pendingpaidreferences/webhooks-and-clocks.mdMulti-PSP: Adyen, PayPal, Braintree
多PSP适配: Adyen, PayPal, Braintree
Stripe test cards do not work on other PSPs. Each has its own sandbox cards and sandbox
buyer accounts. Port the structure of your Stripe tests; swap in the PSP's sandbox values.
Never reuse Stripe PANs or live/production keys.
- Adyen — own test cards (e.g. for 3DS2); many declines are driven by the transaction amount (
4212345678910014refused,.13referral), not the card. Events arrive as HMAC-signed notifications..51 - PayPal — log in with a sandbox buyer account (sandbox personal email/password), not
a card. Confirm server-side via the Orders API / webhooks, not the client .
onApprove - Braintree — own sandbox test card numbers via Drop-in UI / Hosted Fields; amount drives transaction outcome, card number drives verification.
What stays the same: separate test/sandbox credentials, no real card, and fulfillment on the
verified server-side event/notification. Details: .
references/multi-psp.mdStripe测试卡号无法用于其他PSP。每个PSP都有自己的沙箱卡号和沙箱买家账户。可复用Stripe测试用例的结构,替换为对应PSP的沙箱参数。切勿复用Stripe卡号或生产/测试密钥。
- Adyen — 使用专属测试卡号(如用于3DS2);多数拒付场景由交易金额驱动(
4212345678910014金额会被拒绝,.13金额会被转介),而非卡号。事件以HMAC签名通知的形式传递。.51 - PayPal — 使用沙箱买家账户(沙箱个人邮箱/密码)登录,而非银行卡。需通过Orders API/Webhook在服务端确认支付,而非客户端回调。
onApprove - Braintree — 通过Drop-in UI/Hosted Fields使用专属沙箱测试卡号;交易金额驱动交易结果,卡号驱动验证流程。
通用规则:使用独立的测试/沙箱凭证,不使用真实银行卡,基于验证后的服务端事件/通知执行履约。详细内容详见:。
references/multi-psp.mdAnti-Patterns
反模式
1. Reaching for 4111111111111111
41111111111111111. 使用4111111111111111
卡号
4111111111111111That Luhn-valid number is a Braintree/PayPal-era generic PAN, not a Stripe test card. Use
for success and the specific decline cards (,
).
424242424242424240000000000000024000000000009995该Luhn校验合法的卡号是Braintree/PayPal时代的通用卡号,并非Stripe测试卡号。请使用测试支付成功,使用特定拒付卡号(、)测试拒付场景。
4242424242424242400000000000000240000000000099952. Treating the card field as a normal input
2. 将卡号输入框视为普通输入框
page.locator('#card-number')frameLocatorpage.locator('#card-number')frameLocator3. Selecting iframes by index
3. 通过索引选择iframe
page.frames()[1]iframe[name^="__privateStripeFrame"]frameLocatorpage.frames()[1]iframe[name^="__privateStripeFrame"]frameLocator4. waitForTimeout
to "wait for the challenge"
waitForTimeout4. 使用waitForTimeout
等待挑战加载
waitForTimeoutFlaky on slow CI, wasteful on fast CI. Wait on the element ( /
auto-waiting locator actions), never the clock.
expect(...).toBeVisible()在慢速CI环境中会不稳定,在快速CI环境中会浪费时间。应等待元素加载完成( / 自动等待的定位器操作),而非固定时长。
expect(...).toBeVisible()5. Client-side time mocking for billing
5. 客户端时间伪造用于账单测试
jest.useFakeTimersDatejest.useFakeTimersDate6. ngrok or polling for local webhooks
6. 使用ngrok或轮询处理本地Webhook
stripe listen --forward-to localhost:3000/webhooksstripe triggerstripe listen --forward-to localhost:3000/webhooksstripe trigger7. Parsing the body before verifying the signature
7. 验证签名前解析请求体
A global ahead of the webhook route destroys the raw body
needs, so verification can never pass. Mount on the webhook
route first.
express.json()constructEventexpress.rawWebhook路由之前的全局会破坏所需的原始请求体,导致验证无法通过。需先在Webhook路由上挂载。
express.json()constructEventexpress.raw8. Confusing the request idempotency key with webhook dedup, or using an in-memory set
8. 将请求幂等密钥与Webhook去重混淆,或使用内存集合
Outbound idempotency keys don't dedup inbound webhooks; an in-memory dies on restart.
Persist with a UNIQUE constraint.
Setevent.id请求端幂等密钥无法去重入站Webhook;内存会在重启后丢失。需将存储在带有唯一约束的持久化存储中。
Setevent.id9. Fulfilling on the redirect / client success flag
9. 基于跳转链接/客户端成功标记执行履约
The can be premature, replayed, or forged. Fulfill only on the verified
webhook.
return_urlpayment_intent.succeededreturn_urlpayment_intent.succeeded10. "Fixing" a failed renewal by deleting the subscription
10. 通过删除订阅“修复”续费失败场景
The correct resolution is a refund via , leaving the dunning lifecycle
( → ) intact and testable.
refunds.createinvoice.payment_failedpast_due正确的处理方式是通过发起退款,保留完整的催缴生命周期( → )以便测试。
refunds.createinvoice.payment_failedpast_due11. Rationalizing a real card "just in CI"
11. 为CI环境“破例”使用真实银行卡
A hardcoded real PAN is a PCI/compliance violation regardless of environment. The fix is a
test card in test mode — plus removing the secret from the repo and git history and rotating
any exposed key. Masking or encrypting it does not make it acceptable.
硬编码真实银行卡号无论在何种环境中都违反PCI合规要求。正确的做法是使用测试模式下的测试卡号——同时需从代码库及Git历史中移除相关密钥,并轮换所有暴露的密钥。掩码或加密真实卡号无法解决合规问题。
Verification
验证标准
- prints a
stripe listen --forward-to localhost:3000/webhooksand shows events arriving when you runwhsec_….stripe trigger payment_intent.succeeded - Running the 3DS test with reaches and clicks the Complete authentication button (the test fails loudly, not silently, if the nested frame isn't found).
4000000000003220 - The signature test: a tampered returns 400; a header from
Stripe-Signaturereturns 200.generateTestHeaderString - The idempotency test: delivering the same twice fulfills once.
event.id - over the test suite returns nothing.
grep -rE 'pk_live|sk_live|4111111111111111'
- 执行时会打印
stripe listen --forward-to localhost:3000/webhooks密钥,且执行whsec_…时能看到事件到达。stripe trigger payment_intent.succeeded - 使用执行3DS测试时,能定位并点击「完成验证」按钮(若未找到嵌套iframe,测试会直接失败,而非静默通过)。
4000000000003220 - 签名测试:篡改返回400;使用
Stripe-Signature生成的签名返回200。generateTestHeaderString - 幂等性测试:同一传递两次,仅执行一次履约。
event.id - 在测试套件中执行无任何结果。
grep -rE 'pk_live|sk_live|4111111111111111'
Done When
完成标准
- Checkout suite covers success (),
4242424242424242(card_declined), and4000000000000002(insufficient_funds), each asserting the matching outcome, using4000000000009995/pk_test_keys.sk_test_ - A 3DS test fills , reaches the nested challenge frame via chained
4000000000003220, clicks Complete authentication, and asserts the succeeded state — noframeLocator, noframes()[index].waitForTimeout - A subscription-renewal test uses a Stripe test clock (+
testHelpers.testClocks.create, forward-only, customer attached at creation) instead of any client-side time mock.advance - Local webhooks are received via /
stripe listen --forward-to, with thestripe triggerwired intowhsec_…(distinct fromSTRIPE_WEBHOOK_SECRET).STRIPE_SECRET_KEY - The webhook handler verifies the signature with on the raw body before parsing, returns 400 on a forged event, and a test proves it.
constructEvent - Idempotency is enforced by persisting with a UNIQUE constraint; a duplicate delivery returns 200 and fulfills exactly once, proven by a test.
event.id - A failed-renewal test drives →
invoice.payment_failed→past_duevia a test clock and the attach-then-fail cardrefunds.create.4000000000000341 - A reconciliation test confirms the order is only after the verified
paidwebhook (re-checked withpayment_intent.succeeded), not on the redirect.paymentIntents.retrieve - finds no live key or banned PAN in the test suite. (A bare
grep -rE 'pk_live|sk_live|4111111111111111'scan would false-positive on every legitimate test card — match live-key prefixes and the banned[0-9]{16}number, not all 16-digit strings.)4111…
- 结账测试套件覆盖成功()、
4242424242424242(card_declined)、4000000000000002(insufficient_funds)场景,每个场景都断言对应结果,且使用4000000000009995/pk_test_密钥。sk_test_ - 3DS测试使用卡号,通过链式
4000000000003220定位嵌套挑战iframe,点击「完成验证」按钮,并断言支付成功状态——未使用frameLocator或frames()[index]。waitForTimeout - 订阅续费测试使用Stripe测试时钟(+
testHelpers.testClocks.create,仅向前调整时间,创建客户时关联时钟),未使用任何客户端时间伪造方式。advance - 本地Webhook通过/
stripe listen --forward-to接收,stripe trigger密钥已配置到whsec_…(与STRIPE_WEBHOOK_SECRET区分开)。STRIPE_SECRET_KEY - Webhook处理器在解析请求体前,使用基于原始请求体验证签名;伪造事件返回400,且有测试用例验证该逻辑。
constructEvent - 通过将存储在带有唯一约束的持久化存储中实现幂等性;重复事件返回200且仅执行一次履约,有测试用例验证该逻辑。
event.id - 续费失败测试通过测试时钟及绑定后失败卡号驱动
4000000000000341→invoice.payment_failed→past_due流程。refunds.create - 对账测试确认订单仅在收到验证后的Webhook(通过
payment_intent.succeeded再次确认)后才变为paymentIntents.retrieve状态,而非跳转后立即标记。paid - 在测试套件中执行未找到生产密钥或禁用卡号。(扫描
grep -rE 'pk_live|sk_live|4111111111111111'会误报所有合法测试卡号——需匹配生产密钥前缀及禁用的[0-9]{16}卡号,而非所有16位数字字符串。)4111…
Related Skills
相关技能
- api-testing — General REST/GraphQL endpoint testing, schema validation, and auth flows for non-payment endpoints. Go there when the target isn't a PSP checkout/webhook.
- playwright-automation — Page Object Model, fixtures, and general browser E2E mechanics that the 3DS flow here builds on.
- compliance-testing — PCI-DSS, GDPR, and regulatory audit work. This skill keeps you out of PCI scope by using test cards; go there for a formal compliance audit.
- test-data-management — Seeding customers, subscriptions, and fixtures; managing the test-clock-bound customers this skill creates.
- qa-project-context — The universal first stop: PSP, stack, and fixture conventions that every question above should defer to.
- api-testing — 非支付端点的通用REST/GraphQL端点测试、Schema验证及认证流程。当测试目标不是PSP结账/Webhook时,使用该技能。
- playwright-automation — 页面对象模型、夹具及通用浏览器端到端测试机制,本技能中的3DS流程基于该技能实现。
- compliance-testing — PCI-DSS、GDPR及合规性审计工作。本技能通过使用测试卡号帮助你规避PCI合规范围;如需正式合规审计,使用该技能。
- test-data-management — 客户、订阅及夹具数据初始化;管理本技能中创建的与测试时钟绑定的客户数据。
- qa-project-context — 通用前置技能:PSP、技术栈及夹具约定,本技能中的所有问题均需优先参考该技能。
Reference Files (in references/
)
references/参考文档(位于references/
目录)
references/- stripe-test-cards.md — Full test-card catalogue with decline codes and the Playwright success/decline/insufficient-funds tests.
- playwright-3ds.md — Nested-iframe 3DS challenge handling, complete and fail variants, and selector notes.
- webhooks-and-clocks.md — /
stripe listen, raw-body signature verification, idempotency bytrigger, test clocks, failed-renewal dunning + refunds, and reconciliation.event.id - multi-psp.md — Adyen, PayPal, and Braintree sandbox patterns and what differs from Stripe.
- stripe-test-cards.md — 完整测试卡号目录,包含拒付码及Playwright成功/拒付/余额不足测试用例。
- playwright-3ds.md — 嵌套iframe 3DS挑战处理方式、完整及失败场景变体、选择器说明。
- webhooks-and-clocks.md — /
stripe listen使用方法、原始请求体签名验证、基于trigger的幂等性、测试时钟、续费失败催缴+退款、对账流程。event.id - multi-psp.md — Adyen、PayPal及Braintree沙箱测试模式,以及与Stripe的差异点。