diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5d3123..118e4ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,6 @@ jobs: set -euo pipefail node --check main.js node tests/test_auth_code_extraction.js + node tests/test_auth_retry.js node tests/test_proxy_config.js bash tests/test_workflow_config_sources.sh diff --git a/lib/auth_retry.js b/lib/auth_retry.js index 4def3fb..f6aef1b 100644 --- a/lib/auth_retry.js +++ b/lib/auth_retry.js @@ -14,19 +14,17 @@ const BANNER_PATTERNS = [ /locked/i, /too many failed/i, /suspicious/i, - /risk/i, + /credential\/risk rejection|flagged (?:for )?risk/i, + /(?:2fa|two.factor|verification code).*(?:reject|invalid|fail|denied)/i, + /(?:invalid|incorrect|rejected).*security code/i, + /security code.*(?:incorrect|invalid|rejected|expired)/i, + /enter a valid 6-digit security code/i, ]; const RETRYABLE_ERROR_PATTERNS = [ /net::ERR_TUNNEL_CONNECTION_FAILED/i, /net::ERR_PROXY_CONNECTION_FAILED/i, /net::ERR_CONNECTION_REFUSED/i, - /net::ERR_CONNECTION_RESET/i, - /net::ERR_CONNECTION_CLOSED/i, - /login form did not become visible after/i, - /credential\/risk rejection/i, - /login page rejected credentials or flagged risk/i, - /token exchange network error/i, ]; function looksLikeCredentialOrRiskBanner(value) { @@ -34,9 +32,11 @@ function looksLikeCredentialOrRiskBanner(value) { return BANNER_PATTERNS.some(pattern => pattern.test(text)); } -function isRetryableWithProxy(value) { +function isRetryableWithProxy(value, credentialsNotSubmitted = false) { const text = normalizeText(value); - return RETRYABLE_ERROR_PATTERNS.some(pattern => pattern.test(text)); + return credentialsNotSubmitted === true + && !looksLikeCredentialOrRiskBanner(text) + && RETRYABLE_ERROR_PATTERNS.some(pattern => pattern.test(text)); } module.exports = { diff --git a/main.js b/main.js index 05a027b..8d91195 100644 --- a/main.js +++ b/main.js @@ -51,8 +51,6 @@ const DELAYS = { const VIEWPORT = { width: 1280, height: 800 }; const TOTP_PERIOD_SECONDS = 30; const TOTP_MIN_VALIDITY_SECONDS = 20; -const TWO_FA_MAX_ATTEMPTS = 2; -const AUTH_NAVIGATION_MAX_ATTEMPTS = 3; const FORCE_PROXY_FIRST = String(process.env.SCHWAB_FORCE_PROXY_FIRST || '').toLowerCase() === 'true'; const FALLBACK_PROXY_URL = resolveProxyUrl(process.env); const SECRET_VERSION_RETENTION = Math.max( @@ -264,40 +262,22 @@ async function navigateToLoginForm(page, authUrl) { const loginInput = page.getByRole('textbox', { name: /Login ID/i }); const passwordInput = page.getByRole('textbox', { name: /Password/i }); - for (let attempt = 1; attempt <= AUTH_NAVIGATION_MAX_ATTEMPTS; attempt += 1) { - console.log(`1. Navigating to auth page, attempt ${attempt}/${AUTH_NAVIGATION_MAX_ATTEMPTS}...`); + console.log('1. Navigating to auth page...'); + try { await page.goto(authUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUTS.AUTH_PAGE }); - await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); - await humanDelay(DELAYS.CREDENTIAL_ENTRY.min, DELAYS.CREDENTIAL_ENTRY.max); - - try { - await loginInput.waitFor({ state: 'visible', timeout: TIMEOUTS.LOGIN_FORM }); - await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.LOGIN_FORM }); - return { loginInput, passwordInput }; - } catch (e) { - const title = await page.title().catch(() => ''); - console.log(`Login form was not visible on attempt ${attempt}/${AUTH_NAVIGATION_MAX_ATTEMPTS}.`); - console.log(`Current auth page state: ${JSON.stringify({ url: summarizeUrl(page.url()), title: title || null })}`); - await saveScreenshot(page, `auth_page_attempt_${attempt}.png`); - - if (attempt === AUTH_NAVIGATION_MAX_ATTEMPTS) { - throw new Error(`Login form did not become visible after ${AUTH_NAVIGATION_MAX_ATTEMPTS} attempts: ${sanitizeError(e.message)}`); - } - - await humanDelay(4000, 7000); - } + } catch (err) { + // Only a known initial connection failure precedes any credential entry. + err.retryBeforeCredentialEntry = isRetryableWithProxy(err.message, true); + throw err; } - - throw new Error('Login form navigation attempts were exhausted.'); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await humanDelay(DELAYS.CREDENTIAL_ENTRY.min, DELAYS.CREDENTIAL_ENTRY.max); + await loginInput.waitFor({ state: 'visible', timeout: TIMEOUTS.LOGIN_FORM }); + await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUTS.LOGIN_FORM }); + return { loginInput, passwordInput }; } -async function detectLoginPageRejection(page, loginInput, passwordInput) { - const loginVisible = await loginInput.isVisible().catch(() => false); - const passwordVisible = await passwordInput.isVisible().catch(() => false); - if (!loginVisible || !passwordVisible) { - return null; - } - +async function detectLoginPageRejection(page) { const bodyText = await page.locator('body').innerText({ timeout: 2000 }).catch(() => ''); if (looksLikeCredentialOrRiskBanner(bodyText)) { return bodyText; @@ -342,31 +322,23 @@ async function submitTwoFactorCode(page) { const totp = new TOTP({ secret: TOTP_SECRET.replace(/\s/g, "") }); - for (let attempt = 1; attempt <= TWO_FA_MAX_ATTEMPTS; attempt += 1) { - await waitForFreshTotpWindow(); - const token = totp.generate(); - console.log(`Submitting 2FA code, attempt ${attempt}/${TWO_FA_MAX_ATTEMPTS}...`); - await codeInput.fill(''); - await codeInput.fill(token); - await continueButton.click(); - await page.waitForTimeout(3000); - - const invalidCodeMessage = page.getByText('Enter a valid 6-digit security code.'); - const loginErrorBanner = page.getByText(/We cant log you in right now/i); - const stillOnCodePage = - (await codeInput.isVisible().catch(() => false)) && - ((await invalidCodeMessage.isVisible().catch(() => false)) || - (await loginErrorBanner.isVisible().catch(() => false))); - - if (!stillOnCodePage) { - return; - } - - if (attempt === TWO_FA_MAX_ATTEMPTS) { - throw new Error('2FA code was rejected after retry.'); - } - - console.log('2FA code was rejected, retrying with a fresh TOTP code...'); + await waitForFreshTotpWindow(); + const token = totp.generate(); + console.log('Submitting 2FA code...'); + await codeInput.fill(''); + await codeInput.fill(token); + await continueButton.click(); + await page.waitForTimeout(3000); + + const invalidCodeMessage = page.getByText('Enter a valid 6-digit security code.'); + const loginErrorBanner = page.getByText(/We cant log you in right now/i); + const stillOnCodePage = + (await codeInput.isVisible().catch(() => false)) && + ((await invalidCodeMessage.isVisible().catch(() => false)) || + (await loginErrorBanner.isVisible().catch(() => false))); + const bodyText = await page.locator('body').innerText({ timeout: 1500 }).catch(() => ''); + if (stillOnCodePage || looksLikeCredentialOrRiskBanner(bodyText)) { + throw new Error('2FA code was rejected; human review required.'); } } @@ -456,14 +428,6 @@ async function exchangeCodeForToken(code, proxyUrl) { } } -class RetryWithProxyError extends Error { - constructor(message) { - super(message); - this.name = 'RetryWithProxyError'; - this.retryWithProxy = true; - } -} - function buildAttemptPlan() { if (FORCE_PROXY_FIRST) { return [ @@ -502,6 +466,7 @@ async function runRefreshOnce({ label, modeLabel = label, proxyUrl }) { const page = context.pages()[0] || await context.newPage(); attachPageDiagnostics(page); let interceptedCode = null; + let credentialsStarted = false; page.on('request', r => { const requestUrl = r.url(); @@ -522,14 +487,16 @@ async function runRefreshOnce({ label, modeLabel = label, proxyUrl }) { try { const { loginInput, passwordInput } = await navigateToLoginForm(page, authUrl); console.log("2. Entering credentials..."); + // Filling can trigger browser-side submission; do not wait for click success. + credentialsStarted = true; await loginInput.fill(USERNAME); await passwordInput.fill(PASSWORD); await page.getByRole('button', { name: 'Log in' }).click(); await page.waitForTimeout(3000); - const rejectionText = await detectLoginPageRejection(page, loginInput, passwordInput); + const rejectionText = await detectLoginPageRejection(page); if (rejectionText) { - throw new RetryWithProxyError(`Login page rejected credentials or flagged risk: ${sanitizeError(rejectionText)}`); + throw new Error(`Login page rejected credentials or flagged risk: ${sanitizeError(rejectionText)}`); } console.log("3. Processing 2FA code..."); @@ -543,7 +510,7 @@ async function runRefreshOnce({ label, modeLabel = label, proxyUrl }) { .filter(Boolean) .join(' '); if (looksLikeCredentialOrRiskBanner(rejectionText)) { - throw new RetryWithProxyError(`Login page rejected credentials or flagged risk during 2FA step: ${sanitizeError(rejectionText)}`); + throw new Error(`Login page rejected credentials or flagged risk during 2FA step: ${sanitizeError(rejectionText)}`); } throw new Error(`2FA step failed: ${sanitizeError(e.message)}`); } @@ -578,6 +545,7 @@ async function runRefreshOnce({ label, modeLabel = label, proxyUrl }) { console.log("SUCCESS! Token refreshed and synced."); } catch (err) { + err.retryBeforeCredentialEntry = !credentialsStarted && err.retryBeforeCredentialEntry === true; await saveScreenshot(page, 'last_error_state.png'); throw err; } finally { @@ -602,7 +570,8 @@ async function main() { return; } catch (err) { lastError = err; - const shouldRetry = index < attemptPlan.length - 1 && (err.retryWithProxy || isRetryableWithProxy(err.message)); + const shouldRetry = index < attemptPlan.length - 1 + && isRetryableWithProxy(err.message, err.retryBeforeCredentialEntry === true); if (shouldRetry) { console.log(`Retryable Schwab error on ${attempt.label} mode; trying ${attemptPlan[index + 1].label} mode next.`); continue; @@ -616,8 +585,10 @@ async function main() { } } -main().catch(err => { - console.error("Failure:", sanitizeError(err.message)); - if (err.stack) console.error("Stack:", sanitizeError(err.stack)); - process.exit(1); -}); +if (require.main === module) { + main().catch(err => { + console.error("Failure:", sanitizeError(err.message)); + if (err.stack) console.error("Stack:", sanitizeError(err.stack)); + process.exit(1); + }); +} diff --git a/tests/test_auth_retry.js b/tests/test_auth_retry.js index 782f60c..f976855 100644 --- a/tests/test_auth_retry.js +++ b/tests/test_auth_retry.js @@ -1,43 +1,182 @@ const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const { isRetryableWithProxy, looksLikeCredentialOrRiskBanner } = require('../lib/auth_retry'); -const { - isRetryableWithProxy, - looksLikeCredentialOrRiskBanner, -} = require('../lib/auth_retry'); +function loadSyntheticMain(scenario = {}) { + const counts = { launches: 0, navigations: 0, fills: 0, logins: 0, codes: 0, exchanges: 0, writes: 0 }; + let redirect; + const failure = message => Object.assign(new Error(message), { retryWithProxy: true }); + const input = kind => ({ + first() { return this; }, + async waitFor() { + if (kind === 'login' && scenario.formTimeout) throw failure('login visibility timeout'); + }, + async fill() { + if (kind === 'login' || kind === 'password') counts.fills += 1; + if (kind === 'login' && scenario.fillError) throw failure(scenario.fillError); + }, + async isVisible() { return !(scenario.hiddenLogin && kind === 'login' && counts.logins > 0); }, + }); + const login = input('login'); + const password = input('password'); + const code = input('code'); + const button = name => ({ + first() { return this; }, async waitFor() {}, + async click() { + if (name === 'Log in') { + counts.logins += 1; + if (scenario.submitError) throw failure(scenario.submitError); + } else counts.codes += 1; + }, + }); + const page = { + on(event, callback) { if (event === 'request') redirect = callback; }, + async goto() { + counts.navigations += 1; + if (scenario.navigationError && (scenario.alwaysFail || counts.launches === 1)) { + throw failure(scenario.navigationError); + } + }, + async waitForLoadState() {}, async waitForTimeout() {}, + async title() { return ''; }, url() { return 'https://example.invalid'; }, + getByRole(role, options) { + if (role === 'textbox') return /Login/.test(options.name.source) ? login : password; + if (role === 'spinbutton') return code; + return button(options.name); + }, + getByText() { return { async isVisible() { return !!scenario.twoFactorRejected; } }; }, + locator(selector) { + if (selector === 'body') return { async innerText() { return (counts.codes > 0 && scenario.twoFactorBanner) || scenario.banner || ''; } }; + return code; + }, + }; + const forbidden = () => { throw new Error('FORBIDDEN_REAL_OPERATION'); }; + const fakeRequire = name => { + if (name === 'playwright-extra') return { chromium: { + use() {}, async launchPersistentContext() { + counts.launches += 1; + return { pages: () => [page], async close() {} }; + }, + } }; + if (name === 'puppeteer-extra-plugin-stealth') return () => ({}); + if (name === 'axios') return { post: forbidden }; + if (name === 'otpauth') return { TOTP: class { generate() { return 'synthetic-code'; } } }; + if (name === '@google-cloud/secret-manager') return { SecretManagerServiceClient: forbidden }; + if (name === 'path') return path; + if (name === 'fs') return { writeFileSync: forbidden }; + if (name === './lib/auth_retry') return require('../lib/auth_retry'); + if (name === './lib/proxy') return { + resolveProxyUrl: () => 'http://proxy.invalid', buildPlaywrightProxy: () => ({}), + buildAxiosProxyConfig: () => ({}), maskProxyForLogs: () => 'synthetic-proxy', + }; + if (name === './lib/oauth') return { + extractAuthorizationCodeFromUrl: () => 'synthetic-code', summarizeAuthorizationCode: () => ({}), + }; + throw new Error(`UNEXPECTED_MODULE: ${name}`); + }; + fakeRequire.main = {}; + const context = vm.createContext({ + require: fakeRequire, module: {}, __dirname: '/synthetic', URL, URLSearchParams, Buffer, + console: { log() {}, error() {} }, setTimeout(callback) { callback(); }, + process: { env: { SCHWAB_TOTP_SECRET: '' }, exit: forbidden }, + }); + const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + // The baseline has an unconditional CLI bootstrap; never run it in this test. + const definitions = source.replace(/\nmain\(\)\.catch\([\s\S]*$/, '\n'); + vm.runInContext(definitions, context); + context.syntheticExchange = async () => { + counts.exchanges += 1; + if (scenario.exchangeError) throw failure('Token exchange network error: net::ERR_CONNECTION_REFUSED'); + return { expires_in: 1 }; + }; + context.syntheticWrite = async () => { counts.writes += 1; }; + context.syntheticConsent = async () => redirect({ url: () => 'https://example.invalid/callback' }); + vm.runInContext(` + validateEnv = () => {}; + waitForFreshTotpWindow = async () => {}; + saveScreenshot = async () => {}; + collectPageDiagnostics = async () => ({ title: '', bodyTextPreview: '' }); + exchangeCodeForToken = syntheticExchange; + updateAndCleanupSecrets = syntheticWrite; + smartClick = syntheticConsent; + `, context); + return { counts, main: vm.runInContext('main', context), source }; +} -assert.strictEqual( - looksLikeCredentialOrRiskBanner('We can’t log you in right now. Please try again later.'), - true, -); +(async () => { + const denials = [ + 'Your login ID or password is incorrect', 'Account locked', 'Too many failed attempts', + 'Suspicious activity', 'Credential/risk rejection', 'Login page flagged risk', '2FA code was rejected', + 'Enter a valid 6-digit security code.', + ]; + for (const message of denials) { + assert.strictEqual(isRetryableWithProxy(`${message}: net::ERR_CONNECTION_REFUSED`, true), false); + } + assert.strictEqual(looksLikeCredentialOrRiskBanner('We can’t log you in right now.'), true); + assert.strictEqual(looksLikeCredentialOrRiskBanner('Login ID Password'), false); + assert.strictEqual(looksLikeCredentialOrRiskBanner('Investing involves risk.'), false); + assert.strictEqual(isRetryableWithProxy('net::ERR_TUNNEL_CONNECTION_FAILED'), false); + assert.strictEqual(isRetryableWithProxy('net::ERR_TUNNEL_CONNECTION_FAILED', true), true); + for (const message of ['net::ERR_CONNECTION_RESET', 'net::ERR_CONNECTION_CLOSED', 'navigation timeout', + 'Token exchange network error', 'Login form did not become visible after 3 attempts']) { + assert.strictEqual(isRetryableWithProxy(message, true), false); + } + const normal = loadSyntheticMain(); + await normal.main(); + assert.strictEqual(normal.counts.launches, 1); + assert.strictEqual(normal.counts.logins, 1); + assert.strictEqual(normal.counts.codes, 1); + assert.strictEqual(normal.counts.writes, 1); + assert.match(normal.source, /if\s*\(require\.main\s*===\s*module\)/); -assert.strictEqual( - looksLikeCredentialOrRiskBanner('Log In Invalid login ID or password. Need help? Login ID Password'), - true, -); + for (const scenario of [ + { banner: 'Investing involves risk. Read the disclosures before continuing.' }, + { twoFactorBanner: 'Investing involves risk. Never share your security code.' }, + ]) { + const run = loadSyntheticMain(scenario); + await run.main(); + assert.strictEqual(run.counts.launches, 1); + assert.strictEqual(run.counts.logins, 1); + assert.strictEqual(run.counts.codes, 1); + assert.strictEqual(run.counts.writes, 1); + } -assert.strictEqual( - looksLikeCredentialOrRiskBanner('Login ID Password'), - false, -); - -assert.strictEqual( - isRetryableWithProxy('Login page rejected credentials or flagged risk: We can’t log you in right now.'), - true, -); - -assert.strictEqual( - isRetryableWithProxy('Login page rejected credentials or flagged risk during 2FA step: Invalid login ID or password'), - true, -); - -assert.strictEqual( - isRetryableWithProxy('Failure: page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://api.schwabapi.com/v1/oauth/authorize'), - true, -); - -assert.strictEqual( - isRetryableWithProxy('2FA code was rejected after retry.'), - false, -); - -console.log('schwab auth retry checks passed'); + for (const banner of denials.slice(0, 6)) { + const run = loadSyntheticMain({ banner, hiddenLogin: true }); + await assert.rejects(run.main()); + assert.strictEqual(run.counts.launches, 1); + assert.strictEqual(run.counts.logins, 1); + assert.strictEqual(run.counts.codes, 0); + } + for (const scenario of [ + { fillError: 'net::ERR_CONNECTION_REFUSED' }, { submitError: 'net::ERR_CONNECTION_REFUSED' }, + { submitError: 'login click timeout' }, { twoFactorRejected: true }, { exchangeError: true }, + { twoFactorBanner: 'Account locked' }, { twoFactorBanner: 'Suspicious activity' }, + { twoFactorBanner: 'Security code is invalid' }, + { formTimeout: true }, { navigationError: 'net::ERR_CONNECTION_RESET' }, + { navigationError: 'navigation timeout' }, + ]) { + const run = loadSyntheticMain(scenario); + await assert.rejects(run.main()); + assert.strictEqual(run.counts.launches, 1, JSON.stringify(scenario)); + assert.ok(run.counts.logins <= 1); + assert.ok(run.counts.codes <= 1); + assert.strictEqual(run.counts.navigations, 1); + assert.strictEqual(run.counts.writes, 0); + } + for (const navigationError of ['net::ERR_CONNECTION_REFUSED', 'net::ERR_TUNNEL_CONNECTION_FAILED', + 'net::ERR_PROXY_CONNECTION_FAILED']) { + const fallback = loadSyntheticMain({ navigationError }); + await fallback.main(); + assert.strictEqual(fallback.counts.launches, 2); + assert.strictEqual(fallback.counts.logins, 1); + assert.strictEqual(fallback.counts.writes, 1); + } + const exhausted = loadSyntheticMain({ navigationError: 'net::ERR_PROXY_CONNECTION_FAILED', alwaysFail: true }); + await assert.rejects(exhausted.main()); + assert.strictEqual(exhausted.counts.launches, 2); + assert.strictEqual(exhausted.counts.fills, 0); + console.log('schwab auth classification and synthetic caller checks passed'); +})().catch(error => { console.error(error); process.exitCode = 1; });