'use strict'; const assert = require('assert'); const crypto = require('crypto'); const { GPTPAY_BASE_URL, createGptPayClient, createCardPayIdempotencyKeys, assertAccountEmail, } = require('../src/services/gptpay_service'); const KEY_ID = 'unit-test-key-id'; const API_SECRET = 'unit-test-signing-secret'; const ACCOUNT_EMAIL = 'unit.account@example.com'; const CARD_KEY = 'CARD-UNIT-FIXTURE'; const ACCESS_TOKEN = 'ACCESS-UNIT-FIXTURE'; const PREPARE_TOKEN = 'PREPARE-UNIT-FIXTURE'; const SESSION = Object.freeze({ user: Object.freeze({ id: 'user-unit-fixture', email: ACCOUNT_EMAIL, idp: 'auth0' }), expires: '2099-01-01T00:00:00.000Z', account: Object.freeze({ id: 'account-unit-fixture', planType: 'free' }), accessToken: ACCESS_TOKEN, authProvider: 'auth0', sessionToken: 'SESSION-UNIT-FIXTURE', }); const LOGIN_CONTENT = JSON.stringify(SESSION); function response(status, payload, headers = {}) { const text = typeof payload === 'string' ? payload : JSON.stringify(payload); const normalizedHeaders = new Map(Object.entries(headers).map(([key, value]) => ( [String(key).toLowerCase(), String(value)] ))); return { status, ok: status >= 200 && status < 300, headers: { get: name => normalizedHeaders.get(String(name).toLowerCase()) || null }, text: async () => text, }; } function independentSignature({ method, path, timestamp, nonce, body, secret = API_SECRET }) { const bodyHash = crypto.createHash('sha256').update(Buffer.from(body || '', 'utf8')).digest('hex'); const canonical = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyHash}`; return crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex'); } async function testPrepareSignsExactJsonBytes() { const calls = []; const fetchImpl = async (url, options) => { calls.push({ url, options }); return response(200, { success: true, checkout_ready: true, prepare_token: PREPARE_TOKEN, task_id: 125, expires_at: '2026-07-15T12:15:00+08:00', account_email: ACCOUNT_EMAIL, type: 1001, fulfillment_mode: 'card_pay', }); }; const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl, now: () => 1784044800000, nonceFactory: () => 'nonce-prepare-00000001', maxAttempts: 1, }); const keys = createCardPayIdempotencyKeys('batch-1:item-1'); const result = await client.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL.toUpperCase(), idempotencyKey: keys.prepare, }); assert.strictEqual(calls.length, 1); const call = calls[0]; assert.strictEqual(call.options.redirect, 'error', '签名请求不得自动跟随重定向'); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options, 'dispatcher'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options, 'agent'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options, 'proxy'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(JSON.parse(call.options.body), 'proxy'), false, '充值请求不得读取或发送账号保存的代理'); assert.strictEqual(call.url, `${GPTPAY_BASE_URL}/redeem/card-pay/prepare`); assert.strictEqual(call.options.method, 'POST'); const expectedBody = JSON.stringify({ card_key: CARD_KEY, access_token: LOGIN_CONTENT, idp: '', force_recharge: false, confirm_duplicate: false, }); assert.strictEqual(call.options.body, expectedBody, '签名后的 JSON 字节不得再次格式化'); assert.strictEqual(Object.prototype.hasOwnProperty.call(JSON.parse(call.options.body), 'session'), false); assert.deepStrictEqual(JSON.parse(JSON.parse(call.options.body).access_token), SESSION); assert.strictEqual(call.options.headers['X-API-Idempotency-Key'], keys.prepare); assert.strictEqual(call.options.headers['X-API-Signature'], independentSignature({ method: 'POST', path: '/api/open/v1/redeem/card-pay/prepare', timestamp: call.options.headers['X-API-Timestamp'], nonce: call.options.headers['X-API-Nonce'], body: call.options.body, })); assert.strictEqual(result.taskId, '125'); assert.strictEqual(result.accountEmail, ACCOUNT_EMAIL); assert.strictEqual(result.prepareToken, PREPARE_TOKEN); } function testStrictIdentityNormalization() { assert.strictEqual( assertAccountEmail({ account_email: 'Unit.Account@Example.com' }, ACCOUNT_EMAIL), ACCOUNT_EMAIL, ); assert.throws( () => assertAccountEmail({ account_email: 'same@gmail.com' }, 'same@example.com'), error => error && error.code === 'GPTPAY_ACCOUNT_EMAIL_MISMATCH', ); assert.strictEqual( assertAccountEmail({ account_email: 'firstlast@gmail.com' }, 'First.Last@gmail.com'), 'firstlast@gmail.com', '主系统返回 Gmail 无点号地址时应识别为同一账号', ); assert.strictEqual( assertAccountEmail({ account_email: 'firstlast@googlemail.com' }, 'First.Last@gmail.com'), 'firstlast@gmail.com', 'googlemail.com 和 gmail.com 应按同一 Gmail 域名校验', ); assert.throws( () => assertAccountEmail({ account_email: 'firstlast@example.com' }, 'first.last@example.com'), error => error && error.code === 'GPTPAY_ACCOUNT_EMAIL_MISMATCH', '非 Gmail 邮箱不得忽略用户名中的点', ); assert.throws( () => assertAccountEmail({ account_email: 'first.last+tag@gmail.com' }, 'first.last@gmail.com'), error => error && error.code === 'GPTPAY_ACCOUNT_EMAIL_MISMATCH', 'Gmail +tag 仍需严格匹配', ); } async function testRetryKeepsIdempotencyAndBodyButRefreshesSignature() { const calls = []; const replies = [ response(503, { code: 9001, error_code: 'card_lookup_failed', request_id: 'req-1' }), response(200, { success: true, checkout_ready: true, prepare_token: PREPARE_TOKEN, task_id: 126, account_email: ACCOUNT_EMAIL, type: 1001, fulfillment_mode: 'card_pay', }), ]; let nonceIndex = 0; const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async (url, options) => { calls.push({ url, options }); return replies.shift(); }, now: () => 1784044800000, nonceFactory: () => `nonce-retry-${String(++nonceIndex).padStart(8, '0')}`, retryDelayMs: 0, maxAttempts: 2, }); const key = createCardPayIdempotencyKeys('batch-1:item-2').prepare; await client.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, idempotencyKey: key, }); assert.strictEqual(calls.length, 2); assert.strictEqual(calls[0].options.body, calls[1].options.body); assert.strictEqual( calls[0].options.headers['X-API-Idempotency-Key'], calls[1].options.headers['X-API-Idempotency-Key'], ); assert.notStrictEqual(calls[0].options.headers['X-API-Nonce'], calls[1].options.headers['X-API-Nonce']); assert.notStrictEqual(calls[0].options.headers['X-API-Timestamp'], calls[1].options.headers['X-API-Timestamp']); assert.notStrictEqual(calls[0].options.headers['X-API-Signature'], calls[1].options.headers['X-API-Signature']); } async function testPrepareAndConfirmUseDifferentStableKeys() { const keys = createCardPayIdempotencyKeys('durable-batch:item-9'); assert(keys.prepare.startsWith('gptpay-cardprep-v2-')); assert(keys.confirm.startsWith('gptpay-cardconfirm-v2-')); assert.notStrictEqual(keys.prepare, keys.confirm); assert.notStrictEqual(keys.prepare, keys.prepareDuplicate); assert.notStrictEqual(keys.prepareDuplicate, keys.confirm); assert.deepStrictEqual( createCardPayIdempotencyKeys('durable-batch:item-9'), keys, '同一个持久化业务项必须生成相同的幂等键', ); const calls = []; const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async (url, options) => { calls.push({ url, options }); if (url.endsWith('/tasks/127')) { return response(200, { code: 0, success: true, task_id: 127, card_code: CARD_KEY, account: ACCOUNT_EMAIL, status: 'success', fail_reason: '', }); } return response(200, { success: true, status: 'processing', task_id: 127, fulfillment_mode: 'card_pay', type: 1001, }); }, nonceFactory: () => `nonce-confirm-${String(calls.length + 1).padStart(7, '0')}`, maxAttempts: 1, }); const result = await client.confirmCardPay({ prepareToken: PREPARE_TOKEN, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'durable-batch:item-9', }); assert.strictEqual(result.status, 'processing'); assert.strictEqual(calls[0].options.headers['X-API-Idempotency-Key'], keys.confirm); assert.deepStrictEqual(JSON.parse(calls[0].options.body), { prepare_token: PREPARE_TOKEN, access_token: LOGIN_CONTENT, idp: '', force_recharge: false, confirm_duplicate: false, }); assert.strictEqual(Object.prototype.hasOwnProperty.call(JSON.parse(calls[0].options.body), 'session'), false); const completed = await client.getTask({ taskId: result.taskId, expectedEmail: ACCOUNT_EMAIL }); assert.strictEqual(completed.status, 'success'); assert.strictEqual(completed.accountEmail, ACCOUNT_EMAIL); assert.strictEqual(JSON.stringify(completed).includes(CARD_KEY), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(calls[1].options.headers, 'X-API-Idempotency-Key'), false); await assert.rejects( client.confirmCardPay({ prepareToken: PREPARE_TOKEN, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, expectedTaskId: 126, operationId: 'durable-batch:item-9', }), error => error && error.code === 'GPTPAY_TASK_ID_MISMATCH', ); await assert.rejects( client.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, idempotencyKey: keys.confirm, }), error => error && error.code === 'GPTPAY_IDEMPOTENCY_PURPOSE_CONFLICT', ); await assert.rejects( client.prepareCardPay({ cardKey: CARD_KEY, accessToken: ACCESS_TOKEN, operationId: 'legacy-access-token:item-1', }), error => error && error.code === 'GPTPAY_INVALID_ARGUMENT', '充值接口不得再接受旧的单 accessToken 入参', ); } async function testDuplicateConfirmationIsTypedAndChecked() { const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, { success: false, requires_duplicate_confirm: true, duplicate_account: ACCOUNT_EMAIL, duplicate_redeemed_at: '2026-07-23T10:00:00+08:00', }), nonceFactory: () => 'nonce-duplicate-000001', maxAttempts: 1, }); const result = await client.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'batch-duplicate:item-1', }); assert.strictEqual(result.success, false); assert.strictEqual(result.requiresDuplicateConfirm, true); assert.strictEqual(result.duplicateAccount, ACCOUNT_EMAIL); } async function testGetTaskUsesEmptyBodySignatureAndOmitsCardCode() { const calls = []; const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async (url, options) => { calls.push({ url, options }); return response(200, { code: 0, success: true, task_id: 128, card_code: CARD_KEY, account: ACCOUNT_EMAIL, status: 'success', fail_reason: '', created_at: '2026-07-15T12:00:00+08:00', updated_at: '2026-07-15T12:00:08+08:00', }); }, now: () => 1784044802000, nonceFactory: () => 'nonce-task-query-00001', maxAttempts: 1, }); const result = await client.getTask({ taskId: 128, expectedEmail: ACCOUNT_EMAIL }); const call = calls[0]; assert.strictEqual(call.url, `${GPTPAY_BASE_URL}/tasks/128`); assert.strictEqual(call.options.method, 'GET'); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options, 'body'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options.headers, 'Content-Type'), false); assert.strictEqual(Object.prototype.hasOwnProperty.call(call.options.headers, 'X-API-Idempotency-Key'), false); assert.strictEqual(call.options.headers['X-API-Signature'], independentSignature({ method: 'GET', path: '/api/open/v1/tasks/128', timestamp: call.options.headers['X-API-Timestamp'], nonce: call.options.headers['X-API-Nonce'], body: '', })); assert.strictEqual(result.status, 'success'); assert.strictEqual(result.card_code, undefined); assert.strictEqual(result.cardCode, undefined); assert.strictEqual(JSON.stringify(result).includes(CARD_KEY), false); } async function testTimeoutIsStrictEvenWhenFetchIgnoresAbort() { const client = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => new Promise(() => {}), timeoutMs: 10, maxAttempts: 1, nonceFactory: () => 'nonce-timeout-0000001', }); const startedAt = Date.now(); await assert.rejects( client.getTask({ taskId: 129 }), error => error && error.code === 'GPTPAY_TIMEOUT' && error.retryable === true, ); assert(Date.now() - startedAt < 1000, '超时必须由客户端主动结束,不能依赖 fetch 自己返回'); } async function testStrictParsingAndSafeErrors() { const invalidJsonClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, 'not json'), maxAttempts: 1, nonceFactory: () => 'nonce-invalid-json-001', }); await assert.rejects( invalidJsonClient.getTask({ taskId: 130 }), error => error && error.code === 'GPTPAY_RESPONSE_INVALID_JSON', ); const mismatchClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, { success: true, checkout_ready: true, prepare_token: PREPARE_TOKEN, task_id: 131, account_email: 'different.account@example.com', fulfillment_mode: 'card_pay', }), maxAttempts: 1, nonceFactory: () => 'nonce-mismatch-0000001', }); await assert.rejects( mismatchClient.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'batch-mismatch:item-1', }), error => { const serialized = `${error.message} ${JSON.stringify(error)}`; return error.code === 'GPTPAY_ACCOUNT_EMAIL_MISMATCH' && error.taskId === '131' && !serialized.includes(ACCOUNT_EMAIL) && !serialized.includes('different.account@example.com') && !serialized.includes(CARD_KEY) && !serialized.includes(ACCESS_TOKEN) && !serialized.includes(PREPARE_TOKEN) && !serialized.includes(API_SECRET); }, ); const rejectedClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(401, { code: 1001, error_code: 'signature_invalid', request_id: 'safe-request-id', message: `${ACCESS_TOKEN} ${CARD_KEY} ${PREPARE_TOKEN} ${API_SECRET}`, }), maxAttempts: 1, nonceFactory: () => 'nonce-rejected-0000001', }); await assert.rejects( rejectedClient.getTask({ taskId: 132 }), error => { const serialized = `${error.message} ${JSON.stringify(error)}`; return error.code === 'GPTPAY_API_REJECTED' && error.errorCode === 'signature_invalid' && !serialized.includes(ACCESS_TOKEN) && !serialized.includes(CARD_KEY) && !serialized.includes(PREPARE_TOKEN) && !serialized.includes(API_SECRET); }, ); const transientRejectClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(503, { code: 1503, error_code: 'temporarily_unavailable', request_id: 'safe-transient-request', }), maxAttempts: 1, nonceFactory: () => 'nonce-transient-000001', }); await assert.rejects( transientRejectClient.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'batch-transient:item-1', }), error => error && error.code === 'GPTPAY_API_REJECTED' && error.httpStatus === 503 && error.retryable === true, ); const legacyBusinessRejectClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, { success: false, error: 'fulfillment_mode_mismatch', request_id: 'safe-business-request', message: `${ACCESS_TOKEN} ${CARD_KEY} ${PREPARE_TOKEN} ${API_SECRET}`, }), maxAttempts: 1, nonceFactory: () => 'nonce-business-reject-001', }); await assert.rejects( legacyBusinessRejectClient.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'batch-business-reject:item-1', }), error => { const serialized = `${error.message} ${JSON.stringify(error)}`; return error.code === 'GPTPAY_BUSINESS_REJECTED' && error.errorCode === 'fulfillment_mode_mismatch' && error.requestId === 'safe-business-request' && !serialized.includes(ACCESS_TOKEN) && !serialized.includes(CARD_KEY) && !serialized.includes(PREPARE_TOKEN) && !serialized.includes(API_SECRET); }, ); assert.strictEqual(assertAccountEmail({ account_email: ACCOUNT_EMAIL }, ACCOUNT_EMAIL.toUpperCase()), ACCOUNT_EMAIL); const contradictoryTaskClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, { code: 0, success: true, task_id: 133, account: ACCOUNT_EMAIL, status: 'success', fail_reason: 'upstream conflict', }), maxAttempts: 1, nonceFactory: () => 'nonce-conflict-0000001', }); await assert.rejects( contradictoryTaskClient.getTask({ taskId: 133, expectedEmail: ACCOUNT_EMAIL }), error => error && error.code === 'GPTPAY_RESPONSE_INVALID', ); const contradictoryPrepareClient = createGptPayClient({ keyId: KEY_ID, apiSecret: API_SECRET, fetchImpl: async () => response(200, { success: true, checkout_ready: true, prepare_token: PREPARE_TOKEN, task_id: 134, account_email: ACCOUNT_EMAIL, fulfillment_mode: 'card_pay', fail_reason: 'upstream conflict', }), maxAttempts: 1, nonceFactory: () => 'nonce-prepconflict-001', }); await assert.rejects( contradictoryPrepareClient.prepareCardPay({ cardKey: CARD_KEY, loginContent: LOGIN_CONTENT, expectedEmail: ACCOUNT_EMAIL, operationId: 'batch-prepconflict:item-1', }), error => error && error.code === 'GPTPAY_RESPONSE_INVALID' && error.taskId === '134', ); } (async () => { testStrictIdentityNormalization(); await testPrepareSignsExactJsonBytes(); await testRetryKeepsIdempotencyAndBodyButRefreshesSignature(); await testPrepareAndConfirmUseDifferentStableKeys(); await testDuplicateConfirmationIsTypedAndChecked(); await testGetTaskUsesEmptyBodySignatureAndOmitsCardCode(); await testTimeoutIsStrictEvenWhenFetchIgnoresAbort(); await testStrictParsingAndSafeErrors(); console.log('gptpay_service.test.js: PASS'); })().catch(error => { console.error(error && error.message ? error.message : 'gptpay_service.test.js: FAIL'); process.exitCode = 1; });