feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
236
apps/web/e2e/payple-checkout.spec.ts
Normal file
236
apps/web/e2e/payple-checkout.spec.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue