Files
go_bt/tests/openai_oauth_service.test.js
T

325 lines
12 KiB
JavaScript

'use strict';
const assert = require('assert');
const crypto = require('crypto');
const zlib = require('zlib');
const { loginOpenAIOAuthWithGoogle, _private } = require('../src/services/openai_oauth_service');
function jwt(payload) {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.signature`;
}
(async () => {
const { verifier, challenge } = _private.generatePKCE();
assert.match(verifier, /^[a-f0-9]{128}$/);
assert.strictEqual(
challenge,
crypto.createHash('sha256').update(verifier).digest().toString('base64url'),
);
const generatedState = _private.generateState();
assert.match(generatedState, /^[a-f0-9]{64}$/);
const authorize = new URL(_private.buildAuthorizeUrl({ challenge, state: 'state-1' }));
assert.strictEqual(authorize.origin, 'https://auth.openai.com');
assert.strictEqual(authorize.pathname, '/oauth/authorize');
assert.strictEqual(authorize.searchParams.get('client_id'), _private.OAUTH_CLIENT_ID);
assert.strictEqual(authorize.searchParams.get('redirect_uri'), _private.OAUTH_REDIRECT_URI);
assert.strictEqual(authorize.searchParams.get('state'), 'state-1');
assert.strictEqual(authorize.searchParams.get('code_challenge'), challenge);
assert.deepStrictEqual(
[...authorize.searchParams.keys()],
[
'client_id',
'code_challenge',
'code_challenge_method',
'codex_cli_simplified_flow',
'id_token_add_organizations',
'redirect_uri',
'response_type',
'scope',
'state',
],
);
const callback = _private.readCallback(
`${_private.OAUTH_REDIRECT_URI}?code=code-1&state=state-1`,
'state-1',
);
assert.strictEqual(callback.code, 'code-1');
assert.strictEqual(_private.readCallback('http://localhost:1455/other?code=x', 'state-1'), null);
assert.throws(
() => _private.readCallback(`${_private.OAUTH_REDIRECT_URI}?code=x&state=wrong`, 'state-1'),
error => error && error.code === 'OPENAI_OAUTH_STATE_MISMATCH',
);
const session = { workspaces: [{ id: 'personal' }, { id: 'team' }] };
const encoded = Buffer.from(JSON.stringify(session)).toString('base64url');
assert.deepStrictEqual(_private.decodeAuthSessionCookie(`${encoded}.stamp.signature`), session);
const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(session))).toString('base64url');
assert.deepStrictEqual(_private.decodeAuthSessionCookie(`.${compressed}.stamp.signature`), session);
assert.strictEqual(_private.chooseWorkspace(session, 'team').id, 'team');
assert.strictEqual(_private.chooseWorkspace(session, 'missing').id, 'personal');
const accessToken = jwt({
email: 'user@gmail.com',
'https://api.openai.com/auth': {
user_id: 'user-1',
chatgpt_account_id: 'account-1',
},
});
const idToken = jwt({ sub: 'user-1', email: 'user@gmail.com' });
const auth = _private.buildAuthJson('code-1', {
access_token: accessToken,
refresh_token: 'refresh-1',
id_token: idToken,
token_type: 'Bearer',
expires_in: 3600,
});
assert.strictEqual(auth.code, 'code-1');
assert.strictEqual(auth.code_used, true);
assert.strictEqual(auth.access_token, accessToken);
assert.strictEqual(auth.refresh_token, 'refresh-1');
assert.strictEqual(auth.id_token, idToken);
assert.strictEqual(auth.tokens.account_id, 'account-1');
assert.strictEqual(auth.email, 'user@gmail.com');
assert.ok(auth.expires_at);
assert.strictEqual(_private.normalizeIdentityEmail('U.ser+tag@googlemail.com'), 'user@gmail.com');
assert.strictEqual(
_private.shouldRunOAuthInteractor('https://auth.openai.com/choose-an-account', true),
true,
'即使已有授权 Cookie,也必须先处理账号选择页',
);
assert.strictEqual(
_private.shouldRunOAuthInteractor('https://auth.openai.com/sign-in-with-chatgpt/codex/consent', true),
false,
'已建立授权会话后才允许进入协议 consent',
);
assert.strictEqual(
_private.shouldRunOAuthInteractor('https://auth.openai.com/sign-in-with-chatgpt/codex/consent', false),
true,
'consent 页面缺少授权 Cookie 时仍需恢复登录态',
);
let accountClicked = false;
const accountCandidate = {
isVisible: async () => true,
innerText: async () => 'Caufield Amistadi\namistadicaufield@gmail.com',
getAttribute: async () => null,
isDisabled: async () => false,
click: async () => { accountClicked = true; },
};
const accountPage = {
locator: () => ({
count: async () => 1,
nth: () => accountCandidate,
}),
getByText: () => ({
first: () => ({ isVisible: async () => false }),
}),
};
const selected = await _private.clickMatchingOpenAIAccount(
accountPage,
'AmistadiCaufield@gmail.com',
);
assert.strictEqual(selected.ok, true);
assert.strictEqual(accountClicked, true);
const busyPage = {
locator: () => ({
count: async () => 1,
nth: () => ({
isVisible: async () => true,
innerText: async () => 'amistadicaufield@gmail.com',
getAttribute: async name => (name === 'aria-busy' ? 'true' : null),
isDisabled: async () => true,
click: async () => { throw new Error('busy account button must not be clicked'); },
}),
}),
getByText: () => ({ first: () => ({ isVisible: async () => false }) }),
};
const pending = await _private.clickMatchingOpenAIAccount(
busyPage,
'AmistadiCaufield@gmail.com',
);
assert.strictEqual(pending.ok, true);
assert.strictEqual(pending.pending, true);
assert.strictEqual(
_private.isOpenAIPhoneRequiredPage('https://auth.openai.com/add-phone'),
true,
);
assert.strictEqual(
_private.isOpenAIPhoneRequiredPage('https://auth.openai.com/other', 'Phone number required'),
true,
);
const deactivatedCopy = 'Authentication Error. You do not have an account because it has been deleted or deactivated.';
assert.strictEqual(
_private.detectOpenAIAccountDeactivated('https://auth.openai.com/error', deactivatedCopy).code,
'CHATGPT_ACCOUNT_DEACTIVATED',
);
const deactivatedPage = {
url: () => 'https://auth.openai.com/error',
waitForTimeout: async () => {},
locator: selector => (selector === 'body'
? { innerText: async () => deactivatedCopy }
: { first: () => ({ isVisible: async () => false }) }),
};
const deactivatedResult = await loginOpenAIOAuthWithGoogle(
deactivatedPage,
{ email: 'deleted@example.com' },
() => {},
);
assert.strictEqual(deactivatedResult.code, 'CHATGPT_ACCOUNT_DEACTIVATED');
assert.strictEqual(deactivatedResult.retryable, false);
assert.strictEqual(deactivatedResult.restartBrowser, false);
assert.strictEqual(
_private.isOpenAIWorkspaceSelectionPage(
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
'Select a workspace Personal account Continue',
),
true,
);
let workspaceContinueClicked = false;
const workspaceContinueButton = {
isVisible: async () => true,
innerText: async () => 'Continue',
getAttribute: async () => null,
isDisabled: async () => false,
click: async () => { workspaceContinueClicked = true; },
};
const workspacePage = {
url: () => 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
locator: selector => (selector === 'body'
? { innerText: async () => 'Select a workspace Personal account Continue' }
: { count: async () => 1, nth: () => workspaceContinueButton }),
waitForTimeout: async () => {},
};
const workspaceSubmitted = await _private.submitOpenAIWorkspaceSelection(workspacePage);
assert.strictEqual(workspaceSubmitted.ok, true);
assert.strictEqual(workspaceContinueClicked, true);
let nativeSubmitCalled = false;
const protocolCandidate = {
isVisible: async () => true,
innerText: async () => 'Caufield Amistadi\namistadicaufield@gmail.com',
evaluate: async (fn) => {
const button = {
name: 'session_id',
value: 'session-1',
closest: () => ({
action: 'https://auth.openai.com/choose-an-account',
method: 'POST',
requestSubmit: submittedButton => {
nativeSubmitCalled = submittedButton === button;
},
}),
};
const OriginalFormData = global.FormData;
global.FormData = class MockFormData {
entries() { return [['intent', 'select']][Symbol.iterator](); }
};
try { return fn(button); } finally { global.FormData = OriginalFormData; }
},
};
const protocolPage = {
url: () => 'https://auth.openai.com/choose-an-account',
locator: () => ({ count: async () => 1, nth: () => protocolCandidate }),
};
const submitted = await _private.submitMatchingOpenAIAccount(
protocolPage,
'AmistadiCaufield@gmail.com',
);
assert.strictEqual(submitted.ok, true);
assert.strictEqual(nativeSubmitCalled, true);
assert.strictEqual(submitted.targetUrl, 'https://auth.openai.com/choose-an-account');
let exchangePayload = null;
const exchangePage = {
url: () => 'https://auth.openai.com/oauth/token',
evaluate: async (_fn, payload) => {
exchangePayload = payload;
return {
ok: true,
status: 200,
url: payload.targetUrl,
data: {
access_token: 'access-2',
refresh_token: 'refresh-2',
id_token: 'id-2',
},
};
},
};
const exchanged = await _private.exchangeCodeInBrowser(exchangePage, 'code-2', 'verifier-2');
assert.strictEqual(exchanged.refresh_token, 'refresh-2');
assert.strictEqual(exchangePayload.targetUrl, 'https://auth.openai.com/oauth/token');
assert.strictEqual(exchangePayload.method, 'POST');
assert.strictEqual(exchangePayload.form.code, 'code-2');
assert.strictEqual(exchangePayload.form.code_verifier, 'verifier-2');
let chatGPTGoogleClicks = 0;
let chatGPTLoginUrl = 'https://chatgpt.com/';
const missingAction = {
first: () => ({ isVisible: async () => false }),
};
const chatGPTGoogleButton = {
isVisible: async () => true,
click: async () => {
chatGPTGoogleClicks++;
chatGPTLoginUrl = 'https://accounts.google.com/signin/v2';
},
};
const chatGPTPanelPage = {
url: () => chatGPTLoginUrl,
waitForTimeout: async () => {},
locator: selector => selector === 'button:text-is("Continue with Google")'
? { first: () => chatGPTGoogleButton }
: missingAction,
};
const panelResult = await loginOpenAIOAuthWithGoogle(
chatGPTPanelPage,
{ email: 'target@example.com' },
() => {},
{
isSignedInChatGPTHome: async () => false,
handleGoogleOAuth: async () => ({ ok: false, code: 'TEST_GOOGLE_REACHED', msg: 'reached' }),
},
);
assert.strictEqual(chatGPTGoogleClicks, 1, 'OAuth二次登录应点击ChatGPT右侧面板的Continue with Google');
assert.strictEqual(panelResult.code, 'TEST_GOOGLE_REACHED');
let chooserCalls = 0;
let chooserUrl = 'https://chatgpt.com/';
const chooserPage = {
url: () => chooserUrl,
waitForTimeout: async () => {},
locator: () => missingAction,
};
const chooserResult = await loginOpenAIOAuthWithGoogle(
chooserPage,
{ email: 'target@example.com' },
() => {},
{
isSignedInChatGPTHome: async () => false,
handleChatGPTAccountChooser: async () => {
chooserCalls++;
chooserUrl = 'https://accounts.google.com/signin/v2';
return { found: true, handled: true, transitioned: true };
},
handleGoogleOAuth: async () => ({ ok: false, code: 'TEST_CHOOSER_REACHED', msg: 'reached' }),
},
);
assert.strictEqual(chooserCalls, 1, 'OAuth二次登录应复用ChatGPT账号选择弹窗处理器');
assert.strictEqual(chooserResult.code, 'TEST_CHOOSER_REACHED');
console.log('openai_oauth_service.test.js: PASS');
})().catch(error => {
console.error(error);
process.exitCode = 1;
});