236 lines
9 KiB
TypeScript
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)
|
|
})
|
|
})
|