Files
go_bt/tests/gptpay_service.test.js

554 lines
19 KiB
JavaScript

'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, '<html>not json</html>'),
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;
});