mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
1
changelog.d/fixes/13197-deprecated-provider-purge.md
Normal file
1
changelog.d/fixes/13197-deprecated-provider-purge.md
Normal 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))
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
78
src/app/api/providers/deprecated/route.ts
Normal file
78
src/app/api/providers/deprecated/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
49
src/lib/providers/deprecatedProviderCleanup.ts
Normal file
49
src/lib/providers/deprecatedProviderCleanup.ts
Normal 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);
|
||||
}
|
||||
70
tests/unit/deprecated-provider-banner-13067.test.ts
Normal file
70
tests/unit/deprecated-provider-banner-13067.test.ts
Normal 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);
|
||||
});
|
||||
144
tests/unit/deprecated-provider-by-provider-cleanup-13067.test.ts
Normal file
144
tests/unit/deprecated-provider-by-provider-cleanup-13067.test.ts
Normal 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");
|
||||
});
|
||||
50
tests/unit/deprecated-provider-orphan-13067.test.ts
Normal file
50
tests/unit/deprecated-provider-orphan-13067.test.ts
Normal 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"]);
|
||||
});
|
||||
230
tests/unit/deprecated-provider-route-13067.test.ts
Normal file
230
tests/unit/deprecated-provider-route-13067.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user