'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: 'Enable JavaScript and cookies to continue', }; } 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; });