fix(dashboard): list and purge leftover gemini-cli rows (#13197)

A catalog drop with no DELETE path is a real dead end for operators — no card, no route, no way out. Good that by-provider delete now bumps the proxy generation and purges synced models the way single-row delete already did, and that a cleanup error there does not turn a successful row delete into a 500.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded with the other 19 PRs of this batch. Two in-batch conflicts, both additive and resolved by keeping each side: the `ENVIRONMENT.md` table (#13035 + #13011) and the `chatHelpers.ts` import block (#12975 on the tip + #13017).

- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK; `check:docs-counts` migrations ✓
- complexity 2816 / baseline 3218 and cognitive-complexity 1271 / baseline 1437 — both under baseline
- 531 of 532 focused assertions green across the batch's 46 test files
- `check-file-size` rebaselined for the batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_houminxi`, landed on #13038), attributed per PR

The single red is **not this batch**: `tests/unit/combo/quota-weighted-strategy.test.ts` → "A/B isolation: 7 hard-empty + 2 at 0.5% + 1 at 40%, floor=1" asserts an order between two connections of identical weight and flakes on the pure tip too — 2 failures in 4 runs at `origin/release/v3.8.51` with nothing from this batch applied.

⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` reproduce on the pure tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098, untouched here).

Thanks @HouMinXi — the live evidence on these (X500 logs, `storage.sqlite` state, real `/v1/models` probes, the 36-minute outage write-up) is what let a 20-PR batch be reviewed as a unit.
This commit is contained in:
Bob.Hou
2026-09-11 18:28:05 -04:00
committed by GitHub
parent aedc506cce
commit 9adc2f87d0
10 changed files with 778 additions and 4 deletions

View File

@@ -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))

View File

@@ -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, unknown>
) => string) & {
has?: (key: string) => boolean;
};
function providerText(
t: ProviderMessageTranslator,
key: string,
fallback: string,
values?: Record<string, unknown>
): 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<DeprecatedProviderLeftoverGroup[]>([]);
const [dismissed, setDismissed] = useState<Record<string, boolean>>({});
const [pending, setPending] = useState<DeprecatedProviderLeftoverGroup | null>(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 (
<Card key={row.provider} padding="lg">
<p className="text-sm text-text-main">
{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 }
)}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="danger" size="sm" onClick={() => setPending(row)}>
{providerText(t, "purgeLeftovers", "Purge leftovers")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
setDismissed((prev) => ({ ...prev, [row.provider]: true }))
}
>
{providerText(t, "dismissForSession", "Dismiss for this session")}
</Button>
</div>
</Card>
);
})}
<ConfirmModal
isOpen={pending !== null}
onClose={() => 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}
/>
</>
);
}

View File

@@ -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 (
<OpenRouterProviderStatsProvider entries={openRouterProviderStats}>
<div className="flex flex-col gap-6">
<DeprecatedProviderBanner />
{showFirstProviderHint && (
<Card padding="lg">
<div className="flex flex-col items-center justify-center text-center">

View File

@@ -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 });
}

View File

@@ -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<TRow = unknown> {
@@ -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;
}

View File

@@ -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<string, DeprecatedProviderLeftoverGroup>();
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);
}

View File

@@ -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);
});

View File

@@ -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");
});

View File

@@ -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"]);
});

View File

@@ -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);
});