fix: resolve CPA auth hardening conflicts with dev

This commit is contained in:
QLHazyCoder
2026-04-29 12:24:15 +08:00
45 changed files with 5804 additions and 1289 deletions
@@ -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) => {
+164
View File
@@ -0,0 +1,164 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => {
const events = {
order: [],
preflightCalls: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: 'hotmail-api',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async () => {},
appendAccountRunRecord: async () => null,
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
ensureHotmailMailboxReadyForAutoRunRound: async (payload = {}) => {
events.order.push('preflight');
events.preflightCalls.push({ ...payload });
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.order.push('run');
events.runCalls += 1;
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: false,
mode: 'restart',
});
assert.equal(events.runCalls, 1);
assert.equal(events.preflightCalls.length, 1);
assert.deepEqual(events.order, ['preflight', 'run']);
assert.match(
JSON.stringify(events.preflightCalls[0]),
/"targetRun":1/
);
});
+1
View File
@@ -713,6 +713,7 @@ function broadcastDataUpdate() {}
function isLocalhostOAuthCallbackUrl() {
return true;
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
${bundle}
@@ -68,7 +68,7 @@ test('ensureMail2925MailboxSession reuses current mailbox page without sending l
});
assert.equal(sendCalls, 0);
assert.equal(readyCalls, 0);
assert.equal(readyCalls, 1);
assert.equal(result.result.usedExistingSession, true);
});
@@ -12,6 +12,7 @@ function createRouter(overrides = {}) {
stepStatuses: [],
emailStates: [],
finalizePayloads: [],
phoneFinalizations: [],
notifyCompletions: [],
notifyErrors: [],
securityBlocks: [],
@@ -49,10 +50,13 @@ 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);
}),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: overrides.finalizeIcloudAliasAfterSuccessfulFlow || (async () => {}),
findHotmailAccount: async () => null,
flushCommand: async () => {},
getCurrentLuckmailPurchase: () => null,
@@ -263,6 +267,68 @@ 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 does not finalize pending phone activation when icloud finalization fails', 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' : '' }),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {
throw new Error('icloud finalize failed');
},
});
await assert.rejects(
() => router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
}),
/icloud finalize failed/
);
assert.deepStrictEqual(events.phoneFinalizations, []);
});
test('message router marks step 3 failed when post-submit finalize fails', async () => {
const { router, events } = createRouter({
finalizeStep3Completion: async () => {
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports paypal account store module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/paypal-account-store\.js/);
assert.match(source, /paypal-utils\.js/);
});
test('paypal account store module exposes a factory', () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
assert.equal(typeof api?.createPayPalAccountStore, 'function');
});
test('paypal account store selects account and keeps legacy paypal credentials in sync', async () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: '',
paypalEmail: '',
paypalPassword: '',
};
const broadcasts = [];
const store = api.createPayPalAccountStore({
broadcastDataUpdate(payload) {
broadcasts.push(payload);
},
findPayPalAccount(accounts, accountId) {
return (Array.isArray(accounts) ? accounts : []).find((account) => account.id === accountId) || null;
},
getState: async () => latestState,
normalizePayPalAccount(account = {}) {
return {
id: String(account.id || 'generated'),
email: String(account.email || '').trim().toLowerCase(),
password: String(account.password || ''),
createdAt: Number(account.createdAt) || 1,
updatedAt: Number(account.updatedAt) || 1,
lastUsedAt: Number(account.lastUsedAt) || 0,
};
},
normalizePayPalAccounts(accounts) {
return Array.isArray(accounts) ? accounts.slice() : [];
},
setPersistentSettings: async (updates) => {
latestState = { ...latestState, ...updates };
},
setState: async (updates) => {
latestState = { ...latestState, ...updates };
},
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
});
const account = await store.upsertPayPalAccount({
id: 'pp-1',
email: 'User@Example.com',
password: 'secret',
});
assert.equal(account.email, 'user@example.com');
const selected = await store.setCurrentPayPalAccount('pp-1');
assert.equal(selected.id, 'pp-1');
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.deepStrictEqual(
broadcasts.at(-1),
{
currentPayPalAccountId: 'pp-1',
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
}
);
});
+72
View File
@@ -310,6 +310,78 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve
assert.equal(events.ensureCalls >= 3, true);
});
test('step 8 keeps resend cooldown timestamp across in-place retries to avoid repeated resend storms', async () => {
const events = {
resolveCalls: 0,
resolveLastResendAts: [],
sleepMs: [],
};
const realDateNow = Date.now;
Date.now = () => 230000;
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
rerunStep7ForStep8Recovery: async () => {},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret', loginVerificationRequestedAt: null }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async (_step, _state, _mail, options) => {
events.resolveCalls += 1;
events.resolveLastResendAts.push(Number(options?.lastResendAt) || 0);
if (events.resolveCalls === 1) {
await options.onResendRequestedAt(222000);
throw new Error('步骤 8:页面通信异常 did not respond in 1s');
}
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async (ms) => {
events.sleepMs.push(ms);
},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
try {
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
} finally {
Date.now = realDateNow;
}
assert.equal(events.resolveCalls, 2);
assert.deepStrictEqual(events.resolveLastResendAts, [0, 222000]);
assert.equal(events.sleepMs.length >= 1, true);
assert.equal(events.sleepMs[0], 3000);
});
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
const events = {
ensureCalls: 0,
+314
View File
@@ -0,0 +1,314 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { pickHotmailAccountForRun } = require('../hotmail-utils.js');
const source = fs.readFileSync('background.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) {
return '';
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
return '';
}
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 isAuthorizedHotmailRunAccountSource = extractFunction('isAuthorizedHotmailRunAccount');
const isPendingHotmailVerificationCandidateSource = extractFunction('isPendingHotmailVerificationCandidate');
const compareHotmailAccountAllocationPrioritySource = extractFunction('compareHotmailAccountAllocationPriority');
const pickPendingHotmailAccountForVerificationSource = extractFunction('pickPendingHotmailAccountForVerification');
const ensureHotmailAccountForFlowSource = extractFunction('ensureHotmailAccountForFlow');
const ensureHotmailMailboxReadyForAutoRunRoundSource = extractFunction('ensureHotmailMailboxReadyForAutoRunRound');
function createHotmailPreflightApi(initialState, verifyImpl = async () => ({ account: null, messageCount: 0 })) {
const factory = new Function('deps', `
let currentState = JSON.parse(JSON.stringify(deps.initialState));
const getState = async () => ({
...currentState,
hotmailAccounts: Array.isArray(currentState.hotmailAccounts)
? currentState.hotmailAccounts.map((account) => ({ ...account }))
: [],
});
const normalizeHotmailAccounts = (accounts) => Array.isArray(accounts)
? accounts.map((account) => ({ ...account }))
: [];
const findHotmailAccount = (accounts, accountId) => normalizeHotmailAccounts(accounts)
.find((account) => account.id === accountId) || null;
const setCurrentHotmailAccount = async (accountId) => {
const state = await getState();
const account = findHotmailAccount(state.hotmailAccounts, accountId);
if (!account) {
throw new Error('missing Hotmail account');
}
currentState = {
...currentState,
currentHotmailAccountId: accountId,
};
return account;
};
const pickHotmailAccountForRun = deps.pickHotmailAccountForRun;
const verifyHotmailAccount = async (accountId) => deps.verifyHotmailAccount(accountId, async () => getState());
const isHotmailProvider = (stateOrProvider) => {
const provider = typeof stateOrProvider === 'string'
? stateOrProvider
: stateOrProvider?.mailProvider;
return provider === 'hotmail-api';
};
const addLog = async (message, level = 'info') => {
deps.logs.push({ message, level });
};
const throwIfStopped = () => {};
${isAuthorizedHotmailRunAccountSource}
${isPendingHotmailVerificationCandidateSource}
${compareHotmailAccountAllocationPrioritySource}
${pickPendingHotmailAccountForVerificationSource}
${ensureHotmailAccountForFlowSource}
${ensureHotmailMailboxReadyForAutoRunRoundSource}
return {
ensureHotmailAccountForFlow,
ensureHotmailMailboxReadyForAutoRunRound: typeof ensureHotmailMailboxReadyForAutoRunRound === 'function'
? ensureHotmailMailboxReadyForAutoRunRound
: undefined,
getState,
};
`);
const logs = [];
return {
api: factory({
initialState,
logs,
pickHotmailAccountForRun,
verifyHotmailAccount: verifyImpl,
}),
logs,
};
}
test('ensureHotmailAccountForFlow skips excluded current hotmail account when allocating a fresh account', async () => {
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
});
const account = await api.ensureHotmailAccountForFlow({
allowAllocate: true,
markUsed: false,
excludeIds: ['primary'],
});
assert.equal(account.id, 'backup');
});
test('ensureHotmailMailboxReadyForAutoRunRound switches to another hotmail account after a verification failure', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'primary') {
throw new Error('INBOX unavailable');
}
return {
account,
messageCount: 4,
};
});
assert.equal(typeof api.ensureHotmailMailboxReadyForAutoRunRound, 'function');
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 3,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run preflight timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'backup');
assert.equal(state.currentHotmailAccountId, 'backup');
assert.deepEqual(verifyCalls, ['primary', 'backup']);
assert.ok(logs.some(({ message }) => /切换下一个 Hotmail 账号/.test(message)));
});
test('ensureHotmailMailboxReadyForAutoRunRound verifies pending hotmail accounts when no authorized account exists yet', async () => {
const verifyCalls = [];
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: null,
hotmailAccounts: [
{
id: 'pending-1',
email: 'pending-1@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-1',
used: false,
lastUsedAt: 0,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
return {
account: {
...account,
status: 'authorized',
},
messageCount: 2,
};
});
const account = await api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 1,
attemptRun: 1,
});
const state = await api.getState();
assert.equal(account.id, 'pending-1');
assert.equal(state.currentHotmailAccountId, 'pending-1');
assert.deepEqual(verifyCalls, ['pending-1']);
});
test('ensureHotmailMailboxReadyForAutoRunRound falls back to pending hotmail accounts after authorized accounts fail', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'authorized-primary',
hotmailAccounts: [
{
id: 'authorized-primary',
email: 'authorized-primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-authorized-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'pending-backup',
email: 'pending-backup@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'authorized-primary') {
throw new Error('INBOX unavailable');
}
return {
account: {
...account,
status: 'authorized',
},
messageCount: 3,
};
});
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 2,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run pending fallback timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'pending-backup');
assert.equal(state.currentHotmailAccountId, 'pending-backup');
assert.deepEqual(verifyCalls, ['authorized-primary', 'pending-backup']);
assert.ok(logs.some(({ message }) => /待校验|未校验/.test(message)));
});
+175
View File
@@ -0,0 +1,175 @@
import importlib.util
import io
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
def load_hotmail_helper():
module_path = Path(__file__).resolve().parents[1] / "scripts" / "hotmail_helper.py"
spec = importlib.util.spec_from_file_location("hotmail_helper", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
hotmail_helper = load_hotmail_helper()
class HotmailHelperLoggingTest(unittest.TestCase):
def test_select_latest_code_can_use_full_body_when_preview_is_truncated(self):
css_prefix = (
'Your temporary ChatGPT verification code '
'@font-face { font-family: "Söhne"; src: url(https://cdn.openai.com/common/fonts/soehne/soehne-buch.woff2) format("woff2"); } '
'.ExternalClass { width: 100%; } '
'#bodyTable { width: 560px; } '
'body { min-width: 100% !important; } '
) * 8
full_body = (
css_prefix
+ 'Enter this temporary verification code to continue: 272964 '
+ 'Please ignore this email if this was not you.'
)
message = {
"id": "imap-1",
"mailbox": "INBOX",
"subject": "Your temporary ChatGPT verification code",
"from": {
"emailAddress": {
"address": "otp@tm1.openai.com",
"name": "OpenAI",
}
},
"bodyPreview": full_body[:500],
"body": {
"content": full_body,
},
"receivedTimestamp": 200,
}
result = hotmail_helper.select_latest_code(
[message],
['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
[],
0,
)
self.assertEqual(result["code"], "272964")
self.assertEqual(result["message"]["id"], "imap-1")
def test_log_openai_messages_logs_full_body_when_available(self):
messages = [{
"mailbox": "INBOX",
"subject": "Your verification code",
"from": {
"emailAddress": {
"address": "account-security@openai.com",
"name": "OpenAI",
}
},
"bodyPreview": "Use 123456 to continue.",
"body": {
"content": "Hello there\nUse 123456 to continue.",
},
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="imap")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail full body start transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn("Hello there\nUse 123456 to continue.", rendered)
self.assertIn("[HotmailHelper] openai mail full body end", rendered)
def test_log_openai_messages_falls_back_to_preview_without_full_body(self):
messages = [{
"mailbox": "Junk",
"subject": "Verify your sign in",
"from": {
"emailAddress": {
"address": "noreply@tm.openai.com",
"name": "ChatGPT",
}
},
"bodyPreview": "Use 654321 to continue.",
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="graph")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail preview transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in preview=Use 654321 to continue.",
rendered,
)
self.assertNotIn("openai mail full body start", rendered)
def test_refresh_access_token_logs_invalid_grant_and_direct_connection_refused_separately(self):
failures = [
{
"ok": False,
"endpoint": "entra-common-delegated",
"url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"status": 400,
"error": '{"error":"invalid_grant","error_description":"AADSTS70000"}',
"elapsed_ms": 101,
},
{
"ok": False,
"endpoint": "entra-consumers-delegated",
"url": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
"status": None,
"error": "Token request failed: <urlopen error [Errno 61] Connection refused>",
"elapsed_ms": 88,
},
]
with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures), \
mock.patch.object(hotmail_helper, "get_proxy_debug_context", return_value="direct"):
output = io.StringIO()
with redirect_stdout(output):
with self.assertRaises(RuntimeError):
hotmail_helper.refresh_access_token(
"client-id-demo",
"refresh-token-demo",
["entra-common-delegated", "entra-consumers-delegated"],
)
rendered = output.getvalue()
self.assertIn("category=invalid_grant", rendered)
self.assertIn("category=connection_refused", rendered)
def test_graph_and_outlook_message_urls_are_encoded(self):
captured_urls = []
def fake_get_json(url, headers=None):
captured_urls.append(url)
return 200, {"value": []}
with mock.patch.object(hotmail_helper, "get_json", side_effect=fake_get_json):
hotmail_helper.fetch_graph_messages("access-token-demo", mailbox="INBOX", top=5)
hotmail_helper.fetch_outlook_api_messages("access-token-demo", mailbox="INBOX", top=5)
self.assertEqual(len(captured_urls), 2)
self.assertTrue(all(" " not in url for url in captured_urls))
self.assertIn("%24orderby=receivedDateTime+desc", captured_urls[0])
self.assertIn("%24orderby=ReceivedDateTime+desc", captured_urls[1])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -319,6 +319,7 @@ return {
test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => {
const bundle = [
extractFunction('extractEmails'),
extractFunction('extractForwardedTargetEmails'),
extractFunction('emailMatchesTarget'),
extractFunction('getTargetEmailMatchState'),
extractFunction('normalizeMinuteTimestamp'),
+26
View File
@@ -107,6 +107,32 @@ test('PayPal approve keeps original combined email and password login path', asy
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal approve prefers the selected paypal pool account over legacy fields', async () => {
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' },
{ needsLogin: false, approveReady: true },
{ needsLogin: false, approveReady: true },
],
submitResults: [
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
});
await executor.executePayPalApprove({
paypalEmail: '',
paypalPassword: '',
currentPayPalAccountId: 'pp-1',
paypalAccounts: [
{ id: 'pp-1', email: 'pool@example.com', password: 'pool-secret' },
],
});
assert.deepStrictEqual(events.submittedPayloads, [
{ email: 'pool@example.com', password: 'pool-secret' },
]);
});
test('PayPal approve discovers an already open unregistered PayPal tab', async () => {
const { executor, events } = createExecutor({
pageStates: [
+105
View File
@@ -145,6 +145,57 @@ return {
`)(document, window);
}
function createSubmitApi(overrides = {}) {
const bindings = {
waitForDocumentComplete: async () => {},
normalizeText: (text = '') => String(text || '').replace(/\s+/g, ' ').trim(),
findPasswordInput: () => null,
findEmailInput: () => null,
findEmailNextButton: () => null,
isEnabledControl: () => true,
findPasswordLoginButton: () => null,
fillInput: () => {},
simulateClick: () => {},
waitUntil: async (predicate) => predicate(),
findLoginNextButton: () => null,
sleep: async () => {},
...overrides,
};
return new Function(
'waitForDocumentComplete',
'normalizeText',
'findPasswordInput',
'findEmailInput',
'findEmailNextButton',
'isEnabledControl',
'findPasswordLoginButton',
'fillInput',
'simulateClick',
'waitUntil',
'findLoginNextButton',
'sleep',
`
${extractFunction('refillPayPalEmailInput')}
${extractFunction('submitPayPalLogin')}
return { refillPayPalEmailInput, submitPayPalLogin };
`
)(
bindings.waitForDocumentComplete,
bindings.normalizeText,
bindings.findPasswordInput,
bindings.findEmailInput,
bindings.findEmailNextButton,
bindings.isEnabledControl,
bindings.findPasswordLoginButton,
bindings.fillInput,
bindings.simulateClick,
bindings.waitUntil,
bindings.findLoginNextButton,
bindings.sleep
);
}
test('PayPal email page ignores hidden pre-rendered password input', () => {
const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } });
const emailInput = createElement({
@@ -203,3 +254,57 @@ test('PayPal combined login page still sees visible password input', () => {
assert.equal(api.findPasswordLoginButton(), loginButton);
assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined');
});
test('PayPal email submit refills a prefilled email before clicking next', async () => {
const emailInput = createElement({
tag: 'input',
type: 'text',
id: 'login_email',
name: 'login_email',
value: 'user@example.com',
placeholder: 'Email',
});
const nextButton = createElement({
tag: 'button',
id: 'btnNext',
text: 'Next',
});
const fillValues = [];
const clicked = [];
let focusCount = 0;
let blurCount = 0;
emailInput.focus = () => {
focusCount += 1;
};
emailInput.blur = () => {
blurCount += 1;
};
const api = createSubmitApi({
findEmailInput: () => emailInput,
findEmailNextButton: () => nextButton,
fillInput: (element, value) => {
fillValues.push(value);
element.value = value;
},
simulateClick: (element) => {
clicked.push(element);
},
});
const result = await api.submitPayPalLogin({
email: 'user@example.com',
password: 'secret',
});
assert.deepEqual(fillValues, ['', 'user@example.com']);
assert.equal(focusCount, 1);
assert.equal(blurCount, 1);
assert.deepEqual(clicked, [nextButton]);
assert.deepEqual(result, {
submitted: false,
phase: 'email_submitted',
awaiting: 'password_page',
});
});
+346 -242
View File
@@ -32,7 +32,7 @@ function buildHeroSmsStatusV2Payload({ smsCode = '', smsText = '', callCode = ''
});
}
test('phone verification helper requests HeroSMS numbers with fixed OpenAI and Thailand parameters', async () => {
test('phone verification helper requests HeroSMS numbers with manual maxPrice and fixed OpenAI/Thailand parameters', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
@@ -40,26 +40,22 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
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(),
};
}
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
const activation = await helpers.requestPhoneActivation({
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
});
assert.deepStrictEqual(activation, {
activationId: '123456',
@@ -70,111 +66,80 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
successfulUses: 0,
maxUses: 3,
});
assert.equal(requests.length, 2);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests.length, 1);
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
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');
assert.equal(requests[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
});
test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => {
const requests = [];
let getPricesAttempt = 0;
test('phone verification helper requires manual HeroSMS maxPrice', async () => {
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}`);
fetchImpl: async () => {
throw new Error('should not request HeroSMS without maxPrice');
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '' }),
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');
await assert.rejects(
() => helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '' }),
/HeroSMS maxPrice is missing/i
);
});
test('phone verification helper falls back to plain getNumber only after HeroSMS getPrices fails three times', async () => {
test('phone verification helper still clears existing activation when maxPrice is missing', async () => {
const requests = [];
let getPricesAttempt = 0;
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '',
currentPhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
reusablePhoneActivation: null,
pendingPhoneActivationConfirmation: null,
};
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}`);
return {
ok: true,
text: async () => 'ACCESS_CANCEL',
};
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
await assert.rejects(
() => helpers.completePhoneVerificationFlow(1, { addPhonePage: true }),
/HeroSMS maxPrice is missing/i
);
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);
assert.equal(requests.length, 1);
assert.equal(requests[0].searchParams.get('action'), 'setStatus');
assert.equal(requests[0].searchParams.get('id'), '123456');
assert.equal(requests[0].searchParams.get('status'), '8');
assert.equal(requests[0].searchParams.has('maxPrice'), false);
assert.equal(currentState.currentPhoneActivation, null);
});
test('phone verification helper retries with HeroSMS getNumberV2 when getNumber reports NO_NUMBERS', async () => {
@@ -186,12 +151,6 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
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,
@@ -209,7 +168,7 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsCountryId: 16 }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08', heroSmsCountryId: 16 }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
@@ -218,6 +177,7 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
const activation = await helpers.requestPhoneActivation({
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
});
@@ -231,17 +191,15 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
maxUses: 3,
statusAction: 'getStatusV2',
});
assert.equal(requests.length, 3);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests.length, 2);
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('country'), '16');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[1].searchParams.get('action'), 'getNumberV2');
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 () => {
@@ -249,6 +207,7 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -264,12 +223,6 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
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,
@@ -361,7 +314,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',
},
@@ -372,7 +337,6 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
]);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, [
'getPrices',
'getNumber',
'getNumberV2',
'getStatusV2',
@@ -381,117 +345,32 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
]);
});
test('phone verification helper refreshes maxPrice when HeroSMS returns WRONG_MAX_PRICE', async () => {
const requests = [];
let getNumberAttempt = 0;
test('phone verification helper keeps the user-provided maxPrice and surfaces HeroSMS price errors', async () => {
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',
};
return {
ok: false,
text: async () => 'WRONG_MAX_PRICE:0.09',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
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);
await assert.rejects(
() => helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
/WRONG_MAX_PRICE:0.09/
);
});
test('phone verification helper completes add-phone flow, clears current activation, and stores reusable number state', async () => {
@@ -499,6 +378,7 @@ test('phone verification helper completes add-phone flow, clears current activat
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -511,12 +391,6 @@ 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,
@@ -593,7 +467,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,
},
},
@@ -603,7 +488,87 @@ test('phone verification helper completes add-phone flow, clears current activat
]);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, ['getPrices', 'getNumber', 'getStatus', 'setStatus']);
assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']);
});
test('phone verification helper still succeeds when HeroSMS setStatus(3) fails after a successful submit', async () => {
const requests = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
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 === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus') {
return {
ok: false,
text: async () => 'TEMPORARY_ERROR',
};
}
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',
});
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']);
});
test('phone verification helper uses the configured HeroSMS country for both number acquisition and add-phone submission', async () => {
@@ -611,6 +576,7 @@ test('phone verification helper uses the configured HeroSMS country for both num
const submittedPayloads = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -625,12 +591,6 @@ 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,
@@ -688,12 +648,10 @@ 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'), 'getPrices');
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
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[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
assert.deepStrictEqual(submittedPayloads, [{
phoneNumber: '447911123456',
countryId: 16,
@@ -704,8 +662,10 @@ test('phone verification helper uses the configured HeroSMS country for both num
test('phone verification helper throws a step-7 restart error after 60 seconds plus one resend window without SMS', async () => {
const requests = [];
const messages = [];
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -725,13 +685,6 @@ 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,
@@ -775,6 +728,7 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
stateUpdates.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {
@@ -799,7 +753,6 @@ 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',
@@ -807,6 +760,11 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
'setStatus:123456',
]);
assert.equal(currentState.currentPhoneActivation, null);
assert.equal(
stateUpdates.some((updates) => Number(updates.currentPhoneActivation?.successfulUses) > 0),
false,
'60 seconds without SMS should not increment reuse count'
);
} finally {
Date.now = realDateNow;
}
@@ -817,6 +775,7 @@ test('phone verification helper replaces the number when code submission returns
const messages = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -838,13 +797,6 @@ 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;
@@ -925,11 +877,9 @@ 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',
@@ -941,15 +891,25 @@ 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',
heroSmsMaxPrice: '0.08',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: {
@@ -1031,13 +991,31 @@ 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',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -1122,5 +1100,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);
});
@@ -61,6 +61,9 @@ function createRow(initialDisplay = 'none') {
test('sidepanel html places cloudflare temp email controls in a standalone section', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="cloudflare-temp-email-section"/);
assert.match(html, /id="btn-toggle-cloudflare-temp-email-section"/);
assert.match(html, /aria-controls="cloudflare-temp-email-section-body"/);
assert.match(html, /id="cloudflare-temp-email-section-body" class="section-collapse-body" hidden/);
assert.match(html, /id="btn-cloudflare-temp-email-usage-guide"/);
assert.match(html, /id="btn-cloudflare-temp-email-github"/);
assert.match(html, /id="row-temp-email-random-subdomain-toggle"/);
@@ -68,6 +71,16 @@ test('sidepanel html places cloudflare temp email controls in a standalone secti
assert.doesNotMatch(html, /id="row-temp-email-random-subdomain-domain"/);
});
test('sidepanel persists cloudflare temp email section collapse state', () => {
assert.match(source, /CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-cloudflare-temp-email-section-expanded'/);
assert.match(source, /let cloudflareTempEmailSectionExpanded = false/);
assert.match(source, /function updateCloudflareTempEmailSectionExpandedUI\(\)/);
assert.match(source, /cloudflareTempEmailSectionBody\.hidden = !expanded/);
assert.match(source, /btnToggleCloudflareTempEmailSection\.setAttribute\('aria-expanded', String\(expanded\)\)/);
assert.match(source, /btnToggleCloudflareTempEmailSection\?\.addEventListener\('click', \(\) => \{\s*toggleCloudflareTempEmailSectionExpanded\(\)/);
assert.match(source, /initCloudflareTempEmailSectionExpandedState\(\)/);
});
test('sidepanel modal message preserves line breaks and supports inline links', () => {
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
assert.match(css, /\.modal-message\s*\{[\s\S]*white-space:\s*pre-line;/);
+16
View File
@@ -2,6 +2,8 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function createAccountPoolUiStub() {
return {
createAccountPoolFormController({
@@ -60,11 +62,25 @@ test('sidepanel loads hotmail manager before sidepanel bootstrap', () => {
test('sidepanel html contains collapsible hotmail form controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="btn-toggle-hotmail-section"/);
assert.match(html, /aria-controls="hotmail-section-body"/);
assert.match(html, /id="hotmail-section-body" class="section-collapse-body" hidden/);
assert.match(html, /id="btn-toggle-hotmail-form"/);
assert.match(html, /id="hotmail-form-shell"/);
assert.match(html, /id="btn-import-hotmail-accounts"[^>]*>批量导入</);
});
test('sidepanel keeps hotmail account pool behind a persisted section collapse', () => {
assert.match(sidepanelSource, /HOTMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-hotmail-section-expanded'/);
assert.match(sidepanelSource, /let hotmailSectionExpanded = false/);
assert.match(sidepanelSource, /function updateHotmailSectionExpandedUI\(\)/);
assert.match(sidepanelSource, /hotmailSectionBody\.hidden = !expanded/);
assert.match(sidepanelSource, /btnToggleHotmailSection\.setAttribute\('aria-expanded', String\(expanded\)\)/);
assert.match(sidepanelSource, /btnToggleHotmailSection\?\.addEventListener\('click', \(\) => \{\s*toggleHotmailSectionExpanded\(\)/);
assert.match(sidepanelSource, /btnToggleHotmailForm\?\.addEventListener\('click', \(\) => \{\s*setHotmailSectionExpanded\(true\)/);
assert.match(sidepanelSource, /initHotmailSectionExpandedState\(\)/);
});
test('hotmail manager exposes a factory and renders empty state', () => {
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
const windowObject = {
+2
View File
@@ -321,6 +321,7 @@ const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsMaxPrice = { value: '' };
const selectHeroSmsCountry = { value: '52', options: [{ value: '52' }] };
const inputRunCount = { value: '' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
@@ -347,6 +348,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function formatAutoStepDelayInputValue(value) { return value == null ? '' : String(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizeHeroSmsMaxPriceValue(value) { return String(value ?? '').trim(); }
function normalizeHeroSmsCountryId() { return 52; }
function getSelectedHeroSmsCountryOption() { return { label: 'Thailand' }; }
function updateHeroSmsPlatformDisplay() {}
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel password inputs expose visibility toggles', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const passwordInputIds = Array.from(
html.matchAll(/<input\b[^>]*type="password"[^>]*id="([^"]+)"/g),
(match) => match[1]
);
const legacyToggleIds = new Map([
['input-vps-url', 'btn-toggle-vps-url'],
['input-vps-password', 'btn-toggle-vps-password'],
['input-ip-proxy-username', 'btn-toggle-ip-proxy-username'],
['input-ip-proxy-password', 'btn-toggle-ip-proxy-password'],
['input-ip-proxy-api-url', 'btn-toggle-ip-proxy-api-url'],
['input-password', 'btn-toggle-password'],
]);
assert.ok(passwordInputIds.length > 0);
for (const inputId of passwordInputIds) {
const hasDataToggle = html.includes(`data-password-toggle="${inputId}"`);
const legacyToggleId = legacyToggleIds.get(inputId);
const hasLegacyToggle = legacyToggleId ? html.includes(`id="${legacyToggleId}"`) : false;
assert.equal(
hasDataToggle || hasLegacyToggle,
true,
`${inputId} should have a visibility toggle button`
);
}
});
test('shared form dialog adds visibility toggles for password fields', () => {
const source = fs.readFileSync('sidepanel/form-dialog.js', 'utf8');
assert.match(source, /field\.type === 'password'[\s\S]*data-input-with-icon/);
assert.match(source, /syncPasswordToggleButton\(toggleButton,\s*input,\s*labels\)/);
assert.match(source, /input\.type = input\.type === 'password' \? 'text' : 'password'/);
});
+133
View File
@@ -0,0 +1,133 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel loads reusable form dialog and paypal manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const formDialogIndex = html.indexOf('<script src="form-dialog.js"></script>');
const managerIndex = html.indexOf('<script src="paypal-manager.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.notEqual(formDialogIndex, -1);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(formDialogIndex < managerIndex);
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
test('paypal manager saves a paypal account and selects it immediately', async () => {
const source = fs.readFileSync('sidepanel/paypal-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelPayPalManager;`)(windowObject);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: null,
paypalEmail: '',
paypalPassword: '',
};
const events = [];
const clickHandlers = {};
const changeHandlers = {};
const selectNode = {
innerHTML: '',
value: '',
disabled: false,
addEventListener(type, handler) {
changeHandlers[type] = handler;
},
};
const addButton = {
disabled: false,
addEventListener(type, handler) {
clickHandlers[type] = handler;
},
};
const manager = api.createPayPalManager({
state: {
getLatestState: () => latestState,
syncLatestState(updates) {
latestState = { ...latestState, ...updates };
},
},
dom: {
btnAddPayPalAccount: addButton,
selectPayPalAccount: selectNode,
},
helpers: {
escapeHtml: (value) => String(value || ''),
getPayPalAccounts: (state) => Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [],
openFormDialog: async () => ({ email: 'user@example.com', password: 'secret' }),
showToast(message, tone) {
events.push({ type: 'toast', message, tone });
},
},
runtime: {
sendMessage: async (message) => {
events.push({ type: 'message', message });
if (message.type === 'UPSERT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
if (message.type === 'SELECT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
throw new Error(`unexpected message ${message.type}`);
},
},
paypalUtils: {
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
},
});
manager.bindPayPalEvents();
manager.renderPayPalAccounts();
assert.match(selectNode.innerHTML, /请先添加 PayPal 账号/);
clickHandlers.click();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.deepStrictEqual(
events.filter((event) => event.type === 'message').map((event) => event.message.type),
['UPSERT_PAYPAL_ACCOUNT', 'SELECT_PAYPAL_ACCOUNT']
);
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.equal(selectNode.value, 'pp-1');
assert.equal(selectNode.disabled, false);
assert.match(events.at(-1)?.message || '', /已保存 PayPal 账号/);
});
@@ -7,6 +7,7 @@ const {
} = require('../mail-provider-utils');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const ipProxyPanelSource = fs.readFileSync('sidepanel/ip-proxy-panel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
@@ -57,17 +58,51 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-phone-verification-enabled"/);
assert.match(html, /id="input-phone-verification-enabled"/);
assert.match(html, /id="ip-proxy-section"/);
assert.match(html, /id="row-ip-proxy-enabled"/);
assert.match(html, /id="input-ip-proxy-enabled"/);
assert.match(html, /id="row-hero-sms-platform"/);
assert.match(html, /id="row-hero-sms-country"/);
assert.match(html, /id="row-hero-sms-max-price"/);
assert.match(html, /id="row-hero-sms-api-key"/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('sidepanel renders IP proxy as a standalone card after sms verification without proxy status chrome', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const phoneToggleIndex = html.indexOf('id="row-phone-verification-enabled"');
const ipProxySectionIndex = html.indexOf('id="ip-proxy-section"');
const ipProxyToggleIndex = html.indexOf('id="row-ip-proxy-enabled"');
const cloudflareSectionIndex = html.indexOf('id="cloudflare-temp-email-section"');
assert.match(html, /id="ip-proxy-section" class="data-card ip-proxy-card"/);
assert.match(html, /id="btn-toggle-ip-proxy-section"/);
assert.match(html, /aria-controls="row-ip-proxy-fold"/);
assert.match(html, />展开设置<\/button>/);
assert.ok(phoneToggleIndex >= 0);
assert.ok(ipProxySectionIndex > phoneToggleIndex);
assert.ok(ipProxyToggleIndex > phoneToggleIndex);
assert.ok(cloudflareSectionIndex > ipProxySectionIndex);
assert.doesNotMatch(html, /id="ip-proxy-enabled-status"/);
assert.doesNotMatch(html, /id="row-ip-proxy-runtime-status"/);
});
test('IP proxy standalone card supports persisted collapse control', () => {
assert.match(ipProxyPanelSource, /IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded'/);
assert.match(ipProxyPanelSource, /let ipProxySectionExpanded = false/);
assert.match(ipProxyPanelSource, /const showSettings = enabled && ipProxySectionExpanded/);
assert.match(ipProxyPanelSource, /rowIpProxyFold\.style\.display = showSettings \? '' : 'none'/);
assert.match(ipProxyPanelSource, /btnToggleIpProxySection\.setAttribute\('aria-expanded', String\(showSettings\)\)/);
assert.match(sidepanelSource, /btnToggleIpProxySection\?\.addEventListener\('click', \(\) => \{\s*if \(typeof toggleIpProxySectionExpanded === 'function'\)/);
assert.match(sidepanelSource, /initIpProxySectionExpandedState\(\)/);
});
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
const api = new Function(`
const inputPhoneVerificationEnabled = { checked: false };
const rowHeroSmsPlatform = { style: { display: 'none' } };
const rowHeroSmsCountry = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
${extractFunction('updatePhoneVerificationSettingsUI')}
@@ -76,6 +111,7 @@ return {
inputPhoneVerificationEnabled,
rowHeroSmsPlatform,
rowHeroSmsCountry,
rowHeroSmsMaxPrice,
rowHeroSmsApiKey,
updatePhoneVerificationSettingsUI,
};
@@ -84,12 +120,14 @@ return {
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
api.inputPhoneVerificationEnabled.checked = true;
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsPlatform.style.display, '');
assert.equal(api.rowHeroSmsCountry.style.display, '');
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
assert.equal(api.rowHeroSmsApiKey.style.display, '');
});
@@ -141,6 +179,7 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputPhoneVerificationEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
const inputHeroSmsMaxPrice = { value: '0.08' };
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
@@ -169,6 +208,7 @@ function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Num
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('getSelectedHeroSmsCountryOption')}
${extractFunction('collectSettingsPayload')}
return { collectSettingsPayload };
@@ -180,6 +220,7 @@ return { collectSettingsPayload };
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
assert.equal(payload.heroSmsMaxPrice, '0.08');
assert.equal(payload.heroSmsCountryId, 52);
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
});
+3 -2
View File
@@ -68,6 +68,7 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
test('sidepanel html exposes Plus mode and PayPal settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="input-paypal-email"/);
assert.match(html, /id="input-paypal-password"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
@@ -114,6 +114,7 @@ function broadcastDataUpdate() {}
async function addLog(message) {
logMessages.push(message);
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
function matchesSourceUrlFamily() {
return false;
+71
View File
@@ -1030,6 +1030,77 @@ test('verification flow waits during resend cooldown instead of tight-looping',
assert.ok(sleepCalls[0] >= 1000);
});
test('verification flow notifies onResendRequestedAt when resend is triggered', async () => {
const resendRequestedAtCalls = [];
const stateUpdates = [];
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') {
return { resent: true };
}
return {};
},
sendToMailContentScriptResilient: async (_mail, message) => {
if (message.type !== 'POLL_EMAIL') {
return {};
}
pollCalls += 1;
return pollCalls === 1
? {}
: { code: '654321', emailTimestamp: 123 };
},
setState: async (payload) => {
stateUpdates.push(payload);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.resolveVerificationStep(
8,
{
email: 'user@example.com',
lastLoginCode: null,
},
{ provider: 'qq', label: 'QQ 邮箱' },
{
maxResendRequests: 1,
resendIntervalMs: 25000,
onResendRequestedAt: async (requestedAt) => {
resendRequestedAtCalls.push(Number(requestedAt) || 0);
},
}
);
assert.equal(resendRequestedAtCalls.length >= 1, true);
assert.equal(resendRequestedAtCalls[0] > 0, true);
assert.equal(
stateUpdates.some((payload) => Number(payload?.loginVerificationRequestedAt) > 0),
true
);
});
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
const resilientCalls = [];