From 68e7366e467c473576e6a925ff6ae5b68a8580b1 Mon Sep 17 00:00:00 2001 From: zhangkun <1184253234@qq.com> Date: Sun, 26 Apr 2026 23:05:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20HeroSMS=20=E5=8F=96?= =?UTF-8?q?=E5=8F=B7=E5=9B=9E=E9=80=80=E4=B8=8E=20V2=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E8=BD=AE=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background/phone-verification-flow.js | 221 +++++++++- tests/phone-verification-flow.test.js | 583 +++++++++++++++++++++++++- 2 files changed, 787 insertions(+), 17 deletions(-) diff --git a/background/phone-verification-flow.js b/background/phone-verification-flow.js index 58a0509..04b4065 100644 --- a/background/phone-verification-flow.js +++ b/background/phone-verification-flow.js @@ -27,6 +27,7 @@ const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3; const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000; const DEFAULT_PHONE_NUMBER_MAX_USES = 3; + const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3; const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::'; const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::'; @@ -70,6 +71,7 @@ if (!activationId || !phoneNumber) { return null; } + const statusAction = String(record.statusAction || '').trim(); return { activationId, phoneNumber, @@ -78,6 +80,7 @@ countryId: Number(record.countryId) || HERO_SMS_COUNTRY_ID, successfulUses: normalizeUseCount(record.successfulUses), maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_PHONE_NUMBER_MAX_USES)), + ...(statusAction ? { statusAction } : {}), }; } @@ -182,7 +185,10 @@ const text = await response.text(); const payload = parseHeroSmsPayload(text); if (!response.ok) { - throw new Error(`${actionLabel} failed: ${describeHeroSmsPayload(payload) || response.status}`); + const requestError = new Error(`${actionLabel} failed: ${describeHeroSmsPayload(payload) || response.status}`); + requestError.payload = payload; + requestError.status = response.status; + throw requestError; } return payload; } catch (error) { @@ -212,10 +218,15 @@ const normalizedFallback = normalizeActivation(fallback); const directActivation = normalizeActivation(payload); if (directActivation) { + const statusAction = normalizedFallback?.statusAction || directActivation.statusAction; return { ...directActivation, + provider: normalizedFallback?.provider || directActivation.provider, + serviceCode: normalizedFallback?.serviceCode || directActivation.serviceCode, + countryId: normalizedFallback?.countryId || directActivation.countryId, successfulUses: normalizedFallback?.successfulUses || directActivation.successfulUses, maxUses: normalizedFallback?.maxUses || directActivation.maxUses, + ...(statusAction ? { statusAction } : {}), }; } @@ -230,6 +241,7 @@ countryId: normalizedFallback?.countryId || HERO_SMS_COUNTRY_ID, successfulUses: normalizedFallback?.successfulUses || 0, maxUses: normalizedFallback?.maxUses || DEFAULT_PHONE_NUMBER_MAX_USES, + ...(normalizedFallback?.statusAction ? { statusAction: normalizedFallback.statusAction } : {}), }; } @@ -240,21 +252,178 @@ return null; } + function resolveActivationStatusAction(activation) { + return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus'; + } + + function normalizeHeroSmsPrice(value) { + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + return null; + } + return price; + } + + function collectHeroSmsPriceCandidates(payload, candidates = []) { + if (Array.isArray(payload)) { + payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates)); + return candidates; + } + if (!payload || typeof payload !== 'object') { + return candidates; + } + + const cost = normalizeHeroSmsPrice(payload.cost); + if (cost !== null) { + const count = Number(payload.count); + const physicalCount = Number(payload.physicalCount); + const hasCount = Number.isFinite(count); + const hasPhysicalCount = Number.isFinite(physicalCount); + if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) { + candidates.push(cost); + } + } + + Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates)); + return candidates; + } + + function findLowestHeroSmsPrice(payload) { + const candidates = collectHeroSmsPriceCandidates(payload, []); + if (!candidates.length) { + return null; + } + return Math.min(...candidates); + } + + function isHeroSmsNoNumbersPayload(payload) { + return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload)); + } + + function extractHeroSmsWrongMaxPrice(payload) { + if (payload && typeof payload === 'object') { + const title = String(payload.title || '').trim(); + const minPrice = normalizeHeroSmsPrice(payload.info?.min); + if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) { + return minPrice; + } + } + + const text = describeHeroSmsPayload(payload); + const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i); + if (!match) { + return null; + } + return normalizeHeroSmsPrice(match[1]); + } + + function isNetworkFetchFailure(error) { + const message = String(error?.message || '').trim(); + return /failed to fetch|networkerror|load failed/i.test(message); + } + + async function resolveCheapestPhoneActivationPrice(config, countryConfig) { + for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) { + try { + const payload = await fetchHeroSmsPayload(config, { + action: 'getPrices', + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }, 'HeroSMS getPrices'); + const price = findLowestHeroSmsPrice(payload); + if (price !== null) { + return price; + } + } catch (_) { + // Best-effort lookup only. + } + } + return null; + } + + async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) { + const query = { + action, + service: HERO_SMS_SERVICE_CODE, + country: countryConfig.id, + }; + if (options.maxPrice !== null && options.maxPrice !== undefined) { + query.maxPrice = options.maxPrice; + query.fixedPrice = 'true'; + } + return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`); + } + + async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice) { + let nextMaxPrice = maxPrice; + let retriedWithUpdatedPrice = false; + let retriedWithoutPrice = false; + + while (true) { + try { + return await fetchPhoneActivationPayload(config, countryConfig, action, { + maxPrice: nextMaxPrice, + }); + } catch (error) { + const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message); + if ( + nextMaxPrice !== null + && nextMaxPrice !== undefined + && !retriedWithUpdatedPrice + && updatedMaxPrice !== null + ) { + nextMaxPrice = updatedMaxPrice; + retriedWithUpdatedPrice = true; + continue; + } + + if ( + nextMaxPrice !== null + && nextMaxPrice !== undefined + && !retriedWithoutPrice + && isNetworkFetchFailure(error) + ) { + nextMaxPrice = null; + retriedWithoutPrice = true; + continue; + } + + throw error; + } + } + } + async function requestPhoneActivation(state = {}) { const config = resolvePhoneConfig(state); const countryConfig = resolveCountryConfig(state); - const payload = await fetchHeroSmsPayload(config, { - action: 'getNumber', - service: HERO_SMS_SERVICE_CODE, - country: countryConfig.id, - }, 'HeroSMS getNumber'); - - const activation = parseActivationPayload(payload, { + const maxPrice = await resolveCheapestPhoneActivationPrice(config, countryConfig); + const buildFallbackActivation = (requestAction) => ({ countryId: countryConfig.id, + ...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}), }); + let requestAction = 'getNumber'; + let payload; + + try { + payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + } catch (error) { + if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) { + throw error; + } + requestAction = 'getNumberV2'; + payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + } + + let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) { + requestAction = 'getNumberV2'; + payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice); + activation = parseActivationPayload(payload, buildFallbackActivation(requestAction)); + } + if (!activation) { const text = describeHeroSmsPayload(payload); - throw new Error(`HeroSMS getNumber failed: ${text || 'empty response'}`); + throw new Error(`HeroSMS ${requestAction} failed: ${text || 'empty response'}`); } return activation; @@ -318,6 +487,7 @@ if (!normalizedActivation) { throw new Error('Phone activation is missing.'); } + const statusAction = resolveActivationStatusAction(normalizedActivation); const config = resolvePhoneConfig(state); const configuredTimeoutMs = Math.max(1000, Number(options.timeoutMs) || 0); @@ -337,9 +507,9 @@ while (Date.now() - start < timeoutMs) { throwIfStopped(); const payload = await fetchHeroSmsPayload(config, { - action: 'getStatus', + action: statusAction, id: normalizedActivation.activationId, - }, 'HeroSMS getStatus'); + }, `HeroSMS ${statusAction}`); const text = describeHeroSmsPayload(payload); lastResponse = text; pollCount += 1; @@ -354,6 +524,28 @@ }); } + const extractVerificationCode = (rawCode) => { + const trimmed = String(rawCode || '').trim(); + if (!trimmed) { + return ''; + } + const digitMatch = trimmed.match(/\b(\d{4,8})\b/); + return digitMatch?.[1] || trimmed; + }; + + const v2Code = ( + payload + && typeof payload === 'object' + && !Array.isArray(payload) + && ( + extractVerificationCode(payload.sms?.code) + || extractVerificationCode(payload.call?.code) + ) + ); + if (v2Code) { + return v2Code; + } + const okMatch = text.match(/^STATUS_OK:(.+)$/i); if (okMatch) { const rawCode = String(okMatch[1] || '').trim(); @@ -366,11 +558,16 @@ continue; } + if (statusAction === 'getStatusV2' && payload && typeof payload === 'object' && !Array.isArray(payload)) { + await sleepWithStop(intervalMs); + continue; + } + if (/^STATUS_CANCEL$/i.test(text)) { throw new Error('HeroSMS activation was cancelled before the SMS arrived.'); } - throw new Error(`HeroSMS getStatus failed: ${text || 'empty response'}`); + throw new Error(`HeroSMS ${statusAction} failed: ${text || 'empty response'}`); } throw buildPhoneCodeTimeoutError(lastResponse); diff --git a/tests/phone-verification-flow.test.js b/tests/phone-verification-flow.test.js index e6065b0..712a2a8 100644 --- a/tests/phone-verification-flow.test.js +++ b/tests/phone-verification-flow.test.js @@ -6,13 +6,47 @@ const source = fs.readFileSync('background/phone-verification-flow.js', 'utf8'); const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundPhoneVerification;`)(globalScope); +function buildHeroSmsPricesPayload({ country = '52', service = 'dr', cost = 0.08, count = 25370, physicalCount = 14528 } = {}) { + return JSON.stringify({ + [country]: { + [service]: { + cost, + count, + physicalCount, + }, + }, + }); +} + +function buildHeroSmsStatusV2Payload({ smsCode = '', smsText = '', callCode = '' } = {}) { + return JSON.stringify({ + verificationType: 2, + sms: { + dateTime: '2026-02-18T16:11:33+00:00', + code: smsCode, + text: smsText, + }, + call: { + code: callCode, + }, + }); +} + test('phone verification helper requests HeroSMS numbers with fixed OpenAI and Thailand parameters', async () => { const requests = []; const helpers = api.createPhoneVerificationHelpers({ addLog: async () => {}, ensureStep8SignupPageReady: async () => {}, fetchImpl: async (url) => { - requests.push(new URL(url)); + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } return { ok: true, text: async () => 'ACCESS_NUMBER:123456:66959916439', @@ -36,11 +70,428 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T successfulUses: 0, maxUses: 3, }); - assert.equal(requests.length, 1); - assert.equal(requests[0].searchParams.get('action'), 'getNumber'); + assert.equal(requests.length, 2); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); assert.equal(requests[0].searchParams.get('service'), 'dr'); assert.equal(requests[0].searchParams.get('country'), '52'); assert.equal(requests[0].searchParams.get('api_key'), 'demo-key'); + assert.equal(requests[1].searchParams.get('action'), 'getNumber'); + assert.equal(requests[1].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[1].searchParams.get('fixedPrice'), 'true'); + assert.equal(requests[1].searchParams.get('service'), 'dr'); + assert.equal(requests[1].searchParams.get('country'), '52'); + assert.equal(requests[1].searchParams.get('api_key'), 'demo-key'); +}); + +test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => { + const requests = []; + let getPricesAttempt = 0; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + getPricesAttempt += 1; + return getPricesAttempt < 3 + ? { + ok: true, + text: async () => JSON.stringify({ unavailable: true }), + } + : { + ok: true, + text: async () => buildHeroSmsPricesPayload({ cost: 0.09 }), + }; + } + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' }); + + assert.equal(requests.length, 4); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); + assert.equal(requests[1].searchParams.get('action'), 'getPrices'); + assert.equal(requests[2].searchParams.get('action'), 'getPrices'); + assert.equal(requests[3].searchParams.get('action'), 'getNumber'); + assert.equal(requests[3].searchParams.get('maxPrice'), '0.09'); + assert.equal(requests[3].searchParams.get('fixedPrice'), 'true'); +}); + +test('phone verification helper falls back to plain getNumber only after HeroSMS getPrices fails three times', async () => { + const requests = []; + let getPricesAttempt = 0; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + getPricesAttempt += 1; + return { + ok: true, + text: async () => JSON.stringify({ unavailable: getPricesAttempt }), + }; + } + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' }); + + assert.equal(requests.length, 4); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); + assert.equal(requests[1].searchParams.get('action'), 'getPrices'); + assert.equal(requests[2].searchParams.get('action'), 'getPrices'); + assert.equal(requests[2].searchParams.get('service'), 'dr'); + assert.equal(requests[2].searchParams.get('country'), '52'); + assert.equal(requests[2].searchParams.get('api_key'), 'demo-key'); + assert.equal(requests[3].searchParams.get('action'), 'getNumber'); + assert.equal(requests[3].searchParams.get('maxPrice'), null); + assert.equal(requests[3].searchParams.get('fixedPrice'), null); +}); + +test('phone verification helper retries with HeroSMS getNumberV2 when getNumber reports NO_NUMBERS', async () => { + const requests = []; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload({ country: '16' }), + }; + } + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'NO_NUMBERS', + }; + } + if (action === 'getNumberV2') { + return { + ok: true, + text: async () => JSON.stringify({ + activationId: '654321', + phoneNumber: '447911123456', + }), + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsCountryId: 16 }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const activation = await helpers.requestPhoneActivation({ + heroSmsApiKey: 'demo-key', + heroSmsCountryId: 16, + }); + + assert.deepStrictEqual(activation, { + activationId: '654321', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 0, + maxUses: 3, + statusAction: 'getStatusV2', + }); + assert.equal(requests.length, 3); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); + assert.equal(requests[0].searchParams.get('country'), '16'); + assert.equal(requests[1].searchParams.get('action'), 'getNumber'); + assert.equal(requests[1].searchParams.get('country'), '16'); + assert.equal(requests[1].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[1].searchParams.get('fixedPrice'), 'true'); + assert.equal(requests[2].searchParams.get('action'), 'getNumberV2'); + assert.equal(requests[2].searchParams.get('country'), '16'); + assert.equal(requests[2].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[2].searchParams.get('fixedPrice'), 'true'); +}); + +test('phone verification helper uses HeroSMS getStatusV2 after acquiring a number via getNumberV2', async () => { + const requests = []; + const stateUpdates = []; + let currentState = { + heroSmsApiKey: 'demo-key', + heroSmsCountryId: 16, + heroSmsCountryLabel: 'United Kingdom', + verificationResendCount: 0, + currentPhoneActivation: null, + reusablePhoneActivation: null, + }; + let statusPollCount = 0; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload({ country: '16' }), + }; + } + if (action === 'getNumber') { + return { + ok: true, + text: async () => 'NO_NUMBERS', + }; + } + if (action === 'getNumberV2') { + return { + ok: true, + text: async () => JSON.stringify({ + activationId: '654321', + phoneNumber: '447911123456', + }), + }; + } + if (action === 'getStatusV2') { + statusPollCount += 1; + return { + ok: true, + text: async () => ( + statusPollCount === 1 + ? buildHeroSmsStatusV2Payload() + : buildHeroSmsStatusV2Payload({ smsCode: '112233', smsText: 'Your code is 112233' }) + ), + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + stateUpdates.push(updates); + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + assert.deepStrictEqual(stateUpdates, [ + { + currentPhoneActivation: { + activationId: '654321', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 0, + maxUses: 3, + statusAction: 'getStatusV2', + }, + }, + { + reusablePhoneActivation: { + activationId: '654321', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 1, + maxUses: 3, + statusAction: 'getStatusV2', + }, + }, + { + currentPhoneActivation: null, + }, + ]); + const actions = requests.map((url) => url.searchParams.get('action')); + assert.deepStrictEqual(actions, [ + 'getPrices', + 'getNumber', + 'getNumberV2', + 'getStatusV2', + 'getStatusV2', + 'setStatus', + ]); +}); + +test('phone verification helper refreshes maxPrice when HeroSMS returns WRONG_MAX_PRICE', async () => { + const requests = []; + let getNumberAttempt = 0; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } + if (action === 'getNumber') { + getNumberAttempt += 1; + return getNumberAttempt === 1 + ? { + ok: false, + text: async () => 'WRONG_MAX_PRICE:0.09', + } + : { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' }); + + assert.deepStrictEqual(activation, { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }); + assert.equal(requests.length, 3); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); + assert.equal(requests[1].searchParams.get('action'), 'getNumber'); + assert.equal(requests[1].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[2].searchParams.get('action'), 'getNumber'); + assert.equal(requests[2].searchParams.get('maxPrice'), '0.09'); + assert.equal(requests[2].searchParams.get('fixedPrice'), 'true'); +}); + +test('phone verification helper falls back to plain getNumber when priced request fails to fetch', async () => { + const requests = []; + let getNumberAttempt = 0; + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } + if (action === 'getNumber') { + getNumberAttempt += 1; + if (getNumberAttempt === 1) { + throw new TypeError('Failed to fetch'); + } + return { + ok: true, + text: async () => 'ACCESS_NUMBER:123456:66959916439', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getState: async () => ({ heroSmsApiKey: 'demo-key' }), + sendToContentScriptResilient: async () => ({}), + setState: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' }); + + assert.deepStrictEqual(activation, { + activationId: '123456', + phoneNumber: '66959916439', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 52, + successfulUses: 0, + maxUses: 3, + }); + assert.equal(requests.length, 3); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); + assert.equal(requests[1].searchParams.get('action'), 'getNumber'); + assert.equal(requests[1].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[1].searchParams.get('fixedPrice'), 'true'); + assert.equal(requests[2].searchParams.get('action'), 'getNumber'); + assert.equal(requests[2].searchParams.get('maxPrice'), null); + assert.equal(requests[2].searchParams.get('fixedPrice'), null); }); test('phone verification helper completes add-phone flow, clears current activation, and stores reusable number state', async () => { @@ -60,6 +511,12 @@ test('phone verification helper completes add-phone flow, clears current activat const parsedUrl = new URL(url); requests.push(parsedUrl); const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } if (action === 'getNumber') { return { ok: true, @@ -146,7 +603,7 @@ test('phone verification helper completes add-phone flow, clears current activat ]); const actions = requests.map((url) => url.searchParams.get('action')); - assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']); + assert.deepStrictEqual(actions, ['getPrices', 'getNumber', 'getStatus', 'setStatus']); }); test('phone verification helper uses the configured HeroSMS country for both number acquisition and add-phone submission', async () => { @@ -168,6 +625,12 @@ test('phone verification helper uses the configured HeroSMS country for both num const parsedUrl = new URL(url); requests.push(parsedUrl); const action = parsedUrl.searchParams.get('action'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload({ country: '16' }), + }; + } if (action === 'getNumber') { return { ok: true, @@ -225,8 +688,12 @@ test('phone verification helper uses the configured HeroSMS country for both num consentReady: true, url: 'https://auth.openai.com/authorize', }); - assert.equal(requests[0].searchParams.get('action'), 'getNumber'); + assert.equal(requests[0].searchParams.get('action'), 'getPrices'); assert.equal(requests[0].searchParams.get('country'), '16'); + assert.equal(requests[1].searchParams.get('action'), 'getNumber'); + assert.equal(requests[1].searchParams.get('country'), '16'); + assert.equal(requests[1].searchParams.get('maxPrice'), '0.08'); + assert.equal(requests[1].searchParams.get('fixedPrice'), 'true'); assert.deepStrictEqual(submittedPayloads, [{ phoneNumber: '447911123456', countryId: 16, @@ -258,6 +725,13 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p const action = parsedUrl.searchParams.get('action'); const id = parsedUrl.searchParams.get('id'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } + if (action === 'getNumber') { return { ok: true, @@ -325,6 +799,7 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`); assert.deepStrictEqual(actions, [ + 'getPrices:', 'getNumber:', 'getStatus:123456', 'setStatus:123456', @@ -363,6 +838,13 @@ test('phone verification helper replaces the number when code submission returns const action = parsedUrl.searchParams.get('action'); const id = parsedUrl.searchParams.get('id'); + if (action === 'getPrices') { + return { + ok: true, + text: async () => buildHeroSmsPricesPayload(), + }; + } + if (action === 'getNumber') { const nextNumber = numbers[numberIndex]; numberIndex += 1; @@ -443,9 +925,11 @@ test('phone verification helper replaces the number when code submission returns const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`); assert.deepStrictEqual(actions, [ + 'getPrices:', 'getNumber:', 'getStatus:111111', 'setStatus:111111', + 'getPrices:', 'getNumber:', 'getStatus:222222', 'setStatus:222222', @@ -549,3 +1033,92 @@ test('phone verification helper reuses the same number up to three successful re assert.equal(requests[0].searchParams.get('id'), '123456'); assert.deepStrictEqual(currentState.reusablePhoneActivation, null); }); + +test('phone verification helper keeps maxUses behavior for reused V2 activations', async () => { + const requests = []; + let currentState = { + heroSmsApiKey: 'demo-key', + verificationResendCount: 0, + currentPhoneActivation: null, + reusablePhoneActivation: { + activationId: '123456', + phoneNumber: '447911123456', + provider: 'hero-sms', + serviceCode: 'dr', + countryId: 16, + successfulUses: 2, + maxUses: 3, + statusAction: 'getStatusV2', + }, + }; + + const helpers = api.createPhoneVerificationHelpers({ + addLog: async () => {}, + ensureStep8SignupPageReady: async () => {}, + fetchImpl: async (url) => { + const parsedUrl = new URL(url); + requests.push(parsedUrl); + const action = parsedUrl.searchParams.get('action'); + if (action === 'reactivate') { + return { + ok: true, + text: async () => JSON.stringify({ + activationId: '222333', + phoneNumber: '447911123456', + }), + }; + } + if (action === 'getStatusV2') { + return { + ok: true, + text: async () => buildHeroSmsStatusV2Payload({ smsCode: '654321', smsText: 'Your code is 654321' }), + }; + } + if (action === 'setStatus') { + return { + ok: true, + text: async () => 'ACCESS_ACTIVATION', + }; + } + throw new Error(`Unexpected HeroSMS action: ${action}`); + }, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs, + getState: async () => ({ ...currentState }), + sendToContentScriptResilient: async (_source, message) => { + if (message.type === 'SUBMIT_PHONE_NUMBER') { + return { + phoneVerificationPage: true, + url: 'https://auth.openai.com/phone-verification', + }; + } + if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') { + return { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }; + } + throw new Error(`Unexpected content-script message: ${message.type}`); + }, + setState: async (updates) => { + currentState = { ...currentState, ...updates }; + }, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + }); + + const result = await helpers.completePhoneVerificationFlow(1, { + addPhonePage: true, + phoneVerificationPage: false, + url: 'https://auth.openai.com/add-phone', + }); + + assert.deepStrictEqual(result, { + success: true, + consentReady: true, + url: 'https://auth.openai.com/authorize', + }); + const actions = requests.map((url) => url.searchParams.get('action')); + assert.deepStrictEqual(actions, ['reactivate', 'getStatusV2', 'setStatus']); + assert.deepStrictEqual(currentState.reusablePhoneActivation, null); +});