179 lines
6.6 KiB
TypeScript
179 lines
6.6 KiB
TypeScript
import {
|
|
buildPaypleConfig,
|
|
generateOrderId,
|
|
parsePaypleTimestamp,
|
|
paypleAuth,
|
|
paypleBilling,
|
|
PaypleBillingError,
|
|
payplePaymentEventDigest,
|
|
payplePaymentEventId,
|
|
payplePayerNumber,
|
|
PaypleConfigurationError,
|
|
PaypleVerificationError,
|
|
resolvePaypleOrderDate,
|
|
} from './payple.ts'
|
|
|
|
function assert(condition: boolean, message: string): asserts condition {
|
|
if (!condition) throw new Error(message)
|
|
}
|
|
|
|
const validConfig = {
|
|
PAYPLE_ENVIRONMENT: 'live',
|
|
PAYPLE_CST_ID: 'merchant-id',
|
|
PAYPLE_CUST_KEY: 'merchant-secret',
|
|
PAYPLE_REFUND_KEY: 'refund-secret',
|
|
PAYPLE_CLIENT_KEY: 'client-key',
|
|
PAYPLE_SITE_URL: 'https://d3ro.chanpaca.net/payments',
|
|
}
|
|
|
|
Deno.test('Payple configuration has no implicit demo credential fallback', () => {
|
|
for (const missing of Object.keys(validConfig)) {
|
|
const candidate = { ...validConfig, [missing]: undefined }
|
|
let error: unknown
|
|
try {
|
|
buildPaypleConfig(candidate)
|
|
} catch (caught) {
|
|
error = caught
|
|
}
|
|
assert(error instanceof PaypleConfigurationError, `${missing} must be required`)
|
|
}
|
|
})
|
|
|
|
Deno.test('Payple environment selects only the official fixed API host', () => {
|
|
const live = buildPaypleConfig(validConfig)
|
|
assert(live.baseUrl === 'https://cpay.payple.kr', 'live host')
|
|
assert(live.siteUrl === 'https://d3ro.chanpaca.net', 'referer uses registered origin')
|
|
const test = buildPaypleConfig({ ...validConfig, PAYPLE_ENVIRONMENT: 'test' })
|
|
assert(test.baseUrl === 'https://democpay.payple.kr', 'test host')
|
|
})
|
|
|
|
Deno.test('Payple timestamps are interpreted as Korea standard time', () => {
|
|
const timestamp = parsePaypleTimestamp('20260821153045')
|
|
assert(timestamp.toISOString() === '2026-08-21T06:30:45.000Z', 'KST conversion')
|
|
assert(
|
|
parsePaypleTimestamp('2026-08-21 15:30:45').toISOString() === timestamp.toISOString(),
|
|
'documented formatted timestamp',
|
|
)
|
|
let error: unknown
|
|
try {
|
|
parsePaypleTimestamp('2026-99-99')
|
|
} catch (caught) {
|
|
error = caught
|
|
}
|
|
assert(error instanceof PaypleVerificationError, 'invalid timestamp must fail')
|
|
})
|
|
|
|
Deno.test('Payple order identifiers are unique and carry a reconcilable payment date', () => {
|
|
const first = generateOrderId('11111111-2222-4333-8444-555555555555', 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')
|
|
const second = generateOrderId('11111111-2222-4333-8444-555555555555', 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee')
|
|
assert(first !== second, 'nonce must prevent same-second collision')
|
|
assert(/^[A-Za-z0-9._-]{8,64}$/.test(first), 'Payple order syntax')
|
|
assert(resolvePaypleOrderDate(first) === first.slice(5, 13), 'order date is recoverable')
|
|
assert(resolvePaypleOrderDate('legacy-order', '20260821153045') === '20260821', 'legacy fallback date')
|
|
assert(
|
|
resolvePaypleOrderDate('D3RO-20260820153045-test-nonce', '20260821003045') === '20260821',
|
|
'authoritative Payple time wins across the Korea/UTC date boundary',
|
|
)
|
|
})
|
|
|
|
Deno.test('Payple payer number is deterministic, numeric and user-specific', async () => {
|
|
const first = await payplePayerNumber('11111111-2222-4333-8444-555555555555')
|
|
const same = await payplePayerNumber('11111111-2222-4333-8444-555555555555')
|
|
const other = await payplePayerNumber('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')
|
|
assert(/^\d{18}$/.test(first), 'payer number must satisfy Payple 18-digit contract')
|
|
assert(first === same, 'same user must map consistently')
|
|
assert(first !== other, 'different users must not share the fixture mapping')
|
|
})
|
|
|
|
Deno.test('Payple synchronous charge and webhook share one provider event identity', async () => {
|
|
const identity = {
|
|
orderId: 'D3RO-20260821153045-test-nonce',
|
|
payerId: 'payer-fixture',
|
|
payType: 'card' as const,
|
|
amount: 9900,
|
|
}
|
|
const directDigest = await payplePaymentEventDigest(identity)
|
|
const webhookDigest = await payplePaymentEventDigest({ ...identity })
|
|
const differentCharge = await payplePaymentEventDigest({ ...identity, amount: 29900 })
|
|
assert(payplePaymentEventId(identity.orderId) === `payment:${identity.orderId}`, 'canonical event id')
|
|
assert(directDigest === webhookDigest, 'same external charge must be an exact replay')
|
|
assert(directDigest !== differentCharge, 'different charge identity must not collide')
|
|
})
|
|
|
|
Deno.test('Payple auth response cannot redirect server calls to another origin', async () => {
|
|
const originalFetch = globalThis.fetch
|
|
globalThis.fetch = () => Promise.resolve(new Response(JSON.stringify({
|
|
result: 'success',
|
|
cst_id: 'encrypted-id',
|
|
custKey: 'encrypted-key',
|
|
AuthKey: 'auth-key',
|
|
PCD_PAY_HOST: 'https://cpay.payple.kr.evil.example',
|
|
PCD_PAY_URL: '/php/PayChkAct.php',
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
|
let error: unknown
|
|
try {
|
|
await paypleAuth(buildPaypleConfig(validConfig), { payCheckFlag: true })
|
|
} catch (caught) {
|
|
error = caught
|
|
} finally {
|
|
globalThis.fetch = originalFetch
|
|
}
|
|
assert(
|
|
error instanceof PaypleVerificationError
|
|
&& error.code === 'payple_untrusted_api_host',
|
|
'untrusted Payple response host must fail closed',
|
|
)
|
|
})
|
|
|
|
Deno.test('Payple billing separates definitive declines from ambiguous transport outcomes', async () => {
|
|
const config = buildPaypleConfig(validConfig)
|
|
const auth = {
|
|
PCD_CST_ID: 'encrypted-id',
|
|
PCD_CUST_KEY: 'encrypted-key',
|
|
PCD_AUTH_KEY: 'auth-key',
|
|
PCD_PAY_HOST: 'https://cpay.payple.kr',
|
|
PCD_PAY_URL: '',
|
|
}
|
|
const originalFetch = globalThis.fetch
|
|
const cases: Array<{
|
|
fetcher: typeof fetch
|
|
definitive: boolean
|
|
}> = [
|
|
{
|
|
fetcher: () => Promise.reject(new TypeError('connection reset')),
|
|
definitive: false,
|
|
},
|
|
{
|
|
fetcher: () => Promise.resolve(new Response('upstream unavailable', { status: 503 })),
|
|
definitive: false,
|
|
},
|
|
{
|
|
fetcher: () => Promise.resolve(new Response(JSON.stringify({
|
|
PCD_PAY_RST: 'error',
|
|
PCD_PAY_CODE: 'BILL0001',
|
|
PCD_PAY_MSG: 'declined',
|
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } })),
|
|
definitive: true,
|
|
},
|
|
]
|
|
try {
|
|
for (const testCase of cases) {
|
|
globalThis.fetch = testCase.fetcher
|
|
let error: unknown
|
|
try {
|
|
await paypleBilling(config, auth, {
|
|
payerId: 'payer-fixture',
|
|
amount: 9900,
|
|
orderId: 'D3RO-20260821153045-test-nonce',
|
|
goodsName: 'D3RO Voice Pro',
|
|
})
|
|
} catch (caught) {
|
|
error = caught
|
|
}
|
|
assert(error instanceof PaypleBillingError, 'billing failure must be typed')
|
|
assert(error.definitive === testCase.definitive, 'billing certainty classification')
|
|
}
|
|
} finally {
|
|
globalThis.fetch = originalFetch
|
|
}
|
|
})
|