1061 lines
38 KiB
JavaScript
1061 lines
38 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
|
||
test('step 6 waits for registration success and completes from background', async () => {
|
||
const source = fs.readFileSync('background/steps/wait-registration-success.js', 'utf8');
|
||
const globalScope = {};
|
||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope);
|
||
|
||
const events = {
|
||
logs: [],
|
||
waits: [],
|
||
completedSteps: [],
|
||
};
|
||
|
||
const executor = api.createStep6Executor({
|
||
addLog: async (message, level = 'info') => {
|
||
events.logs.push({ message, level });
|
||
},
|
||
completeNodeFromBackground: async (step) => {
|
||
events.completedSteps.push(step);
|
||
},
|
||
sleepWithStop: async (ms) => {
|
||
events.waits.push(ms);
|
||
},
|
||
});
|
||
|
||
await executor.executeStep6();
|
||
|
||
assert.deepStrictEqual(events.waits, [4000]);
|
||
assert.deepStrictEqual(events.completedSteps, ['wait-registration-success']);
|
||
assert.ok(events.logs.some(({ message }) => /等待 4 秒/.test(message)));
|
||
});
|
||
|
||
test('step 6 only clears cookies when cleanup switch is enabled', async () => {
|
||
const source = fs.readFileSync('background/steps/wait-registration-success.js', 'utf8');
|
||
const globalScope = {};
|
||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope);
|
||
|
||
const events = {
|
||
removedCookies: [],
|
||
browsingDataCalls: [],
|
||
completedSteps: [],
|
||
};
|
||
const chromeApi = {
|
||
cookies: {
|
||
getAllCookieStores: async () => [{ id: 'store-a' }],
|
||
getAll: async () => [
|
||
{ domain: '.chatgpt.com', path: '/auth', name: 'session', storeId: 'store-a' },
|
||
{ domain: '.example.com', path: '/', name: 'keep', storeId: 'store-a' },
|
||
],
|
||
remove: async (details) => {
|
||
events.removedCookies.push(details);
|
||
return details;
|
||
},
|
||
},
|
||
browsingData: {
|
||
removeCookies: async (details) => {
|
||
events.browsingDataCalls.push(details);
|
||
},
|
||
},
|
||
};
|
||
|
||
const executor = api.createStep6Executor({
|
||
addLog: async () => {},
|
||
chrome: chromeApi,
|
||
completeNodeFromBackground: async (step) => {
|
||
events.completedSteps.push(step);
|
||
},
|
||
sleepWithStop: async () => {},
|
||
});
|
||
|
||
await executor.executeStep6({ step6CookieCleanupEnabled: false });
|
||
|
||
assert.deepStrictEqual(events.removedCookies, []);
|
||
assert.deepStrictEqual(events.browsingDataCalls, []);
|
||
|
||
await executor.executeStep6({ step6CookieCleanupEnabled: true });
|
||
|
||
assert.deepStrictEqual(events.completedSteps, ['wait-registration-success', 'wait-registration-success']);
|
||
assert.deepStrictEqual(events.removedCookies, [
|
||
{
|
||
url: 'https://chatgpt.com/auth',
|
||
name: 'session',
|
||
storeId: 'store-a',
|
||
},
|
||
]);
|
||
assert.equal(events.browsingDataCalls.length, 1);
|
||
assert.ok(events.browsingDataCalls[0].origins.includes('https://chatgpt.com'));
|
||
});
|
||
|
||
test('local cpa json no-RT export runs as step 7 after Plus checkout completes', async () => {
|
||
const source = fs.readFileSync('background/steps/wait-registration-success.js', 'utf8');
|
||
const globalScope = {};
|
||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep6;`)(globalScope);
|
||
|
||
const events = {
|
||
artifactOptions: [],
|
||
completed: [],
|
||
ensureCalls: [],
|
||
fetchCalls: [],
|
||
logs: [],
|
||
sendCalls: [],
|
||
waits: [],
|
||
};
|
||
|
||
const executor = api.createStep6Executor({
|
||
addLog: async (message, level = 'info') => {
|
||
events.logs.push({ message, level });
|
||
},
|
||
buildLocalHelperEndpoint: (baseUrl, path) => `${String(baseUrl || '').replace(/\/+$/g, '')}${path}`,
|
||
chrome: {
|
||
tabs: {
|
||
create: async (payload) => {
|
||
events.tabCreate = payload;
|
||
return { id: 17 };
|
||
},
|
||
remove: async (tabId) => {
|
||
events.tabRemove = tabId;
|
||
},
|
||
},
|
||
},
|
||
completeNodeFromBackground: async (nodeId, payload) => {
|
||
events.completed.push({ nodeId, payload });
|
||
},
|
||
createLocalCliProxyApi: () => ({
|
||
buildAuthJsonArtifact: async (options = {}) => {
|
||
events.artifactOptions.push(options);
|
||
return {
|
||
filePath: 'C:/plugin/.cli-proxy-api/codex-user@example.com-plus.json',
|
||
directoryPath: 'C:/plugin/.cli-proxy-api',
|
||
jsonText: '{"email":"user@example.com"}\n',
|
||
warnings: ['Missing refresh_token; imported account cannot refresh automatically after access token expiry.'],
|
||
};
|
||
},
|
||
}),
|
||
ensureContentScriptReadyOnTab: async (sourceId, tabId, options = {}) => {
|
||
events.ensureCalls.push({ sourceId, tabId, options });
|
||
},
|
||
getPanelMode: () => 'local-cpa-json-no-rt',
|
||
getTabId: async () => null,
|
||
normalizeHotmailLocalBaseUrl: (value) => String(value || '').trim(),
|
||
registrationSuccessWaitMs: 4000,
|
||
sendToContentScriptResilient: async (sourceId, message, options = {}) => {
|
||
events.sendCalls.push({ sourceId, message, options });
|
||
return {
|
||
accessToken: 'access-token-from-session',
|
||
email: 'user@example.com',
|
||
expiresAt: '2026-05-20T00:00:00.000Z',
|
||
session: {
|
||
sessionToken: 'session-cookie-token',
|
||
user: { id: 'user-1', email: 'user@example.com' },
|
||
account: { id: 'acct-1', planType: 'plus' },
|
||
expires: '2026-05-20T00:00:00.000Z',
|
||
},
|
||
};
|
||
},
|
||
sleepWithStop: async (ms) => {
|
||
events.waits.push(ms);
|
||
},
|
||
});
|
||
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = async (url, options = {}) => {
|
||
events.fetchCalls.push({ url, options });
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({
|
||
ok: true,
|
||
filePath: 'C:/plugin/.cli-proxy-api/codex-user@example.com-plus.json',
|
||
}),
|
||
};
|
||
};
|
||
|
||
try {
|
||
await executor.executeLocalCpaJsonNoRtExport({
|
||
panelMode: 'local-cpa-json-no-rt',
|
||
hotmailLocalBaseUrl: 'http://127.0.0.1:17373',
|
||
localCpaJsonPluginDir: 'C:/plugin',
|
||
localCpaJsonRelativeAuthDir: '.cli-proxy-api',
|
||
step6CookieCleanupEnabled: false,
|
||
});
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
|
||
assert.deepStrictEqual(events.waits, [5000]);
|
||
assert.deepStrictEqual(events.tabCreate, { url: 'https://chatgpt.com/', active: false });
|
||
assert.equal(events.tabRemove, 17);
|
||
assert.equal(events.ensureCalls.length, 1);
|
||
assert.equal(events.ensureCalls[0].sourceId, 'plus-checkout');
|
||
assert.deepStrictEqual(events.ensureCalls[0].options.inject, ['content/utils.js', 'content/operation-delay.js', 'content/plus-checkout.js']);
|
||
assert.equal(events.sendCalls.length, 1);
|
||
assert.equal(events.sendCalls[0].sourceId, 'plus-checkout');
|
||
assert.equal(events.sendCalls[0].message.type, 'PLUS_CHECKOUT_GET_STATE');
|
||
assert.equal(events.sendCalls[0].message.payload.includeSession, true);
|
||
assert.equal(events.fetchCalls.length, 1);
|
||
assert.equal(events.artifactOptions.length, 1);
|
||
assert.equal(events.artifactOptions[0].session.account.id, 'acct-1');
|
||
assert.equal(events.artifactOptions[0].session.user.id, 'user-1');
|
||
assert.equal(events.artifactOptions[0].sessionToken, 'session-cookie-token');
|
||
assert.equal(events.artifactOptions[0].planType, 'plus');
|
||
assert.equal(events.artifactOptions[0].lastRefresh, '');
|
||
assert.equal(JSON.parse(events.fetchCalls[0].options.body).content, '{"email":"user@example.com"}\n');
|
||
assert.deepStrictEqual(events.completed, [{
|
||
nodeId: 'local-cpa-json-export',
|
||
payload: {
|
||
verifiedStatus: '本地CPA JSON 无RT 已导出:C:/plugin/.cli-proxy-api/codex-user@example.com-plus.json',
|
||
localCpaJsonFilePath: 'C:/plugin/.cli-proxy-api/codex-user@example.com-plus.json',
|
||
},
|
||
}]);
|
||
assert.ok(events.logs.some(({ message }) => /Plus Checkout 已完成,等待 5 秒后导出/.test(message)));
|
||
assert.ok(events.logs.some(({ message, level }) => level === 'warn' && /Missing refresh_token/.test(message)));
|
||
assert.ok(events.logs.some(({ message }) => /本地CPA JSON 无RT 已导出/.test(message)));
|
||
});
|
||
|
||
test('step 7 retries up to configured limit and then fails', 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 = {
|
||
refreshCalls: 0,
|
||
sendCalls: 0,
|
||
completed: 0,
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {
|
||
events.completed += 1;
|
||
},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => {
|
||
events.refreshCalls += 1;
|
||
return `https://oauth.example/${events.refreshCalls}`;
|
||
},
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
events.sendCalls += 1;
|
||
return {
|
||
step6Outcome: 'recoverable',
|
||
state: 'email_page',
|
||
message: '当前仍停留在邮箱页。',
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await assert.rejects(
|
||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||
/已重试 2 次,仍未成功/
|
||
);
|
||
|
||
assert.equal(events.refreshCalls, 3);
|
||
assert.equal(events.sendCalls, 3);
|
||
assert.equal(events.completed, 0);
|
||
});
|
||
|
||
test('step 7 falls back to the current hotmail pool email when runtime email is empty', 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 = {
|
||
payloads: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({
|
||
email: '',
|
||
password: 'secret',
|
||
mailProvider: 'hotmail-api',
|
||
currentHotmailAccountId: 'hot-1',
|
||
hotmailAccounts: [
|
||
{ id: 'hot-1', email: 'pool-user@hotmail.com' },
|
||
],
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_source, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'verification_page',
|
||
loginVerificationRequestedAt: 123,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 1,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({ password: 'secret' });
|
||
|
||
assert.equal(events.payloads.length, 1);
|
||
assert.equal(events.payloads[0].email, 'pool-user@hotmail.com');
|
||
assert.equal(events.payloads[0].accountIdentifier, 'pool-user@hotmail.com');
|
||
assert.equal(events.payloads[0].loginIdentifierType, 'email');
|
||
});
|
||
|
||
test('step 7 prefers the latest runtime email over a stale empty state payload', 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 = {
|
||
payloads: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({
|
||
email: 'fresh@example.com',
|
||
password: 'secret',
|
||
oauthUrl: 'https://oauth.example/latest',
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_source, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'verification_page',
|
||
loginVerificationRequestedAt: 123,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 1,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
email: '',
|
||
password: 'secret',
|
||
oauthUrl: 'https://oauth.example/stale',
|
||
});
|
||
|
||
assert.equal(events.payloads.length, 1);
|
||
assert.equal(events.payloads[0].email, 'fresh@example.com');
|
||
assert.equal(events.payloads[0].accountIdentifier, 'fresh@example.com');
|
||
});
|
||
|
||
test('step 7 hands add-phone to the dedicated post-login phone node without internal retry', 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 = {
|
||
refreshCalls: 0,
|
||
sendCalls: 0,
|
||
completions: [],
|
||
logs: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async (message, level = 'info') => {
|
||
events.logs.push({ message, level });
|
||
},
|
||
completeNodeFromBackground: async (step, payload) => {
|
||
events.completions.push({ step, payload });
|
||
},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => {
|
||
events.refreshCalls += 1;
|
||
return `https://oauth.example/${events.refreshCalls}`;
|
||
},
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
events.sendCalls += 1;
|
||
throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone');
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({ email: 'user@example.com', password: 'secret' });
|
||
|
||
assert.equal(events.refreshCalls, 1, 'add-phone should stop further OAuth refresh attempts');
|
||
assert.equal(events.sendCalls, 1, 'add-phone should stop after the first failed login attempt');
|
||
assert.deepStrictEqual(events.completions, [
|
||
{
|
||
step: 'oauth-login',
|
||
payload: {
|
||
loginVerificationRequestedAt: null,
|
||
skipLoginVerificationStep: true,
|
||
addPhonePage: true,
|
||
directOAuthConsentPage: false,
|
||
},
|
||
},
|
||
]);
|
||
assert.ok(
|
||
!events.logs.some(({ message }) => /准备重试/.test(message)),
|
||
'add-phone failure should not be logged as an internal retryable attempt'
|
||
);
|
||
});
|
||
|
||
test('step 7 no longer runs shared phone verification inside oauth-login', 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 = {
|
||
refreshCalls: 0,
|
||
phoneCalls: [],
|
||
completions: [],
|
||
};
|
||
|
||
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: 'user@example.com',
|
||
password: 'secret',
|
||
phoneVerificationEnabled: true,
|
||
}),
|
||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 91 : 0),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
phoneVerificationHelpers: {
|
||
completePhoneVerificationFlow: async (tabId, pageState, options) => {
|
||
events.phoneCalls.push({ tabId, pageState, options });
|
||
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize/resume' };
|
||
},
|
||
},
|
||
refreshOAuthUrlBeforeStep6: async () => {
|
||
events.refreshCalls += 1;
|
||
return `https://oauth.example/${events.refreshCalls}`;
|
||
},
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone');
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
email: 'user@example.com',
|
||
password: 'secret',
|
||
phoneVerificationEnabled: true,
|
||
});
|
||
|
||
assert.equal(events.refreshCalls, 1);
|
||
assert.deepStrictEqual(events.phoneCalls, []);
|
||
assert.deepStrictEqual(events.completions, [
|
||
{
|
||
step: 'oauth-login',
|
||
payload: {
|
||
loginVerificationRequestedAt: null,
|
||
skipLoginVerificationStep: true,
|
||
addPhonePage: true,
|
||
directOAuthConsentPage: false,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('step 7 add-phone handoff does not depend on phone verification being enabled', 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 = {
|
||
phoneCalls: 0,
|
||
completions: 0,
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {
|
||
events.completions += 1;
|
||
},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: false }),
|
||
getTabId: async () => 91,
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
phoneVerificationHelpers: {
|
||
completePhoneVerificationFlow: async () => {
|
||
events.phoneCalls += 1;
|
||
return {};
|
||
},
|
||
},
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone');
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: false });
|
||
assert.equal(events.phoneCalls, 0);
|
||
assert.equal(events.completions, 1);
|
||
});
|
||
|
||
test('step 7 ignores obsolete shared add-phone verifier during handoff', 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 = {
|
||
phoneCalls: 0,
|
||
completions: 0,
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {
|
||
events.completions += 1;
|
||
},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: true }),
|
||
getTabId: async () => 91,
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
phoneVerificationHelpers: {
|
||
completePhoneVerificationFlow: async () => {
|
||
events.phoneCalls += 1;
|
||
throw new Error('步骤 9:没有可用手机号。');
|
||
},
|
||
},
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
throw new Error('提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: https://auth.openai.com/add-phone');
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({ email: 'user@example.com', password: 'secret', phoneVerificationEnabled: true });
|
||
assert.equal(events.phoneCalls, 0);
|
||
assert.equal(events.completions, 1);
|
||
});
|
||
|
||
test('step 7 starts a new oauth timeout window for each refreshed oauth url', 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 = {
|
||
startedWindows: [],
|
||
timeoutRequests: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, options) => {
|
||
events.timeoutRequests.push({ defaultTimeoutMs, options });
|
||
return 5000;
|
||
},
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_source, _message, options) => ({
|
||
step6Outcome: 'success',
|
||
state: 'verification_page',
|
||
usedTimeoutMs: options.timeoutMs,
|
||
}),
|
||
startOAuthFlowTimeoutWindow: async (payload) => {
|
||
events.startedWindows.push(payload);
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({ email: 'user@example.com', password: 'secret' });
|
||
|
||
assert.deepStrictEqual(events.startedWindows, [
|
||
{ step: 7, oauthUrl: 'https://oauth.example/latest' },
|
||
]);
|
||
assert.deepStrictEqual(events.timeoutRequests, [
|
||
{
|
||
defaultTimeoutMs: 180000,
|
||
options: {
|
||
step: 7,
|
||
actionLabel: 'OAuth 登录并进入验证码页',
|
||
oauthUrl: 'https://oauth.example/latest',
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('step 7 forwards direct OAuth consent skip metadata when completing', 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: [],
|
||
};
|
||
|
||
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: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => ({
|
||
step6Outcome: 'success',
|
||
state: 'oauth_consent_page',
|
||
skipLoginVerificationStep: true,
|
||
directOAuthConsentPage: true,
|
||
}),
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
email: 'user@example.com',
|
||
password: 'secret',
|
||
visibleStep: 10,
|
||
});
|
||
|
||
assert.deepStrictEqual(events.completions, [
|
||
{
|
||
step: 'oauth-login',
|
||
payload: {
|
||
loginVerificationRequestedAt: null,
|
||
skipLoginVerificationStep: true,
|
||
directOAuthConsentPage: true,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('step 7 forwards phone login identity payload when account identifier is phone', 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: [],
|
||
payloads: [],
|
||
};
|
||
|
||
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 () => ({
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '66959916439',
|
||
signupPhoneNumber: '66959916439',
|
||
signupPhoneCompletedActivation: {
|
||
activationId: 'signup-done',
|
||
phoneNumber: '66959916439',
|
||
countryId: 52,
|
||
countryLabel: 'Thailand',
|
||
},
|
||
password: 'secret',
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'phone_verification_page',
|
||
loginVerificationRequestedAt: 123456,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '66959916439',
|
||
signupPhoneNumber: '66959916439',
|
||
signupPhoneCompletedActivation: {
|
||
activationId: 'signup-done',
|
||
phoneNumber: '66959916439',
|
||
countryId: 52,
|
||
countryLabel: 'Thailand',
|
||
},
|
||
password: 'secret',
|
||
});
|
||
|
||
assert.deepStrictEqual(events.payloads, [
|
||
{
|
||
email: '',
|
||
phoneNumber: '66959916439',
|
||
countryId: 52,
|
||
countryLabel: 'Thailand',
|
||
accountIdentifier: '66959916439',
|
||
loginIdentifierType: 'phone',
|
||
password: 'secret',
|
||
visibleStep: 7,
|
||
},
|
||
]);
|
||
assert.deepStrictEqual(events.completions, [
|
||
{
|
||
step: 'oauth-login',
|
||
payload: {
|
||
loginVerificationRequestedAt: 123456,
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '66959916439',
|
||
signupPhoneNumber: '66959916439',
|
||
signupPhoneCompletedActivation: {
|
||
activationId: 'signup-done',
|
||
phoneNumber: '66959916439',
|
||
countryId: 52,
|
||
countryLabel: 'Thailand',
|
||
},
|
||
signupPhoneActivation: null,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('step 7 keeps Plus email login even when phone sms runtime exists', 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 = {
|
||
payloads: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({
|
||
plusModeEnabled: true,
|
||
phoneVerificationEnabled: true,
|
||
signupMethod: 'phone',
|
||
email: 'plus.user@example.com',
|
||
password: 'secret',
|
||
signupPhoneNumber: '+441111111111',
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'verification_page',
|
||
loginVerificationRequestedAt: 123456,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
plusModeEnabled: true,
|
||
phoneVerificationEnabled: true,
|
||
signupMethod: 'phone',
|
||
email: 'plus.user@example.com',
|
||
password: 'secret',
|
||
signupPhoneNumber: '+441111111111',
|
||
visibleStep: 10,
|
||
});
|
||
|
||
assert.equal(events.payloads[0].loginIdentifierType, 'email');
|
||
assert.equal(events.payloads[0].email, 'plus.user@example.com');
|
||
assert.equal(events.payloads[0].phoneNumber, '');
|
||
assert.equal(events.payloads[0].accountIdentifier, 'plus.user@example.com');
|
||
});
|
||
|
||
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 = {};
|
||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
|
||
|
||
const events = {
|
||
payloads: [],
|
||
};
|
||
|
||
const phoneSignupState = {
|
||
phoneVerificationEnabled: true,
|
||
signupMethod: 'phone',
|
||
resolvedSignupMethod: 'email',
|
||
email: 'bound.step8@example.com',
|
||
accountIdentifierType: 'email',
|
||
accountIdentifier: 'bound.step8@example.com',
|
||
signupPhoneNumber: '66959916439',
|
||
signupPhoneCompletedActivation: {
|
||
activationId: 'signup-done',
|
||
phoneNumber: '66959916439',
|
||
countryId: 52,
|
||
countryLabel: 'Thailand',
|
||
},
|
||
password: 'secret',
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ ...phoneSignupState }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'phone_verification_page',
|
||
loginVerificationRequestedAt: 123456,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7(phoneSignupState);
|
||
|
||
assert.equal(events.payloads[0].loginIdentifierType, 'phone');
|
||
assert.equal(events.payloads[0].phoneNumber, '66959916439');
|
||
assert.equal(events.payloads[0].email, '');
|
||
assert.equal(events.payloads[0].accountIdentifier, '66959916439');
|
||
});
|
||
|
||
test('step 7 can infer phone login from an available phone signup configuration before step 2 finishes', 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 = {
|
||
payloads: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async () => {},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({
|
||
phoneVerificationEnabled: true,
|
||
signupMethod: 'phone',
|
||
signupPhoneNumber: '+447780579093',
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'phone_verification_page',
|
||
loginVerificationRequestedAt: 987654,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
phoneVerificationEnabled: true,
|
||
signupMethod: 'phone',
|
||
signupPhoneNumber: '+447780579093',
|
||
});
|
||
|
||
assert.equal(events.payloads[0].loginIdentifierType, 'phone');
|
||
assert.equal(events.payloads[0].phoneNumber, '+447780579093');
|
||
assert.equal(events.payloads[0].email, '');
|
||
});
|
||
|
||
test('step 7 can start from a manually filled signup phone without completed step 2 or step 3 state', 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 = {
|
||
payloads: [],
|
||
completions: [],
|
||
};
|
||
|
||
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 () => ({
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '+447780579093',
|
||
signupPhoneNumber: '+447780579093',
|
||
stepStatuses: { 2: 'pending', 3: 'pending' },
|
||
}),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async (_sourceName, message) => {
|
||
events.payloads.push(message.payload);
|
||
return {
|
||
step6Outcome: 'success',
|
||
state: 'phone_verification_page',
|
||
loginVerificationRequestedAt: 987654,
|
||
};
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await executor.executeStep7({
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '+447780579093',
|
||
signupPhoneNumber: '+447780579093',
|
||
stepStatuses: { 2: 'pending', 3: 'pending' },
|
||
});
|
||
|
||
assert.equal(events.payloads[0].loginIdentifierType, 'phone');
|
||
assert.equal(events.payloads[0].phoneNumber, '+447780579093');
|
||
assert.equal(events.payloads[0].email, '');
|
||
assert.equal(events.payloads[0].password, '');
|
||
assert.deepStrictEqual(events.completions, [
|
||
{
|
||
step: 'oauth-login',
|
||
payload: {
|
||
loginVerificationRequestedAt: 987654,
|
||
accountIdentifierType: 'phone',
|
||
accountIdentifier: '+447780579093',
|
||
signupPhoneNumber: '+447780579093',
|
||
signupPhoneCompletedActivation: null,
|
||
signupPhoneActivation: null,
|
||
},
|
||
},
|
||
]);
|
||
});
|
||
|
||
test('step 7 stops immediately when management secret is missing', 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 = {
|
||
refreshCalls: 0,
|
||
sendCalls: 0,
|
||
logs: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async (message, level = 'info') => {
|
||
events.logs.push({ message, level });
|
||
},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => {
|
||
events.refreshCalls += 1;
|
||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||
},
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => {
|
||
events.sendCalls += 1;
|
||
return { step6Outcome: 'success' };
|
||
},
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await assert.rejects(
|
||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||
/管理密钥/
|
||
);
|
||
|
||
assert.equal(events.refreshCalls, 1);
|
||
assert.equal(events.sendCalls, 0);
|
||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||
});
|
||
|
||
test('step 7 stops immediately when management secret is invalid', 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 = {
|
||
refreshCalls: 0,
|
||
logs: [],
|
||
};
|
||
|
||
const executor = api.createStep7Executor({
|
||
addLog: async (message, level = 'info') => {
|
||
events.logs.push({ message, level });
|
||
},
|
||
completeNodeFromBackground: async () => {},
|
||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||
refreshOAuthUrlBeforeStep6: async () => {
|
||
events.refreshCalls += 1;
|
||
throw new Error('Codex2API 请求失败(HTTP 401)。X-Admin-Key 无效或未授权。');
|
||
},
|
||
reuseOrCreateTab: async () => {},
|
||
sendToContentScriptResilient: async () => ({ step6Outcome: 'success' }),
|
||
STEP6_MAX_ATTEMPTS: 3,
|
||
throwIfStopped: () => {},
|
||
});
|
||
|
||
await assert.rejects(
|
||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||
/401|未授权|无效/
|
||
);
|
||
|
||
assert.equal(events.refreshCalls, 1);
|
||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||
});
|