From 1ed128e1186360f7faf5c8fed3c76698f012eb02 Mon Sep 17 00:00:00 2001 From: InkCrow Date: Tue, 12 May 2026 16:41:09 +0800 Subject: [PATCH] fix(auth): preserve phone login identity across step8 retries --- background.js | 22 ++++--- background/steps/fetch-login-code.js | 18 ++++-- background/steps/oauth-login.js | 2 - background/verification-flow.js | 7 +++ tests/background-step6-retry-limit.test.js | 2 +- tests/background-step7-recovery.test.js | 24 ++++++-- tests/verification-flow-polling.test.js | 71 ++++++++++++++++++++++ 7 files changed, 127 insertions(+), 19 deletions(-) diff --git a/background.js b/background.js index b65b5d1..c5a6ccd 100644 --- a/background.js +++ b/background.js @@ -2993,14 +2993,22 @@ function isPhoneActivationForNumber(activation, phoneNumber) { async function setEmailStateSilently(email, options = {}) { const currentState = await getState(); - const updates = buildRegistrationEmailStateUpdates(currentState, { - currentEmail: email, - preservePrevious: Boolean(options?.preservePrevious), - source: options?.source || '', - }); + const preserveAccountIdentity = Boolean(options?.preserveAccountIdentity); + const updates = preserveAccountIdentity + ? buildFlowRegistrationEmailStateUpdates(currentState, { + currentEmail: email, + preservePrevious: Boolean(options?.preservePrevious), + preserveAccountIdentity: true, + source: options?.source || '', + }) + : buildRegistrationEmailStateUpdates(currentState, { + currentEmail: email, + preservePrevious: Boolean(options?.preservePrevious), + source: options?.source || '', + }); const normalizedEmail = updates.email; - if (normalizedEmail) { + if (!preserveAccountIdentity && normalizedEmail) { updates.accountIdentifierType = 'email'; updates.accountIdentifier = normalizedEmail; updates.phoneNumber = ''; @@ -3009,7 +3017,7 @@ async function setEmailStateSilently(email, options = {}) { updates.signupPhoneCompletedActivation = null; updates.signupPhoneVerificationRequestedAt = null; updates.signupPhoneVerificationPurpose = ''; - } else if (String(currentState?.accountIdentifierType || '').trim().toLowerCase() === 'email') { + } else if (!preserveAccountIdentity && String(currentState?.accountIdentifierType || '').trim().toLowerCase() === 'email') { updates.accountIdentifierType = null; updates.accountIdentifier = ''; } diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index edfcecd..2388709 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -31,6 +31,7 @@ reuseOrCreateTab, sendToContentScriptResilient, buildRegistrationEmailStateUpdates = null, + persistRegistrationEmailState = null, phoneVerificationHelpers = null, setState, shouldUseCustomRegistrationEmail, @@ -181,10 +182,17 @@ } const displayedEmail = normalizeStep8VerificationTargetEmail(result?.displayedEmail || resolvedEmail); - await setState({ - email: resolvedEmail, - step8VerificationTargetEmail: displayedEmail, - }); + if (typeof persistRegistrationEmailState === 'function') { + await persistRegistrationEmailState(latestState, resolvedEmail, { + source: 'step8_add_email', + preserveAccountIdentity: true, + }); + } else { + await setState({ + email: resolvedEmail, + step8VerificationTargetEmail: displayedEmail, + }); + } return { state: { @@ -535,6 +543,8 @@ } }, targetEmail: fixedTargetEmail, + maxResendRequests: mail.provider === '2925' ? 1 : undefined, + initialPollMaxAttempts: mail.provider === '2925' ? 5 : undefined, resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER ? 15000 : ((mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') diff --git a/background/steps/oauth-login.js b/background/steps/oauth-login.js index ba39ca8..e330125 100644 --- a/background/steps/oauth-login.js +++ b/background/steps/oauth-login.js @@ -71,9 +71,7 @@ } function shouldPreferStep7PhoneSignupIdentity(state = {}) { - const frozenSignupMethod = normalizeStep7IdentifierType(state?.resolvedSignupMethod); return canUseConfiguredPhoneSignup(state) - && frozenSignupMethod !== 'email' && hasStep7PhoneSignupIdentity(state); } diff --git a/background/verification-flow.js b/background/verification-flow.js index 25fcb3d..32f6192 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -874,6 +874,7 @@ onResendRequestedAt, maxRounds: _ignoredMaxRounds, maxResendRequests: _ignoredMaxResendRequests, + initialPollMaxAttempts: _ignoredInitialPollMaxAttempts, ...cleanPollOverrides } = pollOverrides; const basePayload = { @@ -927,6 +928,7 @@ onResendRequestedAt, maxRounds: _ignoredMaxRounds, maxResendRequests: _ignoredMaxResendRequests, + initialPollMaxAttempts: _ignoredInitialPollMaxAttempts, ...cleanPollOverrides } = pollOverrides; @@ -982,6 +984,7 @@ let filterAfterTimestamp = cleanPollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; const maxResendRequests = resolveMaxResendRequests(pollOverrides); const maxRounds = maxResendRequests + 1; + const initialPollMaxAttempts = Math.max(0, Math.floor(Number(pollOverrides.initialPollMaxAttempts) || 0)); let usedResendRequests = 0; for (let round = 1; round <= maxRounds; round++) { @@ -1002,6 +1005,9 @@ filterAfterTimestamp, excludeCodes: [...rejectedCodes], }); + if (round === 1 && initialPollMaxAttempts > 0) { + payload.maxAttempts = initialPollMaxAttempts; + } try { const timedPoll = await applyMailPollingTimeBudget( @@ -1307,6 +1313,7 @@ disableTimeBudgetCap: Boolean(options.disableTimeBudgetCap), getRemainingTimeMs: options.getRemainingTimeMs, maxResendRequests: remainingAutomaticResendCount, + initialPollMaxAttempts: options.initialPollMaxAttempts, resendIntervalMs, lastResendAt, onResendRequestedAt: updateFilterAfterTimestampForVerificationStep, diff --git a/tests/background-step6-retry-limit.test.js b/tests/background-step6-retry-limit.test.js index 6528ba6..39df900 100644 --- a/tests/background-step6-retry-limit.test.js +++ b/tests/background-step6-retry-limit.test.js @@ -592,7 +592,7 @@ test('step 7 keeps phone login after step 8 stores an unbound email for phone si const phoneSignupState = { phoneVerificationEnabled: true, signupMethod: 'phone', - resolvedSignupMethod: 'phone', + resolvedSignupMethod: 'email', email: 'bound.step8@example.com', accountIdentifierType: 'email', accountIdentifier: 'bound.step8@example.com', diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index bfd3fe2..674bafd 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -266,6 +266,7 @@ test('step 8 submits add-email before polling the email verification code', asyn resolvedStates: [], setStates: [], mailStates: [], + persistCalls: [], }; const executor = api.createStep8Executor({ @@ -300,6 +301,9 @@ test('step 8 submits add-email before polling the email verification code', asyn isTabAlive: async () => true, isVerificationMailPollingError: () => false, LUCKMAIL_PROVIDER: 'luckmail-api', + persistRegistrationEmailState: async (state, email, options) => { + calls.persistCalls.push({ state, email, options }); + }, resolveSignupEmailForFlow: async (state, options = {}) => { calls.resolvedStates.push(state); calls.resolveOptions = options; @@ -338,14 +342,14 @@ test('step 8 submits add-email before polling the email verification code', asyn assert.equal(calls.contentMessages.length, 1); assert.equal(calls.resolvedStates.length, 1); assert.equal(calls.resolveOptions.preserveAccountIdentity, true); + assert.equal(calls.persistCalls.length, 1); + assert.equal(calls.persistCalls[0].email, 'new.user@example.com'); + assert.equal(calls.persistCalls[0].options.preserveAccountIdentity, true); + assert.equal(calls.persistCalls[0].options.source, 'step8_add_email'); assert.equal(calls.mailStates[0].email, 'new.user@example.com'); assert.equal(calls.resolvedVerification.state.email, 'new.user@example.com'); assert.equal(calls.resolvedVerification.options.targetEmail, 'new.user@example.com'); assert.deepStrictEqual(calls.setStates, [ - { - email: 'new.user@example.com', - step8VerificationTargetEmail: 'new.user@example.com', - }, { step8VerificationTargetEmail: 'new.user@example.com', }, @@ -407,6 +411,14 @@ test('step 8 reruns step 7 with preserved phone login identity after add-email v isTabAlive: async () => true, isVerificationMailPollingError: () => false, LUCKMAIL_PROVIDER: 'luckmail-api', + persistRegistrationEmailState: async (_state, email, options) => { + assert.equal(email, 'new.user@example.com'); + assert.equal(options.preserveAccountIdentity, true); + runtimeState = { + ...runtimeState, + email, + }; + }, resolveSignupEmailForFlow: async (_state, options = {}) => { assert.equal(options.preserveAccountIdentity, true); runtimeState = { @@ -944,7 +956,7 @@ test('step 8 completes when polling fails but recovery probe shows oauth consent ]); }); -test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => { +test('step 8 uses a fixed 10-minute lookback window and delays 2925 resend until after one full poll', async () => { let capturedOptions = null; let ensureCalls = 0; let ensureOptions = null; @@ -1036,6 +1048,8 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval ]); assert.equal(capturedOptions.filterAfterTimestamp, 300000); assert.equal(capturedOptions.resendIntervalMs, 0); + assert.equal(capturedOptions.maxResendRequests, 1); + assert.equal(capturedOptions.initialPollMaxAttempts, 5); assert.equal(capturedOptions.targetEmail, ''); assert.equal(capturedOptions.beforeSubmit, undefined); assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function'); diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 14eedb8..d28aa41 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -835,6 +835,77 @@ test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even w assert.ok(pollCall.options.timeoutMs >= 250000); }); +test('verification flow delays 2925 login resend until after the first full mailbox poll fails', async () => { + const events = []; + const pollMaxAttempts = []; + let pollCalls = 0; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { tabs: { update: async () => {} } }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + 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) => { + if (message.type === 'RESEND_VERIFICATION_CODE') { + events.push('resend'); + return { resent: true }; + } + if (message.type === 'FILL_CODE') { + events.push('fill'); + } + return {}; + }, + sendToContentScriptResilient: async () => ({}), + sendToMailContentScriptResilient: async (_mail, message) => { + events.push('poll'); + pollMaxAttempts.push(message.payload.maxAttempts); + pollCalls += 1; + return pollCalls === 1 + ? { error: '步骤 8:邮箱轮询结束,但未获取到验证码。' } + : { code: '654321', emailTimestamp: 123 }; + }, + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { + email: 'user@example.com', + mailProvider: '2925', + lastLoginCode: null, + }, + { provider: '2925', label: '2925 邮箱' }, + { + maxResendRequests: 1, + initialPollMaxAttempts: 5, + requestFreshCodeFirst: false, + filterAfterTimestamp: 123, + resendIntervalMs: 0, + } + ); + + assert.deepStrictEqual(events.slice(0, 3), ['poll', 'resend', 'poll']); + assert.deepStrictEqual(pollMaxAttempts.slice(0, 2), [5, 15]); + assert.equal(events.filter((event) => event === 'resend').length, 1); +}); + test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => { const pollPayloads = [];