From 25249e229a197b0e8505c3ea68de5843099933ab Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Wed, 20 May 2026 03:26:08 +0800 Subject: [PATCH] Fix bound email relogin callback recovery --- background.js | 16 ++- content/signup-page.js | 15 +++ tests/background-auth-chain-guard.test.js | 140 +++++++++++++++++++++ tests/background-step6-retry-limit.test.js | 66 ++++++++++ tests/signup-page-node-resolution.test.js | 86 +++++++++++++ tests/verification-flow-polling.test.js | 51 ++++++++ 6 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 tests/signup-page-node-resolution.test.js diff --git a/background.js b/background.js index ef96f90..24fa642 100644 --- a/background.js +++ b/background.js @@ -15261,9 +15261,17 @@ async function recoverOAuthLocalhostTimeout(details = {}) { return null; } - const authLoginStep = typeof getAuthChainStartStepId === 'function' + const defaultAuthLoginStep = typeof getAuthChainStartStepId === 'function' ? getAuthChainStartStepId(state || {}) : FINAL_OAUTH_CHAIN_START_STEP; + const reloginBoundEmailStep = typeof getStepIdByKeyForState === 'function' + ? Number(getStepIdByKeyForState('relogin-bound-email', state || {})) + : 0; + const authLoginStep = Number.isFinite(reloginBoundEmailStep) + && reloginBoundEmailStep > 0 + && reloginBoundEmailStep < Number(visibleStep) + ? reloginBoundEmailStep + : defaultAuthLoginStep; const authLoginNodeId = String(getNodeIdByStepForState(authLoginStep, state || {}) || 'oauth-login').trim(); const confirmNodeId = String(getNodeIdByStepForState(visibleStep, state || {}) || 'confirm-oauth').trim(); @@ -15336,6 +15344,12 @@ async function recoverOAuthLocalhostTimeout(details = {}) { return step8Executor.executeBindEmail(payload); case 'fetch-bind-email-code': return step8Executor.executeFetchBindEmailCode(payload); + case 'relogin-bound-email': + return executeReloginBoundEmail(payload); + case 'fetch-bound-email-login-code': + return step8Executor.executeBoundEmailLoginCode(payload); + case 'post-bound-email-phone-verification': + return step8Executor.executeBoundEmailPostLoginPhoneVerification(payload); default: throw new Error(`OAuth localhost 恢复不支持节点 ${nodeId}。`); } diff --git a/content/signup-page.js b/content/signup-page.js index 086e571..11c9e95 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -89,6 +89,21 @@ function resolveCommandNodeId(message = {}) { } const visibleStep = Number(message.payload?.visibleStep || message.step) || 0; if (visibleStep === 4) return 'fetch-signup-code'; + if (message.type === 'FILL_CODE' && (visibleStep === 12 || visibleStep === 15)) { + return 'fetch-bound-email-login-code'; + } + if ( + ( + message.type === 'SUBMIT_PHONE_NUMBER' + || message.type === 'SUBMIT_PHONE_VERIFICATION_CODE' + || message.type === 'RESEND_PHONE_VERIFICATION_CODE' + || message.type === 'CHECK_PHONE_RESEND_ERROR' + || message.type === 'RETURN_TO_ADD_PHONE' + ) + && (visibleStep === 13 || visibleStep === 16) + ) { + return 'post-bound-email-phone-verification'; + } if (visibleStep === 8 || visibleStep === 11) return 'fetch-login-code'; if (visibleStep === 9 || visibleStep === 12) return 'post-login-phone-verification'; if (visibleStep === 10 || visibleStep === 13) return 'confirm-oauth'; diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js index a013aa0..bcf38d9 100644 --- a/tests/background-auth-chain-guard.test.js +++ b/tests/background-auth-chain-guard.test.js @@ -470,6 +470,146 @@ return { assert.match(events.logs[0].message, /授权后链总超时已关闭/); }); +test('oauth localhost timeout recovery resumes from bound-email relogin tail when present', async () => { + const events = { + logs: [], + recoveryRuns: [], + stateUpdates: [], + timeoutWindows: [], + }; + + const api = new Function('events', ` +const FINAL_OAUTH_CHAIN_START_STEP = 7; +const state = { + oauthUrl: 'https://oauth.example/current', + phoneSignupReloginAfterBindEmailEnabled: true, +}; +const STEP_NODE_IDS = { + 7: 'oauth-login', + 8: 'fetch-login-code', + 9: 'bind-email', + 10: 'fetch-bind-email-code', + 11: 'relogin-bound-email', + 12: 'fetch-bound-email-login-code', + 13: 'post-bound-email-phone-verification', + 14: 'confirm-oauth', + 15: 'platform-verify', +}; +const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)])); + +function getErrorMessage(error) { + return error?.message || String(error || ''); +} +function getAuthChainStartStepId() { + return 7; +} +function getStepIdByKeyForState(stepKey) { + return NODE_STEP_IDS[String(stepKey || '').trim()] || null; +} +function getNodeIdByStepForState(step) { + return STEP_NODE_IDS[Number(step)] || ''; +} +function getStepIdByNodeIdForState(nodeId) { + return NODE_STEP_IDS[String(nodeId || '').trim()] || null; +} +function getAutoRunWorkflowNodeIds() { + return [ + 'oauth-login', + 'fetch-login-code', + 'bind-email', + 'fetch-bind-email-code', + 'relogin-bound-email', + 'fetch-bound-email-login-code', + 'post-bound-email-phone-verification', + 'confirm-oauth', + 'platform-verify', + ]; +} +async function addLog(message, level = 'info', options = {}) { + events.logs.push({ message, level, options }); +} +async function getLoginAuthStateFromContent() { + return { state: 'oauth_consent_page' }; +} +function isAddPhoneAuthState() { + return false; +} +function getLoginAuthStateLabel(value) { + return value || 'unknown'; +} +async function getState() { + return { ...state }; +} +async function setState(update) { + events.stateUpdates.push(update); + Object.assign(state, update); +} +async function startOAuthFlowTimeoutWindow(payload) { + events.timeoutWindows.push(payload); +} +const step7Executor = { + async executeStep7(payload) { + events.recoveryRuns.push({ nodeId: 'oauth-login', visibleStep: payload.visibleStep }); + }, +}; +const step8Executor = { + async executeStep8(payload) { + events.recoveryRuns.push({ nodeId: 'fetch-login-code', visibleStep: payload.visibleStep }); + }, + async executePostLoginPhoneVerification(payload) { + events.recoveryRuns.push({ nodeId: 'post-login-phone-verification', visibleStep: payload.visibleStep }); + }, + async executeBindEmail(payload) { + events.recoveryRuns.push({ nodeId: 'bind-email', visibleStep: payload.visibleStep }); + }, + async executeFetchBindEmailCode(payload) { + events.recoveryRuns.push({ nodeId: 'fetch-bind-email-code', visibleStep: payload.visibleStep }); + }, + async executeBoundEmailLoginCode(payload) { + events.recoveryRuns.push({ nodeId: 'fetch-bound-email-login-code', visibleStep: payload.visibleStep }); + }, + async executeBoundEmailPostLoginPhoneVerification(payload) { + events.recoveryRuns.push({ nodeId: 'post-bound-email-phone-verification', visibleStep: payload.visibleStep }); + }, +}; +async function executeReloginBoundEmail(payload) { + events.recoveryRuns.push({ nodeId: 'relogin-bound-email', visibleStep: payload.visibleStep }); +} + +${extractFunction('isStep9OAuthLocalhostTimeoutError')} +${extractFunction('recoverOAuthLocalhostTimeout')} + +return { + recoverOAuthLocalhostTimeout, +}; +`)(events); + + const recoveredState = await api.recoverOAuthLocalhostTimeout({ + error: new Error('步骤 14:从拿到 OAuth 登录地址开始,5 分钟内未完成 OAuth localhost 回调,结束当前链路。'), + state: { + oauthUrl: 'https://oauth.example/current', + phoneSignupReloginAfterBindEmailEnabled: true, + }, + visibleStep: 14, + }); + + assert.deepStrictEqual(events.recoveryRuns, [ + { nodeId: 'relogin-bound-email', visibleStep: 11 }, + { nodeId: 'fetch-bound-email-login-code', visibleStep: 12 }, + { nodeId: 'post-bound-email-phone-verification', visibleStep: 13 }, + ]); + assert.deepStrictEqual(events.stateUpdates, [{ localhostUrl: null }]); + assert.deepStrictEqual(events.timeoutWindows, [ + { + step: 14, + oauthUrl: 'https://oauth.example/current', + }, + ]); + assert.equal(recoveredState.localhostUrl, null); + assert.ok(events.logs.some(({ message }) => /步骤 11/.test(message))); + assert.equal(events.recoveryRuns.some(({ nodeId }) => nodeId === 'oauth-login'), false); +}); + test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => { const api = new Function(` const LOG_PREFIX = '[test]'; diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index a834799..42f6ac1 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -588,6 +588,72 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async ( assert.equal(events.payloads[0].accountIdentifier, 'plus.user@example.com'); }); +test('step 7 keeps relogin-bound-email as the active node id', async () => { + const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope); + + const events = { + completions: [], + messages: [], + }; + + const executor = api.createStep7Executor({ + addLog: async () => {}, + completeNodeFromBackground: async (step, payload) => { + events.completions.push({ step, payload }); + }, + getErrorMessage: (error) => error?.message || String(error || ''), + getLoginAuthStateLabel: (state) => state || 'unknown', + getState: async () => ({ + email: 'bound.user@example.com', + password: 'secret', + plusModeEnabled: true, + signupMethod: 'phone', + phoneSignupReloginAfterBindEmailEnabled: true, + }), + isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable', + isStep6SuccessResult: (result) => result?.step6Outcome === 'success', + refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/relogin', + reuseOrCreateTab: async () => {}, + sendToContentScriptResilient: async (_sourceName, message) => { + events.messages.push(message); + return { + step6Outcome: 'success', + state: 'verification_page', + loginVerificationRequestedAt: 223344, + }; + }, + STEP6_MAX_ATTEMPTS: 3, + throwIfStopped: () => {}, + }); + + await executor.executeStep7({ + nodeId: 'relogin-bound-email', + visibleStep: 14, + forceLoginIdentifierType: 'email', + forceEmailLogin: true, + signupMethod: 'email', + resolvedSignupMethod: 'email', + accountIdentifierType: 'email', + accountIdentifier: 'bound.user@example.com', + email: 'bound.user@example.com', + password: 'secret', + }); + + assert.equal(events.messages.length, 1); + assert.equal(events.messages[0].nodeId, 'relogin-bound-email'); + assert.equal(events.messages[0].payload.visibleStep, 14); + assert.deepStrictEqual(events.completions, [ + { + step: 'relogin-bound-email', + payload: { + loginVerificationRequestedAt: 223344, + }, + }, + ]); +}); + test('step 7 keeps phone login after step 8 stores an unbound email for phone signup', async () => { const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8'); const globalScope = {}; diff --git a/tests/signup-page-node-resolution.test.js b/tests/signup-page-node-resolution.test.js new file mode 100644 index 0000000..038a26e --- /dev/null +++ b/tests/signup-page-node-resolution.test.js @@ -0,0 +1,86 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('content/signup-page.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const api = new Function(` +${extractFunction('resolveCommandNodeId')} +return { + resolveCommandNodeId, +}; +`)(); + +test('signup page resolves bound-email relogin verification nodes from dynamic visible steps', () => { + assert.equal( + api.resolveCommandNodeId({ + type: 'FILL_CODE', + step: 8, + payload: { visibleStep: 15 }, + }), + 'fetch-bound-email-login-code' + ); + assert.equal( + api.resolveCommandNodeId({ + type: 'SUBMIT_PHONE_VERIFICATION_CODE', + step: 16, + payload: { visibleStep: 16 }, + }), + 'post-bound-email-phone-verification' + ); + assert.equal( + api.resolveCommandNodeId({ + type: 'FILL_CODE', + step: 8, + payload: { nodeId: 'fetch-bound-email-login-code', visibleStep: 15 }, + }), + 'fetch-bound-email-login-code' + ); +}); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 2a9eb11..f7da2a8 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -1682,6 +1682,57 @@ test('verification flow does not replay step 8 code submit after transient auth- assert.deepStrictEqual(resilientMessages, ['GET_LOGIN_AUTH_STATE']); }); +test('verification flow forwards dynamic completion node id when submitting bound-email login code', async () => { + const directCalls = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeNodeFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getNodeIdByStepForState: (step) => ({ + 14: 'fetch-bound-email-login-code', + })[Number(step)] || '', + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + directCalls.push(message); + return { success: true }; + }, + sendToContentScriptResilient: async () => { + throw new Error('step 8 code submit should use the direct channel once'); + }, + sendToMailContentScriptResilient: async () => ({}), + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.submitVerificationCode(8, '246810', { completionStep: 14 }); + + assert.equal(directCalls.length, 1); + assert.equal(directCalls[0].type, 'FILL_CODE'); + assert.equal(directCalls[0].payload.code, '246810'); + assert.equal(directCalls[0].payload.nodeId, 'fetch-bound-email-login-code'); +}); + test('verification flow keeps step 8 code submit response timeout above local floor when flow budget is nearly exhausted', async () => { const directCalls = [];