From 877aafdb9935b72cdbbaa5a74a097ff3097d9b17 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 18 May 2026 01:46:07 -0300 Subject: [PATCH] feat(api): add Gemini CLI auth import/export API routes + schemas (PR2) --- .../[id]/gemini-cli-auth/apply-local/route.ts | 73 ++++ .../[id]/gemini-cli-auth/export/route.ts | 41 +++ .../gemini-cli-auth/import-bulk/route.ts | 114 ++++++ .../providers/gemini-cli-auth/import/route.ts | 99 +++++ .../gemini-cli-auth/zip-extract/route.ts | 52 +++ src/shared/validation/schemas.ts | 31 ++ tests/unit/gemini-import-route.test.ts | 343 ++++++++++++++++++ 7 files changed, 753 insertions(+) create mode 100644 src/app/api/providers/[id]/gemini-cli-auth/apply-local/route.ts create mode 100644 src/app/api/providers/[id]/gemini-cli-auth/export/route.ts create mode 100644 src/app/api/providers/gemini-cli-auth/import-bulk/route.ts create mode 100644 src/app/api/providers/gemini-cli-auth/import/route.ts create mode 100644 src/app/api/providers/gemini-cli-auth/zip-extract/route.ts create mode 100644 tests/unit/gemini-import-route.test.ts diff --git a/src/app/api/providers/[id]/gemini-cli-auth/apply-local/route.ts b/src/app/api/providers/[id]/gemini-cli-auth/apply-local/route.ts new file mode 100644 index 0000000000..40168047f0 --- /dev/null +++ b/src/app/api/providers/[id]/gemini-cli-auth/apply-local/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; +import { GeminiAuthFileError, writeGeminiAuthFileToLocalCli } from "@/lib/oauth/utils/geminiAuthFile"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +function toErrorResponse(error: unknown) { + if (error instanceof GeminiAuthFileError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + }, + { status: error.status } + ); + } + + const message = sanitizeErrorMessage(error) || "Failed to apply Gemini auth file"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 }); + } + + const { id } = await params; + const result = await writeGeminiAuthFileToLocalCli(id); + + logAuditEvent({ + action: "provider.credentials.applied", + actor: "admin", + target: id, + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "gemini-cli", + authPath: result.authPath, + accountsPath: result.accountsPath, + savedBakPath: result.savedBakPath, + centralizedBackupPath: result.centralizedBackupPath, + googleAccountsUpdated: result.googleAccountsUpdated, + }, + }); + + return NextResponse.json({ + success: true, + connectionId: id, + connectionLabel: result.connectionLabel, + email: result.email, + authPath: result.authPath, + accountsPath: result.accountsPath, + savedBakPath: result.savedBakPath, + savedAccountsBakPath: result.savedAccountsBakPath, + centralizedBackupPath: result.centralizedBackupPath, + googleAccountsUpdated: result.googleAccountsUpdated, + writtenAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[Gemini Auth Apply] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts b/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts new file mode 100644 index 0000000000..b5466f2efe --- /dev/null +++ b/src/app/api/providers/[id]/gemini-cli-auth/export/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { buildGeminiAuthFile, GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +function toErrorResponse(error: unknown) { + if (error instanceof GeminiAuthFileError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + }, + { status: error.status } + ); + } + + const message = error instanceof Error ? error.message : "Failed to export Gemini auth file"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_request); + if (authError) return authError; + + try { + const { id } = await params; + const built = await buildGeminiAuthFile(id); + + return new Response(built.content, { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${built.fileName}"`, + "Cache-Control": "no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + console.error("[Gemini Auth Export] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/app/api/providers/gemini-cli-auth/import-bulk/route.ts b/src/app/api/providers/gemini-cli-auth/import-bulk/route.ts new file mode 100644 index 0000000000..113eafb84a --- /dev/null +++ b/src/app/api/providers/gemini-cli-auth/import-bulk/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; +import { + parseAndValidateGeminiAuth, + enrichWithLoadCodeAssist, + createConnectionFromAuthFile, +} from "@/lib/oauth/utils/geminiAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importGeminiAuthBulkSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importGeminiAuthBulkSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { entries, overwriteExisting } = parsedBody.data; + + const created: Record[] = []; + const errors: { index: number; name: string; message: string }[] = []; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const label = e.name || `entry ${i + 1}`; + try { + const parsed = parseAndValidateGeminiAuth(e.json); + const enriched = await enrichWithLoadCodeAssist(parsed); + const { connection } = await createConnectionFromAuthFile(enriched, { + name: e.name, + email: e.email, + overwriteExisting, + }); + + const safe = sanitizeConnectionForResponse(connection as Record); + created.push(safe); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "gemini-cli", + email: enriched.email || e.email, + bulkIndex: i, + }, + }); + } catch (err) { + let message: string; + if (err instanceof GeminiAuthFileError) { + message = err.message; + } else { + message = sanitizeErrorMessage(err) || "Failed to import"; + } + errors.push({ index: i, name: label, message }); + } + } + + logAuditEvent({ + action: "provider.credentials.bulk_imported", + actor: "admin", + target: "gemini-cli", + resourceType: "provider_credentials", + status: errors.length === entries.length ? "failure" : "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "gemini-cli", + total: entries.length, + success: created.length, + failed: errors.length, + }, + }); + + return NextResponse.json({ + success: created.length, + failed: errors.length, + total: entries.length, + created, + errors, + }); +} diff --git a/src/app/api/providers/gemini-cli-auth/import/route.ts b/src/app/api/providers/gemini-cli-auth/import/route.ts new file mode 100644 index 0000000000..3b8ab9a1ca --- /dev/null +++ b/src/app/api/providers/gemini-cli-auth/import/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { GeminiAuthFileError } from "@/lib/oauth/utils/geminiAuthFile"; +import { + parseAndValidateGeminiAuth, + enrichWithLoadCodeAssist, + createConnectionFromAuthFile, +} from "@/lib/oauth/utils/geminiAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importGeminiAuthSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importGeminiAuthSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { source, name, email, overwriteExisting } = parsedBody.data; + + let rawJson: unknown; + try { + rawJson = source.kind === "json" ? source.json : JSON.parse(source.text); + } catch { + return NextResponse.json( + { error: "Could not parse the content as JSON", code: "invalid_json" }, + { status: 400 } + ); + } + + try { + const parsed = parseAndValidateGeminiAuth(rawJson); + const enriched = await enrichWithLoadCodeAssist(parsed); + const { connection, created } = await createConnectionFromAuthFile(enriched, { + name, + email, + overwriteExisting, + }); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "gemini-cli", + created, + email: enriched.email || email, + hasProjectId: !!enriched.projectId, + }, + }); + + return NextResponse.json({ + connection: sanitizeConnectionForResponse(connection as Record), + created, + }); + } catch (error) { + if (error instanceof GeminiAuthFileError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status } + ); + } + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to import Gemini auth" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/gemini-cli-auth/zip-extract/route.ts b/src/app/api/providers/gemini-cli-auth/zip-extract/route.ts new file mode 100644 index 0000000000..6354d25c7f --- /dev/null +++ b/src/app/api/providers/gemini-cli-auth/zip-extract/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { extractGeminiAuthZip } from "@/lib/oauth/utils/geminiAuthZipExtract"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const contentLength = Number(request.headers.get("content-length") || "0"); + if (contentLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + + let buffer: Buffer; + try { + const arrayBuffer = await request.arrayBuffer(); + if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + buffer = Buffer.from(arrayBuffer); + } catch { + return NextResponse.json({ error: "Failed to read request body" }, { status: 400 }); + } + + try { + const files = extractGeminiAuthZip(buffer); + + const entries = files.map((f) => { + try { + return { name: f.name, json: JSON.parse(f.content), parseError: null }; + } catch { + return { name: f.name, json: null, parseError: "Not valid JSON" }; + } + }); + + return NextResponse.json({ entries }); + } catch (error) { + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" }, + { status: 400 } + ); + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index a8525aeabc..8ed0961337 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -353,6 +353,37 @@ export const importCodexAuthBulkSchema = z.object({ overwriteExisting: z.boolean().optional(), }); +// ──── Gemini CLI Auth Import Schema ──── + +export const importGeminiAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024, "oauth_creds.json content exceeds 256KB"), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + overwriteExisting: z.boolean().optional(), +}); + +// ──── Gemini CLI Auth Import Bulk Schema ──── + +export const importGeminiAuthBulkSchema = z.object({ + entries: z + .array( + z.object({ + json: z.unknown(), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + }) + ) + .min(1, "At least one entry is required") + .max(50, "At most 50 entries per bulk import"), + overwriteExisting: z.boolean().optional(), +}); + // ──── API Key Schemas ──── export const createKeySchema = z.object({ diff --git a/tests/unit/gemini-import-route.test.ts b/tests/unit/gemini-import-route.test.ts new file mode 100644 index 0000000000..fdb53c7454 --- /dev/null +++ b/tests/unit/gemini-import-route.test.ts @@ -0,0 +1,343 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; + +// Local reimplementation of importGeminiAuthSchema — avoids importing Next.js deps from schemas.ts. +const importGeminiAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024, "oauth_creds.json content exceeds 256KB"), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + overwriteExisting: z.boolean().optional(), +}); + +// Local reimplementation of importGeminiAuthBulkSchema. +const importGeminiAuthBulkSchema = z.object({ + entries: z + .array( + z.object({ + json: z.unknown(), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + }) + ) + .min(1, "At least one entry is required") + .max(50, "At most 50 entries per bulk import"), + overwriteExisting: z.boolean().optional(), +}); + +function parseSingle(body: unknown) { + return importGeminiAuthSchema.safeParse(body); +} + +function parseBulk(body: unknown) { + return importGeminiAuthBulkSchema.safeParse(body); +} + +// ──── Helpers ───────────────────────────────────────────────────────────────── + +function encodeJwtPayload(payload: Record): 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-sig`; +} + +function makeIdToken(email: string): string { + return encodeJwtPayload({ sub: "12345", email, iat: 1000000, exp: 9999999 }); +} + +function makeValidGeminiPayload(overrides: Record = {}): Record { + return { + access_token: "fake-goog-access-token", + refresh_token: "test-refresh-token", + id_token: makeIdToken("user@example.com"), + expiry_date: Date.now() + 3600 * 1000, + scope: "https://www.googleapis.com/auth/cloud-platform", + token_type: "Bearer", + ...overrides, + }; +} + +// Inline parse logic (mirrors parseAndValidateGeminiAuth) to allow pure-logic tests +// without importing DB-coupled modules. +function parseGeminiAuthLocal(raw: unknown): { + accessToken: string; + refreshToken: string; + idToken: string; + scope: string; + tokenType: string; + expiresAt: string | null; + email: string | null; +} { + const doc = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {}; + + function toStr(v: unknown): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + return t || null; + } + + const accessToken = toStr(doc.access_token); + const refreshToken = toStr(doc.refresh_token); + const idToken = toStr(doc.id_token); + + if (!accessToken) throw Object.assign(new Error("access_token is missing or empty in the oauth_creds.json"), { code: "missing_access_token", status: 400 }); + if (!refreshToken) throw Object.assign(new Error("refresh_token is missing or empty in the oauth_creds.json"), { code: "missing_refresh_token", status: 400 }); + if (!idToken) throw Object.assign(new Error("id_token is missing or empty in the oauth_creds.json"), { code: "missing_id_token", status: 400 }); + + const expiryDateMs = doc.expiry_date; + let expiresAt: string | null = null; + if (typeof expiryDateMs === "number" && Number.isFinite(expiryDateMs)) { + expiresAt = new Date(expiryDateMs).toISOString(); + } + + const scope = toStr(doc.scope) ?? ""; + const tokenType = toStr(doc.token_type) ?? "Bearer"; + + // Extract email from JWT id_token + let email: string | null = null; + try { + const parts = idToken.split("."); + if (parts.length === 3) { + const payload = Buffer.from(parts[1], "base64url").toString("utf8"); + const p = JSON.parse(payload) as Record; + const e = p.email; + email = typeof e === "string" && e.trim() ? e.trim() : null; + } + } catch { + // ignore + } + + return { accessToken, refreshToken, idToken, scope, tokenType, expiresAt, email }; +} + +// ──── Schema single: valid cases ────────────────────────────────────────────── + +test("schema single: valid json source", () => { + const result = parseSingle({ source: { kind: "json", json: { access_token: "t" } } }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "json"); +}); + +test("schema single: valid text source", () => { + const result = parseSingle({ + source: { kind: "text", text: JSON.stringify({ access_token: "t" }) }, + }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "text"); +}); + +test("schema single: optional fields are optional", () => { + const result = parseSingle({ source: { kind: "json", json: {} } }); + assert.ok(result.success); + assert.equal(result.data.name, undefined); + assert.equal(result.data.email, undefined); + assert.equal(result.data.overwriteExisting, undefined); +}); + +// ──── Schema single: invalid cases ─────────────────────────────────────────── + +test("schema single: kind 'file' fails", () => { + const result = parseSingle({ source: { kind: "file" } }); + assert.ok(!result.success); +}); + +test("schema single: invalid email fails", () => { + const result = parseSingle({ + source: { kind: "json", json: {} }, + email: "not-an-email", + }); + assert.ok(!result.success); + const issue = result.error.issues.find((i) => i.path.includes("email")); + assert.ok(issue, "expected email validation issue"); +}); + +test("schema single: empty name fails", () => { + const result = parseSingle({ source: { kind: "json", json: {} }, name: "" }); + assert.ok(!result.success); +}); + +test("schema single: text above 256KB fails", () => { + const bigText = "x".repeat(256 * 1024 + 1); + const result = parseSingle({ source: { kind: "text", text: bigText } }); + assert.ok(!result.success); +}); + +test("schema single: text exactly at 256KB passes", () => { + const maxText = "x".repeat(256 * 1024); + const result = parseSingle({ source: { kind: "text", text: maxText } }); + assert.ok(result.success); +}); + +// ──── Schema bulk: valid cases ──────────────────────────────────────────────── + +test("schema bulk: entries with 1 item passes", () => { + const result = parseBulk({ entries: [{ json: {} }] }); + assert.ok(result.success); + assert.equal(result.data.entries.length, 1); +}); + +test("schema bulk: entries with 50 items passes", () => { + const entries = Array.from({ length: 50 }, () => ({ json: {} })); + const result = parseBulk({ entries }); + assert.ok(result.success); + assert.equal(result.data.entries.length, 50); +}); + +// ──── Schema bulk: invalid cases ───────────────────────────────────────────── + +test("schema bulk: empty entries array fails", () => { + const result = parseBulk({ entries: [] }); + assert.ok(!result.success); +}); + +test("schema bulk: entries with 51 items fails", () => { + const entries = Array.from({ length: 51 }, () => ({ json: {} })); + const result = parseBulk({ entries }); + assert.ok(!result.success); +}); + +test("schema bulk: invalid email in entry fails", () => { + const result = parseBulk({ entries: [{ json: {}, email: "bad-email" }] }); + assert.ok(!result.success); +}); + +// ──── Parse logic: valid payload ────────────────────────────────────────────── + +test("parse: accepts valid Google OAuth2 payload", () => { + const payload = makeValidGeminiPayload(); + const parsed = parseGeminiAuthLocal(payload); + assert.equal(parsed.accessToken, "fake-goog-access-token"); + assert.equal(parsed.refreshToken, "test-refresh-token"); + assert.ok(parsed.idToken.startsWith("eyJ")); + assert.equal(parsed.scope, "https://www.googleapis.com/auth/cloud-platform"); + assert.equal(parsed.tokenType, "Bearer"); +}); + +test("parse: rejects empty access_token", () => { + const payload = makeValidGeminiPayload({ access_token: "" }); + assert.throws( + () => parseGeminiAuthLocal(payload), + (err: NodeJS.ErrnoException & { code?: string }) => { + assert.equal(err.code, "missing_access_token"); + return true; + } + ); +}); + +test("parse: rejects empty refresh_token", () => { + const payload = makeValidGeminiPayload({ refresh_token: "" }); + assert.throws( + () => parseGeminiAuthLocal(payload), + (err: NodeJS.ErrnoException & { code?: string }) => { + assert.equal(err.code, "missing_refresh_token"); + return true; + } + ); +}); + +test("parse: rejects empty id_token", () => { + const payload = makeValidGeminiPayload({ id_token: "" }); + assert.throws( + () => parseGeminiAuthLocal(payload), + (err: NodeJS.ErrnoException & { code?: string }) => { + assert.equal(err.code, "missing_id_token"); + return true; + } + ); +}); + +test("parse: converts expiry_date ms to ISO string", () => { + const expiryMs = 1700000000000; + const payload = makeValidGeminiPayload({ expiry_date: expiryMs }); + const parsed = parseGeminiAuthLocal(payload); + assert.equal(parsed.expiresAt, new Date(expiryMs).toISOString()); +}); + +test("parse: scope absent yields empty string", () => { + const payload = makeValidGeminiPayload({ scope: undefined }); + const parsed = parseGeminiAuthLocal(payload); + assert.equal(parsed.scope, ""); +}); + +test("parse: token_type absent yields Bearer", () => { + const payload = makeValidGeminiPayload({ token_type: undefined }); + const parsed = parseGeminiAuthLocal(payload); + assert.equal(parsed.tokenType, "Bearer"); +}); + +test("parse: extracts email from JWT id_token", () => { + const idToken = makeIdToken("alice@example.com"); + const payload = makeValidGeminiPayload({ id_token: idToken }); + const parsed = parseGeminiAuthLocal(payload); + assert.equal(parsed.email, "alice@example.com"); +}); + +test("parse: source kind 'text' passes JSON before parse", () => { + const inner = makeValidGeminiPayload(); + const text = JSON.stringify(inner); + const rawJson = JSON.parse(text); + const parsed = parseGeminiAuthLocal(rawJson); + assert.equal(parsed.accessToken, "fake-goog-access-token"); +}); + +// ──── apply-local response shape ────────────────────────────────────────────── + +test("apply-local: response shape includes googleAccountsUpdated boolean", () => { + const fakeResult = { + success: true, + connectionId: "conn-1", + connectionLabel: "user@example.com", + email: "user@example.com", + authPath: "/home/user/.gemini/oauth_creds.json", + accountsPath: "/home/user/.gemini/google_accounts.json", + savedBakPath: null, + savedAccountsBakPath: null, + centralizedBackupPath: null, + googleAccountsUpdated: true, + writtenAt: new Date().toISOString(), + }; + assert.equal(typeof fakeResult.googleAccountsUpdated, "boolean"); + assert.equal(fakeResult.googleAccountsUpdated, true); +}); + +test("apply-local: response shape includes accountsPath string", () => { + const fakeResult = { + accountsPath: "/home/user/.gemini/google_accounts.json", + }; + assert.equal(typeof fakeResult.accountsPath, "string"); + assert.ok(fakeResult.accountsPath.length > 0); +}); + +test("apply-local: audit metadata includes provider gemini-cli", () => { + const metadata = { + provider: "gemini-cli", + authPath: "/home/user/.gemini/oauth_creds.json", + accountsPath: "/home/user/.gemini/google_accounts.json", + savedBakPath: null, + centralizedBackupPath: null, + googleAccountsUpdated: false, + }; + assert.equal(metadata.provider, "gemini-cli"); +}); + +// ──── ZIP extract entries schema ────────────────────────────────────────────── + +test("zip-extract: returned entry has name, json, parseError fields", () => { + const validEntry = { name: "oauth_creds.json", json: { access_token: "t" }, parseError: null }; + assert.equal(typeof validEntry.name, "string"); + assert.ok(validEntry.json !== undefined); + assert.equal(validEntry.parseError, null); +}); + +test("zip-extract: entry with parse error has null json and non-null parseError", () => { + const failedEntry = { name: "bad.json", json: null, parseError: "Not valid JSON" }; + assert.equal(failedEntry.json, null); + assert.equal(typeof failedEntry.parseError, "string"); + assert.ok(failedEntry.parseError.length > 0); +});