Skip to content

Commit be7d166

Browse files
mppx validate: Stripe testmode payment support (#641)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> GIT_VALID_PII_OVERRIDE
1 parent ec1aaf9 commit be7d166

7 files changed

Lines changed: 541 additions & 222 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mppx': patch
3+
---
4+
5+
`mppx validate`: Stripe testmode payment support. Auto-detects Stripe CLI test keys and completes card payment flows end-to-end using a test card, with graceful livemode detection.

src/cli/plugins/stripe.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ export function stripe() {
1919
const stripeOpts = parseOptions(
2020
z.object({
2121
paymentMethod: z.string(),
22+
secretKey: z.string().optional(),
2223
}),
2324
methodOpts,
24-
['paymentMethod'],
25+
['paymentMethod', 'secretKey'],
2526
)
2627

27-
const stripeSecretKey = process.env.MPPX_STRIPE_SECRET_KEY
28+
const stripeSecretKey = stripeOpts.secretKey ?? process.env.MPPX_STRIPE_SECRET_KEY
2829
if (!stripeSecretKey)
2930
throw new Errors.IncurError({
3031
code: 'MISSING_ENV',

src/cli/validate.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,3 +677,97 @@ describe('validate: multi-challenge', () => {
677677
expect(output).toContain('Amount is valid integer string (Got: 1.50)')
678678
})
679679
})
680+
681+
describe('validate: payment methods coverage', () => {
682+
test(
683+
'every method in Constants.Methods produces a payment result',
684+
{ timeout: 15_000 },
685+
async () => {
686+
const methods = Object.values(Constants.Methods)
687+
const challenges = methods.map((method) => ({
688+
id: `${method}-id`,
689+
realm: 'localhost',
690+
method,
691+
intent: 'charge',
692+
request: { amount: '100', currency: 'usd', methodDetails: {} },
693+
expires: new Date(Date.now() + 300_000).toISOString(),
694+
})) as Challenge.Challenge[]
695+
696+
const header = challenges.map((c) => Challenge.serialize(c)).join(', ')
697+
const server = await testServer((req, res) => {
698+
const url = new URL(req.url!, 'http://localhost')
699+
if (url.pathname === '/openapi.json') {
700+
res.setHeader('Content-Type', 'application/json')
701+
res.end(
702+
JSON.stringify({
703+
openapi: '3.1.0',
704+
info: { title: 'T', version: '1' },
705+
paths: {
706+
'/api/test': {
707+
post: { 'x-payment-info': { amount: '100' }, responses: { '402': {} } },
708+
},
709+
},
710+
}),
711+
)
712+
return
713+
}
714+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: header })
715+
res.end()
716+
})
717+
const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
718+
const jsonStart = output.indexOf('{')
719+
const jsonEnd = output.lastIndexOf('}')
720+
const result = JSON.parse(output.slice(jsonStart, jsonEnd + 1))
721+
const paymentLabels = result.endpoints[0].payment.map((r: { label: string }) => r.label)
722+
for (const method of methods) {
723+
expect(paymentLabels.some((l: string) => l.includes(`[${method}]`))).toBe(true)
724+
}
725+
},
726+
)
727+
})
728+
729+
describe('validate: JSON mode', () => {
730+
function mainnetChallenge() {
731+
return makeChallenge({
732+
request: {
733+
amount: '10000',
734+
currency: '0x20c0000000000000000000000000000000000000',
735+
recipient: '0x1234567890123456789012345678901234567890',
736+
methodDetails: { chainId: 4217 },
737+
},
738+
} as Partial<Challenge.Challenge>)
739+
}
740+
741+
function parseJson(output: string) {
742+
const jsonStart = output.indexOf('{')
743+
const jsonEnd = output.lastIndexOf('}')
744+
return JSON.parse(output.slice(jsonStart, jsonEnd + 1))
745+
}
746+
747+
test('does not prompt (non-interactive)', { timeout: 15_000 }, async () => {
748+
const server = await mppServer(mainnetChallenge())
749+
const { output } = await serve(['validate', server.url, '--outputJson'])
750+
const result = parseJson(output)
751+
const allPayment = result.endpoints.flatMap((ep: { payment: unknown[] }) => ep.payment)
752+
expect(allPayment.length).toBeGreaterThan(0)
753+
expect(allPayment.every((r: { severity: string }) => r.severity === 'skip')).toBe(true)
754+
})
755+
756+
test('no console leaks before JSON', { timeout: 15_000 }, async () => {
757+
const server = await mppServer(mainnetChallenge())
758+
const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
759+
const jsonStart = output.indexOf('{')
760+
expect(jsonStart).toBeGreaterThanOrEqual(0)
761+
const beforeJson = output.slice(0, jsonStart)
762+
expect(beforeJson).not.toContain('Using wallet')
763+
expect(beforeJson).not.toContain('Auto-approved')
764+
expect(beforeJson).not.toContain('Attempting')
765+
})
766+
767+
test('includes suggestions', { timeout: 15_000 }, async () => {
768+
const server = await mppServer(mainnetChallenge())
769+
const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
770+
const result = parseJson(output)
771+
expect(Array.isArray(result.suggestions)).toBe(true)
772+
})
773+
})

