355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
'use strict';
|
||
|
||
const assert = require('assert');
|
||
|
||
const { _private: loginPrivate } = require('../src/services/login_service');
|
||
const { _private: chatgptPrivate } = require('../src/services/chatgpt_service');
|
||
const {
|
||
handleIdentityVerification,
|
||
handleChallengeSelection,
|
||
inputCurrent2fa,
|
||
_private: commonAuthPrivate,
|
||
} = require('../src/services/common_auth');
|
||
|
||
function emptyLocator() {
|
||
return {
|
||
count: async () => 0,
|
||
nth: () => { throw new Error('empty locator'); },
|
||
};
|
||
}
|
||
|
||
(async () => {
|
||
const securityCodeText = [
|
||
'Get a code to sign in',
|
||
'To get your security code, go to g.co/sc in a new browser window',
|
||
'Enter security code',
|
||
'Try another way',
|
||
].join('\n');
|
||
|
||
assert.strictEqual(loginPrivate.hasSecurityCodePageText(securityCodeText), true);
|
||
assert.strictEqual(commonAuthPrivate.hasSecurityCodePageText(securityCodeText), true);
|
||
assert.strictEqual(
|
||
loginPrivate.hasSecurityCodePageText('Get a verification code from the Google Authenticator app'),
|
||
false,
|
||
'Authenticator 页面不能被识别为 g.co 安全码页面',
|
||
);
|
||
|
||
const phoneTabletOnlyText = [
|
||
"Verify it's you",
|
||
'Choose a way to verify',
|
||
"Use your phone or tablet to get a security code (even if it's offline)",
|
||
].join('\n');
|
||
{
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/selection',
|
||
locator: selector => selector === 'body'
|
||
? { innerText: async () => phoneTabletOnlyText }
|
||
: emptyLocator(),
|
||
};
|
||
assert.strictEqual(
|
||
await commonAuthPrivate.detectPhoneTabletOnlyChallenge(page),
|
||
true,
|
||
'只有手机/平板安全码时应立即识别',
|
||
);
|
||
assert.strictEqual(await loginPrivate.detectPhoneTabletOnlyChallenge(page), true);
|
||
}
|
||
|
||
{
|
||
let optionsSettled = false;
|
||
const makeMissing = () => ({
|
||
count: async () => 0,
|
||
nth() { return this; },
|
||
first() { return this; },
|
||
or() { return this; },
|
||
waitFor: async () => { throw new Error('not visible'); },
|
||
isVisible: async () => false,
|
||
textContent: async () => '',
|
||
});
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/selection',
|
||
locator: selector => {
|
||
if (selector === 'body') {
|
||
return {
|
||
...makeMissing(),
|
||
innerText: async () => optionsSettled
|
||
? phoneTabletOnlyText
|
||
: "Verify it's you\nChoose a way to verify",
|
||
};
|
||
}
|
||
return makeMissing();
|
||
},
|
||
waitForLoadState: async () => {},
|
||
waitForTimeout: async () => { optionsSettled = true; },
|
||
};
|
||
const result = await handleIdentityVerification(page, {}, () => {});
|
||
assert.deepStrictEqual(
|
||
result,
|
||
[false, '需手机/平板验证', 'PHONE_TABLET_VERIFY'],
|
||
'selection 首次正文未稳定时,等待后必须复检唯一手机/平板方式',
|
||
);
|
||
}
|
||
|
||
{
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/selection',
|
||
locator: selector => selector === 'body'
|
||
? { innerText: async () => `${phoneTabletOnlyText}\nGet a verification code from the Google Authenticator app` }
|
||
: emptyLocator(),
|
||
};
|
||
assert.strictEqual(
|
||
await commonAuthPrivate.detectPhoneTabletOnlyChallenge(page),
|
||
false,
|
||
'仍有Authenticator选项时不得误判为只能手机/平板验证',
|
||
);
|
||
}
|
||
|
||
{
|
||
const heading = {
|
||
first() { return this; },
|
||
textContent: async () => "Verify it's you",
|
||
};
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/ootp',
|
||
locator: selector => {
|
||
if (selector === 'body') return { innerText: async () => phoneTabletOnlyText };
|
||
if (selector.startsWith('h1#headingText')) return heading;
|
||
return emptyLocator();
|
||
},
|
||
};
|
||
assert.strictEqual(await commonAuthPrivate.detectVerifyItsYouOotp(page), true);
|
||
assert.strictEqual(
|
||
await commonAuthPrivate.detectPhoneTabletOnlyChallenge(page),
|
||
true,
|
||
'OOTP 页面只剩手机/平板安全码时也应直接识别',
|
||
);
|
||
}
|
||
|
||
{
|
||
const makeMissing = () => ({
|
||
count: async () => 0,
|
||
nth() { return this; },
|
||
first() { return this; },
|
||
or() { return this; },
|
||
waitFor: async () => { throw new Error('not visible'); },
|
||
isVisible: async () => false,
|
||
textContent: async () => '',
|
||
});
|
||
const heading = {
|
||
...makeMissing(),
|
||
textContent: async () => "Verify it's you",
|
||
};
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/ootp',
|
||
locator: selector => {
|
||
if (selector === 'body') return { ...makeMissing(), innerText: async () => phoneTabletOnlyText };
|
||
if (selector.startsWith('h1#headingText')) return heading;
|
||
return makeMissing();
|
||
},
|
||
waitForLoadState: async () => {},
|
||
waitForTimeout: async () => {},
|
||
};
|
||
const result = await handleIdentityVerification(page, {}, () => {});
|
||
assert.deepStrictEqual(
|
||
result,
|
||
[false, '需手机/平板验证', 'PHONE_TABLET_VERIFY'],
|
||
'Verify it’s you + 唯一手机/平板方式必须返回明确机器分类',
|
||
);
|
||
const oauthResult = await chatgptPrivate.handleGoogleOAuth(page, {}, () => {});
|
||
assert.strictEqual(oauthResult.code, 'PHONE_TABLET_VERIFY', 'ChatGPT OAuth 必须保留手机/平板分类');
|
||
}
|
||
|
||
{
|
||
const makeMissing = () => ({
|
||
count: async () => 0,
|
||
nth() { return this; },
|
||
first() { return this; },
|
||
or() { return this; },
|
||
waitFor: async () => { throw new Error('not visible'); },
|
||
isVisible: async () => false,
|
||
textContent: async () => '',
|
||
});
|
||
const heading = {
|
||
...makeMissing(),
|
||
textContent: async () => "Verify it's you",
|
||
};
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/ootp',
|
||
locator: selector => {
|
||
if (selector === 'body') return { ...makeMissing(), innerText: async () => "Verify it's you" };
|
||
if (selector.startsWith('h1#headingText')) return heading;
|
||
return makeMissing();
|
||
},
|
||
waitForLoadState: async () => {},
|
||
waitForTimeout: async () => {},
|
||
};
|
||
const result = await handleIdentityVerification(page, {}, () => {});
|
||
assert.strictEqual(result[0], false);
|
||
assert.strictEqual(result[2], 'VERIFY_ITS_YOU');
|
||
assert.match(result[1], /Verify it.s you/);
|
||
}
|
||
|
||
{
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/custom',
|
||
locator: selector => selector === 'body'
|
||
? { innerText: async () => securityCodeText }
|
||
: emptyLocator(),
|
||
};
|
||
assert.strictEqual(await loginPrivate.isSecurityCodeChallenge(page), true);
|
||
assert.strictEqual(await commonAuthPrivate.isSecurityCodeChallenge(page), true);
|
||
}
|
||
|
||
{
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/signin/challenge/selection',
|
||
locator: selector => selector === 'body'
|
||
? { innerText: async () => `Choose how you want to sign in\n${securityCodeText}\nGoogle Authenticator` }
|
||
: emptyLocator(),
|
||
};
|
||
assert.strictEqual(
|
||
await loginPrivate.isSecurityCodeChallenge(page),
|
||
false,
|
||
'验证方式列表即使展示 g.co 选项,也不能被当作当前安全码页面',
|
||
);
|
||
assert.strictEqual(await commonAuthPrivate.isSecurityCodeChallenge(page), false);
|
||
}
|
||
|
||
{
|
||
let bodyText = securityCodeText;
|
||
const tryAnotherButton = {
|
||
isVisible: async () => true,
|
||
scrollIntoViewIfNeeded: async () => {},
|
||
click: async () => {
|
||
bodyText = 'Choose how you want to sign in\nGoogle Authenticator';
|
||
},
|
||
evaluate: async () => true,
|
||
};
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/v3/signin/challenge/custom',
|
||
locator: selector => {
|
||
if (selector === 'body') return { innerText: async () => bodyText };
|
||
if (selector === 'text="Try another way"') {
|
||
return { count: async () => 1, nth: () => tryAnotherButton };
|
||
}
|
||
return emptyLocator();
|
||
},
|
||
waitForTimeout: async () => {},
|
||
};
|
||
const result = await loginPrivate.tryLeaveAlternateChallenge(page, () => {}, 50);
|
||
assert.strictEqual(result.clicked, true);
|
||
assert.strictEqual(result.moved, true, '同一 URL 内切换到验证方式列表也应视为成功离开');
|
||
}
|
||
|
||
{
|
||
const attemptedSelectors = [];
|
||
let currentUrl = 'https://accounts.google.com/signin/challenge/selection';
|
||
const authButton = {
|
||
isVisible: async () => true,
|
||
scrollIntoViewIfNeeded: async () => {},
|
||
click: async () => {
|
||
currentUrl = 'https://accounts.google.com/signin/challenge/totp';
|
||
},
|
||
evaluate: async () => true,
|
||
};
|
||
const page = {
|
||
url: () => currentUrl,
|
||
locator: selector => {
|
||
attemptedSelectors.push(selector);
|
||
if (selector === 'li:has-text("Get a verification code from the Google Authenticator app")') {
|
||
return { count: async () => 1, nth: () => authButton };
|
||
}
|
||
return emptyLocator();
|
||
},
|
||
waitForTimeout: async () => {},
|
||
waitForLoadState: async () => {},
|
||
};
|
||
assert.strictEqual(await handleChallengeSelection(page, () => {}), true);
|
||
assert.strictEqual(
|
||
attemptedSelectors.includes('div[data-challengetype="6"]'),
|
||
false,
|
||
'不稳定的 challenge type 不能再作为 Authenticator 兜底',
|
||
);
|
||
}
|
||
|
||
{
|
||
const invalidResult = await inputCurrent2fa(
|
||
{ url: () => 'https://accounts.google.com/signin/challenge/totp' },
|
||
'1//not-a-totp-secret',
|
||
() => {},
|
||
);
|
||
assert.strictEqual(invalidResult.ok, false);
|
||
assert.match(invalidResult.msg, /2FA密钥格式不正确|Base32/);
|
||
}
|
||
|
||
{
|
||
let filledCode = '';
|
||
const input = {
|
||
first() { return this; },
|
||
waitFor: async () => {},
|
||
fill: async value => { if (value) filledCode = value; },
|
||
press: async () => {},
|
||
};
|
||
const missingButton = {
|
||
first() { return this; },
|
||
waitFor: async () => { throw new Error('button unavailable'); },
|
||
};
|
||
const page = {
|
||
url: () => 'https://accounts.google.com/signin/challenge/totp',
|
||
locator: selector => selector.includes('input[id="totpPin"]') ? input : missingButton,
|
||
waitForLoadState: async () => {},
|
||
};
|
||
const result = await inputCurrent2fa(page, 'JBSWY3DPEHPK3PXP', () => {});
|
||
assert.strictEqual(result.ok, true, '页面只有totpPin输入框时也应能提交2FA');
|
||
assert.match(filledCode, /^\d{6}$/);
|
||
}
|
||
|
||
{
|
||
const alert = {
|
||
isVisible: async () => true,
|
||
innerText: async () => 'Wrong code. Try again.',
|
||
};
|
||
const page = {
|
||
locator: selector => {
|
||
if (selector.startsWith('[role="alert"]')) {
|
||
return { count: async () => 1, nth: () => alert };
|
||
}
|
||
if (selector === 'body') return { innerText: async () => 'Wrong code. Try again.' };
|
||
return emptyLocator();
|
||
},
|
||
};
|
||
assert.strictEqual(await commonAuthPrivate.detectTotpRejection(page), 'Wrong code. Try again.');
|
||
assert.strictEqual(await loginPrivate.detectTotpRejection(page), 'Wrong code. Try again.');
|
||
}
|
||
|
||
{
|
||
let secondClicked = false;
|
||
const failingButton = {
|
||
isVisible: async () => true,
|
||
scrollIntoViewIfNeeded: async () => {},
|
||
click: async () => { throw new Error('intercepted'); },
|
||
evaluate: async () => { throw new Error('intercepted'); },
|
||
};
|
||
const workingButton = {
|
||
isVisible: async () => true,
|
||
scrollIntoViewIfNeeded: async () => {},
|
||
click: async () => { secondClicked = true; },
|
||
evaluate: async () => true,
|
||
};
|
||
const page = {
|
||
locator: selector => {
|
||
if (selector === 'first') return { count: async () => 1, nth: () => failingButton };
|
||
if (selector === 'second') return { count: async () => 1, nth: () => workingButton };
|
||
return emptyLocator();
|
||
},
|
||
};
|
||
const selector = await commonAuthPrivate.clickFirstVisible(page, ['first', 'second'], 20);
|
||
assert.strictEqual(selector, 'second');
|
||
assert.strictEqual(secondClicked, true, '点击失败后必须继续尝试下一个可点击元素');
|
||
}
|
||
|
||
console.log('security_code_challenge.test.js: PASS');
|
||
})().catch(error => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|