d3ro-voice/apps/web/e2e/payple-checkout.spec.ts
2026-08-29 18:33:45 +09:00

236 lines
9 KiB
TypeScript

import { expect, test } from '@playwright/test'
import {
assertSuccessfulCheckoutResponse,
clearPaypleIdempotencyKey,
createPaypleAuthRequest,
getOrCreatePaypleIdempotencyKey,
payplePayerNumber,
PaypleClientError,
runPaypleRegistration,
type PaypleAuthRequest
} from '../src/components/billing/payple-client'
const USER_ID = '11111111-2222-4333-8444-555555555555'
const OTHER_USER_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
const EXPECTED_PAYER_NUMBER = '957057365735311784'
function baseOptions() {
return {
clientKey: 'test-client-key',
userId: USER_ID,
email: 'payer@example.test',
tier: 'pro' as const,
catalogPrice: {
provider: 'payple' as const,
unitAmount: 9900,
currency: 'KRW',
interval: 'month',
intervalCount: 1
},
resultUrl: 'https://d3ro.chanpaca.net/billing'
}
}
async function waitForRequest(read: () => PaypleAuthRequest | null): Promise<PaypleAuthRequest> {
await expect.poll(() => read()).not.toBeNull()
return read()!
}
test.describe('Payple payer number and fail-closed checkout', () => {
test('matches the server SHA-256/BigInt vector and official 18-digit shape', async () => {
await expect(payplePayerNumber(USER_ID)).resolves.toBe(EXPECTED_PAYER_NUMBER)
await expect(payplePayerNumber(USER_ID)).resolves.toBe(EXPECTED_PAYER_NUMBER)
await expect(payplePayerNumber(OTHER_USER_ID)).resolves.toBe('891654841482901925')
await expect(payplePayerNumber(USER_ID)).resolves.toMatch(/^\d{18}$/)
})
test('Chromium Web Crypto independently produces the same payer number', async ({ page }) => {
await page.route('http://127.0.0.1/payple-crypto', async (route) => {
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Payple Crypto</title>' })
})
await page.goto('http://127.0.0.1/payple-crypto')
const browserValue = await page.evaluate(async (userId) => {
const bytes = new TextEncoder().encode(`d3ro-payple:${userId}`)
const digest = await crypto.subtle.digest('SHA-256', bytes)
const hex = Array.from(new Uint8Array(digest))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
return (BigInt(`0x${hex}`) % 1_000_000_000_000_000_000n)
.toString()
.padStart(18, '0')
}, USER_ID)
expect(browserValue).toBe(EXPECTED_PAYER_NUMBER)
})
test('rejects malformed user identifiers before opening the SDK', async () => {
for (const userId of ['', 'not-a-uuid', `${USER_ID} `, '11111111-2222-0333-8444-555555555555']) {
let opened = false
await expect(runPaypleRegistration({
...baseOptions(),
userId,
openSdk: () => { opened = true },
chargeBillingKey: async () => undefined,
timeoutMs: 50
})).rejects.toMatchObject({ code: 'invalid_user' })
expect(opened).toBe(false)
}
await expect(payplePayerNumber(USER_ID, null)).rejects.toMatchObject({ code: 'crypto_unavailable' })
await expect(runPaypleRegistration({
...baseOptions(),
clientKey: ' ',
openSdk: () => { throw new Error('must not open') },
chargeBillingKey: async () => { throw new Error('must not charge') },
timeoutMs: 50
})).rejects.toMatchObject({ code: 'not_configured' })
})
test('never exposes the raw UUID in the Payple registration request', async () => {
const request = await createPaypleAuthRequest(baseOptions())
expect(request.PCD_PAYER_NO).toBe(EXPECTED_PAYER_NUMBER)
expect(request.PCD_PAYER_NO).toMatch(/^\d{18}$/)
expect(JSON.stringify(request)).not.toContain(USER_ID)
})
test('rejects missing or tampered catalog price before opening Payple', async () => {
for (const catalogPrice of [
{ provider: 'stripe', unitAmount: 9900, currency: 'KRW', interval: 'month', intervalCount: 1 },
{ provider: 'payple', unitAmount: 1, currency: 'USD', interval: 'month', intervalCount: 1 },
{ provider: 'payple', unitAmount: 9900, currency: 'KRW', interval: 'year', intervalCount: 1 },
{ provider: 'payple', unitAmount: 0, currency: 'KRW', interval: 'month', intervalCount: 1 }
]) {
let opened = false
await expect(runPaypleRegistration({
...baseOptions(),
catalogPrice: catalogPrice as ReturnType<typeof baseOptions>['catalogPrice'],
openSdk: () => { opened = true },
chargeBillingKey: async () => undefined
})).rejects.toMatchObject({ code: 'checkout_response_invalid' })
expect(opened).toBe(false)
}
})
test('reuses a pending key and rotates it only when payer or tier changes', () => {
const values = new Map<string, string>()
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value) },
removeItem: (key: string) => { values.delete(key) }
}
const uuids = [
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'33333333-3333-4333-8333-333333333333'
]
let index = 0
const randomUuid = () => uuids[index++]!
const first = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro', randomUuid)
const retry = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro', randomUuid)
const changedTier = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro_plus', randomUuid)
const changedUser = getOrCreatePaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', randomUuid)
expect(retry).toBe(first)
expect(changedTier).not.toBe(first)
expect(changedUser).not.toBe(changedTier)
clearPaypleIdempotencyKey(storage, USER_ID, 'pro', first)
expect(getOrCreatePaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', randomUuid)).toBe(changedUser)
clearPaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', changedUser)
expect(values.size).toBe(0)
})
test('completes one server charge for a successful SDK callback', async () => {
let request: PaypleAuthRequest | null = null
let chargeCount = 0
let chargedPayerId = ''
const checkout = runPaypleRegistration({
...baseOptions(),
openSdk: (candidate) => { request = candidate },
chargeBillingKey: async (payerId) => {
chargeCount += 1
chargedPayerId = payerId
},
timeoutMs: 1000
})
const captured = await waitForRequest(() => request)
captured.callbackFunction({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'billing-key-1' })
captured.callbackFunction({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'billing-key-2' })
await checkout
expect(chargeCount).toBe(1)
expect(chargedPayerId).toBe('billing-key-1')
})
test('cancellation and malformed success never call the charge endpoint', async () => {
for (const result of [
{ PCD_PAY_RST: 'error', PCD_PAY_MSG: '사용자 취소' },
{ PCD_PAY_RST: 'success' },
null
]) {
let request: PaypleAuthRequest | null = null
let charged = false
const checkout = runPaypleRegistration({
...baseOptions(),
openSdk: (candidate) => { request = candidate },
chargeBillingKey: async () => { charged = true },
timeoutMs: 1000
})
const rejection = checkout.catch((error: unknown) => error)
const captured = await waitForRequest(() => request)
captured.callbackFunction(result)
const error = await rejection
expect(error).toBeInstanceOf(PaypleClientError)
expect(['cancelled', 'missing_billing_key']).toContain((error as PaypleClientError).code)
expect(charged).toBe(false)
}
})
test('SDK throw and timeout remain failures without charging', async () => {
let charged = false
await expect(runPaypleRegistration({
...baseOptions(),
openSdk: () => { throw new Error('sdk exploded') },
chargeBillingKey: async () => { charged = true },
timeoutMs: 1000
})).rejects.toMatchObject({ code: 'sdk_failed' })
expect(charged).toBe(false)
await expect(runPaypleRegistration({
...baseOptions(),
openSdk: () => undefined,
chargeBillingKey: async () => { charged = true },
timeoutMs: 10
})).rejects.toMatchObject({ code: 'sdk_timeout' })
expect(charged).toBe(false)
})
test('requires an exact successful server confirmation', () => {
expect(() => assertSuccessfulCheckoutResponse({
success: true,
tier: 'pro',
order_id: 'D3RO-20260821-order',
amount: 9900
}, 'pro', 9900)).not.toThrow()
for (const response of [
null,
{},
{ success: false, tier: 'pro', order_id: 'order', amount: 9900 },
{ success: true, tier: 'pro_plus', order_id: 'order', amount: 9900 },
{ success: true, tier: 'pro', order_id: '', amount: 9900 },
{ success: true, tier: 'pro', order_id: 'order', amount: 1 }
]) {
expect(() => assertSuccessfulCheckoutResponse(response, 'pro', 9900)).toThrow(PaypleClientError)
}
expect(() => assertSuccessfulCheckoutResponse({
success: true,
tier: 'pro',
order_id: 'order',
amount: 9900
}, 'pro', 29900)).toThrow(PaypleClientError)
})
})