diff --git a/changelog.d/fixes/13197-deprecated-provider-purge.md b/changelog.d/fixes/13197-deprecated-provider-purge.md new file mode 100644 index 0000000000..aa7f960b32 --- /dev/null +++ b/changelog.d/fixes/13197-deprecated-provider-purge.md @@ -0,0 +1 @@ +- **fix(dashboard):** leftover catalog-removed provider rows (gemini-cli) can be listed and purged from the providers page ([#13067](https://github.com/diegosouzapw/OmniRoute/issues/13067)) ([#13197](https://github.com/diegosouzapw/OmniRoute/pull/13197)) diff --git a/src/app/(dashboard)/dashboard/providers/components/DeprecatedProviderBanner.tsx b/src/app/(dashboard)/dashboard/providers/components/DeprecatedProviderBanner.tsx new file mode 100644 index 0000000000..d64d799b97 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/DeprecatedProviderBanner.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Card, ConfirmModal } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; +import type { DeprecatedProviderLeftoverGroup } from "@/lib/providers/deprecatedProviderCleanup"; + +type ProviderMessageTranslator = (( + key: string, + values?: Record +) => string) & { + has?: (key: string) => boolean; +}; + +function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + + +export default function DeprecatedProviderBanner() { + const t = useTranslations("providers") as ProviderMessageTranslator; + const notify = useNotificationStore(); + const [leftovers, setLeftovers] = useState([]); + const [dismissed, setDismissed] = useState>({}); + const [pending, setPending] = useState(null); + const [purging, setPurging] = useState(false); + + useEffect(() => { + let cancelled = false; + void fetch("/api/providers/deprecated", { credentials: "same-origin" }) + .then((res) => (res.ok ? res.json() : { leftovers: [] })) + .then((body: { leftovers?: DeprecatedProviderLeftoverGroup[] }) => { + if (cancelled) return; + setLeftovers(Array.isArray(body?.leftovers) ? body.leftovers : []); + }) + .catch(() => { + if (!cancelled) setLeftovers([]); + }); + return () => { + cancelled = true; + }; + }, []); + + const visible = leftovers.filter((row) => !dismissed[row.provider]); + if (visible.length === 0) return null; + + async function purge(provider: string) { + setPurging(true); + try { + const res = await fetch("/api/providers/deprecated", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }); + if (res.ok) { + setLeftovers((prev) => prev.filter((row) => row.provider !== provider)); + notify.success( + providerText(t, "purgeLeftoversSuccess", "Leftover connections removed.") + ); + } else { + notify.error( + providerText(t, "purgeLeftoversFailed", "Failed to purge leftover connections.") + ); + } + } catch { + notify.error( + providerText(t, "purgeLeftoversFailed", "Failed to purge leftover connections.") + ); + } finally { + setPurging(false); + setPending(null); + } + } + + return ( + <> + {visible.map((row) => { + const n = row.connectionIds.length; + return ( + +

+ {providerText( + t, + "deprecatedProviderLeftover", + "Provider {name} was removed from OmniRoute. {n} leftover connection(s) are still in the database and cannot be opened from a card. Re-add the account under {migrateTo}, then remove leftovers.", + { name: row.provider, n, migrateTo: row.migrateTo } + )} +

+
+ + +
+
+ ); + })} + setPending(null)} + onConfirm={() => { + if (pending) return purge(pending.provider); + }} + title={providerText(t, "purgeLeftovers", "Purge leftovers")} + message={ + pending + ? providerText( + t, + "purgeLeftoversConfirm", + "Remove {n} leftover {name} connection(s)? This cannot be undone.", + { name: pending.provider, n: pending.connectionIds.length } + ) + : "" + } + confirmText={providerText(t, "purgeLeftovers", "Purge leftovers")} + cancelText={providerText(t, "cancel", "Cancel")} + loading={purging} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index a9cde7c6f7..c093ef2a95 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -61,6 +61,7 @@ import NoAuthProvidersSection from "./components/NoAuthProvidersSection"; import HighlightableProviderCard from "./components/HighlightableProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; import ProviderSummaryCard from "./components/ProviderSummaryCard"; +import DeprecatedProviderBanner from "./components/DeprecatedProviderBanner"; import { buildCompactProviderEntriesForPage, getCompactProviderAuthType, @@ -331,8 +332,6 @@ function ProvidersPageContent() { setOauthEnvRepairStatus(await loadOauthEnvRepairStatus()); }, []); - // Inline-in-effect (calling the component-scope callback synchronously from - // an effect is rejected by the compiler rules); setState runs after the await. useEffect(() => { const run = async () => { const status = await loadOauthEnvRepairStatus(); @@ -463,8 +462,6 @@ function ProvidersPageContent() { // Toggle all connections for a provider on/off const handleToggleProvider = async (providerId: string, authType: string, newActive: boolean) => { - // Mirror getProviderStats: dual-auth providers (qoder, …) toggle BOTH their - // oauth and apikey/PAT connections from the single OAuth card. const matchesToggle = (c: { provider: string; authType?: string }) => connectionMatchesProviderCard(c, providerId, authType as "oauth" | "free" | "apikey"); const providerConns = connections.filter(matchesToggle); @@ -892,6 +889,9 @@ function ProvidersPageContent() { return (
+ + + {showFirstProviderHint && (
diff --git a/src/app/api/providers/deprecated/route.ts b/src/app/api/providers/deprecated/route.ts new file mode 100644 index 0000000000..f3e445ee4c --- /dev/null +++ b/src/app/api/providers/deprecated/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getRawProviderConnections } from "@/lib/db/providers"; +import { deleteProviderConnectionsByProvider } from "@/lib/db/providers/deletion"; +import { listDeprecatedProviderLeftovers } from "@/lib/providers/deprecatedProviderCleanup"; +import { isDeprecatedProvider } from "@omniroute/open-sse/services/tokenRefresh.ts"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +const purgeSchema = z.object({ + provider: z.string().min(1), +}); + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const rows = await getRawProviderConnections({}, undefined, undefined, [ + "id", + "provider", + "name", + ]); + const connections = rows.flatMap((row) => { + if (typeof row.id !== "string") return []; + return [ + { + id: row.id, + provider: typeof row.provider === "string" ? row.provider : null, + name: typeof row.name === "string" ? row.name : null, + }, + ]; + }); + return NextResponse.json({ leftovers: listDeprecatedProviderLeftovers(connections) }); +} + +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 validation = validateBody(purgeSchema, body); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { provider } = validation.data; + if (!isDeprecatedProvider(provider)) { + return NextResponse.json({ error: "Provider is not deprecated" }, { status: 400 }); + } + + const deleted = Number(await deleteProviderConnectionsByProvider(provider) || 0); + + logAuditEvent({ + action: "provider.credentials.revoked", + actor: "admin", + target: provider, + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider, + reason: "deprecated_provider_purge", + deleted, + }, + }); + + return NextResponse.json({ deleted }); +} diff --git a/src/lib/db/providers/deletion.ts b/src/lib/db/providers/deletion.ts index 965fd3734b..39cbec3a4e 100644 --- a/src/lib/db/providers/deletion.ts +++ b/src/lib/db/providers/deletion.ts @@ -19,6 +19,7 @@ import { import { invalidateDbCache } from "../readCache"; import { invalidateReasoningRoutingRuleCache } from "../reasoningRoutingRules"; import { bumpProxyConfigGeneration } from "../settings"; +import { deleteSyncedAvailableModelsForProvider } from "../models"; import { toRecord } from "./columns"; interface StatementLike { @@ -189,6 +190,12 @@ export async function deleteProviderConnectionsByProvider(providerId: string) { backupDbFile("pre-write"); invalidateDbCache("connections"); invalidateReasoningRoutingRuleCache(); + bumpProxyConfigGeneration(); + try { + await deleteSyncedAvailableModelsForProvider(providerId); + } catch { + // Rows are already gone. Do not turn a leftover purge into a 500. + } return result.changes; } diff --git a/src/lib/providers/deprecatedProviderCleanup.ts b/src/lib/providers/deprecatedProviderCleanup.ts new file mode 100644 index 0000000000..8443dc286d --- /dev/null +++ b/src/lib/providers/deprecatedProviderCleanup.ts @@ -0,0 +1,49 @@ +import { + getDeprecationNotice, + isDeprecatedProvider, +} from "@omniroute/open-sse/services/tokenRefresh.ts"; + +export function isOrphanDeprecatedConnection(conn: { provider?: string | null }): boolean { + return isDeprecatedProvider(String(conn.provider || "")); +} + +export type DeprecatedProviderLeftoverGroup = { + provider: string; + migrateTo: string; + reason: string; + connectionIds: string[]; + names: string[]; +}; + +export function listDeprecatedProviderLeftovers( + connections: Array<{ + id: string; + provider?: string | null; + name?: string | null; + }> +): DeprecatedProviderLeftoverGroup[] { + const groups = new Map(); + + for (const conn of connections) { + if (!isOrphanDeprecatedConnection(conn)) continue; + const provider = String(conn.provider || ""); + const notice = getDeprecationNotice(provider); + if (!notice) continue; + + let group = groups.get(provider); + if (!group) { + group = { + provider, + migrateTo: notice.migrateTo, + reason: notice.reason, + connectionIds: [], + names: [], + }; + groups.set(provider, group); + } + group.connectionIds.push(conn.id); + group.names.push(typeof conn.name === "string" ? conn.name : ""); + } + + return [...groups.values()].filter((group) => group.connectionIds.length > 0); +} diff --git a/tests/unit/deprecated-provider-banner-13067.test.ts b/tests/unit/deprecated-provider-banner-13067.test.ts new file mode 100644 index 0000000000..19043b84b9 --- /dev/null +++ b/tests/unit/deprecated-provider-banner-13067.test.ts @@ -0,0 +1,70 @@ +/** + * leftover banner must stay session-only: no localStorage/sessionStorage. + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const bannerPath = path.join( + repoRoot, + "src/app/(dashboard)/dashboard/providers/components/DeprecatedProviderBanner.tsx" +); +const pagePath = path.join(repoRoot, "src/app/(dashboard)/dashboard/providers/page.tsx"); + +test("banner file exists and never persists dismiss in web storage", () => { + assert.ok(fs.existsSync(bannerPath), "DeprecatedProviderBanner.tsx must exist"); + const source = fs.readFileSync(bannerPath, "utf8"); + assert.equal(source.includes("localStorage"), false); + assert.equal(source.includes("sessionStorage"), false); + assert.equal(source.includes("../../providerPageHelpers"), false); + assert.match(source, /fetch\(\s*"\/api\/providers\/deprecated"/); + assert.match(source, /method:\s*"POST"/); + assert.match(source, /credentials:\s*"same-origin"/); +}); + +test("providers page mounts the leftover banner", () => { + const source = fs.readFileSync(pagePath, "utf8"); + assert.match(source, /DeprecatedProviderBanner/); +}); + +test("providers page stays frozen at 2025 lines", () => { + const lines = fs.readFileSync(pagePath, "utf8").split("\n").length; + assert.equal(lines, 2025); +}); + +test("purge surfaces notify.error when POST is not ok", () => { + const source = fs.readFileSync(bannerPath, "utf8"); + assert.match(source, /useNotificationStore/); + assert.match(source, /notify\.error\(/); + const purgeIdx = source.indexOf("async function purge"); + assert.ok(purgeIdx >= 0, "purge helper must exist"); + const purgeBody = source.slice(purgeIdx); + const okIdx = purgeBody.indexOf("if (res.ok)"); + const errIdx = purgeBody.indexOf("notify.error("); + assert.ok(okIdx >= 0, "purge must branch on res.ok"); + assert.ok(errIdx > okIdx, "failed POST must notify after the ok branch"); +}); + +test("purge surfaces notify.success when POST is ok", () => { + const source = fs.readFileSync(bannerPath, "utf8"); + const purgeIdx = source.indexOf("async function purge"); + assert.ok(purgeIdx >= 0, "purge helper must exist"); + const purgeBody = source.slice(purgeIdx); + const okIdx = purgeBody.indexOf("if (res.ok)"); + const successIdx = purgeBody.indexOf("notify.success("); + assert.ok(okIdx >= 0, "purge must branch on res.ok"); + assert.ok(successIdx > okIdx, "successful POST must notify.success in the ok branch"); +}); + +test("banner leftover type comes from the classifier module", () => { + const source = fs.readFileSync(bannerPath, "utf8"); + assert.match(source, /DeprecatedProviderLeftoverGroup/); + assert.match( + source, + /from\s+["']@\/lib\/providers\/deprecatedProviderCleanup["']/ + ); + assert.equal(source.includes("type LeftoverGroup = {"), false); +}); diff --git a/tests/unit/deprecated-provider-by-provider-cleanup-13067.test.ts b/tests/unit/deprecated-provider-by-provider-cleanup-13067.test.ts new file mode 100644 index 0000000000..9ddfc9332a --- /dev/null +++ b/tests/unit/deprecated-provider-by-provider-cleanup-13067.test.ts @@ -0,0 +1,144 @@ +/** + * by-provider leftover purge must finish the same post-steps as + * single-row delete: bump the proxy cache generation and drop + * synced model lists for that provider. + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13067-by-provider-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const deletionPath = path.join(repoRoot, "src/lib/db/providers/deletion.ts"); + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const models = await import("../../src/lib/db/models.ts"); + +const TEST_PROVIDER = "__test_provider_13067__"; +const OTHER_PROVIDER = "__other_provider_13067__"; + +function extractFunctionBody(source: string, name: string): string { + const start = source.indexOf(`export async function ${name}`); + assert.ok(start >= 0, `${name} must exist`); + const nextExport = source.indexOf("\nexport ", start + 1); + return nextExport >= 0 ? source.slice(start, nextExport) : source.slice(start); +} + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | undefined)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("by-provider delete calls proxy bump and synced-model purge", () => { + const source = fs.readFileSync(deletionPath, "utf8"); + const body = extractFunctionBody(source, "deleteProviderConnectionsByProvider"); + assert.match( + source, + /deleteSyncedAvailableModelsForProvider/, + "deletion helper must import the existing synced-model purge" + ); + assert.match( + body, + /\bbumpProxyConfigGeneration\s*\(/, + "by-provider path must bump proxy generation like single-row delete" + ); + assert.match( + body, + /\bdeleteSyncedAvailableModelsForProvider\s*\(/, + "by-provider path must drop synced models for the purged provider" + ); +}); + +test("single-row delete does not take the by-provider synced-model helper", () => { + const source = fs.readFileSync(deletionPath, "utf8"); + const body = extractFunctionBody(source, "deleteProviderConnection"); + assert.match(body, /\bbumpProxyConfigGeneration\s*\(/); + assert.doesNotMatch( + body, + /\bdeleteSyncedAvailableModelsForProvider\s*\(/, + "per-id delete already cleans models at the route; do not fork a second deleter here" + ); +}); + +test("by-provider delete drops this provider's synced models and leaves others", async () => { + const target = await providersDb.createProviderConnection({ + provider: TEST_PROVIDER, + authType: "apikey", + name: "leftover-a", + apiKey: `sk-13067-a-${Date.now()}`, + }); + const sibling = await providersDb.createProviderConnection({ + provider: TEST_PROVIDER, + authType: "apikey", + name: "leftover-b", + apiKey: `sk-13067-b-${Date.now()}`, + }); + const other = await providersDb.createProviderConnection({ + provider: OTHER_PROVIDER, + authType: "apikey", + name: "keep-me", + apiKey: `sk-13067-keep-${Date.now()}`, + }); + assert.ok(target?.id && sibling?.id && other?.id); + + await models.replaceSyncedAvailableModelsForConnection(TEST_PROVIDER, target.id, [ + { id: "orphan-model", name: "Orphan" }, + ]); + await models.replaceSyncedAvailableModelsForConnection(TEST_PROVIDER, sibling.id, [ + { id: "orphan-model-2", name: "Orphan 2" }, + ]); + await models.replaceSyncedAvailableModelsForConnection(OTHER_PROVIDER, other.id, [ + { id: "keep-model", name: "Keep" }, + ]); + + const deleted = await providersDb.deleteProviderConnectionsByProvider(TEST_PROVIDER); + assert.equal(deleted, 2); + + assert.deepEqual(await models.getSyncedAvailableModelsForConnection(TEST_PROVIDER, target.id), []); + assert.deepEqual(await models.getSyncedAvailableModelsForConnection(TEST_PROVIDER, sibling.id), []); + const kept = await models.getSyncedAvailableModelsForConnection(OTHER_PROVIDER, other.id); + assert.equal(kept.length, 1); + assert.equal(kept[0]?.id, "keep-model"); +}); + +test("by-provider synced-model purge must not fail the delete", () => { + const source = fs.readFileSync(deletionPath, "utf8"); + const body = extractFunctionBody(source, "deleteProviderConnectionsByProvider"); + const syncedIdx = body.indexOf("deleteSyncedAvailableModelsForProvider("); + assert.ok(syncedIdx >= 0, "by-provider path must purge synced models"); + const tryIdx = body.lastIndexOf("try {", syncedIdx); + const catchIdx = body.indexOf("catch", syncedIdx); + assert.ok(tryIdx >= 0 && tryIdx < syncedIdx, "synced purge must sit in try"); + assert.ok(catchIdx > syncedIdx, "synced purge must be caught so delete still returns"); +}); diff --git a/tests/unit/deprecated-provider-orphan-13067.test.ts b/tests/unit/deprecated-provider-orphan-13067.test.ts new file mode 100644 index 0000000000..1bff618930 --- /dev/null +++ b/tests/unit/deprecated-provider-orphan-13067.test.ts @@ -0,0 +1,50 @@ +/** + * leftover catalog-removed provider rows (#13067). + * + * Classifier only: isDeprecatedProvider latch. Custom openai-compatible-* + * nodes must never become leftovers. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isOrphanDeprecatedConnection, + listDeprecatedProviderLeftovers, +} from "../../src/lib/providers/deprecatedProviderCleanup.ts"; + +test("classifier matrix: only catalog-removed ids are leftovers", () => { + const cases: Array<{ provider?: string | null; leftover: boolean }> = [ + { provider: "gemini-cli", leftover: true }, + { provider: "gemini", leftover: false }, + { provider: "openai-compatible-foo", leftover: false }, + { provider: "anthropic-compatible-bar", leftover: false }, + { provider: "anthropic-compatible-cc-baz", leftover: false }, + { provider: "", leftover: false }, + { leftover: false }, + { provider: "Gemini-CLI", leftover: false }, + ]; + + for (const row of cases) { + assert.equal( + isOrphanDeprecatedConnection({ provider: row.provider }), + row.leftover, + `provider=${JSON.stringify(row.provider)}` + ); + } +}); + +test("mixed connections group only gemini-cli leftovers", () => { + const leftovers = listDeprecatedProviderLeftovers([ + { id: "g1", provider: "gemini", name: "live gemini" }, + { id: "c1", provider: "gemini-cli", name: "old cli" }, + { id: "c2", provider: "gemini-cli", name: "old cli 2" }, + { id: "o1", provider: "openai-compatible-foo", name: "custom" }, + ]); + + assert.equal(leftovers.length, 1); + assert.equal(leftovers[0]?.provider, "gemini-cli"); + assert.equal(leftovers[0]?.migrateTo, "gemini"); + assert.ok(leftovers[0]?.reason); + assert.deepEqual(leftovers[0]?.connectionIds, ["c1", "c2"]); + assert.deepEqual(leftovers[0]?.names, ["old cli", "old cli 2"]); +}); diff --git a/tests/unit/deprecated-provider-route-13067.test.ts b/tests/unit/deprecated-provider-route-13067.test.ts new file mode 100644 index 0000000000..d6409875dc --- /dev/null +++ b/tests/unit/deprecated-provider-route-13067.test.ts @@ -0,0 +1,230 @@ +/** + * GET/POST /api/providers/deprecated leftover list and purge (#13067). + * + * Real isolated SQLite plus source-scan. Namespace mocks are not + * configurable under the tsx loader, so delete counts come from the + * live helper already covered in the by-provider cleanup test. + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13067-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "deprecated-provider-route-secret"; +delete process.env.INITIAL_PASSWORD; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const routePath = path.join(repoRoot, "src/app/api/providers/deprecated/route.ts"); + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const compliance = await import("../../src/lib/compliance/index.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | undefined)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await settingsDb.updateSettings({ requireLogin: false }); + delete process.env.INITIAL_PASSWORD; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function readRouteSource() { + assert.ok(fs.existsSync(routePath), "deprecated provider route must exist"); + return fs.readFileSync(routePath, "utf8"); +} + +function extractHandler(source: string, name: "GET" | "POST") { + const start = source.indexOf(`export async function ${name}`); + assert.ok(start >= 0, `${name} handler must exist`); + const next = source.indexOf("\nexport ", start + 1); + return next >= 0 ? source.slice(start, next) : source.slice(start); +} + +async function loadRoute() { + return import("../../src/app/api/providers/deprecated/route.ts"); +} + +function makeGetRequest() { + return new Request("http://localhost/api/providers/deprecated", { method: "GET" }); +} + +function makePostRequest(body: unknown) { + return new Request("http://localhost/api/providers/deprecated", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function seedConnection(provider: string, name: string) { + const created = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + name, + apiKey: `sk-test-${Math.random().toString(36).slice(2, 10)}`, + }); + assert.ok(created?.id, `connection ${name} must be created`); + return created as { id: string; provider: string; name: string }; +} + +test("source-scan: GET and POST require management auth before data access", () => { + const source = readRouteSource(); + assert.match(source, /from ["']@\/lib\/api\/requireManagementAuth["']/); + + const getBody = extractHandler(source, "GET"); + const postBody = extractHandler(source, "POST"); + const getAuth = getBody.indexOf("requireManagementAuth(request)"); + const postAuth = postBody.indexOf("requireManagementAuth(request)"); + assert.ok(getAuth >= 0, "GET must call requireManagementAuth"); + assert.ok(postAuth >= 0, "POST must call requireManagementAuth"); + assert.ok(getBody.includes("if (authError) return authError")); + assert.ok(postBody.includes("if (authError) return authError")); + assert.ok( + getAuth < getBody.indexOf("getRawProviderConnections"), + "GET must authorize before listing leftovers" + ); + assert.ok( + postAuth < postBody.indexOf("request.json()"), + "POST must authorize before parsing the body" + ); +}); + +test("source-scan: GET projects id/provider/name via getRawProviderConnections", () => { + const source = readRouteSource(); + const getBody = extractHandler(source, "GET"); + assert.equal(getBody.includes("getProviderConnections("), false); + assert.equal(getBody.includes("createLazyRowProxy"), false); + assert.match(getBody, /getRawProviderConnections\(/); + assert.match(getBody, /undefined\s*,\s*undefined\s*,/); + for (const col of ['"id"', '"provider"', '"name"']) { + assert.ok(getBody.includes(col), `GET projection must include ${col}`); + } + assert.equal( + /decryptConnectionFields|decryptQuiet/.test(source), + false, + "must not decrypt leftover rows" + ); +}); + +test("source-scan: POST latches isDeprecatedProvider before delete", () => { + const source = readRouteSource(); + const postBody = extractHandler(source, "POST"); + assert.match(postBody, /isDeprecatedProvider\(/); + const latch = postBody.indexOf("isDeprecatedProvider("); + const deleteCall = postBody.indexOf("deleteProviderConnectionsByProvider"); + assert.ok(latch >= 0, "POST must call isDeprecatedProvider"); + assert.ok(deleteCall >= 0, "POST must call by-provider delete"); + assert.ok(latch < deleteCall, "latch must reject before delete"); + assert.match(postBody, /deprecated_provider_purge/); +}); + +test("GET groups only gemini-cli leftovers from mixed connections", async () => { + await seedConnection("gemini", "live gemini"); + await seedConnection("gemini-cli", "old cli"); + await seedConnection("gemini-cli", "old cli 2"); + await seedConnection("openai-compatible-foo", "custom node"); + + const route = await loadRoute(); + const res = await route.GET(makeGetRequest()); + assert.equal(res.status, 200); + const body = (await res.json()) as { + leftovers: Array<{ provider: string; migrateTo: string; connectionIds: string[] }>; + }; + assert.equal(body.leftovers.length, 1); + assert.equal(body.leftovers[0]?.provider, "gemini-cli"); + assert.equal(body.leftovers[0]?.migrateTo, "gemini"); + assert.equal(body.leftovers[0]?.connectionIds.length, 2); +}); + +test("POST rejects custom nodes, case variants, empty, missing, and non-string", async () => { + const route = await loadRoute(); + const before = await providersDb.getRawProviderConnections(); + const payloads = [ + { provider: "openai-compatible-x" }, + { provider: "Gemini-CLI" }, + { provider: "" }, + {}, + { provider: 12 }, + ]; + + for (const payload of payloads) { + const res = await route.POST(makePostRequest(payload)); + assert.equal(res.status, 400, JSON.stringify(payload)); + } + + const after = await providersDb.getRawProviderConnections(); + assert.equal(after.length, before.length, "invalid POST must not delete rows"); +}); + +test("POST gemini-cli with two leftover rows returns deleted:2", async () => { + await seedConnection("gemini-cli", "old cli"); + await seedConnection("gemini-cli", "old cli 2"); + await seedConnection("gemini", "live gemini"); + + const route = await loadRoute(); + const res = await route.POST(makePostRequest({ provider: "gemini-cli" })); + assert.equal(res.status, 200); + const body = (await res.json()) as { deleted: number }; + assert.equal(body.deleted, 2); + + const remaining = await providersDb.getRawProviderConnections(); + assert.equal(remaining.length, 1); + assert.equal(remaining[0]?.provider, "gemini"); + + const audits = compliance.getAuditLog({ action: "provider.credentials.revoked" }); + assert.ok(audits.length >= 1); + const details = JSON.stringify(audits[0]?.details ?? audits[0]?.metadata ?? {}); + assert.match(details, /deprecated_provider_purge/); +}); + +test("POST gemini-cli with zero rows returns deleted:0", async () => { + const route = await loadRoute(); + const res = await route.POST(makePostRequest({ provider: "gemini-cli" })); + assert.equal(res.status, 200); + const body = (await res.json()) as { deleted: number }; + assert.equal(body.deleted, 0); +}); + +test("unauthenticated GET and POST return 401/403 and do not delete", async () => { + await seedConnection("gemini-cli", "old cli"); + await settingsDb.updateSettings({ requireLogin: true }); + process.env.INITIAL_PASSWORD = "test-password-deprecated-provider"; + + const route = await loadRoute(); + const getRes = await route.GET(makeGetRequest()); + const postRes = await route.POST(makePostRequest({ provider: "gemini-cli" })); + assert.ok(getRes.status === 401 || getRes.status === 403, `GET status ${getRes.status}`); + assert.ok(postRes.status === 401 || postRes.status === 403, `POST status ${postRes.status}`); + + const remaining = await providersDb.getRawProviderConnections(); + assert.equal(remaining.length, 1); +});