From a41997325dbb3b904740fec25be0e01e693ea054 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Thu, 14 May 2026 08:03:55 +0800 Subject: [PATCH] Use direct SUB2API admin API flow --- background.js | 8 + background/message-router.js | 3 + background/panel-bridge.js | 70 +- background/steps/platform-verify.js | 82 +-- background/sub2api-api.js | 606 ++++++++++++++++++ ...und-cloudflare-temp-email-settings.test.js | 4 + tests/background-panel-bridge-module.test.js | 119 +++- ...ckground-platform-verify-codex2api.test.js | 152 +++-- ...background-platform-verify-cpa-api.test.js | 181 ++++-- 9 files changed, 989 insertions(+), 236 deletions(-) create mode 100644 background/sub2api-api.js diff --git a/background.js b/background.js index 8529b90..ab143b4 100644 --- a/background.js +++ b/background.js @@ -17,6 +17,7 @@ importScripts( 'background/paypal-account-store.js', 'background/ip-proxy-provider-711proxy.js', 'background/ip-proxy-core.js', + 'background/sub2api-api.js', 'background/panel-bridge.js', 'background/registration-email-state.js', 'background/runtime-state.js', @@ -927,6 +928,7 @@ const DEFAULT_STATE = { sub2apiSessionId: null, // SUB2API OpenAI Auth 会话 ID。 sub2apiOAuthState: null, // SUB2API OpenAI Auth state。 sub2apiGroupId: null, // SUB2API 目标分组 ID。 + sub2apiGroupIds: [], // SUB2API 多目标分组 ID。 sub2apiDraftName: null, // SUB2API 本轮预生成的账号名称。 sub2apiProxyId: null, // SUB2API 本轮使用的代理 ID。 codex2apiSessionId: null, // Codex2API OAuth 会话 ID。 @@ -8270,7 +8272,9 @@ function getDownstreamStateResets(step, state = {}) { sub2apiSessionId: null, sub2apiOAuthState: null, sub2apiGroupId: null, + sub2apiGroupIds: [], sub2apiDraftName: null, + sub2apiProxyId: null, codex2apiSessionId: null, codex2apiOAuthState: null, flowStartTime: null, @@ -9171,6 +9175,9 @@ async function handleStepData(step, payload) { if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds) + ? payload.sub2apiGroupIds + : []; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; @@ -11849,6 +11856,7 @@ const step10Executor = self.MultiPageBackgroundStep10?.createStep10Executor({ sendToContentScript, sendToContentScriptResilient, shouldBypassStep9ForLocalCpa, + DEFAULT_SUB2API_GROUP_NAME, SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); const stepExecutorsByKey = { diff --git a/background/message-router.js b/background/message-router.js index 336aae9..9781d56 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -435,6 +435,9 @@ if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null; if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null; if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null; + if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds) + ? payload.sub2apiGroupIds + : []; if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null; if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null; if (payload.cpaOAuthState !== undefined) updates.cpaOAuthState = payload.cpaOAuthState || null; diff --git a/background/panel-bridge.js b/background/panel-bridge.js index 03b5bc9..dd553fa 100644 --- a/background/panel-bridge.js +++ b/background/panel-bridge.js @@ -19,6 +19,25 @@ SUB2API_STEP1_RESPONSE_TIMEOUT_MS, } = deps; + let sub2ApiApi = null; + + function getSub2ApiApi() { + if (sub2ApiApi) { + return sub2ApiApi; + } + const factory = deps.createSub2ApiApi + || self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi; + if (typeof factory !== 'function') { + throw new Error('SUB2API 直连接口模块未加载,无法生成 OAuth 链接。'); + } + sub2ApiApi = factory({ + addLog, + normalizeSub2ApiUrl, + DEFAULT_SUB2API_GROUP_NAME, + }); + return sub2ApiApi; + } + function normalizeAdminKey(value = '') { return String(value || '').trim(); } @@ -250,7 +269,6 @@ async function requestSub2ApiOAuthUrl(state, options = {}) { const { logLabel = 'OAuth 刷新' } = options; const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl); - const groupName = (state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME).trim() || DEFAULT_SUB2API_GROUP_NAME; if (!sub2apiUrl) { throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); @@ -262,54 +280,14 @@ throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); } - await addLog(`${logLabel}:正在打开 SUB2API 后台...`); - - const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl); - - const tab = typeof createAutomationTab === 'function' - ? await createAutomationTab({ url: sub2apiUrl, active: true }) - : await chrome.tabs.create({ url: sub2apiUrl, active: true }); - const tabId = tab.id; - await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); - - await addLog(`${logLabel}:SUB2API 页面已打开,正在等待页面进入目标地址...`); - const matchedTab = await waitForTabUrlFamily('sub2api-panel', tabId, sub2apiUrl, { - timeoutMs: 15000, - retryDelayMs: 400, - }); - if (!matchedTab) { - await addLog(`${logLabel}:SUB2API 页面尚未稳定,继续尝试连接内容脚本...`, 'warn'); - } - - await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { - inject: injectFiles, - injectSource: 'sub2api-panel', - timeoutMs: 45000, - retryDelayMs: 900, - logMessage: `${logLabel}:SUB2API 页面仍在加载,正在重试连接内容脚本...`, - }); - - const result = await sendToContentScript('sub2api-panel', { - type: 'REQUEST_OAUTH_URL', - source: 'background', - payload: { + const api = getSub2ApiApi(); + return api.generateOpenAiAuthUrl({ + ...state, sub2apiUrl, - sub2apiEmail: state.sub2apiEmail, - sub2apiPassword: state.sub2apiPassword, - sub2apiGroupName: groupName, - sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - sub2apiAccountPriority: state.sub2apiAccountPriority, - logStep: 7, - }, }, { - responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, + logLabel, + timeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS, }); - - if (result?.error) { - throw new Error(result.error); - } - return result || {}; } return { diff --git a/background/steps/platform-verify.js b/background/steps/platform-verify.js index 2d39999..6654265 100644 --- a/background/steps/platform-verify.js +++ b/background/steps/platform-verify.js @@ -19,9 +19,29 @@ sendToContentScript, sendToContentScriptResilient, shouldBypassStep9ForLocalCpa, + DEFAULT_SUB2API_GROUP_NAME = 'codex', SUB2API_STEP9_RESPONSE_TIMEOUT_MS, } = deps; + let sub2ApiApi = null; + + function getSub2ApiApi() { + if (sub2ApiApi) { + return sub2ApiApi; + } + const factory = deps.createSub2ApiApi + || self.MultiPageBackgroundSub2ApiApi?.createSub2ApiApi; + if (typeof factory !== 'function') { + throw new Error('SUB2API 直连接口模块未加载,无法提交回调。'); + } + sub2ApiApi = factory({ + addLog, + normalizeSub2ApiUrl, + DEFAULT_SUB2API_GROUP_NAME, + }); + return sub2ApiApi; + } + function normalizeString(value = '') { return String(value || '').trim(); } @@ -346,62 +366,22 @@ if (!sub2apiUrl) { throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); } - const injectFiles = ['content/utils.js', 'content/sub2api-panel.js']; - - await addStepLog(visibleStep, '正在打开 SUB2API 后台...'); - - let tabId = await getTabId('sub2api-panel'); - const alive = tabId && await isTabAlive('sub2api-panel'); - - if (!alive) { - tabId = await reuseOrCreateTab('sub2api-panel', sub2apiUrl, { - inject: injectFiles, - injectSource: 'sub2api-panel', - reloadIfSameUrl: true, - }); - } else { - await closeConflictingTabsForSource('sub2api-panel', sub2apiUrl, { excludeTabIds: [tabId] }); - await chrome.tabs.update(tabId, { active: true }); - await rememberSourceLastUrl('sub2api-panel', sub2apiUrl); - } - - await ensureContentScriptReadyOnTab('sub2api-panel', tabId, { - inject: injectFiles, - injectSource: 'sub2api-panel', - }); - - await addStepLog(visibleStep, '正在向 SUB2API 提交回调并创建账号...'); - const requestMessage = { - type: 'EXECUTE_STEP', - step: platformVerifyStep, - source: 'background', - payload: { - localhostUrl: state.localhostUrl, - visibleStep, - sub2apiUrl, - sub2apiEmail: state.sub2apiEmail, - sub2apiPassword: state.sub2apiPassword, - sub2apiGroupName: state.sub2apiGroupName, - sub2apiDefaultProxyName: state.sub2apiDefaultProxyName, - sub2apiAccountPriority: state.sub2apiAccountPriority, - sub2apiProxyId: state.sub2apiProxyId, - sub2apiSessionId: state.sub2apiSessionId, - sub2apiOAuthState: state.sub2apiOAuthState, - sub2apiGroupId: state.sub2apiGroupId, - sub2apiGroupIds: state.sub2apiGroupIds, - sub2apiDraftName: state.sub2apiDraftName, - }, - }; + const api = getSub2ApiApi(); const maxExchangeAttempts = 3; let lastError = null; for (let attempt = 1; attempt <= maxExchangeAttempts; attempt += 1) { try { - const result = await sendToContentScript('sub2api-panel', requestMessage, { - responseTimeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, + const result = await api.submitOpenAiCallback({ + ...state, + visibleStep, + sub2apiUrl, + }, { + visibleStep, + logLabel: `步骤 ${visibleStep}`, + logOptions: { step: visibleStep, stepKey: 'platform-verify' }, + timeoutMs: SUB2API_STEP9_RESPONSE_TIMEOUT_MS, }); - if (result?.error) { - throw new Error(result.error); - } + await completeStepFromBackground(platformVerifyStep, result); return; } catch (error) { lastError = error; diff --git a/background/sub2api-api.js b/background/sub2api-api.js new file mode 100644 index 0000000..b23ade1 --- /dev/null +++ b/background/sub2api-api.js @@ -0,0 +1,606 @@ +(function attachBackgroundSub2ApiApi(root, factory) { + root.MultiPageBackgroundSub2ApiApi = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundSub2ApiApiModule() { + function createSub2ApiApi(deps = {}) { + const { + addLog = async () => {}, + normalizeSub2ApiUrl = (value) => value, + DEFAULT_SUB2API_GROUP_NAME = 'codex', + fetchImpl = (...args) => fetch(...args), + } = deps; + + const DEFAULT_REDIRECT_URI = 'http://localhost:1455/auth/callback'; + const DEFAULT_PROXY_NAME = ''; + const DEFAULT_CONCURRENCY = 10; + const DEFAULT_PRIORITY = 1; + const DEFAULT_RATE_MULTIPLIER = 1; + + function normalizeString(value = '') { + return String(value || '').trim(); + } + + function extractStateFromAuthUrl(authUrl = '') { + try { + return new URL(authUrl).searchParams.get('state') || ''; + } catch { + return ''; + } + } + + function normalizeRedirectUri(input = DEFAULT_REDIRECT_URI) { + const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`; + const parsed = new URL(withProtocol); + if (!parsed.pathname || parsed.pathname === '/') { + parsed.pathname = '/auth/callback'; + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('SUB2API 回调地址必须是 /auth/callback,例如 http://localhost:1455/auth/callback'); + } + return parsed.toString(); + } + + function getSub2ApiOrigin(rawUrl = '') { + const sub2apiUrl = normalizeSub2ApiUrl(rawUrl); + if (!sub2apiUrl) { + throw new Error('SUB2API URL is not configured. Please fill it in the side panel first.'); + } + try { + return new URL(sub2apiUrl).origin; + } catch { + throw new Error('SUB2API URL 格式无效,请先在侧边栏检查。'); + } + } + + function getSub2ApiErrorMessage(payload, responseStatus = 500, path = '') { + const candidates = [ + payload?.message, + payload?.detail, + payload?.error, + payload?.reason, + ]; + const message = candidates.map(normalizeString).find(Boolean); + return message || `SUB2API 请求失败(HTTP ${responseStatus}):${path}`; + } + + async function requestJson(origin, path, options = {}) { + const controller = new AbortController(); + const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const token = normalizeString(options.token); + const response = await fetchImpl(`${origin}${path}`, { + method: options.method || 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + + if (payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'code')) { + if (Number(payload.code) === 0) { + return payload.data; + } + throw new Error(getSub2ApiErrorMessage(payload, response.status, path)); + } + + if (!response.ok) { + throw new Error(getSub2ApiErrorMessage(payload, response.status, path)); + } + + return payload; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`SUB2API 请求超时:${path}`); + } + throw error; + } finally { + clearTimeout(timer); + } + } + + async function loginSub2Api(state = {}, options = {}) { + const email = normalizeString(state.sub2apiEmail); + const password = String(state.sub2apiPassword || ''); + const origin = getSub2ApiOrigin(state.sub2apiUrl); + + if (!email) { + throw new Error('尚未配置 SUB2API 登录邮箱,请先在侧边栏填写。'); + } + if (!password) { + throw new Error('尚未配置 SUB2API 登录密码,请先在侧边栏填写。'); + } + + const loginData = await requestJson(origin, '/api/v1/auth/login', { + method: 'POST', + timeoutMs: options.timeoutMs, + body: { email, password }, + }); + + const token = normalizeString(loginData?.access_token || loginData?.accessToken); + if (!token) { + throw new Error('SUB2API 登录返回缺少 access_token。'); + } + + return { + origin, + token, + user: loginData?.user || null, + }; + } + + function normalizeSub2ApiGroupNames(value) { + const source = Array.isArray(value) + ? value + : String(value || '').split(/[\r\n,,;;]+/); + const seen = new Set(); + const names = []; + for (const item of source) { + const name = normalizeString(item); + const key = name.toLowerCase(); + if (!name || seen.has(key)) continue; + seen.add(key); + names.push(name); + } + return names.length ? names : [DEFAULT_SUB2API_GROUP_NAME]; + } + + async function getGroupsByNames(origin, token, groupNames, options = {}) { + const targetNames = normalizeSub2ApiGroupNames(groupNames); + const groups = await requestJson(origin, '/api/v1/admin/groups/all', { + method: 'GET', + token, + timeoutMs: options.timeoutMs, + }); + const matched = []; + const missing = []; + + for (const targetName of targetNames) { + const normalized = targetName.toLowerCase(); + const group = (Array.isArray(groups) ? groups : []).find((item) => { + const itemName = normalizeString(item?.name).toLowerCase(); + if (!itemName || itemName !== normalized) return false; + return !item.platform || item.platform === 'openai'; + }); + if (group) { + matched.push(group); + } else { + missing.push(targetName); + } + } + + if (missing.length) { + throw new Error(`SUB2API 中未找到以下 openai 分组:${missing.join('、')}。`); + } + + return matched; + } + + function normalizeSub2ApiProxyPreference(value) { + return normalizeString(value); + } + + function resolveSub2ApiProxyPreference(state = {}) { + if (state.sub2apiDefaultProxyName !== undefined) { + return normalizeSub2ApiProxyPreference(state.sub2apiDefaultProxyName); + } + return DEFAULT_PROXY_NAME; + } + + function resolveSub2ApiAccountPriority(state = {}) { + const rawValue = normalizeString(state.sub2apiAccountPriority); + if (!rawValue) { + return DEFAULT_PRIORITY; + } + const numeric = Number(rawValue); + if (!Number.isSafeInteger(numeric) || numeric < 1) { + throw new Error('SUB2API 账号优先级必须是大于等于 1 的整数。'); + } + return numeric; + } + + function normalizeProxyId(value) { + if (value === undefined || value === null || value === '') { + return null; + } + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) { + return null; + } + return normalized; + } + + function buildProxyDisplayName(proxy = {}) { + const id = normalizeProxyId(proxy.id); + const name = normalizeString(proxy.name); + const protocol = normalizeString(proxy.protocol); + const host = normalizeString(proxy.host); + const port = proxy.port === undefined || proxy.port === null ? '' : normalizeString(proxy.port); + const address = protocol && host && port ? `${protocol}://${host}:${port}` : ''; + return [ + name || '(未命名代理)', + id ? `#${id}` : '', + address, + ].filter(Boolean).join(' '); + } + + function buildProxySearchText(proxy = {}) { + return [ + proxy.id, + proxy.name, + proxy.protocol, + proxy.host, + proxy.port, + buildProxyDisplayName(proxy), + ] + .filter((value) => value !== undefined && value !== null && value !== '') + .map((value) => normalizeString(value).toLowerCase()) + .filter(Boolean) + .join(' '); + } + + function isActiveProxy(proxy = {}) { + const status = normalizeString(proxy.status).toLowerCase(); + return !status || status === 'active'; + } + + function findSub2ApiProxy(proxies = [], preference = '') { + const activeProxies = (Array.isArray(proxies) ? proxies : []) + .filter(isActiveProxy) + .filter((proxy) => normalizeProxyId(proxy.id)); + const normalizedPreference = normalizeSub2ApiProxyPreference(preference).toLowerCase(); + const preferredId = normalizeProxyId(normalizedPreference); + + if (preferredId) { + const matchedById = activeProxies.find((proxy) => normalizeProxyId(proxy.id) === preferredId); + return { + proxy: matchedById || null, + reason: matchedById ? 'id' : 'missing-id', + candidates: activeProxies, + }; + } + + if (normalizedPreference) { + const exactMatches = activeProxies.filter((proxy) => normalizeString(proxy.name).toLowerCase() === normalizedPreference); + if (exactMatches.length === 1) { + return { proxy: exactMatches[0], reason: 'name', candidates: activeProxies }; + } + if (exactMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-name', candidates: exactMatches }; + } + + const fuzzyMatches = activeProxies.filter((proxy) => buildProxySearchText(proxy).includes(normalizedPreference)); + if (fuzzyMatches.length === 1) { + return { proxy: fuzzyMatches[0], reason: 'fuzzy', candidates: activeProxies }; + } + if (fuzzyMatches.length > 1) { + return { proxy: null, reason: 'ambiguous-fuzzy', candidates: fuzzyMatches }; + } + + return { proxy: null, reason: 'missing-name', candidates: activeProxies }; + } + + if (activeProxies.length === 1) { + return { proxy: activeProxies[0], reason: 'single-active', candidates: activeProxies }; + } + return { + proxy: null, + reason: activeProxies.length ? 'no-preference' : 'none-active', + candidates: activeProxies, + }; + } + + async function resolveSub2ApiProxy(origin, token, preference = '', options = {}) { + const proxies = await requestJson(origin, '/api/v1/admin/proxies/all?with_count=true', { + method: 'GET', + token, + timeoutMs: options.timeoutMs, + }); + if (!Array.isArray(proxies)) { + throw new Error('SUB2API 代理列表返回格式异常,无法自动选择代理。'); + } + + const { proxy, reason, candidates } = findSub2ApiProxy(proxies, preference); + if (proxy) { + return proxy; + } + + const configured = normalizeSub2ApiProxyPreference(preference) || '(未配置)'; + const available = (candidates || []) + .slice(0, 8) + .map(buildProxyDisplayName) + .join(';') || '无可用代理'; + if (reason === 'ambiguous-name' || reason === 'ambiguous-fuzzy') { + throw new Error(`SUB2API 默认代理“${configured}”匹配到多个代理,请改填代理 ID。候选:${available}`); + } + if (reason === 'missing-id') { + throw new Error(`SUB2API 默认代理 ID “${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'missing-name') { + throw new Error(`SUB2API 默认代理“${configured}”不存在或未启用。可用代理:${available}`); + } + if (reason === 'no-preference') { + throw new Error(`SUB2API 存在多个可用代理,请在侧边栏填写默认代理名称或 ID;留空则不使用代理。可用代理:${available}`); + } + throw new Error('SUB2API 没有可用代理;请检查默认代理配置,或将其留空以禁用代理。'); + } + + function buildDraftAccountName(groupName) { + const prefix = normalizeString(groupName || DEFAULT_SUB2API_GROUP_NAME) + .replace(/[^\w\u4e00-\u9fa5-]+/g, '-') + .replace(/^-+|-+$/g, '') || DEFAULT_SUB2API_GROUP_NAME; + const stamp = new Date().toISOString().replace(/\D/g, '').slice(2, 14); + const random = Math.floor(Math.random() * 9000 + 1000); + return `${prefix}-${stamp}-${random}`; + } + + function parseLocalhostCallback(rawUrl, visibleStep = 10) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error(`步骤 ${visibleStep} 捕获到的 localhost OAuth 回调地址格式无效。`); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('回调 URL 协议不正确。'); + } + if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) { + throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`); + } + if (parsed.pathname !== '/auth/callback') { + throw new Error('回调 URL 路径必须是 /auth/callback。'); + } + + const code = normalizeString(parsed.searchParams.get('code')); + const state = normalizeString(parsed.searchParams.get('state')); + if (!code || !state) { + throw new Error('回调 URL 中缺少 code 或 state。'); + } + + return { + url: parsed.toString(), + code, + state, + }; + } + + function buildOpenAiCredentials(exchangeData) { + const credentials = {}; + const allowedKeys = [ + 'access_token', + 'refresh_token', + 'id_token', + 'expires_at', + 'email', + 'chatgpt_account_id', + 'chatgpt_user_id', + 'organization_id', + 'plan_type', + 'client_id', + ]; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + credentials[key] = exchangeData[key]; + } + } + + if (!credentials.access_token) { + throw new Error('SUB2API 交换授权码后未返回 access_token。'); + } + + return credentials; + } + + function buildOpenAiExtra(exchangeData) { + const extra = {}; + const allowedKeys = ['email', 'name', 'privacy_mode']; + + for (const key of allowedKeys) { + if (exchangeData?.[key] !== undefined && exchangeData?.[key] !== null && exchangeData?.[key] !== '') { + extra[key] = exchangeData[key]; + } + } + + return Object.keys(extra).length ? extra : undefined; + } + + async function logWithOptions(message, level = 'info', options = {}) { + await addLog(message, level, options.logOptions || {}); + } + + async function generateOpenAiAuthUrl(state = {}, options = {}) { + const logLabel = normalizeString(options.logLabel) || 'OAuth 刷新'; + const redirectUri = normalizeRedirectUri(options.redirectUri || DEFAULT_REDIRECT_URI); + const groupNames = normalizeSub2ApiGroupNames(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME); + const groupName = groupNames[0] || DEFAULT_SUB2API_GROUP_NAME; + + await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口登录并生成 OpenAI Auth 链接...`, 'info', options); + const { origin, token } = await loginSub2Api(state, options); + const groups = await getGroupsByNames(origin, token, groupNames, options); + const group = groups[0]; + const proxyPreference = resolveSub2ApiProxyPreference(state); + const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference, options) : null; + const proxyId = normalizeProxyId(proxy?.id); + const draftName = buildDraftAccountName(group.name || groupName); + const groupLabel = groups.map((item) => `${item.name}(#${item.id})`).join('、'); + + await logWithOptions(`${logLabel}:已登录 SUB2API,使用分组 ${groupLabel}。`, 'info', options); + if (proxy) { + await logWithOptions(`${logLabel}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options); + } else { + await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options); + } + + const authRequestBody = { redirect_uri: redirectUri }; + if (proxyId) { + authRequestBody.proxy_id = proxyId; + } + + const authData = await requestJson(origin, '/api/v1/admin/openai/generate-auth-url', { + method: 'POST', + token, + timeoutMs: options.timeoutMs, + body: authRequestBody, + }); + + const oauthUrl = normalizeString(authData?.auth_url || authData?.authUrl); + const sessionId = normalizeString(authData?.session_id || authData?.sessionId); + const oauthState = normalizeString(authData?.state || extractStateFromAuthUrl(oauthUrl)); + + if (!oauthUrl || !sessionId) { + throw new Error('SUB2API 未返回完整的 auth_url / session_id。'); + } + + await logWithOptions(`${logLabel}:已获取 SUB2API OAuth 链接:${oauthUrl.slice(0, 96)}...`, 'ok', options); + return { + oauthUrl, + sub2apiSessionId: sessionId, + sub2apiOAuthState: oauthState, + sub2apiGroupId: group.id, + sub2apiGroupIds: groups.map((item) => item.id), + sub2apiDraftName: draftName, + sub2apiProxyId: proxyId, + }; + } + + async function submitOpenAiCallback(state = {}, options = {}) { + const visibleStep = Number(options.visibleStep || state.visibleStep) || 10; + const callback = parseLocalhostCallback(state.localhostUrl || '', visibleStep); + const flowEmail = normalizeString(state.email); + const sessionId = normalizeString(state.sub2apiSessionId); + const expectedState = normalizeString(state.sub2apiOAuthState); + const logLabel = normalizeString(options.logLabel) || `步骤 ${visibleStep}`; + + if (!sessionId) { + throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。'); + } + if (expectedState && expectedState !== callback.state) { + throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。'); + } + + const { origin, token } = await loginSub2Api(state, options); + const proxyPreference = resolveSub2ApiProxyPreference(state); + const preferredProxyId = normalizeProxyId(state.sub2apiProxyId); + const proxySelector = preferredProxyId || proxyPreference; + const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector, options) : null; + const proxyId = normalizeProxyId(proxy?.id); + const accountPriority = resolveSub2ApiAccountPriority(state); + const storedGroupIds = Array.isArray(state.sub2apiGroupIds) ? state.sub2apiGroupIds : []; + const groupIdsFromState = storedGroupIds + .map((id) => Number(id)) + .filter((id) => Number.isFinite(id) && id > 0); + const groups = groupIdsFromState.length + ? groupIdsFromState.map((id) => ({ id })) + : (state.sub2apiGroupId + ? [{ id: state.sub2apiGroupId, name: state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME }] + : await getGroupsByNames(origin, token, state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME, options)); + + await logWithOptions(`${logLabel}:正在通过 SUB2API 管理接口交换 OpenAI 授权码...`, 'info', options); + if (proxy) { + await logWithOptions(`${logLabel}:使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}。`, 'info', options); + } else { + await logWithOptions(`${logLabel}:未配置 SUB2API 默认代理,本次将不使用代理。`, 'info', options); + } + + const exchangeRequestBody = { + session_id: sessionId, + code: callback.code, + state: callback.state, + }; + if (proxyId) { + exchangeRequestBody.proxy_id = proxyId; + } + + const exchangeData = await requestJson(origin, '/api/v1/admin/openai/exchange-code', { + method: 'POST', + token, + timeoutMs: options.timeoutMs, + body: exchangeRequestBody, + }); + + const credentials = buildOpenAiCredentials(exchangeData); + const extra = buildOpenAiExtra(exchangeData); + const resolvedEmail = normalizeString(exchangeData?.email || credentials?.email); + const groupIds = groups + .map((group) => Number(group.id)) + .filter((id) => Number.isFinite(id) && id > 0); + if (!groupIds.length) { + throw new Error('SUB2API 返回的目标分组 ID 无效。'); + } + + const accountName = resolvedEmail + || flowEmail + || normalizeString(state.sub2apiDraftName) + || buildDraftAccountName(state.sub2apiGroupName || DEFAULT_SUB2API_GROUP_NAME); + const createPayload = { + name: accountName, + notes: '', + platform: 'openai', + type: 'oauth', + credentials, + concurrency: DEFAULT_CONCURRENCY, + priority: accountPriority, + rate_multiplier: DEFAULT_RATE_MULTIPLIER, + group_ids: groupIds, + auto_pause_on_expired: true, + }; + if (proxyId) { + createPayload.proxy_id = proxyId; + } + if (extra) { + createPayload.extra = extra; + } + + await logWithOptions(`${logLabel}:授权码交换成功,正在创建 SUB2API 账号(名称:${accountName})...`, 'info', options); + const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', { + method: 'POST', + token, + timeoutMs: options.createTimeoutMs, + body: createPayload, + }); + + const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`; + await logWithOptions(verifiedStatus, 'ok', options); + return { + localhostUrl: callback.url, + verifiedStatus, + }; + } + + return { + buildDraftAccountName, + buildOpenAiCredentials, + buildOpenAiExtra, + buildProxyDisplayName, + extractStateFromAuthUrl, + generateOpenAiAuthUrl, + getGroupsByNames, + loginSub2Api, + normalizeProxyId, + normalizeRedirectUri, + normalizeSub2ApiGroupNames, + parseLocalhostCallback, + requestJson, + resolveSub2ApiAccountPriority, + resolveSub2ApiProxy, + submitOpenAiCallback, + }; + } + + return { + createSub2ApiApi, + }; +}); diff --git a/tests/background-cloudflare-temp-email-settings.test.js b/tests/background-cloudflare-temp-email-settings.test.js index c58f5b5..e84b6c1 100644 --- a/tests/background-cloudflare-temp-email-settings.test.js +++ b/tests/background-cloudflare-temp-email-settings.test.js @@ -73,6 +73,8 @@ const PERSISTED_SETTING_DEFAULTS = { }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; } +function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; } +function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); } function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; } function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; } function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; } @@ -163,6 +165,8 @@ const PERSISTED_SETTING_DEFAULTS = { }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; } +function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; } +function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); } function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; } function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; } function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; } diff --git a/tests/background-panel-bridge-module.test.js b/tests/background-panel-bridge-module.test.js index 957a0c2..f2011f4 100644 --- a/tests/background-panel-bridge-module.test.js +++ b/tests/background-panel-bridge-module.test.js @@ -16,10 +16,24 @@ test('panel bridge module exposes a factory', () => { assert.equal(typeof api?.createPanelBridge, 'function'); }); -test('panel bridge requests oauth url with step 7 log label payload', () => { +function loadPanelBridgeWithSub2Api() { + const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8'); const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); - assert.match(source, /logStep:\s*7/); - assert.doesNotMatch(source, /logStep:\s*6/); + return new Function('self', `${sub2apiSource}\n${source}; return self.MultiPageBackgroundPanelBridge;`)({}); +} + +function createSub2ApiResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload), + }; +} + +test('panel bridge requests SUB2API oauth url via direct API', () => { + const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); + assert.match(source, /generateOpenAiAuthUrl/); + assert.doesNotMatch(source, /REQUEST_OAUTH_URL/); }); test('panel bridge can request codex2api oauth url via protocol', async () => { @@ -117,16 +131,63 @@ test('panel bridge can request cpa oauth url via management api', async () => { } }); -test('panel bridge forwards SUB2API account priority when requesting oauth url', async () => { - const source = fs.readFileSync('background/panel-bridge.js', 'utf8'); - const sentMessages = []; +test('panel bridge can request SUB2API oauth url without opening the admin page', async () => { + const originalFetch = globalThis.fetch; + const fetchCalls = []; + globalThis.fetch = async (url, options = {}) => { + const parsed = new URL(url); + const body = options.body ? JSON.parse(options.body) : null; + fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body }); - const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({}); + if (parsed.pathname === '/api/v1/auth/login') { + return createSub2ApiResponse({ + code: 0, + data: { access_token: 'admin-token' }, + }); + } + if (parsed.pathname === '/api/v1/admin/groups/all') { + return createSub2ApiResponse({ + code: 0, + data: [{ id: 5, name: 'codex', platform: 'openai' }], + }); + } + if (parsed.pathname === '/api/v1/admin/proxies/all') { + return createSub2ApiResponse({ + code: 0, + data: [{ + id: 7, + name: 'shadowrocket', + protocol: 'socks5', + host: '127.0.0.1', + port: 1080, + status: 'active', + }], + }); + } + if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') { + return createSub2ApiResponse({ + code: 0, + data: { + auth_url: 'https://auth.openai.com/authorize?state=oauth-state', + session_id: 'session-123', + state: 'oauth-state', + }, + }); + } + return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404); + }; + + const tabCreates = []; + const sentMessages = []; + const api = loadPanelBridgeWithSub2Api(); const bridge = api.createPanelBridge({ addLog: async () => {}, chrome: { tabs: { - create: async () => ({ id: 72 }), + create: async (payload) => { + tabCreates.push(payload); + return { id: 72 }; + }, }, }, closeConflictingTabsForSource: async () => {}, @@ -137,11 +198,7 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url', rememberSourceLastUrl: async () => {}, sendToContentScript: async (sourceName, message, options) => { sentMessages.push({ sourceName, message, options }); - return { - oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state', - sub2apiSessionId: 'session-123', - sub2apiOAuthState: 'oauth-state', - }; + return {}; }, sendToContentScriptResilient: async () => ({}), waitForTabUrlFamily: async () => ({ id: 72 }), @@ -149,16 +206,30 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url', SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000, }); - await bridge.requestOAuthUrlFromPanel({ - panelMode: 'sub2api', - sub2apiUrl: 'https://sub.example/admin/accounts', - sub2apiEmail: 'admin@example.com', - sub2apiPassword: 'secret', - sub2apiGroupName: 'codex', - sub2apiAccountPriority: 3, - }, { logLabel: '步骤 7' }); + try { + const result = await bridge.requestOAuthUrlFromPanel({ + panelMode: 'sub2api', + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + sub2apiDefaultProxyName: 'shadowrocket', + }, { logLabel: '步骤 7' }); - assert.equal(sentMessages.length, 1); - assert.equal(sentMessages[0].sourceName, 'sub2api-panel'); - assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 3); + const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url'); + assert.equal(tabCreates.length, 0); + assert.equal(sentMessages.length, 0); + assert.equal(generateCall.body.proxy_id, 7); + assert.deepStrictEqual(result, { + oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state', + sub2apiSessionId: 'session-123', + sub2apiOAuthState: 'oauth-state', + sub2apiGroupId: 5, + sub2apiGroupIds: [5], + sub2apiDraftName: result.sub2apiDraftName, + sub2apiProxyId: 7, + }); + } finally { + globalThis.fetch = originalFetch; + } }); diff --git a/tests/background-platform-verify-codex2api.test.js b/tests/background-platform-verify-codex2api.test.js index da114e4..b3f95ab 100644 --- a/tests/background-platform-verify-codex2api.test.js +++ b/tests/background-platform-verify-codex2api.test.js @@ -80,72 +80,14 @@ test('platform verify module supports codex2api protocol callback exchange', asy } }); -test('platform verify retries transient SUB2API oauth/token exchange errors before failing', async () => { +test('platform verify retries transient SUB2API oauth/token exchange errors before succeeding', async () => { const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); const logs = []; const attempts = []; let callCount = 0; - const executor = api.createStep10Executor({ - addLog: async (message, level = 'info') => { - logs.push({ message, level }); - }, - chrome: { - tabs: { - update: async () => {}, - }, - }, - closeConflictingTabsForSource: async () => {}, - completeStepFromBackground: async () => {}, - ensureContentScriptReadyOnTab: async () => {}, - getPanelMode: () => 'sub2api', - getTabId: async () => 12, - isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='), - isTabAlive: async () => true, - normalizeCodex2ApiUrl: (value) => value, - normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts', - rememberSourceLastUrl: async () => {}, - reuseOrCreateTab: async () => 12, - sendToContentScript: async (_source, message) => { - attempts.push(message.type); - callCount += 1; - if (callCount === 1) { - return { - error: 'request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF', - }; - } - return { ok: true }; - }, - sendToContentScriptResilient: async () => ({}), - shouldBypassStep9ForLocalCpa: () => false, - SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000, - }); - - await executor.executeStep10({ - panelMode: 'sub2api', - localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', - sub2apiUrl: 'https://sub2api.example.com/admin/accounts', - sub2apiEmail: 'flow@example.com', - sub2apiPassword: 'secret', - sub2apiSessionId: 'session-123', - sub2apiOAuthState: 'oauth-state', - }); - - assert.equal(callCount, 2); - assert.deepStrictEqual(attempts, ['EXECUTE_STEP', 'EXECUTE_STEP']); - assert.equal( - logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'), - true - ); -}); - -test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => { - const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); - const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); - - const logs = []; - let callCount = 0; + let contentScriptCalled = false; const executor = api.createStep10Executor({ addLog: async (message, level = 'info') => { logs.push({ message, level }); @@ -167,14 +109,22 @@ test('platform verify retries transient SUB2API token_exchange_user_error before rememberSourceLastUrl: async () => {}, reuseOrCreateTab: async () => 12, sendToContentScript: async () => { - callCount += 1; - if (callCount === 1) { - return { - error: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }', - }; - } - return { ok: true }; + contentScriptCalled = true; + return {}; }, + createSub2ApiApi: () => ({ + submitOpenAiCallback: async () => { + attempts.push('direct-api'); + callCount += 1; + if (callCount === 1) { + throw new Error('request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF'); + } + return { + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + verifiedStatus: 'SUB2API 已创建账号 #11', + }; + }, + }), sendToContentScriptResilient: async () => ({}), shouldBypassStep9ForLocalCpa: () => false, SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000, @@ -191,6 +141,74 @@ test('platform verify retries transient SUB2API token_exchange_user_error before }); assert.equal(callCount, 2); + assert.equal(contentScriptCalled, false); + assert.deepStrictEqual(attempts, ['direct-api', 'direct-api']); + assert.equal( + logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'), + true + ); +}); + +test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => { + const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + + const logs = []; + let callCount = 0; + let contentScriptCalled = false; + const executor = api.createStep10Executor({ + addLog: async (message, level = 'info') => { + logs.push({ message, level }); + }, + chrome: { + tabs: { + update: async () => {}, + }, + }, + closeConflictingTabsForSource: async () => {}, + completeStepFromBackground: async () => {}, + ensureContentScriptReadyOnTab: async () => {}, + getPanelMode: () => 'sub2api', + getTabId: async () => 12, + isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='), + isTabAlive: async () => true, + normalizeCodex2ApiUrl: (value) => value, + normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts', + rememberSourceLastUrl: async () => {}, + reuseOrCreateTab: async () => 12, + sendToContentScript: async () => { + contentScriptCalled = true; + return { ok: true }; + }, + createSub2ApiApi: () => ({ + submitOpenAiCallback: async () => { + callCount += 1; + if (callCount === 1) { + throw new Error('token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }'); + } + return { + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + verifiedStatus: 'SUB2API 已创建账号 #11', + }; + }, + }), + sendToContentScriptResilient: async () => ({}), + shouldBypassStep9ForLocalCpa: () => false, + SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000, + }); + + await executor.executeStep10({ + panelMode: 'sub2api', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + sub2apiUrl: 'https://sub2api.example.com/admin/accounts', + sub2apiEmail: 'flow@example.com', + sub2apiPassword: 'secret', + sub2apiSessionId: 'session-123', + sub2apiOAuthState: 'oauth-state', + }); + + assert.equal(callCount, 2); + assert.equal(contentScriptCalled, false); assert.equal( logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'), true diff --git a/tests/background-platform-verify-cpa-api.test.js b/tests/background-platform-verify-cpa-api.test.js index 082745d..9ecf3df 100644 --- a/tests/background-platform-verify-cpa-api.test.js +++ b/tests/background-platform-verify-cpa-api.test.js @@ -42,6 +42,20 @@ function createDeps(overrides = {}) { return { deps, logs, completed, uiCalls }; } +function loadStep10WithSub2Api() { + const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8'); + const step10Source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); + return new Function('self', `${sub2apiSource}\n${step10Source}; return self.MultiPageBackgroundStep10;`)({}); +} + +function createSub2ApiResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(payload), + }; +} + test('platform verify module submits CPA callback via management API first', async () => { const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); const originalFetch = globalThis.fetch; @@ -240,11 +254,109 @@ test('platform verify module rejects callback when cpa oauth state mismatches', } }); -test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => { - const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); +test('platform verify module submits Plus visible step 13 to SUB2API via direct API', async () => { + const originalFetch = globalThis.fetch; + const fetchCalls = []; const sentMessages = []; + globalThis.fetch = async (url, options = {}) => { + const parsed = new URL(url); + const body = options.body ? JSON.parse(options.body) : null; + fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body }); - const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); + if (parsed.pathname === '/api/v1/auth/login') { + return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } }); + } + if (parsed.pathname === '/api/v1/admin/groups/all') { + return createSub2ApiResponse({ code: 0, data: [{ id: 5, name: 'codex', platform: 'openai' }] }); + } + if (parsed.pathname === '/api/v1/admin/openai/exchange-code') { + return createSub2ApiResponse({ + code: 0, + data: { + access_token: 'openai-access', + refresh_token: 'openai-refresh', + email: 'flow@example.com', + }, + }); + } + if (parsed.pathname === '/api/v1/admin/accounts') { + return createSub2ApiResponse({ code: 0, data: { id: 11 } }); + } + return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404); + }; + + const api = loadStep10WithSub2Api(); + const { deps, completed } = createDeps({ + getPanelMode: () => 'sub2api', + getTabId: async () => 91, + isTabAlive: async () => true, + sendToContentScript: async (sourceName, message, options) => { + sentMessages.push({ sourceName, message, options }); + return { ok: true }; + }, + }); + const executor = api.createStep10Executor(deps); + + try { + await executor.executeStep10({ + panelMode: 'sub2api', + visibleStep: 13, + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiGroupName: 'codex', + sub2apiSessionId: 'session-1', + sub2apiOAuthState: 'oauth-state', + }); + + const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code'); + const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts'); + assert.equal(sentMessages.length, 0); + assert.equal(exchangeCall.body.code, 'callback-code'); + assert.equal(exchangeCall.body.state, 'oauth-state'); + assert.deepStrictEqual(createCall.body.group_ids, [5]); + assert.deepStrictEqual(completed, [{ + step: 13, + payload: { + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + verifiedStatus: 'SUB2API 已创建账号 #11', + }, + }]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('platform verify module forwards SUB2API account priority to direct create API', async () => { + const originalFetch = globalThis.fetch; + const fetchCalls = []; + const sentMessages = []; + globalThis.fetch = async (url, options = {}) => { + const parsed = new URL(url); + const body = options.body ? JSON.parse(options.body) : null; + fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body }); + + if (parsed.pathname === '/api/v1/auth/login') { + return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } }); + } + if (parsed.pathname === '/api/v1/admin/openai/exchange-code') { + return createSub2ApiResponse({ + code: 0, + data: { + access_token: 'openai-access', + refresh_token: 'openai-refresh', + email: 'flow@example.com', + }, + }); + } + if (parsed.pathname === '/api/v1/admin/accounts') { + return createSub2ApiResponse({ code: 0, data: { id: 11 } }); + } + return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404); + }; + + const api = loadStep10WithSub2Api(); const { deps } = createDeps({ getPanelMode: () => 'sub2api', getTabId: async () => 91, @@ -256,50 +368,23 @@ test('platform verify module sends Plus visible step 13 to SUB2API panel', async }); const executor = api.createStep10Executor(deps); - await executor.executeStep10({ - panelMode: 'sub2api', - visibleStep: 13, - localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', - sub2apiUrl: 'https://sub.example/admin/accounts', - sub2apiEmail: 'admin@example.com', - sub2apiPassword: 'secret', - sub2apiSessionId: 'session-1', - sub2apiOAuthState: 'oauth-state', - }); + try { + await executor.executeStep10({ + panelMode: 'sub2api', + localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', + sub2apiUrl: 'https://sub.example/admin/accounts', + sub2apiEmail: 'admin@example.com', + sub2apiPassword: 'secret', + sub2apiSessionId: 'session-1', + sub2apiOAuthState: 'oauth-state', + sub2apiGroupId: 5, + sub2apiAccountPriority: 2, + }); - assert.equal(sentMessages.length, 1); - assert.equal(sentMessages[0].sourceName, 'sub2api-panel'); - assert.equal(sentMessages[0].message.step, 13); -}); - -test('platform verify module forwards SUB2API account priority to panel', async () => { - const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8'); - const sentMessages = []; - - const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({}); - const { deps } = createDeps({ - getPanelMode: () => 'sub2api', - getTabId: async () => 91, - isTabAlive: async () => true, - sendToContentScript: async (sourceName, message, options) => { - sentMessages.push({ sourceName, message, options }); - return { ok: true }; - }, - }); - const executor = api.createStep10Executor(deps); - - await executor.executeStep10({ - panelMode: 'sub2api', - localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state', - sub2apiUrl: 'https://sub.example/admin/accounts', - sub2apiEmail: 'admin@example.com', - sub2apiPassword: 'secret', - sub2apiSessionId: 'session-1', - sub2apiOAuthState: 'oauth-state', - sub2apiAccountPriority: 2, - }); - - assert.equal(sentMessages.length, 1); - assert.equal(sentMessages[0].sourceName, 'sub2api-panel'); - assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 2); + const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts'); + assert.equal(sentMessages.length, 0); + assert.equal(createCall.body.priority, 2); + } finally { + globalThis.fetch = originalFetch; + } });