const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); function loadModule() { const converterSource = fs.readFileSync('shared/session-to-json-converter.js', 'utf8'); const localModuleSource = fs.readFileSync('background/local-cli-proxy-api.js', 'utf8'); return new Function( 'self', `${converterSource}\n${localModuleSource}; return self.MultiPageBackgroundLocalCliProxyApi;` )({}); } function encodeBase64UrlJson(value) { return Buffer.from(JSON.stringify(value), 'utf8') .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function createJwt(payload) { return `${encodeBase64UrlJson({ alg: 'none', typ: 'JWT' })}.${encodeBase64UrlJson(payload)}.signature`; } test('local cli proxy api module exposes factory', () => { const backgroundSource = fs.readFileSync('background.js', 'utf8'); assert.match(backgroundSource, /background\/local-cli-proxy-api\.js/); const source = fs.readFileSync('background/local-cli-proxy-api.js', 'utf8'); const api = new Function('self', `${source}; return self.MultiPageBackgroundLocalCliProxyApi;`)({}); assert.equal(typeof api?.createLocalCliProxyApi, 'function'); }); test('local cli proxy api generates codex OAuth URL with CLIProxyAPI-compatible params', async () => { const api = loadModule(); const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async () => { throw new Error('fetch should not be called'); }, }); const result = await client.createAuthorizationRequest({ state: 'abc123state', }); const parsed = new URL(result.oauthUrl); assert.equal(parsed.origin + parsed.pathname, 'https://auth.openai.com/oauth/authorize'); assert.equal(parsed.searchParams.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann'); assert.equal(parsed.searchParams.get('response_type'), 'code'); assert.equal(parsed.searchParams.get('redirect_uri'), 'http://localhost:1455/auth/callback'); assert.equal(parsed.searchParams.get('scope'), 'openid email profile offline_access'); assert.equal(parsed.searchParams.get('state'), 'abc123state'); assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256'); assert.equal(parsed.searchParams.get('prompt'), 'login'); assert.equal(parsed.searchParams.get('id_token_add_organizations'), 'true'); assert.equal(parsed.searchParams.get('codex_cli_simplified_flow'), 'true'); assert.ok(result.pkceCodes.codeVerifier.length >= 43); assert.ok(result.pkceCodes.codeChallenge.length >= 43); }); test('local cli proxy api exchanges code for tokens using OpenAI OAuth token endpoint', async () => { const api = loadModule(); const calls = []; const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async (url, options = {}) => { calls.push({ url, options }); return { ok: true, status: 200, text: async () => JSON.stringify({ access_token: 'access-token-value', refresh_token: 'refresh-token-value', id_token: 'id-token-value', token_type: 'Bearer', expires_in: 3600, }), }; }, }); const result = await client.exchangeCodeForTokens({ code: 'oauth-code', pkceCodes: { codeVerifier: 'verifier-123', }, }); assert.equal(calls.length, 1); const [{ url, options }] = calls; assert.equal(url, 'https://auth.openai.com/oauth/token'); assert.equal(options.method, 'POST'); assert.equal(options.headers['Content-Type'], 'application/x-www-form-urlencoded'); assert.equal(options.headers.Accept, 'application/json'); const body = new URLSearchParams(options.body); assert.equal(body.get('grant_type'), 'authorization_code'); assert.equal(body.get('client_id'), 'app_EMoamEEZ73f0CkXaXp7hrann'); assert.equal(body.get('code'), 'oauth-code'); assert.equal(body.get('redirect_uri'), 'http://localhost:1455/auth/callback'); assert.equal(body.get('code_verifier'), 'verifier-123'); assert.equal(result.accessToken, 'access-token-value'); assert.equal(result.refreshToken, 'refresh-token-value'); assert.equal(result.idToken, 'id-token-value'); assert.equal(result.tokenType, 'Bearer'); assert.equal(result.expiresIn, 3600); }); test('local cli proxy api turns exchanged tokens into local codex auth json and writes into plugin directory', async () => { const api = loadModule(); const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'flowpilot-local-cpa-')); const pluginDir = path.join(tmpRoot, 'plugin'); const accessToken = createJwt({ exp: Math.trunc(Date.now() / 1000) + 3600, email: 'local@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-local-123', chatgpt_plan_type: 'plus', chatgpt_user_id: 'user-local-1', }, }); const idToken = createJwt({ email: 'local@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-local-123', chatgpt_plan_type: 'plus', chatgpt_user_id: 'user-local-1', }, }); const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async () => { throw new Error('fetch should not be called'); }, ensureDirectory: async (dirPath) => { await fs.promises.mkdir(dirPath, { recursive: true }); }, writeTextFile: async (filePath, text) => { await fs.promises.writeFile(filePath, text, 'utf8'); }, }); const artifact = await client.buildAuthJsonArtifact({ pluginDir, relativeAuthDir: '.cli-proxy-api', accessToken, refreshToken: 'refresh-local-123', idToken, sourceName: 'CLIProxyAPI Local OAuth', now: new Date('2026-05-20T00:00:00.000Z'), }); assert.equal(artifact.fileName, 'codex-local@example.com-plus.json'); assert.equal(artifact.authJson.type, 'codex'); assert.equal(artifact.authJson.email, 'local@example.com'); assert.equal(artifact.authJson.account_id, 'acct-local-123'); assert.equal(artifact.authJson.plan_type, 'plus'); assert.equal(artifact.authJson.refresh_token, 'refresh-local-123'); assert.deepEqual(artifact.warnings, []); const saved = await client.saveAuthJsonArtifact(artifact); assert.equal(saved.saved, true); assert.equal(fs.existsSync(saved.filePath), true); const savedJson = JSON.parse(fs.readFileSync(saved.filePath, 'utf8')); assert.equal(savedJson.email, 'local@example.com'); assert.equal(savedJson.plan_type, 'plus'); assert.equal(savedJson.type, 'codex'); }); test('local cli proxy api uses CLIProxyAPI-style team filename hashing', async () => { const api = loadModule(); const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'flowpilot-local-cpa-team-')); const pluginDir = path.join(tmpRoot, 'plugin'); const accessToken = createJwt({ exp: Math.trunc(Date.now() / 1000) + 3600, email: 'team@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-team-xyz', chatgpt_plan_type: 'team', }, }); const idToken = createJwt({ email: 'team@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-team-xyz', chatgpt_plan_type: 'team', }, }); const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async () => { throw new Error('fetch should not be called'); }, }); const artifact = await client.buildAuthJsonArtifact({ pluginDir, accessToken, refreshToken: 'refresh-team', idToken, }); assert.match(artifact.fileName, /^codex-[0-9a-f]{8}-team@example\.com-team\.json$/); }); test('local cli proxy api converts ChatGPT session directly without refresh token', async () => { const api = loadModule(); const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async () => { throw new Error('fetch should not be called'); }, }); const artifact = await client.buildAuthJsonArtifact({ pluginDir: 'C:\\plugin', relativeAuthDir: '.cli-proxy-api', session: { accessToken: 'session-access-token', sessionToken: 'session-cookie-token', expires: '2026-05-20T12:00:00.000Z', user: { id: 'user-session-1', email: 'session@example.com', }, account: { id: 'acct-session-1', planType: 'free', }, }, accessToken: 'session-access-token', lastRefresh: '', sourceName: 'SessionToJson Local No RT', }); assert.equal(artifact.fileName, 'codex-session@example.com-free.json'); assert.equal(artifact.authJson.email, 'session@example.com'); assert.equal(artifact.authJson.account_id, 'acct-session-1'); assert.equal(artifact.authJson.plan_type, 'free'); assert.equal(artifact.authJson.session_token, 'session-cookie-token'); assert.equal(artifact.authJson.refresh_token, ''); assert.equal(artifact.authJson.last_refresh, ''); assert.match(artifact.authJson.id_token, /^[^.]+\.[^.]+\.$/); assert.ok(artifact.warnings.some((warning) => /Missing refresh_token/.test(warning))); }); test('local cli proxy api rejects mismatched OAuth callback state', () => { const api = loadModule(); assert.throws( () => api.parseOAuthCallback('http://localhost:1455/auth/callback?code=ok&state=wrong-state', 'expected-state'), /OAuth state 不匹配/ ); }); test('local cli proxy api blocks relative auth dir traversal', async () => { const api = loadModule(); const client = api.createLocalCliProxyApi({ crypto: globalThis.crypto, fetch: async () => { throw new Error('fetch should not be called'); }, }); const accessToken = createJwt({ exp: Math.trunc(Date.now() / 1000) + 3600, email: 'safe@example.com', 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-safe', chatgpt_plan_type: 'plus', }, }); await assert.rejects( () => client.buildAuthJsonArtifact({ pluginDir: 'C:\\plugin', relativeAuthDir: '../escape', accessToken, refreshToken: 'refresh-safe', idToken: accessToken, }), /relativeAuthDir 不能包含/ ); });