diff --git a/src/lib/oauth/utils/geminiAuthFile.ts b/src/lib/oauth/utils/geminiAuthFile.ts new file mode 100644 index 0000000000..517fbb83c5 --- /dev/null +++ b/src/lib/oauth/utils/geminiAuthFile.ts @@ -0,0 +1,412 @@ +import fs from "fs/promises"; +import path from "path"; +import { getProviderConnectionById } from "@/lib/localDb"; +import { createBackup } from "@/shared/services/backupService"; +import { getCliConfigPaths } from "@/shared/services/cliRuntime"; +import { + TOKEN_EXPIRY_BUFFER_MS, + getAccessToken, + updateProviderCredentials, +} from "@/sse/services/tokenRefresh"; +import { isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts"; + +type JsonRecord = Record; + +interface GeminiConnectionLike { + id?: string; + provider?: string; + authType?: string; + name?: string; + email?: string; + displayName?: string; + accessToken?: string | null; + refreshToken?: string | null; + idToken?: string | null; + expiresAt?: string | null; + expiresIn?: number | null; + providerSpecificData?: JsonRecord | null; +} + +export interface GeminiAuthFilePayload { + access_token: string; + scope: string; + token_type: string; + id_token: string; + expiry_date: number; + refresh_token: string; +} + +export interface BuiltGeminiAuthFile { + connectionId: string; + connectionLabel: string; + email: string | null; + fileName: string; + payload: GeminiAuthFilePayload; + content: string; +} + +export class GeminiAuthFileError extends Error { + status: number; + code: string; + + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "GeminiAuthFileError"; + this.status = status; + this.code = code; + } +} + +export interface GoogleAccountsSidecar { + active: string; + old: string[]; +} + +export interface ApplyResult extends BuiltGeminiAuthFile { + authPath: string; + accountsPath: string; + savedBakPath: string | null; + savedAccountsBakPath: string | null; + centralizedBackupPath: string | null; + googleAccountsUpdated: boolean; +} + +const GEMINI_REFRESH_BUFFER_MS = Math.max(TOKEN_EXPIRY_BUFFER_MS, 5 * 60 * 1000); + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function decodeJwtPayload(jwt: string): JsonRecord | null { + try { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const payload = Buffer.from(parts[1], "base64url").toString("utf8"); + return toRecord(JSON.parse(payload)); + } catch { + return null; + } +} + +export function sanitizeFileNamePart(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._@-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || "account"; +} + +export function extractGeminiEmail(connection: GeminiConnectionLike): string | null { + const idToken = toNonEmptyString(connection.idToken); + if (idToken) { + const payload = decodeJwtPayload(idToken); + if (payload) { + const fromClaim = toNonEmptyString(payload.email); + if (fromClaim) return fromClaim; + } + } + return toNonEmptyString(connection.email) || toNonEmptyString(connection.displayName); +} + +export function shouldRefreshGeminiConnection(connection: GeminiConnectionLike): boolean { + if (!toNonEmptyString(connection.accessToken)) { + return true; + } + + const expiresAt = toNonEmptyString(connection.expiresAt); + if (!expiresAt) { + return false; + } + + const expiresAtMs = new Date(expiresAt).getTime(); + if (Number.isNaN(expiresAtMs)) { + return false; + } + + return expiresAtMs - Date.now() <= GEMINI_REFRESH_BUFFER_MS; +} + +function getConnectionLabel(connection: GeminiConnectionLike): string { + return ( + toNonEmptyString(connection.name) || + toNonEmptyString(connection.email) || + toNonEmptyString(connection.displayName) || + toNonEmptyString(connection.id) || + "gemini-account" + ); +} + +function buildGeminiAuthPayload(connection: GeminiConnectionLike): GeminiAuthFilePayload { + const accessToken = toNonEmptyString(connection.accessToken); + const refreshToken = toNonEmptyString(connection.refreshToken); + const idToken = toNonEmptyString(connection.idToken); + + if (!accessToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing access_token. Refresh or re-authenticate this account first.", + 409, + "access_token_missing" + ); + } + + if (!refreshToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing refresh_token. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + if (!idToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing id_token. Re-authenticate this account before exporting.", + 409, + "id_token_missing" + ); + } + + const psd = toRecord(connection.providerSpecificData); + const scope = toNonEmptyString(psd.scope) ?? ""; + const tokenType = toNonEmptyString(psd.tokenType) ?? "Bearer"; + + let expiryDate: number; + const expiresAt = toNonEmptyString(connection.expiresAt); + if (expiresAt) { + const ms = new Date(expiresAt).getTime(); + expiryDate = Number.isNaN(ms) ? Date.now() + 3600 * 1000 : ms; + } else { + expiryDate = Date.now() + 3600 * 1000; + } + + return { + access_token: accessToken, + scope, + token_type: tokenType, + id_token: idToken, + expiry_date: expiryDate, + refresh_token: refreshToken, + }; +} + +async function resolveFreshGeminiConnection(connectionId: string): Promise { + const connection = (await getProviderConnectionById(connectionId)) as GeminiConnectionLike | null; + if (!connection) { + throw new GeminiAuthFileError("Connection not found", 404, "not_found"); + } + + if (connection.provider !== "gemini-cli") { + throw new GeminiAuthFileError( + "Only Gemini CLI provider connections can export Gemini auth files" + ); + } + + if (connection.authType !== "oauth") { + throw new GeminiAuthFileError( + "Only OAuth Gemini CLI connections support oauth_creds.json export" + ); + } + + if (!shouldRefreshGeminiConnection(connection)) { + return connection; + } + + const refreshToken = toNonEmptyString(connection.refreshToken); + if (!refreshToken) { + throw new GeminiAuthFileError( + "Gemini connection requires refresh but no refresh_token is available. Re-authenticate first.", + 409, + "reauth_required" + ); + } + + const refreshed = await getAccessToken("gemini-cli", { + connectionId, + accessToken: connection.accessToken, + refreshToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + idToken: connection.idToken, + providerSpecificData: connection.providerSpecificData, + }); + + if (isUnrecoverableRefreshError(refreshed)) { + throw new GeminiAuthFileError( + "Gemini refresh token is no longer valid. Re-authenticate this account before exporting.", + 409, + "reauth_required" + ); + } + + if (!refreshed?.accessToken) { + throw new GeminiAuthFileError( + "Failed to refresh the Gemini session before exporting the auth file. Re-authenticate this account if the session is stale.", + 502, + "refresh_failed" + ); + } + + await updateProviderCredentials(connectionId, refreshed); + + return { + ...connection, + accessToken: refreshed.accessToken, + refreshToken: toNonEmptyString(refreshed.refreshToken) || refreshToken, + expiresIn: + typeof refreshed.expiresIn === "number" ? refreshed.expiresIn : connection.expiresIn || null, + expiresAt: + typeof refreshed.expiresIn === "number" + ? new Date(Date.now() + refreshed.expiresIn * 1000).toISOString() + : connection.expiresAt || null, + providerSpecificData: refreshed.providerSpecificData + ? { + ...toRecord(connection.providerSpecificData), + ...toRecord(refreshed.providerSpecificData), + } + : connection.providerSpecificData, + }; +} + +export async function buildGeminiAuthFile(connectionId: string): Promise { + const connection = await resolveFreshGeminiConnection(connectionId); + const payload = buildGeminiAuthPayload(connection); + const connectionLabel = getConnectionLabel(connection); + const email = extractGeminiEmail(connection); + const fileNameIdentifier = email || connectionLabel; + const fileName = `gemini-auth-${sanitizeFileNamePart(fileNameIdentifier)}.json`; + const content = JSON.stringify(payload, null, 2) + "\n"; + + return { + connectionId, + connectionLabel, + email, + fileName, + payload, + content, + }; +} + +export async function mergeGoogleAccountsFile( + accountsPath: string, + newEmail: string +): Promise<{ updated: boolean; savedBakPath: string | null }> { + let existing: GoogleAccountsSidecar = { active: "", old: [] }; + let fileExists = false; + try { + const raw = await fs.readFile(accountsPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + existing = { + active: typeof parsed.active === "string" ? parsed.active : "", + old: Array.isArray(parsed.old) + ? parsed.old.filter((s: unknown) => typeof s === "string") + : [], + }; + } + fileExists = true; + } catch { + // file absent or invalid — start fresh + } + + if (existing.active === newEmail) { + return { updated: false, savedBakPath: null }; + } + + // Side-by-side backup + let savedBakPath: string | null = null; + if (fileExists) { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + savedBakPath = `${path.dirname(accountsPath)}${path.sep}google_accounts-${ts}.bak`; + await fs.copyFile(accountsPath, savedBakPath).catch(() => {}); + } + + const newOld = [ + existing.active, + ...existing.old.filter((e) => e !== existing.active && e !== newEmail), + ].filter(Boolean); + + const newDoc: GoogleAccountsSidecar = { + active: newEmail, + old: Array.from(new Set(newOld)), + }; + + await fs.writeFile(accountsPath, JSON.stringify(newDoc, null, 2) + "\n", { + encoding: "utf8", + mode: 0o600, + }); + try { + await fs.chmod(accountsPath, 0o600); + } catch { + // Best effort on platforms that ignore chmod semantics. + } + + return { updated: true, savedBakPath }; +} + +export async function writeGeminiAuthFileToLocalCli(connectionId: string): Promise { + const built = await buildGeminiAuthFile(connectionId); + const paths = getCliConfigPaths("gemini-cli"); + // authPath and accountsPath are sourced exclusively from the static CLI_TOOLS table + // in src/shared/services/cliRuntime.ts joined against os.homedir() — no user input + // ever reaches the path APIs below. + const authPath = paths?.auth; + const accountsPath = paths?.accounts; + + if (!authPath || !accountsPath) { + throw new GeminiAuthFileError( + "Gemini CLI paths could not be resolved", + 500, + "path_unavailable" + ); + } + + const authDir = path.dirname(authPath); + await fs.mkdir(authDir, { recursive: true }); + + // Side-by-side .bak inside the .gemini directory for one-click manual rollback. + let savedBakPath: string | null = null; + try { + await fs.access(authPath); + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + savedBakPath = `${authDir}${path.sep}oauth_creds-${ts}.bak`; + await fs.copyFile(authPath, savedBakPath); + } catch { + // No existing file; nothing to back up side-by-side. + } + + // Centralized history (audit trail across all CLI tools). + const centralizedBackupPath = await createBackup("gemini-cli", authPath); + + await fs.writeFile(authPath, built.content, { encoding: "utf8", mode: 0o600 }); + + try { + await fs.chmod(authPath, 0o600); + } catch { + // Best effort on platforms that ignore chmod semantics. + } + + const newEmail = built.email; + let googleAccountsUpdated = false; + let savedAccountsBakPath: string | null = null; + if (newEmail) { + const merged = await mergeGoogleAccountsFile(accountsPath, newEmail); + googleAccountsUpdated = merged.updated; + savedAccountsBakPath = merged.savedBakPath; + } + + return { + ...built, + authPath, + accountsPath, + savedBakPath, + savedAccountsBakPath, + centralizedBackupPath, + googleAccountsUpdated, + }; +} diff --git a/src/lib/oauth/utils/geminiAuthImport.ts b/src/lib/oauth/utils/geminiAuthImport.ts new file mode 100644 index 0000000000..3926f1ed15 --- /dev/null +++ b/src/lib/oauth/utils/geminiAuthImport.ts @@ -0,0 +1,237 @@ +import { + getProviderConnections, + createProviderConnection, + updateProviderConnection, +} from "@/lib/localDb"; +import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function decodeJwtPayload(jwt: string): JsonRecord | null { + try { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const payload = Buffer.from(parts[1], "base64url").toString("utf8"); + return toRecord(JSON.parse(payload)); + } catch { + return null; + } +} + +function extractJwtEmail(idToken: string): string | null { + const payload = decodeJwtPayload(idToken); + if (!payload) return null; + return toNonEmptyString(payload.email); +} + +// ──── Public types ──────────────────────────────────────────────────────────── + +export interface ParsedGeminiAuth { + accessToken: string; + refreshToken: string; + idToken: string; + scope: string; + tokenType: string; + expiresAt: string | null; + email: string | null; +} + +export interface EnrichedGeminiAuth extends ParsedGeminiAuth { + projectId: string | null; +} + +export interface CreateConnectionOptions { + name?: string; + email?: string; + overwriteExisting?: boolean; +} + +// ──── Parse & validate ──────────────────────────────────────────────────────── + +export function parseAndValidateGeminiAuth(raw: unknown): ParsedGeminiAuth { + const doc = toRecord(raw); + + const accessToken = toNonEmptyString(doc.access_token); + const refreshToken = toNonEmptyString(doc.refresh_token); + const idToken = toNonEmptyString(doc.id_token); + + if (!accessToken) { + throw new GeminiAuthFileError( + "access_token is missing or empty in the oauth_creds.json", + 400, + "missing_access_token" + ); + } + + if (!refreshToken) { + throw new GeminiAuthFileError( + "refresh_token is missing or empty in the oauth_creds.json", + 400, + "missing_refresh_token" + ); + } + + if (!idToken) { + throw new GeminiAuthFileError( + "id_token is missing or empty in the oauth_creds.json", + 400, + "missing_id_token" + ); + } + + const expiryDateMs = doc.expiry_date; + let expiresAt: string | null = null; + if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) { + expiresAt = new Date(expiryDateMs).toISOString(); + } + + const scope = toNonEmptyString(doc.scope) ?? ""; + const tokenType = toNonEmptyString(doc.token_type) ?? "Bearer"; + const email = extractJwtEmail(idToken); + + return { + accessToken, + refreshToken, + idToken, + scope, + tokenType, + expiresAt, + email, + }; +} + +// ──── Enrich with Cloud Code Assist project info ────────────────────────────── + +export async function enrichWithLoadCodeAssist( + parsed: ParsedGeminiAuth +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + + try { + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${parsed.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), + signal: controller.signal, + } + ); + + if (!response.ok) { + return { ...parsed, projectId: null }; + } + + const data = toRecord(await response.json()); + const projectId = toNonEmptyString(data.projectId) ?? toNonEmptyString(data.cloudaiProjectId); + return { ...parsed, projectId }; + } catch { + return { ...parsed, projectId: null }; + } finally { + clearTimeout(timer); + } +} + +// ──── Find existing connection ──────────────────────────────────────────────── + +export async function findExistingGeminiConnection(email: string): Promise { + const connections = await getProviderConnections({ provider: "gemini-cli" }); + const lowerEmail = email.toLowerCase(); + return ( + (connections.find((c) => { + const conn = c as JsonRecord; + if (toNonEmptyString(conn.email)?.toLowerCase() === lowerEmail) return true; + const psd = toRecord(conn.providerSpecificData); + return toNonEmptyString(psd.bootstrapEmail)?.toLowerCase() === lowerEmail; + }) as JsonRecord | undefined) ?? null + ); +} + +// ──── Create / update connection ────────────────────────────────────────────── + +export async function createConnectionFromAuthFile( + enriched: EnrichedGeminiAuth, + options: CreateConnectionOptions +): Promise<{ connection: JsonRecord; created: boolean }> { + const resolvedEmail = options.email || enriched.email; + + if (resolvedEmail) { + const existing = await findExistingGeminiConnection(resolvedEmail); + + if (existing) { + if (!options.overwriteExisting) { + throw new GeminiAuthFileError( + "A Gemini CLI connection for this account already exists. Pass overwriteExisting: true to replace it.", + 409, + "duplicate_account" + ); + } + + const updated = await updateProviderConnection(existing.id as string, { + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + idToken: enriched.idToken, + expiresAt: enriched.expiresAt, + email: resolvedEmail || (existing.email as string | undefined), + name: + options.name || + (existing.name as string | undefined) || + resolvedEmail || + "Gemini (imported)", + testStatus: "active", + providerSpecificData: { + ...toRecord(existing.providerSpecificData), + scope: enriched.scope, + tokenType: enriched.tokenType, + projectId: enriched.projectId ?? (toRecord(existing.providerSpecificData).projectId), + importedAt: new Date().toISOString(), + }, + }); + + return { connection: updated || existing, created: false }; + } + } else if (!options.overwriteExisting) { + throw new GeminiAuthFileError( + "Cannot verify identity from the oauth_creds.json — id_token does not contain an email claim. Pass overwriteExisting: true to import without email verification.", + 409, + "identity_unverified" + ); + } + + const name = options.name || resolvedEmail || "Gemini (imported)"; + + const connection = await createProviderConnection({ + provider: "gemini-cli", + authType: "oauth", + name, + email: resolvedEmail || undefined, + accessToken: enriched.accessToken, + refreshToken: enriched.refreshToken, + idToken: enriched.idToken, + expiresAt: enriched.expiresAt, + isActive: true, + testStatus: "active", + providerSpecificData: { + scope: enriched.scope, + tokenType: enriched.tokenType, + projectId: enriched.projectId, + importedAt: new Date().toISOString(), + }, + }); + + return { connection, created: true }; +} diff --git a/src/lib/oauth/utils/geminiAuthZipExtract.ts b/src/lib/oauth/utils/geminiAuthZipExtract.ts new file mode 100644 index 0000000000..384918aff6 --- /dev/null +++ b/src/lib/oauth/utils/geminiAuthZipExtract.ts @@ -0,0 +1,89 @@ +import path from "path"; +import { unzipSync, type Unzipped } from "fflate"; + +export interface ExtractedZipFile { + name: string; + content: string; +} + +export interface ExtractZipOptions { + maxFiles?: number; + maxFileSizeBytes?: number; + maxTotalSizeBytes?: number; +} + +const DEFAULT_MAX_FILES = 50; +const DEFAULT_MAX_FILE_SIZE = 256 * 1024; +const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024; + +function isSafeEntryName(name: string): boolean { + if (!name.toLowerCase().endsWith(".json")) return false; + if (name.includes("..")) return false; + if (path.isAbsolute(name)) return false; + if (/[\r\n\0]/.test(name)) return false; + return true; +} + +export function extractGeminiAuthZip( + zipBuffer: Buffer, + options: ExtractZipOptions = {} +): ExtractedZipFile[] { + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE; + const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL; + + let unzipped: Unzipped; + try { + unzipped = unzipSync(new Uint8Array(zipBuffer)); + } catch { + throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP"); + } + + const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined); + const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json")); + + if (jsonEntries.length === 0) { + throw new Error("ZIP archive contains no .json files"); + } + + if (jsonEntries.length > maxFiles) { + throw new Error( + `ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}` + ); + } + + let totalBytes = 0; + const result: ExtractedZipFile[] = []; + + for (const [entryName, data] of jsonEntries) { + const baseName = path.basename(entryName); + + if (!isSafeEntryName(baseName)) { + throw new Error( + `ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)` + ); + } + + if (!isSafeEntryName(entryName)) { + throw new Error( + `ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)` + ); + } + + if (data.byteLength > maxFileSize) { + throw new Error( + `ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file` + ); + } + + totalBytes += data.byteLength; + if (totalBytes > maxTotal) { + throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`); + } + + const content = new TextDecoder("utf-8").decode(data); + result.push({ name: baseName, content }); + } + + return result; +} diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index db867849cc..7ed2a88b4d 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -166,6 +166,17 @@ const CLI_TOOLS: Record = { env: ".qwen/.env", }, }, + "gemini-cli": { + defaultCommand: "gemini", + envBinKey: "CLI_GEMINI_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 8000, + paths: { + auth: ".gemini/oauth_creds.json", + accounts: ".gemini/google_accounts.json", + settings: ".gemini/settings.json", + }, + }, }; const isWindows = () => process.platform === "win32"; diff --git a/tests/unit/geminiAuthFile.test.ts b/tests/unit/geminiAuthFile.test.ts new file mode 100644 index 0000000000..5a6fc15563 --- /dev/null +++ b/tests/unit/geminiAuthFile.test.ts @@ -0,0 +1,482 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "os"; +import path from "path"; +import fs from "fs/promises"; + +// Pure-function mirrors of helpers from geminiAuthFile.ts — no DB/cliRuntime deps. + +type JsonRecord = Record; + +function buildJwt(payload: JsonRecord): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.fake-signature`; +} + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function decodeJwtPayload(jwt: string): JsonRecord | null { + try { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const payload = Buffer.from(parts[1], "base64url").toString("utf8"); + return toRecord(JSON.parse(payload)); + } catch { + return null; + } +} + +function sanitizeFileNamePart(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._@-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || "account"; +} + +interface GeminiConnectionLike { + id?: string; + name?: string; + email?: string; + displayName?: string; + accessToken?: string | null; + refreshToken?: string | null; + idToken?: string | null; + expiresAt?: string | null; + providerSpecificData?: JsonRecord | null; +} + +class GeminiAuthFileError extends Error { + status: number; + code: string; + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.name = "GeminiAuthFileError"; + this.status = status; + this.code = code; + } +} + +interface GeminiAuthFilePayload { + access_token: string; + scope: string; + token_type: string; + id_token: string; + expiry_date: number; + refresh_token: string; +} + +const REFRESH_BUFFER_MS = 5 * 60 * 1000; + +function shouldRefreshGeminiConnection(connection: GeminiConnectionLike): boolean { + if (!toNonEmptyString(connection.accessToken)) return true; + const expiresAt = toNonEmptyString(connection.expiresAt); + if (!expiresAt) return false; + const expiresAtMs = new Date(expiresAt).getTime(); + if (Number.isNaN(expiresAtMs)) return false; + return expiresAtMs - Date.now() <= REFRESH_BUFFER_MS; +} + +function extractGeminiEmail(connection: GeminiConnectionLike): string | null { + const idToken = toNonEmptyString(connection.idToken); + if (idToken) { + const payload = decodeJwtPayload(idToken); + if (payload) { + const fromClaim = toNonEmptyString(payload.email); + if (fromClaim) return fromClaim; + } + } + return toNonEmptyString(connection.email) || toNonEmptyString(connection.displayName); +} + +function buildGeminiAuthPayload(connection: GeminiConnectionLike): GeminiAuthFilePayload { + const accessToken = toNonEmptyString(connection.accessToken); + const refreshToken = toNonEmptyString(connection.refreshToken); + const idToken = toNonEmptyString(connection.idToken); + + if (!accessToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing access_token.", + 409, + "access_token_missing" + ); + } + if (!refreshToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing refresh_token.", + 409, + "reauth_required" + ); + } + if (!idToken) { + throw new GeminiAuthFileError( + "Gemini connection is missing id_token.", + 409, + "id_token_missing" + ); + } + + const psd = toRecord(connection.providerSpecificData); + const scope = toNonEmptyString(psd.scope) ?? ""; + const tokenType = toNonEmptyString(psd.tokenType) ?? "Bearer"; + + let expiryDate: number; + const expiresAt = toNonEmptyString(connection.expiresAt); + if (expiresAt) { + const ms = new Date(expiresAt).getTime(); + expiryDate = Number.isNaN(ms) ? Date.now() + 3600 * 1000 : ms; + } else { + expiryDate = Date.now() + 3600 * 1000; + } + + return { + access_token: accessToken, + scope, + token_type: tokenType, + id_token: idToken, + expiry_date: expiryDate, + refresh_token: refreshToken, + }; +} + +interface GoogleAccountsSidecar { + active: string; + old: string[]; +} + +async function mergeGoogleAccountsFile( + accountsPath: string, + newEmail: string +): Promise<{ updated: boolean; savedBakPath: string | null }> { + let existing: GoogleAccountsSidecar = { active: "", old: [] }; + let fileExists = false; + try { + const raw = await fs.readFile(accountsPath, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + existing = { + active: typeof parsed.active === "string" ? parsed.active : "", + old: Array.isArray(parsed.old) + ? parsed.old.filter((s: unknown) => typeof s === "string") + : [], + }; + } + fileExists = true; + } catch { + // absent or invalid + } + + if (existing.active === newEmail) { + return { updated: false, savedBakPath: null }; + } + + let savedBakPath: string | null = null; + if (fileExists) { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + savedBakPath = `${path.dirname(accountsPath)}${path.sep}google_accounts-${ts}.bak`; + await fs.copyFile(accountsPath, savedBakPath).catch(() => {}); + } + + const newOld = [ + existing.active, + ...existing.old.filter((e) => e !== existing.active && e !== newEmail), + ].filter(Boolean); + + const newDoc: GoogleAccountsSidecar = { + active: newEmail, + old: Array.from(new Set(newOld)), + }; + + await fs.writeFile(accountsPath, JSON.stringify(newDoc, null, 2) + "\n", { + encoding: "utf8", + mode: 0o600, + }); + + return { updated: true, savedBakPath }; +} + +// ──── Tests: buildGeminiAuthPayload ─────────────────────────────────────────── + +test("buildGeminiAuthPayload: produces correct Google OAuth2 shape", () => { + const idToken = buildJwt({ email: "user@google.com", sub: "12345" }); + const expiresAt = new Date(Date.now() + 3600 * 1000).toISOString(); + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-aaa", + refreshToken: "1//refresh", + idToken, + expiresAt, + providerSpecificData: { + scope: "https://www.googleapis.com/auth/cloud-platform openid email", + tokenType: "Bearer", + }, + }; + const payload = buildGeminiAuthPayload(conn); + assert.equal(payload.access_token, "fake-access-token-aaa"); + assert.equal(payload.refresh_token, "1//refresh"); + assert.equal(payload.id_token, idToken); + assert.equal(payload.token_type, "Bearer"); + assert.equal(payload.scope, "https://www.googleapis.com/auth/cloud-platform openid email"); + assert.ok(typeof payload.expiry_date === "number", "expiry_date must be a number"); +}); + +test("buildGeminiAuthPayload: expiry_date is unix ms, not ISO", () => { + const idToken = buildJwt({ email: "u@g.com" }); + const expiresAt = "2026-01-01T00:00:00.000Z"; + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + refreshToken: "rt", + idToken, + expiresAt, + providerSpecificData: {}, + }; + const payload = buildGeminiAuthPayload(conn); + assert.equal(payload.expiry_date, new Date(expiresAt).getTime()); + assert.ok(payload.expiry_date > 1_000_000_000_000, "should be ms-epoch, not seconds"); +}); + +test("buildGeminiAuthPayload: scope is string with spaces, not array", () => { + const idToken = buildJwt({ email: "u@g.com" }); + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + refreshToken: "rt", + idToken, + providerSpecificData: { + scope: "https://www.googleapis.com/auth/cloud-platform openid email", + }, + }; + const payload = buildGeminiAuthPayload(conn); + assert.ok(typeof payload.scope === "string", "scope must be a string"); + assert.ok(payload.scope.includes(" "), "scope must use spaces as separator"); + assert.ok(!Array.isArray(payload.scope), "scope must not be an array"); +}); + +test("buildGeminiAuthPayload: token_type defaults to Bearer when providerSpecificData.tokenType absent", () => { + const idToken = buildJwt({ email: "u@g.com" }); + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + refreshToken: "rt", + idToken, + providerSpecificData: {}, + }; + const payload = buildGeminiAuthPayload(conn); + assert.equal(payload.token_type, "Bearer"); +}); + +test("buildGeminiAuthPayload: throws access_token_missing when accessToken absent", () => { + const idToken = buildJwt({ email: "u@g.com" }); + const conn: GeminiConnectionLike = { + accessToken: null, + refreshToken: "rt", + idToken, + providerSpecificData: {}, + }; + assert.throws( + () => buildGeminiAuthPayload(conn), + (err: GeminiAuthFileError) => { + assert.equal(err.code, "access_token_missing"); + assert.equal(err.status, 409); + return true; + } + ); +}); + +test("buildGeminiAuthPayload: throws reauth_required when refreshToken absent", () => { + const idToken = buildJwt({ email: "u@g.com" }); + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + refreshToken: null, + idToken, + providerSpecificData: {}, + }; + assert.throws( + () => buildGeminiAuthPayload(conn), + (err: GeminiAuthFileError) => { + assert.equal(err.code, "reauth_required"); + assert.equal(err.status, 409); + return true; + } + ); +}); + +test("buildGeminiAuthPayload: throws id_token_missing when idToken absent", () => { + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + refreshToken: "rt", + idToken: null, + providerSpecificData: {}, + }; + assert.throws( + () => buildGeminiAuthPayload(conn), + (err: GeminiAuthFileError) => { + assert.equal(err.code, "id_token_missing"); + assert.equal(err.status, 409); + return true; + } + ); +}); + +// ──── Tests: shouldRefreshGeminiConnection ──────────────────────────────────── + +test("shouldRefreshGeminiConnection: true when expiresAt < now+5min", () => { + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + expiresAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(), + }; + assert.ok(shouldRefreshGeminiConnection(conn) === true); +}); + +test("shouldRefreshGeminiConnection: false when expiresAt > now+10min", () => { + const conn: GeminiConnectionLike = { + accessToken: "fake-access-token-x", + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }; + assert.ok(shouldRefreshGeminiConnection(conn) === false); +}); + +test("shouldRefreshGeminiConnection: true when accessToken absent", () => { + const conn: GeminiConnectionLike = { + accessToken: null, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + }; + assert.ok(shouldRefreshGeminiConnection(conn) === true); +}); + +// ──── Tests: extractGeminiEmail ─────────────────────────────────────────────── + +test("extractGeminiEmail: extracts email from id_token claim first", () => { + const idToken = buildJwt({ email: "jwt@google.com" }); + const conn: GeminiConnectionLike = { idToken, email: "other@google.com" }; + assert.equal(extractGeminiEmail(conn), "jwt@google.com"); +}); + +test("extractGeminiEmail: falls back to connection.email", () => { + const conn: GeminiConnectionLike = { email: "conn@google.com" }; + assert.equal(extractGeminiEmail(conn), "conn@google.com"); +}); + +test("extractGeminiEmail: falls back to displayName if no email", () => { + const conn: GeminiConnectionLike = { displayName: "My Account" }; + assert.equal(extractGeminiEmail(conn), "My Account"); +}); + +// ──── Tests: filename ───────────────────────────────────────────────────────── + +test("filename: gemini-auth-{email}.json when email available", () => { + const sanitized = sanitizeFileNamePart("user@google.com"); + const filename = `gemini-auth-${sanitized}.json`; + assert.equal(filename, "gemini-auth-user@google.com.json"); +}); + +test("filename: gemini-auth-{label}.json fallback when no email", () => { + const sanitized = sanitizeFileNamePart("Production Account"); + const filename = `gemini-auth-${sanitized}.json`; + assert.equal(filename, "gemini-auth-production-account.json"); +}); + +// ──── Tests: mergeGoogleAccountsFile ───────────────────────────────────────── + +test("mergeGoogleAccountsFile: absent file creates fresh doc with active=newEmail", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-")); + const accountsPath = path.join(tmpDir, "google_accounts.json"); + try { + const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com"); + assert.equal(result.updated, true); + assert.equal(result.savedBakPath, null); + const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar; + assert.equal(written.active, "new@google.com"); + assert.deepEqual(written.old, []); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("mergeGoogleAccountsFile: noop when active already equals newEmail", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-")); + const accountsPath = path.join(tmpDir, "google_accounts.json"); + try { + await fs.writeFile( + accountsPath, + JSON.stringify({ active: "same@google.com", old: [] }) + "\n" + ); + const result = await mergeGoogleAccountsFile(accountsPath, "same@google.com"); + assert.equal(result.updated, false); + assert.equal(result.savedBakPath, null); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("mergeGoogleAccountsFile: moves old active to old[] when active differs", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-")); + const accountsPath = path.join(tmpDir, "google_accounts.json"); + try { + await fs.writeFile( + accountsPath, + JSON.stringify({ active: "old@google.com", old: ["ancient@google.com"] }) + "\n" + ); + const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com"); + assert.equal(result.updated, true); + assert.ok(result.savedBakPath !== null, "should have created a .bak file"); + const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar; + assert.equal(written.active, "new@google.com"); + assert.ok(written.old.includes("old@google.com")); + assert.ok(written.old.includes("ancient@google.com")); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test("mergeGoogleAccountsFile: overwrites invalid JSON file, updated=true", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-")); + const accountsPath = path.join(tmpDir, "google_accounts.json"); + try { + await fs.writeFile(accountsPath, "not valid json"); + const result = await mergeGoogleAccountsFile(accountsPath, "new@google.com"); + assert.equal(result.updated, true); + const written = JSON.parse(await fs.readFile(accountsPath, "utf8")) as GoogleAccountsSidecar; + assert.equal(written.active, "new@google.com"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +// ──── Tests: sanitizeFileNamePart ───────────────────────────────────────────── + +test("sanitizeFileNamePart keeps @ and . for emails", () => { + assert.equal(sanitizeFileNamePart("user@google.com"), "user@google.com"); + assert.equal(sanitizeFileNamePart("User.Name@Example.Org"), "user.name@example.org"); +}); + +test("sanitizeFileNamePart strips filesystem-invalid chars", () => { + assert.equal(sanitizeFileNamePart("name with spaces"), "name-with-spaces"); + assert.equal(sanitizeFileNamePart("a\\b:c*d?"), "a-b-c-d"); +}); + +test("sanitizeFileNamePart falls back to 'account' on empty/garbage", () => { + assert.equal(sanitizeFileNamePart(""), "account"); + assert.equal(sanitizeFileNamePart("///"), "account"); +}); + +test("sanitizeFileNamePart trims leading/trailing dashes", () => { + assert.equal(sanitizeFileNamePart("--foo--"), "foo"); +}); + +// ──── Tests: .bak timestamp format ─────────────────────────────────────────── + +test(".bak basename uses ISO timestamp with safe replacements", () => { + const ts = new Date("2026-05-17T10:30:45.123Z").toISOString().replace(/[:.]/g, "-"); + const basename = `oauth_creds-${ts}.bak`; + assert.equal(basename, "oauth_creds-2026-05-17T10-30-45-123Z.bak"); + assert.ok(!ts.includes(":"), "timestamp should not contain ':'"); + assert.ok(!ts.includes("."), "timestamp should not contain '.'"); +}); diff --git a/tests/unit/geminiAuthImport.test.ts b/tests/unit/geminiAuthImport.test.ts new file mode 100644 index 0000000000..a810ade55c --- /dev/null +++ b/tests/unit/geminiAuthImport.test.ts @@ -0,0 +1,449 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Pure-function copy of helpers from geminiAuthImport.ts so we don't drag DB deps. + +type JsonRecord = Record; + +function buildJwt(payload: JsonRecord): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.fake-signature`; +} + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function decodeJwtPayload(jwt: string): JsonRecord | null { + try { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const payload = Buffer.from(parts[1], "base64url").toString("utf8"); + return toRecord(JSON.parse(payload)); + } catch { + return null; + } +} + +function extractJwtEmail(idToken: string): string | null { + const payload = decodeJwtPayload(idToken); + if (!payload) return null; + return toNonEmptyString(payload.email); +} + +// Mirror of parseAndValidateGeminiAuth (pure logic, no throws — returns result or error) + +interface ParsedGeminiAuth { + accessToken: string; + refreshToken: string; + idToken: string; + scope: string; + tokenType: string; + expiresAt: string | null; + email: string | null; +} + +function parseGeminiAuth(raw: unknown): ParsedGeminiAuth | { error: string; code: string } { + const doc = toRecord(raw); + + const accessToken = toNonEmptyString(doc.access_token); + const refreshToken = toNonEmptyString(doc.refresh_token); + const idToken = toNonEmptyString(doc.id_token); + + if (!accessToken) return { error: "missing access_token", code: "missing_access_token" }; + if (!refreshToken) return { error: "missing refresh_token", code: "missing_refresh_token" }; + if (!idToken) return { error: "missing id_token", code: "missing_id_token" }; + + const expiryDateMs = doc.expiry_date; + let expiresAt: string | null = null; + if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) { + expiresAt = new Date(expiryDateMs).toISOString(); + } + + const scope = toNonEmptyString(doc.scope) ?? ""; + const tokenType = toNonEmptyString(doc.token_type) ?? "Bearer"; + const email = extractJwtEmail(idToken); + + return { accessToken, refreshToken, idToken, scope, tokenType, expiresAt, email }; +} + +// ──── Tests: parseAndValidateGeminiAuth ────────────────────────────────────── + +test("parseAndValidateGeminiAuth: accepts valid Google OAuth2 payload", () => { + const idToken = buildJwt({ email: "user@example.com", sub: "123" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + scope: "https://www.googleapis.com/auth/cloud-platform openid", + token_type: "Bearer", + expiry_date: 1768527451123, + }); + assert.ok(!("error" in result), `Expected success, got error: ${JSON.stringify(result)}`); + const parsed = result as ParsedGeminiAuth; + assert.equal(parsed.accessToken, "fake-goog-access-token"); + assert.equal(parsed.refreshToken, "1//refresh"); + assert.equal(parsed.idToken, idToken); + assert.equal(parsed.scope, "https://www.googleapis.com/auth/cloud-platform openid"); + assert.equal(parsed.tokenType, "Bearer"); + assert.equal(parsed.email, "user@example.com"); +}); + +test("parseAndValidateGeminiAuth: rejects empty access_token (missing_access_token)", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + access_token: "", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +test("parseAndValidateGeminiAuth: rejects missing access_token", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_access_token"); +}); + +test("parseAndValidateGeminiAuth: rejects empty refresh_token (missing_refresh_token)", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: " ", + id_token: idToken, + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_refresh_token"); +}); + +test("parseAndValidateGeminiAuth: rejects empty id_token (missing_id_token)", () => { + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: "", + }); + assert.ok("error" in result); + assert.equal((result as { code: string }).code, "missing_id_token"); +}); + +test("parseAndValidateGeminiAuth: converts expiry_date ms to ISO string", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const expiryMs = 1768527451123; + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + expiry_date: expiryMs, + }); + assert.ok(!("error" in result)); + const parsed = result as ParsedGeminiAuth; + assert.ok(parsed.expiresAt !== null, "expiresAt should not be null"); + assert.ok(parsed.expiresAt!.includes("T"), "expiresAt should be ISO format"); + assert.equal(new Date(parsed.expiresAt!).getTime(), expiryMs); +}); + +test("parseAndValidateGeminiAuth: expiresAt is null when expiry_date absent", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedGeminiAuth).expiresAt, null); +}); + +test("parseAndValidateGeminiAuth: extracts email from JWT id_token", () => { + const idToken = buildJwt({ email: "diego@example.com", sub: "user-123" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedGeminiAuth).email, "diego@example.com"); +}); + +test("parseAndValidateGeminiAuth: email is null when id_token has no email claim", () => { + const idToken = buildJwt({ sub: "user-123" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedGeminiAuth).email, null); +}); + +test("parseAndValidateGeminiAuth: scope absent defaults to empty string", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedGeminiAuth).scope, ""); +}); + +test("parseAndValidateGeminiAuth: token_type absent defaults to Bearer", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + }); + assert.ok(!("error" in result)); + assert.equal((result as ParsedGeminiAuth).tokenType, "Bearer"); +}); + +test("parseAndValidateGeminiAuth: scope is string with spaces (not array)", () => { + const idToken = buildJwt({ email: "user@example.com" }); + const scopeStr = "https://www.googleapis.com/auth/cloud-platform openid email"; + const result = parseGeminiAuth({ + access_token: "fake-goog-access-token", + refresh_token: "1//refresh", + id_token: idToken, + scope: scopeStr, + }); + assert.ok(!("error" in result)); + const parsed = result as ParsedGeminiAuth; + assert.equal(typeof parsed.scope, "string"); + assert.ok(parsed.scope.includes(" "), "scope should be space-separated string"); +}); + +// ──── Tests: enrichWithLoadCodeAssist (mocked fetch) ──────────────────────── + +test("enrichWithLoadCodeAssist: returns projectId on success", async () => { + const parsed: ParsedGeminiAuth = { + accessToken: "fake-goog-access-token", + refreshToken: "1//refresh", + idToken: "eyJ.eyJ.sig", + scope: "openid", + tokenType: "Bearer", + expiresAt: null, + email: "user@example.com", + }; + + // Inline mirror of enrichWithLoadCodeAssist with injected fetch + async function enrichWithMockFetch( + p: ParsedGeminiAuth, + mockFetch: typeof fetch + ): Promise<{ projectId: string | null } & ParsedGeminiAuth> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + try { + const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), + signal: controller.signal, + }); + if (!response.ok) return { ...p, projectId: null }; + const data = (await response.json()) as Record; + const projectId = + (typeof data.projectId === "string" ? data.projectId.trim() || null : null) ?? + (typeof data.cloudaiProjectId === "string" ? data.cloudaiProjectId.trim() || null : null); + return { ...p, projectId }; + } catch { + return { ...p, projectId: null }; + } finally { + clearTimeout(timer); + } + } + + const mockFetch = async () => + ({ + ok: true, + json: async () => ({ projectId: "my-gcp-project-123" }), + } as Response); + + const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch); + assert.equal(enriched.projectId, "my-gcp-project-123"); + assert.equal(enriched.accessToken, parsed.accessToken); +}); + +test("enrichWithLoadCodeAssist: returns projectId null on 401 (best-effort)", async () => { + const parsed: ParsedGeminiAuth = { + accessToken: "fake-goog-expired-token", + refreshToken: "1//refresh", + idToken: "eyJ.eyJ.sig", + scope: "", + tokenType: "Bearer", + expiresAt: null, + email: null, + }; + + async function enrichWithMockFetch( + p: ParsedGeminiAuth, + mockFetch: typeof fetch + ): Promise<{ projectId: string | null } & ParsedGeminiAuth> { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 8000); + try { + const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + }); + if (!response.ok) return { ...p, projectId: null }; + const data = (await response.json()) as Record; + const projectId = typeof data.projectId === "string" ? data.projectId || null : null; + return { ...p, projectId }; + } catch { + return { ...p, projectId: null }; + } finally { + clearTimeout(timer); + } + } + + const mockFetch = async () => ({ ok: false, status: 401 } as Response); + + const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch); + assert.equal(enriched.projectId, null); +}); + +test("enrichWithLoadCodeAssist: returns projectId null on network error (best-effort)", async () => { + const parsed: ParsedGeminiAuth = { + accessToken: "fake-goog-access-token", + refreshToken: "1//refresh", + idToken: "eyJ.eyJ.sig", + scope: "", + tokenType: "Bearer", + expiresAt: null, + email: null, + }; + + async function enrichWithMockFetch( + p: ParsedGeminiAuth, + mockFetch: typeof fetch + ): Promise<{ projectId: string | null } & ParsedGeminiAuth> { + try { + const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: new AbortController().signal, + }); + if (!response.ok) return { ...p, projectId: null }; + return { ...p, projectId: null }; + } catch { + return { ...p, projectId: null }; + } + } + + const mockFetch = async () => { + throw new Error("network failure"); + }; + + const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch); + assert.equal(enriched.projectId, null); +}); + +// ──── Tests: createConnectionFromAuthFile (pure logic mirrors) ─────────────── + +test("createConnectionFromAuthFile: throws 409 duplicate_account when exists + overwrite=false", () => { + // Mirror the guard logic + function checkDuplicate( + existingEmail: string | null, + newEmail: string | null, + overwriteExisting: boolean + ): string | null { + if (newEmail && existingEmail?.toLowerCase() === newEmail.toLowerCase()) { + if (!overwriteExisting) return "duplicate_account"; + } + return null; + } + + const result = checkDuplicate("user@example.com", "user@example.com", false); + assert.equal(result, "duplicate_account"); +}); + +test("createConnectionFromAuthFile: allows update when exists + overwrite=true", () => { + function checkDuplicate( + existingEmail: string | null, + newEmail: string | null, + overwriteExisting: boolean + ): string | null { + if (newEmail && existingEmail?.toLowerCase() === newEmail.toLowerCase()) { + if (!overwriteExisting) return "duplicate_account"; + } + return null; + } + + const result = checkDuplicate("user@example.com", "user@example.com", true); + assert.equal(result, null); +}); + +test("createConnectionFromAuthFile: throws 409 identity_unverified when no email + overwrite=false", () => { + function checkIdentity( + resolvedEmail: string | null, + overwriteExisting: boolean + ): string | null { + if (!resolvedEmail && !overwriteExisting) return "identity_unverified"; + return null; + } + + assert.equal(checkIdentity(null, false), "identity_unverified"); +}); + +test("createConnectionFromAuthFile: allows create without email when overwrite=true", () => { + function checkIdentity( + resolvedEmail: string | null, + overwriteExisting: boolean + ): string | null { + if (!resolvedEmail && !overwriteExisting) return "identity_unverified"; + return null; + } + + assert.equal(checkIdentity(null, true), null); +}); + +test("createConnectionFromAuthFile: email from options.email takes precedence over enriched.email", () => { + const resolveEmail = (optionsEmail: string | undefined, enrichedEmail: string | null) => + optionsEmail || enrichedEmail; + + assert.equal(resolveEmail("override@example.com", "original@example.com"), "override@example.com"); + assert.equal(resolveEmail(undefined, "original@example.com"), "original@example.com"); + assert.equal(resolveEmail(undefined, null), null); +}); + +test("providerSpecificData: projectId from enrichment is stored", () => { + const buildPsd = (scope: string, tokenType: string, projectId: string | null) => ({ + scope, + tokenType, + projectId, + importedAt: new Date().toISOString(), + }); + + const psd = buildPsd("openid", "Bearer", "my-project-123"); + assert.equal(psd.projectId, "my-project-123"); + assert.equal(psd.scope, "openid"); + assert.equal(psd.tokenType, "Bearer"); + assert.ok(psd.importedAt.includes("T"), "importedAt should be ISO"); +}); + +test("providerSpecificData: projectId is null when enrichment failed", () => { + const buildPsd = (scope: string, tokenType: string, projectId: string | null) => ({ + scope, + tokenType, + projectId, + importedAt: new Date().toISOString(), + }); + + const psd = buildPsd("openid", "Bearer", null); + assert.equal(psd.projectId, null); +});