mppx 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +2 -0
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +32 -12
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +1 -0
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -2
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +8 -2
- package/dist/cli/utils.js.map +1 -1
- package/dist/cli/validate/challenge.d.ts +14 -0
- package/dist/cli/validate/challenge.d.ts.map +1 -0
- package/dist/cli/validate/challenge.js +211 -0
- package/dist/cli/validate/challenge.js.map +1 -0
- package/dist/cli/validate/discovery.d.ts +10 -0
- package/dist/cli/validate/discovery.d.ts.map +1 -0
- package/dist/cli/validate/discovery.js +128 -0
- package/dist/cli/validate/discovery.js.map +1 -0
- package/dist/cli/validate/helpers.d.ts +33 -0
- package/dist/cli/validate/helpers.d.ts.map +1 -0
- package/dist/cli/validate/helpers.js +136 -0
- package/dist/cli/validate/helpers.js.map +1 -0
- package/dist/cli/validate/index.d.ts +17 -0
- package/dist/cli/validate/index.d.ts.map +1 -0
- package/dist/cli/validate/index.js +224 -0
- package/dist/cli/validate/index.js.map +1 -0
- package/dist/cli/validate/payment.d.ts +7 -0
- package/dist/cli/validate/payment.d.ts.map +1 -0
- package/dist/cli/validate/payment.js +381 -0
- package/dist/cli/validate/payment.js.map +1 -0
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +7 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +21 -6
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-token.js +1 -1
- package/dist/tempo/internal/fee-token.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/cli.test.ts +134 -29
- package/src/cli/cli.ts +2 -0
- package/src/cli/mcp.test.ts +1 -0
- package/src/cli/plugins/stripe.ts +31 -12
- package/src/cli/plugins/tempo.ts +1 -0
- package/src/cli/utils.test.ts +36 -2
- package/src/cli/utils.ts +16 -3
- package/src/cli/validate/challenge.ts +335 -0
- package/src/cli/validate/discovery.ts +120 -0
- package/src/cli/validate/helpers.ts +154 -0
- package/src/cli/validate/index.ts +348 -0
- package/src/cli/validate/payment.ts +488 -0
- package/src/cli/validate.test.ts +539 -0
- package/src/mcp/client/McpClient.integration.test.ts +12 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/client/Charge.test.ts +56 -0
- package/src/tempo/client/Charge.ts +20 -2
- package/src/tempo/internal/auto-swap.test.ts +85 -1
- package/src/tempo/internal/auto-swap.ts +52 -6
- package/src/tempo/internal/fee-token.ts +1 -1
- package/src/tempo/server/Charge.test.ts +31 -16
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import type * as http from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from 'vp/test'
|
|
4
|
+
import * as Http from '~test/Http.js'
|
|
5
|
+
|
|
6
|
+
import * as Challenge from '../Challenge.js'
|
|
7
|
+
import * as Constants from '../Constants.js'
|
|
8
|
+
import * as Receipt from '../Receipt.js'
|
|
9
|
+
import validate from './validate/index.js'
|
|
10
|
+
|
|
11
|
+
// Auto-cleanup for test servers
|
|
12
|
+
const servers: Http.TestServer[] = []
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
servers.forEach((s) => s.close())
|
|
15
|
+
servers.length = 0
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
async function testServer(handler: http.RequestListener) {
|
|
19
|
+
const s = await Http.createServer(handler)
|
|
20
|
+
servers.push(s)
|
|
21
|
+
return s
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function serve(argv: string[]) {
|
|
25
|
+
let output = ''
|
|
26
|
+
let exitCode: number | undefined
|
|
27
|
+
const origLog = console.log
|
|
28
|
+
const origStdout = process.stdout.write
|
|
29
|
+
const origStderr = process.stderr.write
|
|
30
|
+
console.log = (...args: unknown[]) => {
|
|
31
|
+
output += `${args.map(String).join(' ')}\n`
|
|
32
|
+
}
|
|
33
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
34
|
+
output += typeof chunk === 'string' ? chunk : String(chunk)
|
|
35
|
+
return true
|
|
36
|
+
}) as typeof process.stdout.write
|
|
37
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
38
|
+
output += typeof chunk === 'string' ? chunk : String(chunk)
|
|
39
|
+
return true
|
|
40
|
+
}) as typeof process.stderr.write
|
|
41
|
+
try {
|
|
42
|
+
const { Cli } = await import('incur')
|
|
43
|
+
const cli = Cli.create('mppx', {})
|
|
44
|
+
cli.command(validate)
|
|
45
|
+
await cli.serve(argv, {
|
|
46
|
+
stdout(s: string) {
|
|
47
|
+
output += s
|
|
48
|
+
},
|
|
49
|
+
exit(code: number) {
|
|
50
|
+
exitCode = code
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
} finally {
|
|
54
|
+
console.log = origLog
|
|
55
|
+
process.stdout.write = origStdout
|
|
56
|
+
process.stderr.write = origStderr
|
|
57
|
+
}
|
|
58
|
+
return { output, exitCode }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeChallenge(overrides?: Partial<Challenge.Challenge>) {
|
|
62
|
+
return {
|
|
63
|
+
id: 'test-id-123',
|
|
64
|
+
realm: 'localhost',
|
|
65
|
+
method: 'tempo',
|
|
66
|
+
intent: 'charge',
|
|
67
|
+
request: {
|
|
68
|
+
amount: '10000',
|
|
69
|
+
currency: '0x20c0000000000000000000000000000000000000',
|
|
70
|
+
recipient: '0x1234567890123456789012345678901234567890',
|
|
71
|
+
methodDetails: { chainId: 42431 },
|
|
72
|
+
},
|
|
73
|
+
expires: new Date(Date.now() + 300_000).toISOString(),
|
|
74
|
+
...overrides,
|
|
75
|
+
} as Challenge.Challenge
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeDiscoveryDoc(
|
|
79
|
+
endpoints: Record<string, { method?: string; amount?: string; requestBody?: unknown }> = {},
|
|
80
|
+
) {
|
|
81
|
+
const paths: Record<string, unknown> = {}
|
|
82
|
+
for (const [path, opts] of Object.entries(endpoints)) {
|
|
83
|
+
const op: Record<string, unknown> = {
|
|
84
|
+
'x-payment-info': { method: 'tempo', intent: 'charge', amount: opts.amount ?? '10000' },
|
|
85
|
+
responses: { '402': { description: 'Payment required' } },
|
|
86
|
+
}
|
|
87
|
+
if (opts.requestBody) op.requestBody = opts.requestBody
|
|
88
|
+
paths[path] = { [opts.method?.toLowerCase() ?? 'post']: op }
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify({ openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function mppServer(
|
|
94
|
+
challenge: Challenge.Challenge,
|
|
95
|
+
opts?: { errorStatus?: number; postPaymentStatus?: number },
|
|
96
|
+
) {
|
|
97
|
+
return testServer((req, res) => {
|
|
98
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
99
|
+
if (url.pathname === '/openapi.json') {
|
|
100
|
+
res.setHeader('Content-Type', 'application/json')
|
|
101
|
+
res.end(makeDiscoveryDoc({ '/api/test': {} }))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const hasAuth = req.headers[Constants.Headers.authorization.toLowerCase()]
|
|
105
|
+
if (hasAuth && hasAuth !== `${Constants.Schemes.payment} dGhpcyBpcyBnYXJiYWdl`) {
|
|
106
|
+
const status = opts?.postPaymentStatus ?? 200
|
|
107
|
+
const receipt = Receipt.serialize({
|
|
108
|
+
method: 'tempo',
|
|
109
|
+
status: 'success',
|
|
110
|
+
reference: '0x' + 'ab'.repeat(32),
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
})
|
|
113
|
+
res.writeHead(status, {
|
|
114
|
+
[Constants.Headers.paymentReceipt]: receipt,
|
|
115
|
+
'content-type': 'application/json',
|
|
116
|
+
})
|
|
117
|
+
res.end(JSON.stringify({ result: 'ok' }))
|
|
118
|
+
} else if (hasAuth) {
|
|
119
|
+
const errorStatus = opts?.errorStatus ?? 402
|
|
120
|
+
if (errorStatus === 402) {
|
|
121
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
122
|
+
} else {
|
|
123
|
+
res.writeHead(errorStatus)
|
|
124
|
+
}
|
|
125
|
+
res.end()
|
|
126
|
+
} else {
|
|
127
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
128
|
+
res.end()
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('validate: discovery', () => {
|
|
134
|
+
test('succeeds with valid discovery doc', { timeout: 15_000 }, async () => {
|
|
135
|
+
const server = await mppServer(makeChallenge())
|
|
136
|
+
const { output } = await serve(['validate', server.url])
|
|
137
|
+
expect(output).toContain('Document found and parseable')
|
|
138
|
+
expect(output).toContain('Valid OpenAPI structure')
|
|
139
|
+
expect(output).toContain('Paid endpoints found')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('reports missing discovery doc', { timeout: 15_000 }, async () => {
|
|
143
|
+
const server = await testServer((_req, res) => {
|
|
144
|
+
res.writeHead(404)
|
|
145
|
+
res.end()
|
|
146
|
+
})
|
|
147
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
148
|
+
expect(exitCode).toBe(1)
|
|
149
|
+
expect(output).toContain('No discovery document found.')
|
|
150
|
+
expect(output).toContain('MPP servers must serve an OpenAPI document at /openapi.json')
|
|
151
|
+
expect(output).toContain(
|
|
152
|
+
'To test a specific endpoint: mppx validate <url> --endpoint POST:/your/path',
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('strips /openapi.json from input URL', { timeout: 15_000 }, async () => {
|
|
157
|
+
const server = await mppServer(makeChallenge())
|
|
158
|
+
const { output } = await serve(['validate', `${server.url}/openapi.json`])
|
|
159
|
+
expect(output).toContain('Document found and parseable')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('reports invalid JSON', { timeout: 15_000 }, async () => {
|
|
163
|
+
const server = await testServer((req, res) => {
|
|
164
|
+
if (req.url?.includes('openapi.json')) {
|
|
165
|
+
res.setHeader('Content-Type', 'application/json')
|
|
166
|
+
res.end('not json{{{')
|
|
167
|
+
} else {
|
|
168
|
+
res.writeHead(404)
|
|
169
|
+
res.end()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
173
|
+
expect(exitCode).toBe(1)
|
|
174
|
+
expect(output).toContain('Invalid JSON')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('handles no paid endpoints in discovery', { timeout: 15_000 }, async () => {
|
|
178
|
+
const server = await testServer((req, res) => {
|
|
179
|
+
if (req.url?.includes('openapi.json')) {
|
|
180
|
+
res.setHeader('Content-Type', 'application/json')
|
|
181
|
+
res.end(
|
|
182
|
+
JSON.stringify({
|
|
183
|
+
openapi: '3.1.0',
|
|
184
|
+
info: { title: 'T', version: '1' },
|
|
185
|
+
paths: { '/health': { get: { responses: { '200': {} } } } },
|
|
186
|
+
}),
|
|
187
|
+
)
|
|
188
|
+
} else {
|
|
189
|
+
res.writeHead(200)
|
|
190
|
+
res.end()
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
194
|
+
expect(exitCode).toBe(1)
|
|
195
|
+
expect(output).toContain('Paid endpoints found (No endpoints with x-payment-info)')
|
|
196
|
+
expect(output).toContain('Use --endpoint to specify endpoints manually.')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('falls back to 402 response heuristic', { timeout: 15_000 }, async () => {
|
|
200
|
+
const challenge = makeChallenge()
|
|
201
|
+
const server = await testServer((req, res) => {
|
|
202
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
203
|
+
if (url.pathname === '/openapi.json') {
|
|
204
|
+
res.setHeader('Content-Type', 'application/json')
|
|
205
|
+
res.end(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
openapi: '3.1.0',
|
|
208
|
+
info: { title: 'T', version: '1' },
|
|
209
|
+
paths: {
|
|
210
|
+
'/api/data': { get: { responses: { '402': { description: 'Payment required' } } } },
|
|
211
|
+
},
|
|
212
|
+
}),
|
|
213
|
+
)
|
|
214
|
+
} else {
|
|
215
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
216
|
+
res.end()
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
const { output } = await serve(['validate', server.url])
|
|
220
|
+
expect(output).toContain('Paid endpoints found')
|
|
221
|
+
expect(output).toContain('Challenge parseable')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('validate: challenge', () => {
|
|
226
|
+
test('validates correct MPP challenge', { timeout: 15_000 }, async () => {
|
|
227
|
+
const server = await mppServer(makeChallenge())
|
|
228
|
+
const { output } = await serve(['validate', server.url])
|
|
229
|
+
expect(output).toContain('Challenge parseable (tempo/charge)')
|
|
230
|
+
expect(output).toContain('Challenge has id')
|
|
231
|
+
expect(output).toContain('Challenge has realm')
|
|
232
|
+
expect(output).toContain('Valid recipient address')
|
|
233
|
+
expect(output).toContain('Valid currency address (testnet)')
|
|
234
|
+
expect(output).toContain('Amount is valid integer string')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('fails on expired challenge', { timeout: 15_000 }, async () => {
|
|
238
|
+
const server = await mppServer(makeChallenge({ expires: '2020-01-01T00:00:00Z' }))
|
|
239
|
+
const { output } = await serve(['validate', server.url])
|
|
240
|
+
expect(output).toContain('Challenge expires in the future (Expired at 2020-01-01T00:00:00Z)')
|
|
241
|
+
expect(output).toContain('The expires timestamp must be in the future')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('warns on realm mismatch', { timeout: 15_000 }, async () => {
|
|
245
|
+
const server = await mppServer(makeChallenge({ realm: 'other.example.com' }))
|
|
246
|
+
const { output } = await serve(['validate', server.url])
|
|
247
|
+
expect(output).toContain(
|
|
248
|
+
'Realm matches server hostname (realm="other.example.com" vs host="localhost")',
|
|
249
|
+
)
|
|
250
|
+
expect(output).toContain('Set the realm to your production hostname')
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('fails on invalid recipient address', { timeout: 15_000 }, async () => {
|
|
254
|
+
const challenge = makeChallenge()
|
|
255
|
+
;(challenge.request as Record<string, unknown>).recipient = 'not-an-address'
|
|
256
|
+
const server = await mppServer(challenge)
|
|
257
|
+
const { output } = await serve(['validate', server.url])
|
|
258
|
+
expect(output).toContain('Valid recipient address (Got: not-an-address)')
|
|
259
|
+
expect(output).toContain('Set request.recipient to a valid 0x-prefixed 40-hex-char address')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('fails on invalid amount', { timeout: 15_000 }, async () => {
|
|
263
|
+
const challenge = makeChallenge()
|
|
264
|
+
;(challenge.request as Record<string, unknown>).amount = '12.5'
|
|
265
|
+
const server = await mppServer(challenge)
|
|
266
|
+
const { output } = await serve(['validate', server.url])
|
|
267
|
+
expect(output).toContain('Amount is valid integer string (Got: 12.5)')
|
|
268
|
+
expect(output).toContain('request.amount must be a string of digits')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('detects non-MPP endpoint (x402)', { timeout: 15_000 }, async () => {
|
|
272
|
+
const server = await testServer((req, res) => {
|
|
273
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
274
|
+
if (url.pathname === '/openapi.json') {
|
|
275
|
+
res.setHeader('Content-Type', 'application/json')
|
|
276
|
+
res.end(makeDiscoveryDoc({ '/api/test': {} }))
|
|
277
|
+
} else {
|
|
278
|
+
res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
|
|
279
|
+
res.end()
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
283
|
+
expect(output).toContain(
|
|
284
|
+
'Not an MPP endpoint (No WWW-Authenticate header (may be x402 or other protocol))',
|
|
285
|
+
)
|
|
286
|
+
expect(exitCode).toBe(1)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('skips 200 response', { timeout: 15_000 }, async () => {
|
|
290
|
+
const server = await testServer((req, res) => {
|
|
291
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
292
|
+
if (url.pathname === '/openapi.json') {
|
|
293
|
+
res.setHeader('Content-Type', 'application/json')
|
|
294
|
+
res.end(makeDiscoveryDoc({ '/api/test': { method: 'GET' } }))
|
|
295
|
+
} else {
|
|
296
|
+
res.writeHead(200)
|
|
297
|
+
res.end('ok')
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
const { output } = await serve(['validate', server.url])
|
|
301
|
+
expect(output).toContain(
|
|
302
|
+
'Returns 402 without credentials (Got 200 (endpoint may not require payment in all cases))',
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('skips 401 as auth-gated', { timeout: 15_000 }, async () => {
|
|
307
|
+
const server = await testServer((req, res) => {
|
|
308
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
309
|
+
if (url.pathname === '/openapi.json') {
|
|
310
|
+
res.setHeader('Content-Type', 'application/json')
|
|
311
|
+
res.end(makeDiscoveryDoc({ '/api/test': {} }))
|
|
312
|
+
} else {
|
|
313
|
+
res.writeHead(401)
|
|
314
|
+
res.end()
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
const { output } = await serve(['validate', server.url])
|
|
318
|
+
expect(output).toContain(
|
|
319
|
+
'Returns 402 without credentials (Got 401 (endpoint requires auth before payment gate))',
|
|
320
|
+
)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
test('retries with body on 400', { timeout: 15_000 }, async () => {
|
|
324
|
+
const challenge = makeChallenge()
|
|
325
|
+
const doc = JSON.stringify({
|
|
326
|
+
openapi: '3.1.0',
|
|
327
|
+
info: { title: 'T', version: '1' },
|
|
328
|
+
paths: {
|
|
329
|
+
'/api/test': {
|
|
330
|
+
post: {
|
|
331
|
+
'x-payment-info': { method: 'tempo', intent: 'charge', amount: '10000' },
|
|
332
|
+
responses: { '402': {} },
|
|
333
|
+
requestBody: {
|
|
334
|
+
required: true,
|
|
335
|
+
content: {
|
|
336
|
+
'application/json': {
|
|
337
|
+
schema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
required: ['q'],
|
|
340
|
+
properties: { q: { type: 'string', example: 'hello' } },
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
})
|
|
349
|
+
let requestCount = 0
|
|
350
|
+
const server = await testServer((req, res) => {
|
|
351
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
352
|
+
if (url.pathname === '/openapi.json') {
|
|
353
|
+
res.setHeader('Content-Type', 'application/json')
|
|
354
|
+
res.end(doc)
|
|
355
|
+
} else {
|
|
356
|
+
requestCount++
|
|
357
|
+
const hasAuth = req.headers[Constants.Headers.authorization.toLowerCase()]
|
|
358
|
+
if (hasAuth) {
|
|
359
|
+
res.writeHead(402, {
|
|
360
|
+
[Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
|
|
361
|
+
})
|
|
362
|
+
res.end()
|
|
363
|
+
} else if (requestCount === 1) {
|
|
364
|
+
res.writeHead(400)
|
|
365
|
+
res.end()
|
|
366
|
+
} else {
|
|
367
|
+
res.writeHead(402, {
|
|
368
|
+
[Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
|
|
369
|
+
})
|
|
370
|
+
res.end()
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
const { output } = await serve(['validate', server.url])
|
|
375
|
+
expect(output).toContain('Challenge parseable')
|
|
376
|
+
expect(requestCount).toBeGreaterThan(1)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe('validate: error handling', () => {
|
|
381
|
+
test('passes when malformed credential returns 402', { timeout: 15_000 }, async () => {
|
|
382
|
+
const server = await mppServer(makeChallenge())
|
|
383
|
+
const { output } = await serve(['validate', server.url])
|
|
384
|
+
expect(output).toContain('Malformed credential returns 402 (not 500)')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('fails when malformed credential returns 500', { timeout: 15_000 }, async () => {
|
|
388
|
+
const server = await mppServer(makeChallenge(), { errorStatus: 500 })
|
|
389
|
+
const { output } = await serve(['validate', server.url])
|
|
390
|
+
expect(output).toContain('Malformed credential returns 402 (Got 500 (server error))')
|
|
391
|
+
expect(output).toContain(
|
|
392
|
+
'When the Authorization header contains an invalid credential, respond with 402 (not 500)',
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('warns when malformed credential returns other status', { timeout: 15_000 }, async () => {
|
|
397
|
+
const server = await mppServer(makeChallenge(), { errorStatus: 422 })
|
|
398
|
+
const { output } = await serve(['validate', server.url])
|
|
399
|
+
expect(output).toContain('Malformed credential returns 402 (Got 422)')
|
|
400
|
+
expect(output).toContain('Returning 422 prevents the client from retrying with a valid payment')
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
describe('validate: --endpoint flag', () => {
|
|
405
|
+
test('checks discovery even with --endpoint', { timeout: 15_000 }, async () => {
|
|
406
|
+
const challenge = makeChallenge()
|
|
407
|
+
const server = await testServer((_req, res) => {
|
|
408
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
409
|
+
res.end()
|
|
410
|
+
})
|
|
411
|
+
const { output } = await serve(['validate', server.url, '--endpoint', 'POST:/api/test'])
|
|
412
|
+
expect(output).toContain('Discovery (/openapi.json)')
|
|
413
|
+
// Discovery fails (server only returns 402) but doesn't block --endpoint testing
|
|
414
|
+
expect(output).toContain('Document found')
|
|
415
|
+
expect(output).toContain('Challenge parseable')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test('uses --body directly with --endpoint', { timeout: 15_000 }, async () => {
|
|
419
|
+
const challenge = makeChallenge()
|
|
420
|
+
const bodies: string[] = []
|
|
421
|
+
const server = await testServer((req, res) => {
|
|
422
|
+
let body = ''
|
|
423
|
+
req.on('data', (chunk) => {
|
|
424
|
+
body += chunk
|
|
425
|
+
})
|
|
426
|
+
req.on('end', () => {
|
|
427
|
+
bodies.push(body)
|
|
428
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
429
|
+
res.end()
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
await serve(['validate', server.url, '--endpoint', 'POST:/api/test', '--body', '{"test":true}'])
|
|
433
|
+
// Challenge phase: bare request first (no body)
|
|
434
|
+
expect(bodies[0]).toBe('')
|
|
435
|
+
// Payment phase: sends body with the credential request
|
|
436
|
+
const paymentRequest = bodies.find((b) => b === '{"test":true}')
|
|
437
|
+
expect(paymentRequest).toBe('{"test":true}')
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
describe('validate: no MPP endpoints', () => {
|
|
442
|
+
test('exits non-zero when all endpoints are x402', { timeout: 15_000 }, async () => {
|
|
443
|
+
const server = await testServer((req, res) => {
|
|
444
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
445
|
+
if (url.pathname === '/openapi.json') {
|
|
446
|
+
res.setHeader('Content-Type', 'application/json')
|
|
447
|
+
res.end(makeDiscoveryDoc({ '/api/a': {}, '/api/b': {} }))
|
|
448
|
+
} else {
|
|
449
|
+
res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
|
|
450
|
+
res.end()
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
454
|
+
expect(exitCode).toBe(1)
|
|
455
|
+
expect(output).toContain('No MPP endpoints found.')
|
|
456
|
+
expect(output).toContain('This server may use x402 or another payment protocol.')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
test('shows auth-gated message when all return 401', { timeout: 15_000 }, async () => {
|
|
460
|
+
const server = await testServer((req, res) => {
|
|
461
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
462
|
+
if (url.pathname === '/openapi.json') {
|
|
463
|
+
res.setHeader('Content-Type', 'application/json')
|
|
464
|
+
res.end(makeDiscoveryDoc({ '/api/a': {}, '/api/b': {} }))
|
|
465
|
+
} else {
|
|
466
|
+
res.writeHead(401)
|
|
467
|
+
res.end()
|
|
468
|
+
}
|
|
469
|
+
})
|
|
470
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
471
|
+
expect(exitCode).toBe(1)
|
|
472
|
+
expect(output).toContain(
|
|
473
|
+
'Could not reach payment gate on any endpoint (all returned 401/403/200).',
|
|
474
|
+
)
|
|
475
|
+
expect(output).toContain('The server may require authentication before payment.')
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
test(
|
|
479
|
+
'mixed: some MPP, some not -- does not show "no MPP" message',
|
|
480
|
+
{ timeout: 15_000 },
|
|
481
|
+
async () => {
|
|
482
|
+
const challenge = makeChallenge()
|
|
483
|
+
const server = await testServer((req, res) => {
|
|
484
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
485
|
+
if (url.pathname === '/openapi.json') {
|
|
486
|
+
res.setHeader('Content-Type', 'application/json')
|
|
487
|
+
res.end(makeDiscoveryDoc({ '/api/paid': {}, '/api/free': {} }))
|
|
488
|
+
} else if (url.pathname === '/api/paid') {
|
|
489
|
+
res.writeHead(402, {
|
|
490
|
+
[Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge),
|
|
491
|
+
})
|
|
492
|
+
res.end()
|
|
493
|
+
} else {
|
|
494
|
+
res.writeHead(402, { 'payment-required': 'eyJ0ZXN0IjoxfQ==' })
|
|
495
|
+
res.end()
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
const { output } = await serve(['validate', server.url])
|
|
499
|
+
expect(output).toContain('Challenge parseable')
|
|
500
|
+
expect(output).toContain('Not an MPP endpoint')
|
|
501
|
+
expect(output).not.toContain('No MPP endpoints found')
|
|
502
|
+
},
|
|
503
|
+
)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
describe('validate: summary', () => {
|
|
507
|
+
test('shows pass count in summary', { timeout: 15_000 }, async () => {
|
|
508
|
+
const server = await mppServer(makeChallenge())
|
|
509
|
+
const { output } = await serve(['validate', server.url])
|
|
510
|
+
expect(output).toMatch(/\d+ passed/)
|
|
511
|
+
expect(output).toContain('Summary:')
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('shows skipped count when endpoints are skipped', { timeout: 15_000 }, async () => {
|
|
515
|
+
const challenge = makeChallenge()
|
|
516
|
+
const server = await testServer((req, res) => {
|
|
517
|
+
const url = new URL(req.url!, 'http://localhost')
|
|
518
|
+
if (url.pathname === '/openapi.json') {
|
|
519
|
+
res.setHeader('Content-Type', 'application/json')
|
|
520
|
+
res.end(makeDiscoveryDoc({ '/api/mpp': {}, '/api/free': { method: 'GET' } }))
|
|
521
|
+
} else if (url.pathname === '/api/mpp') {
|
|
522
|
+
res.writeHead(402, { [Constants.Headers.wwwAuthenticate]: Challenge.serialize(challenge) })
|
|
523
|
+
res.end()
|
|
524
|
+
} else {
|
|
525
|
+
res.writeHead(200)
|
|
526
|
+
res.end('ok')
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
const { output } = await serve(['validate', server.url])
|
|
530
|
+
expect(output).toContain('skipped')
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
test('exits non-zero when there are failures', { timeout: 15_000 }, async () => {
|
|
534
|
+
const server = await mppServer(makeChallenge(), { errorStatus: 500 })
|
|
535
|
+
const { output, exitCode } = await serve(['validate', server.url])
|
|
536
|
+
expect(exitCode).toBe(1)
|
|
537
|
+
expect(output).toContain('failed')
|
|
538
|
+
})
|
|
539
|
+
})
|
|
@@ -33,6 +33,17 @@ const isLocalnet = tempoNetwork === 'localnet'
|
|
|
33
33
|
|
|
34
34
|
let escrowContract: Address
|
|
35
35
|
|
|
36
|
+
// TODO: Remove once the minimum viem version is >=2.54.0, which uses client-first Tempo call builders.
|
|
37
|
+
function tokenGetBalanceCall(parameters: { account: Address; token: Address }) {
|
|
38
|
+
const call = Actions.token.getBalance.call as unknown as {
|
|
39
|
+
length: number
|
|
40
|
+
(parameters: { account: Address; token: Address }): unknown
|
|
41
|
+
(client: unknown, parameters: { account: Address; token: Address }): unknown
|
|
42
|
+
}
|
|
43
|
+
if (call.length >= 2) return call(testClient, parameters)
|
|
44
|
+
return call(parameters)
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
beforeAll(async () => {
|
|
37
48
|
escrowContract = await deployEscrow()
|
|
38
49
|
await fundAccount({ address: accounts[4].address, token: Addresses.pathUsd })
|
|
@@ -621,7 +632,7 @@ async function getPaymentRequiredError(
|
|
|
621
632
|
async function getTokenBalance(account: Address): Promise<bigint> {
|
|
622
633
|
return readContract(
|
|
623
634
|
testClient,
|
|
624
|
-
|
|
635
|
+
tokenGetBalanceCall({ account, token: asset }) as never,
|
|
625
636
|
) as Promise<bigint>
|
|
626
637
|
}
|
|
627
638
|
|