src/cli/validate/challenge.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type { Chain } from 'viem'
2+
import * as viemChains from 'viem/chains'
3+
14
import * as Challenge from '../../Challenge.js'
25
import * as Constants from '../../Constants.js'
3-
import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
46
import * as x402Header from '../../x402/Header.js'
57
import { pc } from '../utils.js'
68
import { buildUrl, extractRequestBodyFromDiscovery } from './discovery.js'
@@ -15,13 +17,13 @@ import {
1517
warn,
1618
} from './helpers.js'
1719

20+
const allChains = Object.values(viemChains) as Chain[]
21+
1822
function detectTestnet(challenge: Challenge.Challenge): boolean {
1923
const request = challenge.request as Record<string, unknown>
20-
const methodDetails = request.methodDetails as Record<string, unknown> | undefined
21-
if (typeof methodDetails?.chainId === 'number') {
22-
return methodDetails.chainId !== tempoChainIds.mainnet
23-
}
24-
return false
24+
const md = request.methodDetails as Record<string, unknown> | undefined
25+
if (typeof md?.chainId !== 'number') return false
26+
return allChains.find((c) => c.id === md.chainId)?.testnet === true
2527
}
2628

2729
export async function validateChallenge(
@@ -279,7 +281,8 @@ function validateMethodFields(
279281
validateTempoFields(request, tag, results, challenge)
280282
else if (challenge.method === Constants.Methods.stripe)
281283
validateStripeFields(request, tag, results)
282-
else if (challenge.method === Constants.Methods.evm) validateEvmFields(request, tag, results)
284+
else if (challenge.method === Constants.Methods.evm)
285+
validateEvmFields(challenge, request, tag, results)
283286
}
284287

285288
function validateTempoFields(
@@ -384,6 +387,7 @@ function validateStripeFields(
384387
}
385388

386389
function validateEvmFields(
390+
challenge: Challenge.Challenge,
387391
request: Record<string, unknown>,
388392
tag: string,
389393
results: CheckResult[],
@@ -401,7 +405,8 @@ function validateEvmFields(
401405
}
402406

403407
if (isValidAddress(request.currency)) {
404-
results.push(check(`${tag}Valid currency address`))
408+
const network = detectTestnet(challenge) ? 'testnet' : 'mainnet'
409+
results.push(check(`${tag}Valid currency address`, network))
405410
} else {
406411
results.push(
407412
fail(

src/cli/validate/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const validate = Cli.create('validate', {
3939
query: c.options.query,
4040
verbose: c.options.verbose > 0,
4141
yes: c.options.yes,
42+
interactive: false,
4243
})
4344
console.log(JSON.stringify(result, null, 2))
4445
const noEndpoints = result.endpoints.length === 0 && !c.options.endpoint
@@ -67,6 +68,7 @@ const validate = Cli.create('validate', {
6768
query: c.options.query,
6869
verbose: c.options.verbose > 0,
6970
yes: c.options.yes,
71+
interactive: !!process.stdin.isTTY,
7072
onPaymentResults: (results) => printResults(results, counts),
7173
})) {
7274
switch (event.phase) {
@@ -106,7 +108,7 @@ const validate = Cli.create('validate', {
106108
if (event.isMpp) {
107109
sawMppEndpoint = true
108110
if (event.isTestnet) sawTestnet = true
109-
else sawMainnet = true
111+
if (event.isMainnet) sawMainnet = true
110112
}
111113
if (event.isNonMppPayment) sawNonMppPaymentEndpoint = true
112114
if (event.isMalformedChallenge) sawMalformedChallenge = true
@@ -208,13 +210,13 @@ function printSummary(
208210
if (flags.paymentSucceeded && flags.sawTestnet && !flags.sawMainnet) {
209211
console.log('')
210212
console.log(
211-
pc.dim(' Tip: validate your mainnet server too to confirm real payments work end-to-end.'),
213+
pc.dim(' Tip: also validate your mainnet server to confirm real payments work end-to-end.'),
212214
)
213215
} else if (flags.sawMainnet && !flags.sawTestnet) {
214216
console.log('')
215217
console.log(
216218
pc.dim(
217-
' Tip: validate a testnet server too for free. This CLI automatically provisions and funds a testnet wallet for testing.',
219+
' Tip: also validate your testnet server for free. This CLI automatically provisions and funds a testnet wallet for testing.',
218220
),
219221
)
220222
}

0 commit comments

Comments
 (0)