402 lines
15 KiB
JavaScript
402 lines
15 KiB
JavaScript
'use strict';
|
||
|
||
const assert = require('assert');
|
||
const {
|
||
extractAccessToken,
|
||
tokenInfo,
|
||
parseUserSession,
|
||
createWorkspaceRefundClient,
|
||
executeWorkspaceRefund,
|
||
safeWorkspaceRefundFailure,
|
||
_private: refundPrivate,
|
||
} = require('../src/services/workspace_refund_service');
|
||
|
||
function jwt(payload) {
|
||
const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
||
}
|
||
|
||
function makeToken({ email, userId, accountId, exp = Math.floor(Date.now() / 1000) + 3600 }) {
|
||
return jwt({
|
||
sub: userId,
|
||
exp,
|
||
'https://api.openai.com/profile': { email, name: email.split('@')[0] },
|
||
'https://api.openai.com/auth': {
|
||
chatgpt_user_id: userId,
|
||
chatgpt_account_id: accountId,
|
||
chatgpt_plan_type: accountId.startsWith('team-') ? 'team' : 'plus',
|
||
},
|
||
});
|
||
}
|
||
|
||
function response(status, data = {}) {
|
||
return {
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
text: async () => JSON.stringify(data),
|
||
};
|
||
}
|
||
|
||
(async () => {
|
||
const adminToken = makeToken({
|
||
email: 'owner@gmail.com',
|
||
userId: 'owner-user',
|
||
accountId: 'team-001',
|
||
});
|
||
const userToken = makeToken({
|
||
email: 'Target.User@gmail.com',
|
||
userId: 'target-user',
|
||
accountId: 'personal-001',
|
||
});
|
||
const session = JSON.stringify({
|
||
user: { email: 'targetuser@gmail.com', name: 'Target' },
|
||
accessToken: userToken,
|
||
expires: new Date(Date.now() + 3600000).toISOString(),
|
||
});
|
||
|
||
assert.strictEqual(extractAccessToken(adminToken), adminToken);
|
||
assert.strictEqual(extractAccessToken(JSON.stringify({ accessToken: adminToken })), adminToken);
|
||
assert.strictEqual(tokenInfo(adminToken).accountId, 'team-001');
|
||
assert.doesNotThrow(
|
||
() => parseUserSession(session, 'Target.User.@gmail.com'),
|
||
'退款 Session 的二次 JWT 校验也必须遵循 Gmail 忽略点号规则',
|
||
);
|
||
|
||
let proxyFactoryInput = '';
|
||
const proxyCalls = [];
|
||
const proxyClient = createWorkspaceRefundClient({
|
||
proxy: 'socks5://user:pass@127.0.0.1:1080',
|
||
createProxyRequester(proxy) {
|
||
proxyFactoryInput = proxy;
|
||
return {
|
||
async request(url, options) {
|
||
proxyCalls.push({ url, options });
|
||
return { ok: true, status: 200, data: { users: [] }, text: '{"users":[]}' };
|
||
},
|
||
};
|
||
},
|
||
});
|
||
const proxyResponse = await proxyClient.request('/accounts/team-001/users', {
|
||
token: adminToken,
|
||
accountId: 'team-001',
|
||
});
|
||
assert.strictEqual(proxyResponse.ok, true);
|
||
assert.strictEqual(proxyFactoryInput, 'socks5://user:pass@127.0.0.1:1080');
|
||
assert.strictEqual(proxyCalls.length, 1);
|
||
assert.strictEqual(proxyCalls[0].options.headers.Authorization, `Bearer ${adminToken}`);
|
||
assert.match(proxyCalls[0].url, /\/backend-api\/accounts\/team-001\/users$/);
|
||
|
||
const poolProxy1 = 'socks5://user:pass@127.0.0.11:1080';
|
||
const poolProxy2 = 'socks5://user:pass@127.0.0.12:1080';
|
||
const poolCalls = [];
|
||
const poolLogs = [];
|
||
const poolClient = createWorkspaceRefundClient({
|
||
proxies: [poolProxy1, poolProxy2],
|
||
onProxyLog: message => poolLogs.push(message),
|
||
createProxyRequester(proxy) {
|
||
return {
|
||
async request() {
|
||
poolCalls.push(proxy);
|
||
if (proxy === poolProxy1) {
|
||
return {
|
||
ok: false,
|
||
status: 403,
|
||
data: null,
|
||
text: '<html>Enable JavaScript and cookies to continue</html>',
|
||
};
|
||
}
|
||
return { ok: true, status: 200, data: { users: [] }, text: '{"users":[]}' };
|
||
},
|
||
};
|
||
},
|
||
});
|
||
assert.strictEqual((await poolClient.request('/accounts/team-001/users', {
|
||
token: adminToken,
|
||
accountId: 'team-001',
|
||
})).ok, true);
|
||
assert.deepStrictEqual(poolCalls, [poolProxy1, poolProxy2]);
|
||
assert(poolLogs.some(message => /1\/2.*Cloudflare.*切换下一条/.test(message)));
|
||
await poolClient.request('/accounts/team-001/users', { token: adminToken, accountId: 'team-001' });
|
||
assert.deepStrictEqual(poolCalls, [poolProxy1, poolProxy2, poolProxy2],
|
||
'成功的代理应在后续退款请求中继续复用');
|
||
|
||
const calls = [];
|
||
const replies = [
|
||
response(200, { users: [
|
||
{ user_id: 'owner-user', role: 'owner' },
|
||
{ user_id: 'old-member', role: 'standard-user' },
|
||
] }),
|
||
response(200, {}),
|
||
response(200, { users: [
|
||
{ user_id: 'owner-user', role: 'owner' },
|
||
] }),
|
||
response(200, {
|
||
seat_capacity: [
|
||
{ type: 'default', available: 0 },
|
||
{ type: 'prolite', available: 1 },
|
||
],
|
||
seats_in_use: 1,
|
||
seats_entitled: 2,
|
||
}),
|
||
response(201, {}),
|
||
response(409, { detail: 'seat pending' }),
|
||
response(200, {}),
|
||
response(200, { users: [
|
||
{ user_id: 'owner-user', role: 'owner' },
|
||
{ user_id: 'target-user', email: 'target.user@gmail.com', role: 'standard-user' },
|
||
] }),
|
||
response(200, {}),
|
||
response(204, {}),
|
||
];
|
||
const progress = [];
|
||
const sleepDelays = [];
|
||
const result = await executeWorkspaceRefund({
|
||
adminAccessToken: adminToken,
|
||
userSessionText: session,
|
||
expectedEmail: 'Target.User@gmail.com',
|
||
fetchImpl: async (url, options) => {
|
||
calls.push({ url, method: options.method, headers: options.headers, body: options.body });
|
||
const next = replies.shift();
|
||
if (!next) throw new Error('unexpected request');
|
||
return next;
|
||
},
|
||
random: () => 0.5,
|
||
sleep: async delay => { sleepDelays.push(delay); },
|
||
onProgress: item => progress.push(item.message),
|
||
});
|
||
|
||
assert.strictEqual(result.success, true);
|
||
assert.strictEqual(result.cleanupOk, true);
|
||
assert.strictEqual(result.removedMembers, 1);
|
||
assert.strictEqual(calls.length, 10);
|
||
assert.match(calls[0].url, /\/accounts\/team-001\/users$/);
|
||
assert.strictEqual(calls[1].method, 'DELETE');
|
||
assert(!calls.some(call => /owner-user$/.test(call.url) && call.method === 'DELETE'), '不得移除母号 Owner');
|
||
assert.match(calls[4].url, /\/invites$/);
|
||
assert.strictEqual(JSON.parse(calls[4].body).seat_type, 'prolite',
|
||
'default 无空位时必须自动使用可用的 prolite 席位发出邀请');
|
||
assert(progress.some(message => message.startsWith('邀请接口返回 HTTP 201')));
|
||
assert.match(calls[8].url, /\/accounts\/transfer$/);
|
||
assert.match(calls[9].url, /\/users\/target-user$/);
|
||
assert(calls.every(call => !JSON.stringify(call).includes('signature') || call.headers.Authorization),
|
||
'Token 只能存在 Authorization 请求头中');
|
||
assert(progress.includes('正在迁移并去除个人空间'));
|
||
assert(progress.includes('接受接口返回 HTTP 200,等待 8 秒确认团队成员状态'));
|
||
assert(sleepDelays.includes(7500), '接受邀请后应随机等待 5–10 秒再迁移个人空间');
|
||
|
||
const realMemberCalls = [];
|
||
await assert.rejects(
|
||
executeWorkspaceRefund({
|
||
adminAccessToken: adminToken,
|
||
userSessionText: session,
|
||
expectedEmail: 'Target.User@gmail.com',
|
||
client: {
|
||
async request(path, options) {
|
||
realMemberCalls.push({ path, method: options.method });
|
||
if (/\/users\/account-member$/.test(path) && options.method === 'DELETE') {
|
||
return { ok: true, status: 204, data: {} };
|
||
}
|
||
if (/\/accounts\/team-001\/users$/.test(path)) {
|
||
const afterCleanup = realMemberCalls.some(call => /\/users\/account-member$/.test(call.path));
|
||
return { ok: true, status: 200, data: { users: afterCleanup ? [
|
||
{ account_user_id: 'account-owner-current', id: 'owner-user', role: 'account-owner' },
|
||
{ account_user_id: 'account-owner-other', id: 'other-owner', role: 'account-owner' },
|
||
] : [
|
||
{ account_user_id: 'account-owner-current', id: 'owner-user', role: 'account-owner' },
|
||
{ account_user_id: 'account-owner-other', id: 'other-owner', role: 'account-owner' },
|
||
{ account_user_id: 'account-member', id: 'legacy-member-id', role: 'standard-user' },
|
||
] } };
|
||
}
|
||
if (/\/subscriptions\?/.test(path)) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
data: {
|
||
seats_in_use: 2,
|
||
seats_entitled: 2,
|
||
seat_capacity: [
|
||
{ type: 'default', available: 0 },
|
||
{ type: 'prolite', available: 0 },
|
||
],
|
||
},
|
||
};
|
||
}
|
||
throw new Error(`unexpected request: ${path}`);
|
||
},
|
||
},
|
||
sleep: async () => {},
|
||
}),
|
||
error => error
|
||
&& error.code === 'REFUND_NO_AVAILABLE_SEAT'
|
||
&& /已使用 2\/2/.test(error.message)
|
||
&& /剩余 2 个所有者/.test(error.message),
|
||
);
|
||
assert(realMemberCalls.some(call => /\/users\/account-member$/.test(call.path)),
|
||
'真实团队成员结构必须优先使用 account_user_id 删除');
|
||
assert(!realMemberCalls.some(call => /account-owner-(?:current|other)$/.test(call.path)),
|
||
'不得自动删除任何 account-owner');
|
||
assert.match(
|
||
safeWorkspaceRefundFailure({
|
||
code: 'REFUND_NO_AVAILABLE_SEAT',
|
||
message: '母号团队没有可用座位(已使用 2/2;剩余 2 个所有者)',
|
||
}).message,
|
||
/已使用 2\/2;剩余 2 个所有者/,
|
||
);
|
||
|
||
const fallbackOldToken = makeToken({
|
||
email: 'fallback.user@gmail.com',
|
||
userId: 'fallback-user',
|
||
accountId: 'personal-fallback',
|
||
exp: Math.floor(Date.now() / 1000) - 10,
|
||
});
|
||
const fallbackNewToken = makeToken({
|
||
email: 'fallback.user@gmail.com',
|
||
userId: 'fallback-user',
|
||
accountId: 'personal-fallback',
|
||
exp: Math.floor(Date.now() / 1000) + 7200,
|
||
});
|
||
const fallbackSession = JSON.stringify({
|
||
user: { email: 'fallback.user@gmail.com' },
|
||
accessToken: fallbackOldToken,
|
||
sessionToken: 'fallback-session-token',
|
||
});
|
||
const fallbackUpdates = [];
|
||
const fallbackTransferAccountIds = [];
|
||
let fallbackAccepted = false;
|
||
const fallbackResult = await executeWorkspaceRefund({
|
||
adminAccessToken: adminToken,
|
||
userSessionText: fallbackSession,
|
||
userAuthJsonText: '',
|
||
expectedEmail: 'fallbackuser@gmail.com',
|
||
client: {
|
||
async request(path, options) {
|
||
if (/\/accounts\/team-001\/users$/.test(path)) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
data: { users: fallbackAccepted ? [
|
||
{ user_id: 'fallback-user', email: 'fallback.user@gmail.com', role: 'standard-user' },
|
||
] : [] },
|
||
};
|
||
}
|
||
if (/\/subscriptions\?/.test(path)) {
|
||
return { ok: true, status: 200, data: { seat_capacity: [{ available: 1 }] } };
|
||
}
|
||
if (/\/invites$/.test(path)) return { ok: true, status: 201, data: {} };
|
||
if (/\/invites\/accept$/.test(path)) {
|
||
if (options.token === fallbackOldToken) return { ok: false, status: 401, data: {} };
|
||
fallbackAccepted = true;
|
||
return { ok: true, status: 200, data: {} };
|
||
}
|
||
if (path === '/accounts/transfer') {
|
||
fallbackTransferAccountIds.push(options.accountId == null ? null : options.accountId);
|
||
return options.accountId === 'personal-fallback'
|
||
? { ok: true, status: 200, data: {} }
|
||
: { ok: false, status: 401, data: { error: { code: 'invalid_workspace_selected' } } };
|
||
}
|
||
if (/\/users\//.test(path) && options.method === 'DELETE') {
|
||
return { ok: true, status: 204, data: {} };
|
||
}
|
||
return { ok: true, status: 200, data: { users: [] } };
|
||
},
|
||
},
|
||
credentialRequester: {
|
||
async request(url) {
|
||
assert.match(url, /\/api\/auth\/session\?refresh=true/);
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
data: {
|
||
user: { email: 'fallback.user@gmail.com' },
|
||
accessToken: fallbackNewToken,
|
||
expires: new Date(Date.now() + 7200000).toISOString(),
|
||
},
|
||
};
|
||
},
|
||
},
|
||
sleep: async () => {},
|
||
onUserCredentialsUpdated: async updated => fallbackUpdates.push(updated),
|
||
});
|
||
assert.strictEqual(fallbackResult.success, true, '退款没有授权 JSON 时仍应使用 sessionToken');
|
||
assert.strictEqual(fallbackUpdates.length, 1);
|
||
assert.strictEqual(JSON.parse(fallbackUpdates[0].sessionText).accessToken, fallbackNewToken);
|
||
assert.strictEqual(JSON.parse(fallbackUpdates[0].sessionText).sessionToken, 'fallback-session-token');
|
||
assert.deepStrictEqual(fallbackTransferAccountIds, ['team-001', 'personal-fallback'],
|
||
'迁移使用母号工作区返回 401 后,应改用目标账号原个人工作区重试');
|
||
assert.strictEqual(
|
||
refundPrivate.safeResponseMessage({ error: { code: 'invalid_workspace_selected' } }),
|
||
'{"error":{"code":"invalid_workspace_selected"}}',
|
||
'嵌套接口错误必须展开为可读 JSON,不能显示 [object Object]',
|
||
);
|
||
assert.strictEqual(refundPrivate.isAccessTokenExpired(fallbackOldToken), true);
|
||
assert.strictEqual(refundPrivate.isAccessTokenExpired(fallbackNewToken), false);
|
||
assert.strictEqual(
|
||
refundPrivate.shouldRefreshAccessToken(
|
||
{ status: 401, data: { error: { code: 'invalid_workspace_selected' } }, message: 'invalid workspace' },
|
||
fallbackNewToken,
|
||
),
|
||
false,
|
||
'有效 Token 遇到工作区 401 时不得错误触发凭据降级',
|
||
);
|
||
assert.strictEqual(
|
||
refundPrivate.shouldRefreshAccessToken(
|
||
{ status: 401, data: { code: 'token_expired' }, message: 'token expired' },
|
||
fallbackNewToken,
|
||
),
|
||
true,
|
||
'只有明确的 Token 失效错误才应触发凭据刷新',
|
||
);
|
||
assert.strictEqual(
|
||
refundPrivate.inviteFailureMessage({
|
||
account_invites: [],
|
||
errored_emails: [{
|
||
email_address: 'Fiorelli.Vlach@gmail.com',
|
||
error: 'Unable to invite user due to an error.',
|
||
}],
|
||
}, 'fiorellivlach@gmail.com'),
|
||
'Unable to invite user due to an error.',
|
||
'邀请接口 HTTP 200 时仍必须识别 Gmail 点号规则下的单邮箱业务失败',
|
||
);
|
||
|
||
let mismatchCalls = 0;
|
||
await assert.rejects(
|
||
executeWorkspaceRefund({
|
||
adminAccessToken: adminToken,
|
||
userSessionText: session,
|
||
expectedEmail: 'someone-else@gmail.com',
|
||
fetchImpl: async () => { mismatchCalls++; return response(500); },
|
||
sleep: async () => {},
|
||
}),
|
||
error => error && error.code === 'REFUND_SESSION_ACCOUNT_MISMATCH',
|
||
);
|
||
assert.strictEqual(mismatchCalls, 0, 'Session 邮箱不一致时不得发送任何外部请求');
|
||
|
||
const expiredAdmin = makeToken({
|
||
email: 'owner@gmail.com',
|
||
userId: 'owner-user',
|
||
accountId: 'team-001',
|
||
exp: Math.floor(Date.now() / 1000) - 10,
|
||
});
|
||
assert.throws(() => tokenInfo(expiredAdmin), error => error.code === 'REFUND_ADMIN_TOKEN_EXPIRED');
|
||
assert.strictEqual(
|
||
safeWorkspaceRefundFailure({ code: 'REFUND_TRANSFER_FAILED' }).message,
|
||
'去除个人空间失败',
|
||
);
|
||
assert.strictEqual(
|
||
safeWorkspaceRefundFailure({
|
||
code: 'REFUND_INVITE_FAILED',
|
||
message: '邀请目标账号失败(HTTP 400):unsupported seat type',
|
||
}).message,
|
||
'邀请目标账号失败(HTTP 400):unsupported seat type',
|
||
);
|
||
assert.strictEqual(
|
||
safeWorkspaceRefundFailure({ code: 'REFUND_CLOUDFLARE_BLOCKED' }).message,
|
||
'退款接口被 Cloudflare 拦截(HTTP 403)',
|
||
);
|
||
|
||
console.log('workspace_refund_service.test.js: PASS');
|
||
})().catch(error => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|