diff --git a/background.js b/background.js index 187e9f6..5eb6bd1 100644 --- a/background.js +++ b/background.js @@ -560,6 +560,7 @@ const DEFAULT_STATE = { currentLuckmailMailCursor: null, currentPhoneActivation: null, reusablePhoneActivation: null, + pendingPhoneActivationConfirmation: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 @@ -5031,6 +5032,13 @@ async function finalizeIcloudAliasAfterSuccessfulFlow(state) { } } +async function finalizePhoneActivationAfterSuccessfulFlow(state) { + if (typeof phoneVerificationHelpers?.finalizePendingPhoneActivationConfirmation !== 'function') { + return null; + } + return phoneVerificationHelpers.finalizePendingPhoneActivationConfirmation(state); +} + // ============================================================ // Tab Registry // ============================================================ @@ -5766,6 +5774,7 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -5780,6 +5789,7 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -5793,6 +5803,7 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -5815,11 +5826,13 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } if (step === 9) { return { + pendingPhoneActivationConfirmation: null, plusReturnUrl: '', localhostUrl: null, }; @@ -5830,11 +5843,13 @@ function getDownstreamStateResets(step, state = {}) { loginVerificationRequestedAt: null, oauthFlowDeadlineAt: null, oauthFlowDeadlineSourceUrl: null, + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } if (stepKey === 'confirm-oauth') { return { + pendingPhoneActivationConfirmation: null, localhostUrl: null, }; } @@ -6676,6 +6691,7 @@ async function handleStepData(step, payload) { excludeLocalhostCallbacks: true, }); } + await finalizePhoneActivationAfterSuccessfulFlow(latestState); await finalizeIcloudAliasAfterSuccessfulFlow(latestState); const shouldClearCustomPoolEmail = String(latestState?.emailGenerator || '').trim().toLowerCase() === ( typeof CUSTOM_EMAIL_POOL_GENERATOR === 'string' @@ -8528,6 +8544,7 @@ const messageRouter = self.MultiPageBackgroundMessageRouter?.createMessageRouter executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion: async () => { const currentState = await getState(); const signupTabId = await getTabId('signup-page'); diff --git a/background/auto-run-controller.js b/background/auto-run-controller.js index 37705a9..da1d0ea 100644 --- a/background/auto-run-controller.js +++ b/background/auto-run-controller.js @@ -391,6 +391,7 @@ inbucketMailbox: prevState.inbucketMailbox, cloudflareDomain: prevState.cloudflareDomain, cloudflareDomains: prevState.cloudflareDomains, + reusablePhoneActivation: prevState.reusablePhoneActivation, autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries), autoRunSessionId: sessionId, tabRegistry: {}, diff --git a/background/message-router.js b/background/message-router.js index 2c5886e..c0dd637 100644 --- a/background/message-router.js +++ b/background/message-router.js @@ -32,6 +32,7 @@ executeStepViaCompletionSignal, exportSettingsBundle, fetchGeneratedEmail, + finalizePhoneActivationAfterSuccessfulFlow, finalizeStep3Completion, finalizeIcloudAliasAfterSuccessfulFlow, findHotmailAccount, @@ -202,6 +203,9 @@ excludeLocalhostCallbacks: true, }); } + if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') { + await finalizePhoneActivationAfterSuccessfulFlow(latestState); + } await finalizeIcloudAliasAfterSuccessfulFlow(latestState); } diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 6b7fc84..009fbb3 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -21,6 +21,7 @@ const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation'; const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation'; + const PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY = 'pendingPhoneActivationConfirmation'; const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000; const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000; const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000; @@ -634,18 +635,16 @@ await persistReusableActivation(null); } - async function incrementCurrentActivationUseCount(activation) { + function incrementActivationUseCount(activation) { const normalizedActivation = normalizeActivation(activation); if (!normalizedActivation) { return null; } - const nextActivation = { + return { ...normalizedActivation, successfulUses: Math.min(normalizedActivation.successfulUses + 1, normalizedActivation.maxUses), }; - await persistCurrentActivation(nextActivation); - return nextActivation; } async function acquirePhoneActivation(state = {}) { @@ -694,6 +693,35 @@ await persistReusableActivation(normalizedActivation); } + async function persistPendingPhoneActivationConfirmation(activation) { + await setState({ + [PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]: activation || null, + }); + } + + async function clearPendingPhoneActivationConfirmation() { + await persistPendingPhoneActivationConfirmation(null); + } + + async function finalizePendingPhoneActivationConfirmation(stateOverride = null) { + const state = stateOverride || await getState(); + const pendingActivation = normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]); + if (!pendingActivation) { + return null; + } + + const committedActivation = incrementActivationUseCount(pendingActivation); + if (!committedActivation) { + await clearPendingPhoneActivationConfirmation(); + await clearReusableActivation(); + return null; + } + + await syncReusableActivationAfterUse(committedActivation); + await clearPendingPhoneActivationConfirmation(); + return committedActivation; + } + async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) { let currentActivation = normalizeActivation(activation); if (!currentActivation) { @@ -781,6 +809,9 @@ } if (pageState?.addPhonePage) { + if (normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY])) { + await clearPendingPhoneActivationConfirmation(); + } if (activation) { await cancelPhoneActivation(state, activation); await clearCurrentActivation(); @@ -871,12 +902,13 @@ continue; } - activation = await incrementCurrentActivationUseCount(activation); try { await completePhoneActivation(state, activation); - await syncReusableActivationAfterUse(activation); + await persistReusableActivation(activation); + await persistPendingPhoneActivationConfirmation(activation); } catch (activationStatusError) { await clearReusableActivation(); + await clearPendingPhoneActivationConfirmation(); await addLog( `Step 9: phone verification succeeded, but HeroSMS setStatus(3) failed. The next flow will request a new number. ${activationStatusError.message}`, 'warn' @@ -903,6 +935,7 @@ return { completePhoneVerificationFlow, + finalizePendingPhoneActivationConfirmation, normalizeActivation, pollPhoneActivationCode, reactivatePhoneActivation, diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js index 8627a15..91bf331 100644 --- a/tests/auto-run-fresh-attempt-reset.test.js +++ b/tests/auto-run-fresh-attempt-reset.test.js @@ -114,6 +114,12 @@ let currentState = { inbucketMailbox: '', cloudflareDomain: '', cloudflareDomains: [], + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + successfulUses: 1, + maxUses: 3, + }, tabRegistry: {}, sourceLastUrls: {}, }; @@ -329,6 +335,16 @@ return { assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion'); assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset'); assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset'); + assert.deepStrictEqual( + snapshot.currentState.reusablePhoneActivation, + { + activationId: '123456', + phoneNumber: '66959916439', + successfulUses: 1, + maxUses: 3, + }, + 'reusable phone activation should survive fresh-attempt reset' + ); console.log('auto-run fresh attempt reset tests passed'); })().catch((error) => { diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js index ee36635..c54efd8 100644 --- a/tests/background-luckmail.test.js +++ b/tests/background-luckmail.test.js @@ -713,6 +713,7 @@ function broadcastDataUpdate() {} function isLocalhostOAuthCallbackUrl() { return true; } +async function finalizePhoneActivationAfterSuccessfulFlow() {} async function finalizeIcloudAliasAfterSuccessfulFlow() {} ${bundle} diff --git a/tests/background-message-router-step2-skip.test.js b/tests/background-message-router-step2-skip.test.js index 9ca45f6..9d29dba 100644 --- a/tests/background-message-router-step2-skip.test.js +++ b/tests/background-message-router-step2-skip.test.js @@ -12,6 +12,7 @@ function createRouter(overrides = {}) { stepStatuses: [], emailStates: [], finalizePayloads: [], + phoneFinalizations: [], notifyCompletions: [], notifyErrors: [], securityBlocks: [], @@ -49,6 +50,9 @@ function createRouter(overrides = {}) { executeStepViaCompletionSignal: async () => {}, exportSettingsBundle: async () => ({}), fetchGeneratedEmail: async () => '', + finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => { + events.phoneFinalizations.push(state); + }), finalizeStep3Completion: overrides.finalizeStep3Completion || (async (payload) => { events.finalizePayloads.push(payload); }), @@ -262,6 +266,34 @@ test('message router finalizes step 3 before marking it completed', async () => assert.deepStrictEqual(response, { ok: true }); }); +test('message router finalizes pending phone activation on platform verify success', async () => { + const state = { + stepStatuses: { 10: 'pending' }, + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + successfulUses: 0, + maxUses: 3, + }, + pendingPhoneActivationConfirmation: { + activationId: '123456', + phoneNumber: '66959916439', + successfulUses: 0, + maxUses: 3, + }, + }; + const { router, events } = createRouter({ + state, + getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }), + }); + + await router.handleStepData(10, { + localhostUrl: 'http://localhost:1455/auth/callback?code=ok', + }); + + assert.deepStrictEqual(events.phoneFinalizations, [state]); +}); + test('message router marks step 3 failed when post-submit finalize fails', async () => { const { router, events } = createRouter({ finalizeStep3Completion: async () => { diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js index 441d21b..d4674b2 100644 --- a/tests/phone-verification-flow.test.js +++ b/tests/phone-verification-flow.test.js @@ -260,18 +260,6 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe statusAction: 'getStatusV2', }, }, - { - currentPhoneActivation: { - activationId: '654321', - phoneNumber: '447911123456', - provider: 'hero-sms', - serviceCode: 'dr', - countryId: 16, - successfulUses: 1, - maxUses: 3, - statusAction: 'getStatusV2', - }, - }, { reusablePhoneActivation: { activationId: '654321', @@ -279,7 +267,19 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe provider: 'hero-sms', serviceCode: 'dr', countryId: 16, - successfulUses: 1, + successfulUses: 0, + maxUses: 3, + statusAction: 'getStatusV2', + }, + }, + { + pendingPhoneActivationConfirmation: { + activationId: '654321', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 0, maxUses: 3, statusAction: 'getStatusV2', }, @@ -413,17 +413,6 @@ test('phone verification helper completes add-phone flow, clears current activat maxUses: 3, }, }, - { - currentPhoneActivation: { - activationId: '123456', - phoneNumber: '66959916439', - provider: 'hero-sms', - serviceCode: 'dr', - countryId: 52, - successfulUses: 1, - maxUses: 3, - }, - }, { reusablePhoneActivation: { activationId: '123456', @@ -431,7 +420,18 @@ test('phone verification helper completes add-phone flow, clears current activat provider: 'hero-sms', serviceCode: 'dr', countryId: 52, - successfulUses: 1, + successfulUses: 0, + maxUses: 3, + }, + }, + { + pendingPhoneActivationConfirmation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, maxUses: 3, }, }, @@ -519,6 +519,7 @@ test('phone verification helper still succeeds when HeroSMS setStatus(3) fails a }); assert.deepStrictEqual(currentState.currentPhoneActivation, null); assert.deepStrictEqual(currentState.reusablePhoneActivation, null); + assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, null); const actions = requests.map((url) => url.searchParams.get('action')); assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']); }); @@ -843,12 +844,21 @@ test('phone verification helper replaces the number when code submission returns provider: 'hero-sms', serviceCode: 'dr', countryId: 52, - successfulUses: 1, + successfulUses: 0, + maxUses: 3, + }); + assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, { + activationId: '222222', + phoneNumber: '66950000002', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, maxUses: 3, }); }); -test('phone verification helper reuses the same number up to three successful registrations', async () => { +test('phone verification helper defers maxUses accounting for reused activations until the full flow succeeds', async () => { const requests = []; let currentState = { heroSmsApiKey: 'demo-key', @@ -934,10 +944,27 @@ test('phone verification helper reuses the same number up to three successful re }); assert.equal(requests[0].searchParams.get('action'), 'reactivate'); assert.equal(requests[0].searchParams.get('id'), '123456'); - assert.deepStrictEqual(currentState.reusablePhoneActivation, null); + assert.deepStrictEqual(currentState.reusablePhoneActivation, { + activationId: '222333', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 2, + maxUses: 3, + }); + assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, { + activationId: '222333', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 2, + maxUses: 3, + }); }); -test('phone verification helper keeps maxUses behavior for reused V2 activations', async () => { +test('phone verification helper defers maxUses accounting for reused V2 activations until the full flow succeeds', async () => { const requests = []; let currentState = { heroSmsApiKey: 'demo-key', @@ -1026,5 +1053,131 @@ test('phone verification helper keeps maxUses behavior for reused V2 activations }); const actions = requests.map((url) => url.searchParams.get('action')); assert.deepStrictEqual(actions, ['reactivate', 'getStatusV2', 'setStatus']); - assert.deepStrictEqual(currentState.reusablePhoneActivation, null); + assert.deepStrictEqual(currentState.reusablePhoneActivation, { + activationId: '222333', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 2, + maxUses: 3, + statusAction: 'getStatusV2', + }); + assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, { + activationId: '222333', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 2, + maxUses: 3, + statusAction: 'getStatusV2', + }); +}); + +test('phone verification helper finalizes pending phone activation confirmation after the full flow succeeds', async () => { + let currentState = { + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }, + pendingPhoneActivationConfirmation: { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async () => ({}), + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const committedActivation = await helpers.finalizePendingPhoneActivationConfirmation(); + + assert.deepStrictEqual(committedActivation, { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 1, + maxUses: 3, + }); + assert.deepStrictEqual(currentState.reusablePhoneActivation, { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 1, + maxUses: 3, + }); + assert.equal(currentState.pendingPhoneActivationConfirmation, null); +}); + +test('phone verification helper clears reusable activation when final success exhausts maxUses', async () => { + let currentState = { + reusablePhoneActivation: { + activationId: '222333', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 2, + maxUses: 3, + statusAction: 'getStatusV2', + }, + pendingPhoneActivationConfirmation: { + activationId: '222333', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 2, + maxUses: 3, + statusAction: 'getStatusV2', + }, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async () => ({}), + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const committedActivation = await helpers.finalizePendingPhoneActivationConfirmation(); + + assert.deepStrictEqual(committedActivation, { + activationId: '222333', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 3, + maxUses: 3, + statusAction: 'getStatusV2', + }); + assert.equal(currentState.reusablePhoneActivation, null); + assert.equal(currentState.pendingPhoneActivationConfirmation, null); }); diff --git a/tests/step9-localhost-cleanup-scope.test.js b/tests/step9-localhost-cleanup-scope.test.js index 41081c9..9abcc1d 100644 --- a/tests/step9-localhost-cleanup-scope.test.js +++ b/tests/step9-localhost-cleanup-scope.test.js @@ -114,6 +114,7 @@ function broadcastDataUpdate() {} async function addLog(message) { logMessages.push(message); } +async function finalizePhoneActivationAfterSuccessfulFlow() {} async function finalizeIcloudAliasAfterSuccessfulFlow() {} function matchesSourceUrlFamily() { return false;