Fix bound email relogin callback recovery
This commit is contained in:
+15
-1
@@ -15261,9 +15261,17 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authLoginStep = typeof getAuthChainStartStepId === 'function'
|
||||
const defaultAuthLoginStep = typeof getAuthChainStartStepId === 'function'
|
||||
? getAuthChainStartStepId(state || {})
|
||||
: FINAL_OAUTH_CHAIN_START_STEP;
|
||||
const reloginBoundEmailStep = typeof getStepIdByKeyForState === 'function'
|
||||
? Number(getStepIdByKeyForState('relogin-bound-email', state || {}))
|
||||
: 0;
|
||||
const authLoginStep = Number.isFinite(reloginBoundEmailStep)
|
||||
&& reloginBoundEmailStep > 0
|
||||
&& reloginBoundEmailStep < Number(visibleStep)
|
||||
? reloginBoundEmailStep
|
||||
: defaultAuthLoginStep;
|
||||
const authLoginNodeId = String(getNodeIdByStepForState(authLoginStep, state || {}) || 'oauth-login').trim();
|
||||
const confirmNodeId = String(getNodeIdByStepForState(visibleStep, state || {}) || 'confirm-oauth').trim();
|
||||
|
||||
@@ -15336,6 +15344,12 @@ async function recoverOAuthLocalhostTimeout(details = {}) {
|
||||
return step8Executor.executeBindEmail(payload);
|
||||
case 'fetch-bind-email-code':
|
||||
return step8Executor.executeFetchBindEmailCode(payload);
|
||||
case 'relogin-bound-email':
|
||||
return executeReloginBoundEmail(payload);
|
||||
case 'fetch-bound-email-login-code':
|
||||
return step8Executor.executeBoundEmailLoginCode(payload);
|
||||
case 'post-bound-email-phone-verification':
|
||||
return step8Executor.executeBoundEmailPostLoginPhoneVerification(payload);
|
||||
default:
|
||||
throw new Error(`OAuth localhost 恢复不支持节点 ${nodeId}。`);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,21 @@ function resolveCommandNodeId(message = {}) {
|
||||
}
|
||||
const visibleStep = Number(message.payload?.visibleStep || message.step) || 0;
|
||||
if (visibleStep === 4) return 'fetch-signup-code';
|
||||
if (message.type === 'FILL_CODE' && (visibleStep === 12 || visibleStep === 15)) {
|
||||
return 'fetch-bound-email-login-code';
|
||||
}
|
||||
if (
|
||||
(
|
||||
message.type === 'SUBMIT_PHONE_NUMBER'
|
||||
|| message.type === 'SUBMIT_PHONE_VERIFICATION_CODE'
|
||||
|| message.type === 'RESEND_PHONE_VERIFICATION_CODE'
|
||||
|| message.type === 'CHECK_PHONE_RESEND_ERROR'
|
||||
|| message.type === 'RETURN_TO_ADD_PHONE'
|
||||
)
|
||||
&& (visibleStep === 13 || visibleStep === 16)
|
||||
) {
|
||||
return 'post-bound-email-phone-verification';
|
||||
}
|
||||
if (visibleStep === 8 || visibleStep === 11) return 'fetch-login-code';
|
||||
if (visibleStep === 9 || visibleStep === 12) return 'post-login-phone-verification';
|
||||
if (visibleStep === 10 || visibleStep === 13) return 'confirm-oauth';
|
||||
|
||||
@@ -470,6 +470,146 @@ return {
|
||||
assert.match(events.logs[0].message, /授权后链总超时已关闭/);
|
||||
});
|
||||
|
||||
test('oauth localhost timeout recovery resumes from bound-email relogin tail when present', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
recoveryRuns: [],
|
||||
stateUpdates: [],
|
||||
timeoutWindows: [],
|
||||
};
|
||||
|
||||
const api = new Function('events', `
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const state = {
|
||||
oauthUrl: 'https://oauth.example/current',
|
||||
phoneSignupReloginAfterBindEmailEnabled: true,
|
||||
};
|
||||
const STEP_NODE_IDS = {
|
||||
7: 'oauth-login',
|
||||
8: 'fetch-login-code',
|
||||
9: 'bind-email',
|
||||
10: 'fetch-bind-email-code',
|
||||
11: 'relogin-bound-email',
|
||||
12: 'fetch-bound-email-login-code',
|
||||
13: 'post-bound-email-phone-verification',
|
||||
14: 'confirm-oauth',
|
||||
15: 'platform-verify',
|
||||
};
|
||||
const NODE_STEP_IDS = Object.fromEntries(Object.entries(STEP_NODE_IDS).map(([step, nodeId]) => [nodeId, Number(step)]));
|
||||
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
function getAuthChainStartStepId() {
|
||||
return 7;
|
||||
}
|
||||
function getStepIdByKeyForState(stepKey) {
|
||||
return NODE_STEP_IDS[String(stepKey || '').trim()] || null;
|
||||
}
|
||||
function getNodeIdByStepForState(step) {
|
||||
return STEP_NODE_IDS[Number(step)] || '';
|
||||
}
|
||||
function getStepIdByNodeIdForState(nodeId) {
|
||||
return NODE_STEP_IDS[String(nodeId || '').trim()] || null;
|
||||
}
|
||||
function getAutoRunWorkflowNodeIds() {
|
||||
return [
|
||||
'oauth-login',
|
||||
'fetch-login-code',
|
||||
'bind-email',
|
||||
'fetch-bind-email-code',
|
||||
'relogin-bound-email',
|
||||
'fetch-bound-email-login-code',
|
||||
'post-bound-email-phone-verification',
|
||||
'confirm-oauth',
|
||||
'platform-verify',
|
||||
];
|
||||
}
|
||||
async function addLog(message, level = 'info', options = {}) {
|
||||
events.logs.push({ message, level, options });
|
||||
}
|
||||
async function getLoginAuthStateFromContent() {
|
||||
return { state: 'oauth_consent_page' };
|
||||
}
|
||||
function isAddPhoneAuthState() {
|
||||
return false;
|
||||
}
|
||||
function getLoginAuthStateLabel(value) {
|
||||
return value || 'unknown';
|
||||
}
|
||||
async function getState() {
|
||||
return { ...state };
|
||||
}
|
||||
async function setState(update) {
|
||||
events.stateUpdates.push(update);
|
||||
Object.assign(state, update);
|
||||
}
|
||||
async function startOAuthFlowTimeoutWindow(payload) {
|
||||
events.timeoutWindows.push(payload);
|
||||
}
|
||||
const step7Executor = {
|
||||
async executeStep7(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'oauth-login', visibleStep: payload.visibleStep });
|
||||
},
|
||||
};
|
||||
const step8Executor = {
|
||||
async executeStep8(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'fetch-login-code', visibleStep: payload.visibleStep });
|
||||
},
|
||||
async executePostLoginPhoneVerification(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'post-login-phone-verification', visibleStep: payload.visibleStep });
|
||||
},
|
||||
async executeBindEmail(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'bind-email', visibleStep: payload.visibleStep });
|
||||
},
|
||||
async executeFetchBindEmailCode(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'fetch-bind-email-code', visibleStep: payload.visibleStep });
|
||||
},
|
||||
async executeBoundEmailLoginCode(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'fetch-bound-email-login-code', visibleStep: payload.visibleStep });
|
||||
},
|
||||
async executeBoundEmailPostLoginPhoneVerification(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'post-bound-email-phone-verification', visibleStep: payload.visibleStep });
|
||||
},
|
||||
};
|
||||
async function executeReloginBoundEmail(payload) {
|
||||
events.recoveryRuns.push({ nodeId: 'relogin-bound-email', visibleStep: payload.visibleStep });
|
||||
}
|
||||
|
||||
${extractFunction('isStep9OAuthLocalhostTimeoutError')}
|
||||
${extractFunction('recoverOAuthLocalhostTimeout')}
|
||||
|
||||
return {
|
||||
recoverOAuthLocalhostTimeout,
|
||||
};
|
||||
`)(events);
|
||||
|
||||
const recoveredState = await api.recoverOAuthLocalhostTimeout({
|
||||
error: new Error('步骤 14:从拿到 OAuth 登录地址开始,5 分钟内未完成 OAuth localhost 回调,结束当前链路。'),
|
||||
state: {
|
||||
oauthUrl: 'https://oauth.example/current',
|
||||
phoneSignupReloginAfterBindEmailEnabled: true,
|
||||
},
|
||||
visibleStep: 14,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.recoveryRuns, [
|
||||
{ nodeId: 'relogin-bound-email', visibleStep: 11 },
|
||||
{ nodeId: 'fetch-bound-email-login-code', visibleStep: 12 },
|
||||
{ nodeId: 'post-bound-email-phone-verification', visibleStep: 13 },
|
||||
]);
|
||||
assert.deepStrictEqual(events.stateUpdates, [{ localhostUrl: null }]);
|
||||
assert.deepStrictEqual(events.timeoutWindows, [
|
||||
{
|
||||
step: 14,
|
||||
oauthUrl: 'https://oauth.example/current',
|
||||
},
|
||||
]);
|
||||
assert.equal(recoveredState.localhostUrl, null);
|
||||
assert.ok(events.logs.some(({ message }) => /步骤 11/.test(message)));
|
||||
assert.equal(events.recoveryRuns.some(({ nodeId }) => nodeId === 'oauth-login'), false);
|
||||
});
|
||||
|
||||
test('executeNode retries fetch-network errors for fetch-signup-code with cooldown and bounded attempts', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
|
||||
@@ -588,6 +588,72 @@ test('step 7 keeps Plus email login even when phone sms runtime exists', async (
|
||||
assert.equal(events.payloads[0].accountIdentifier, 'plus.user@example.com');
|
||||
});
|
||||
|
||||
test('step 7 keeps relogin-bound-email as the active node id', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
|
||||
|
||||
const events = {
|
||||
completions: [],
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async () => {},
|
||||
completeNodeFromBackground: async (step, payload) => {
|
||||
events.completions.push({ step, payload });
|
||||
},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({
|
||||
email: 'bound.user@example.com',
|
||||
password: 'secret',
|
||||
plusModeEnabled: true,
|
||||
signupMethod: 'phone',
|
||||
phoneSignupReloginAfterBindEmailEnabled: true,
|
||||
}),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/relogin',
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||||
events.messages.push(message);
|
||||
return {
|
||||
step6Outcome: 'success',
|
||||
state: 'verification_page',
|
||||
loginVerificationRequestedAt: 223344,
|
||||
};
|
||||
},
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep7({
|
||||
nodeId: 'relogin-bound-email',
|
||||
visibleStep: 14,
|
||||
forceLoginIdentifierType: 'email',
|
||||
forceEmailLogin: true,
|
||||
signupMethod: 'email',
|
||||
resolvedSignupMethod: 'email',
|
||||
accountIdentifierType: 'email',
|
||||
accountIdentifier: 'bound.user@example.com',
|
||||
email: 'bound.user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(events.messages.length, 1);
|
||||
assert.equal(events.messages[0].nodeId, 'relogin-bound-email');
|
||||
assert.equal(events.messages[0].payload.visibleStep, 14);
|
||||
assert.deepStrictEqual(events.completions, [
|
||||
{
|
||||
step: 'relogin-bound-email',
|
||||
payload: {
|
||||
loginVerificationRequestedAt: 223344,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 keeps phone login after step 8 stores an unbound email for phone signup', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.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) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
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 api = new Function(`
|
||||
${extractFunction('resolveCommandNodeId')}
|
||||
return {
|
||||
resolveCommandNodeId,
|
||||
};
|
||||
`)();
|
||||
|
||||
test('signup page resolves bound-email relogin verification nodes from dynamic visible steps', () => {
|
||||
assert.equal(
|
||||
api.resolveCommandNodeId({
|
||||
type: 'FILL_CODE',
|
||||
step: 8,
|
||||
payload: { visibleStep: 15 },
|
||||
}),
|
||||
'fetch-bound-email-login-code'
|
||||
);
|
||||
assert.equal(
|
||||
api.resolveCommandNodeId({
|
||||
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
|
||||
step: 16,
|
||||
payload: { visibleStep: 16 },
|
||||
}),
|
||||
'post-bound-email-phone-verification'
|
||||
);
|
||||
assert.equal(
|
||||
api.resolveCommandNodeId({
|
||||
type: 'FILL_CODE',
|
||||
step: 8,
|
||||
payload: { nodeId: 'fetch-bound-email-login-code', visibleStep: 15 },
|
||||
}),
|
||||
'fetch-bound-email-login-code'
|
||||
);
|
||||
});
|
||||
@@ -1682,6 +1682,57 @@ test('verification flow does not replay step 8 code submit after transient auth-
|
||||
assert.deepStrictEqual(resilientMessages, ['GET_LOGIN_AUTH_STATE']);
|
||||
});
|
||||
|
||||
test('verification flow forwards dynamic completion node id when submitting bound-email login code', async () => {
|
||||
const directCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeNodeFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getNodeIdByStepForState: (step) => ({
|
||||
14: 'fetch-bound-email-login-code',
|
||||
})[Number(step)] || '',
|
||||
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) => {
|
||||
directCalls.push(message);
|
||||
return { success: true };
|
||||
},
|
||||
sendToContentScriptResilient: async () => {
|
||||
throw new Error('step 8 code submit should use the direct channel once');
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.submitVerificationCode(8, '246810', { completionStep: 14 });
|
||||
|
||||
assert.equal(directCalls.length, 1);
|
||||
assert.equal(directCalls[0].type, 'FILL_CODE');
|
||||
assert.equal(directCalls[0].payload.code, '246810');
|
||||
assert.equal(directCalls[0].payload.nodeId, 'fetch-bound-email-login-code');
|
||||
});
|
||||
|
||||
test('verification flow keeps step 8 code submit response timeout above local floor when flow budget is nearly exhausted', async () => {
|
||||
const directCalls = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user