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 { 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: 'Payple Crypto' }) }) 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['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() 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) }) })