Files
go_bt/tests/gemini_cli_credential_feature.test.js
T

221 lines
10 KiB
JavaScript

'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-credential-'));
const Config = require('../src/core/config');
Config.DATA_DIR = tempDir;
Config.DB_PATH = path.join(tempDir, 'accounts.db');
const db = require('../src/core/database');
const accountsRouter = require('../src/routes/accounts');
const {
CLIENT_ID,
REDIRECT_URI,
createGeminiCliOAuthSession,
buildGeminiCliCredential,
_private,
} = require('../src/services/gemini_cli_oauth_service');
function routeHandler(routePath) {
const layer = accountsRouter.stack.find(item => (
item.route && item.route.path === routePath && item.route.methods.get
));
assert(layer, `未找到 GET ${routePath}`);
return layer.route.stack[0].handle;
}
function invoke(handler, params = {}, remoteAddress = '127.0.0.1') {
const response = {
statusCode: 200,
headers: {},
body: null,
status(code) { this.statusCode = code; return this; },
set(name, value) { this.headers[String(name).toLowerCase()] = value; return this; },
json(body) { this.body = body; return this; },
};
handler({ params, socket: { remoteAddress } }, response);
return response;
}
function base64Url(buffer) {
return Buffer.from(buffer).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
(async () => {
try {
const oauthSession = createGeminiCliOAuthSession();
const authUrl = new URL(oauthSession.authUrl);
assert.strictEqual(authUrl.origin + authUrl.pathname, 'https://accounts.google.com/o/oauth2/v2/auth');
assert.strictEqual(authUrl.searchParams.get('client_id'), CLIENT_ID);
assert.strictEqual(authUrl.searchParams.get('redirect_uri'), REDIRECT_URI);
assert.strictEqual(authUrl.searchParams.get('state'), oauthSession.state);
assert.strictEqual(authUrl.searchParams.get('code_challenge_method'), 'S256');
assert.strictEqual(
authUrl.searchParams.get('code_challenge'),
base64Url(crypto.createHash('sha256').update(oauthSession.codeVerifier).digest()),
'授权链接中的 PKCE challenge 必须由本次 verifier 生成',
);
assert.strictEqual(authUrl.searchParams.get('access_type'), 'offline');
assert.strictEqual(authUrl.searchParams.get('prompt'), 'consent');
const displayedCode = '4/0ATxFakeGeminiCliAuthorizationCode_123456789';
assert.strictEqual(await _private.extractGeminiCliAuthorizationCode({
url: () => REDIRECT_URI,
evaluate: async () => ({
values: [displayedCode],
bodyText: 'Sign in to Gemini Code Assist and the Gemini CLI\nEnter the following verification code',
title: 'Gemini CLI authentication',
}),
}), displayedCode, 'Gemini CLI 成功页中展示的 verification code 必须被识别为授权成功');
assert.strictEqual(await _private.extractGeminiCliAuthorizationCode({
url: () => 'https://codeassist.google.com/success',
evaluate: async () => ({
values: [`${displayedCode.slice(0, 28)}\n${displayedCode.slice(28)}`],
bodyText: 'Sign in to Gemini Code Assist and the Gemini CLI\nEnter the following verification code',
title: '',
}),
}), displayedCode, '成功页地址变化且控件值换行时也必须识别授权码');
const framedPage = {
url: () => 'https://accounts.google.com/o/oauth2/v2/auth',
evaluate: async () => ({ values: [], bodyText: 'Google OAuth', title: 'Google' }),
};
const successFrame = {
url: () => REDIRECT_URI,
evaluate: async () => ({
values: [displayedCode],
bodyText: 'Sign in to Gemini Code Assist and the Gemini CLI',
title: '',
}),
};
framedPage.frames = () => [framedPage, successFrame];
assert.strictEqual(await _private.extractGeminiCliAuthorizationCode(framedPage), displayedCode,
'Gemini CLI 成功页位于 frame 中时也必须识别授权码');
assert.strictEqual(await _private.extractGeminiCliAuthorizationCode({
url: () => 'https://accounts.google.com/signin/v2/challenge/pwd',
evaluate: async () => ({ values: [displayedCode], bodyText: 'Gemini CLI', title: '' }),
}), null, '非 Gemini CLI 回调页不得误提取页面文本');
const credential = buildGeminiCliCredential({
access_token: 'cli-access-token',
refresh_token: 'cli-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'scope-a scope-b',
}, {
email: 'cli@example.com',
project_id: 'cloud-ai-companion-project',
tier_id: 'gcp_standard',
});
assert.strictEqual(credential.oauth_type, 'code_assist');
assert.strictEqual(credential.refresh_token, 'cli-refresh-token');
assert.strictEqual(credential.email, 'cli@example.com');
assert.strictEqual(credential.project_id, 'cloud-ai-companion-project');
assert.strictEqual(credential.tier_id, 'gcp_standard');
assert(Number(credential.expires_at) > Math.floor(Date.now() / 1000));
const credentialWithoutProject = buildGeminiCliCredential({
access_token: 'cli-access-without-project',
refresh_token: 'cli-refresh-without-project',
expires_in: 3600,
}, { email: 'no-project@example.com' });
assert.strictEqual(credentialWithoutProject.email, 'no-project@example.com');
assert.strictEqual('project_id' in credentialWithoutProject, false,
'Gemini CLI 授权成功后必须允许在没有 project_id 时保存凭证');
db.initDB();
db.upsertAccount({ email: 'cli@example.com', password: 'password' });
assert.strictEqual(db.updateAccountCliAcCredential('cli@example.com', credential), true);
assert.strictEqual(
JSON.parse(db.getAccount('cli@example.com').cli_ac_credential).refresh_token,
credential.refresh_token,
);
db.updateAccountCliAcCredential('cli@example.com', { ...credential, email: 'wrong@example.com' });
assert.strictEqual(
JSON.parse(db.getAccount('cli@example.com').cli_ac_credential).email,
'cli@example.com',
'写库时必须用账号邮箱覆盖凭证中的错误邮箱',
);
const list = invoke(routeHandler('/'));
assert.strictEqual(list.body.data[0].has_cli_ac_credential, true);
assert.strictEqual('cli_ac_credential' in list.body.data[0], false, '账号列表不得直接暴露 Cli_AC 完整凭证');
const local = invoke(routeHandler('/:email/cli-ac-credential'), {
email: encodeURIComponent('cli@example.com'),
});
assert.strictEqual(local.statusCode, 200);
assert.strictEqual(local.headers['cache-control'], 'no-store');
assert.deepStrictEqual(local.body.data, credential);
const remote = invoke(routeHandler('/:email/cli-ac-credential'), {
email: encodeURIComponent('cli@example.com'),
}, '192.0.2.26');
assert.strictEqual(remote.statusCode, 403);
assert(!JSON.stringify(remote.body).includes(credential.refresh_token));
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'public', 'index.html'), 'utf8');
const appSource = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8');
const operations = fs.readFileSync(path.join(root, 'src', 'routes', 'operations.js'), 'utf8');
const worker = fs.readFileSync(path.join(root, 'src', 'workers', 'task_runner.js'), 'utf8');
assert.match(html, /runAgGetCredential\(\)[\s\S]{0,220}runGeminiCliGetCredential\(\)/,
'获取Cli凭证按钮必须紧跟在获取凭证右侧');
assert.match(html, /<th>AC凭证<\/th>\s*<th>Cli_AC凭证<\/th>/);
assert.match(html, /openCliAcCredential\(account\)/);
assert.match(html, /x-model="exportFields\.cli_ac_credential"[^>]*>\s*Cli_AC凭证/,
'导出弹窗必须提供 Cli_AC 凭证勾选项');
assert(operations.includes("'gemini_cli_get_credential'"), 'Cli 凭证任务必须只允许本机触发');
assert(operations.includes('updateAccountCliAcCredential'), '主线程必须注册 Cli_AC 凭证写库函数');
assert(worker.includes("case 'gemini_cli_get_credential':"), 'Worker 必须实现 Cli 凭证任务');
assert(worker.includes('performGeminiCliOAuthInBrowser'), 'Cli 凭证任务必须使用 Gemini CLI 授权链接');
assert(worker.includes('exchangeGeminiCliCode'), 'Cli 凭证任务必须使用 PKCE verifier 换取 Token');
assert(worker.includes('discoverGeminiCliProject'), 'Cli 凭证任务必须继续识别 Code Assist project_id');
assert.match(worker, /try\s*\{\s*projectData = await discoverGeminiCliProject[\s\S]{0,800}buildGeminiCliCredential/,
'project_id 必须尽量查询,但查询失败后仍继续构建和保存 Cli 凭证');
let clipboardText = '';
const context = vm.createContext({
console, Set, Map, Date, JSON, Math, String, Object, Array, RegExp,
setTimeout, clearTimeout,
navigator: { clipboard: { writeText: async text => { clipboardText = text; } } },
fetch: async () => ({
ok: true,
status: 200,
json: async () => ({ success: true, data: credential }),
}),
});
vm.runInContext(`${appSource}\nthis.__geminiApp = geminiApp;`, context, { filename: 'public/app.js' });
const app = context.__geminiApp();
assert.strictEqual(app.exportFields.cli_ac_credential, false);
assert(app.exportFieldDefs.some(item => item.key === 'cli_ac_credential' && item.label === 'Cli_AC凭证'));
await app.openCliAcCredential({ email: 'cli@example.com' });
assert.strictEqual(app.showCliAcCredentialModal, true);
assert.strictEqual(JSON.parse(app.cliAcCredentialText).refresh_token, credential.refresh_token);
await app.copyCliAcCredential();
assert.strictEqual(clipboardText, app.cliAcCredentialText);
const hydrated = await app.loadCliAcCredentialsForExport([{ email: 'cli@example.com' }]);
assert.strictEqual(hydrated.missingCount, 0);
assert.deepStrictEqual(JSON.parse(hydrated.accounts[0].cli_ac_credential), credential);
let launchedTask = '';
app.runTask = taskType => { launchedTask = taskType; };
app.runGeminiCliGetCredential();
assert.strictEqual(launchedTask, 'gemini_cli_get_credential');
console.log('gemini_cli_credential_feature.test.js: PASS');
} finally {
try { db.getDb().close(); } catch (_) {}
fs.rmSync(tempDir, { recursive: true, force: true });
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});