Files
go_bt/tests/workspace_refund_service.test.js

402 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
});