221 lines
10 KiB
JavaScript
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;
|
|
});
|