diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e7cb37c6..83157df4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **Codex**: rewrite replayed assistant history `input_text`/`text` parts to `output_text` (dropping `annotations`/`logprobs`/`obfuscation`) so the Codex/OpenAI backend accepts codex-cli conversation replays; user and function items unchanged (OmniRoute #6932). - **Codex**: echo the client-requested effort-suffixed model id (e.g. `gpt-5.5-xhigh`) in Responses `response.created`/`response.in_progress`/`response.completed` payloads so the Codex CLI status line shows the active effort, without changing the routed upstream model id (OmniRoute #6820). - **Models**: route `/v1/models` live model-list discovery through the local-first provider-validation SSRF guard (`getProviderValidationGuard`) so LAN-local OpenAI-compatible providers (e.g. LM Studio on 192.168.x.x) appear under default settings while cloud-metadata endpoints stay blocked before any fetch; discovery fetches force `redirect: "manual"` (OmniRoute #6966). +- **Providers**: adding a second API-key connection for the same provider no longer silently overwrites the first — POST /api/providers now runs create-only and returns 409 `PROVIDER_CONNECTION_ALREADY_EXISTS` on a duplicate (provider, apikey, name), the Add-API-key modal pre-fills a unique provider-scoped default name (`main`, `main-2`, …), and PUT /api/providers/[id] remains the explicit update path (OmniRoute #6499). - **Gemini**: omit unsupported thinking config for Gemma 4 on OpenAI-to-Gemini requests (OmniRoute #6708). - **Routing**: clamp reasoning-token headroom to explicit model output caps and isolate fallback attempts (#6714). - **OAuth**: regression-test Codex OAuth connection dedup — Codex same-email logins remain isolated by account and provider, preventing silent token overwrite (OmniRoute #6706; behavior already enforced by account-id-scoped dedup). diff --git a/docs/ports/omniroute-week-2026-07-07.csv b/docs/ports/omniroute-week-2026-07-07.csv index 9fc7f1237..975e064fd 100644 --- a/docs/ports/omniroute-week-2026-07-07.csv +++ b/docs/ports/omniroute-week-2026-07-07.csv @@ -424,7 +424,7 @@ number,title,verdict,evidence 6503,"fix(providers): strip reasoning_effort/reasoning from grok-cli requests (#6288)",SKIP,SKIP:not-selected|files:3 6502,"fix(providers): stop Antigravity false quota-exhausted (#6295)",SKIP,SKIP:not-selected|files:3 6501,"fix(compression): add intra-message dedup to session-dedup engine (#6467)",SKIP,SKIP:not-selected|files:6 -6499,"fix(ui): prevent silent overwrite of existing API key connections on …",SKIP,SKIP:not-selected|files:7 +6499,"fix(ui): prevent silent overwrite of existing API key connections on …",PORT,ported:POST create-only 409 + provider-scoped default name|files:8 commit:fb24740b73,"fix(ci): add the auto-enqueue pull_request_rule to the Mergify config (queue_conditions alone are eligibility-only) (#7179)",SKIP,release-ci-deps-commit commit:01ab5d1fd5,"chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168)",SKIP,release-ci-deps-commit commit:7ee5bbc64d,"fix(build): v3.8.48 hotfix — npm tarball head-response-guard (#7065); electron win spawn; Sonar gate zeroed (#7055)",SKIP,release-ci-deps-commit diff --git a/docs/providers/omniroute-open-provider-catalog.md b/docs/providers/omniroute-open-provider-catalog.md index 846ea4cad..7d13e63d6 100644 --- a/docs/providers/omniroute-open-provider-catalog.md +++ b/docs/providers/omniroute-open-provider-catalog.md @@ -6,6 +6,7 @@ This note records the provider-facing scope reviewed from the current open OmniR - OmniRoute PR #6410: added `hcnsec`, the Huancheng Public API, as an OpenAI-compatible API-key provider. DurinDoor keeps it live-catalog first with `modelsFetcher`, `passthroughModels`, and no static seed models. - OmniRoute PR #6311: API-key connection creation now defaults to the first unused name in the `main`, `main-2`, `main-3`, ... sequence based on all existing API-key connection names, not the current provider's raw connection count. DurinDoor still keeps the backend `authType + name` upsert behavior, but the add-connection modal no longer silently reuses an existing global API-key default name, and connection-list refreshes while the modal is open do not wipe in-progress input. +- OmniRoute PR #6499: reopening the add-key modal after a successful add clears the previous API key and any provider-specific fields (Azure endpoint/deployment, account ID, region, Ollama host) so stale credentials cannot create a duplicate connection. Reset only happens on the closed→open transition; background refetches while the modal is open preserve all typed inputs. ## Already Covered diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index a00458690..17878de05 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -1,15 +1,20 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import PropTypes from "prop-types"; import { Button, Badge, Input, Modal, Select } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { parseBulkApiKeyLine, requiresProviderAccountId } from "@/lib/providerAccountIds"; -import { allocateBulkConnectionName, bulkUsedNameSet } from "./apiKeyConnectionName"; +import { + allocateBulkConnectionName, + bulkUsedNameSet, + buildAddApiKeyModalReset, + createAddApiKeyModalInitialState, +} from "./apiKeyConnectionName"; const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`; -export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) { +export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, existingConnectionNames, error, onSave, onBulkDone, onClose }) { const NONE_PROXY_POOL_VALUE = "__none__"; const isOllamaLocal = provider === "ollama-local"; const isCookie = authType === "cookie"; @@ -26,22 +31,11 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const providerRegions = AI_PROVIDERS?.[provider]?.regions || null; const defaultRegion = AI_PROVIDERS?.[provider]?.defaultRegion || providerRegions?.[0]?.id || ""; - const [formData, setFormData] = useState({ - name: "", - apiKey: "", - defaultModel: "", - priority: 1, - proxyPoolId: NONE_PROXY_POOL_VALUE, - ollamaHostUrl: "", - }); - const [azureData, setAzureData] = useState({ - azureEndpoint: "", - apiVersion: "2024-10-01-preview", - deployment: "", - organization: "", - }); - const [accountIdData, setAccountIdData] = useState({ accountId: "" }); - const [region, setRegion] = useState(defaultRegion); + const initialState = createAddApiKeyModalInitialState(existingConnectionNames, defaultRegion); + const [formData, setFormData] = useState(initialState.formData); + const [azureData, setAzureData] = useState(initialState.azureData); + const [accountIdData, setAccountIdData] = useState(initialState.accountIdData); + const [region, setRegion] = useState(initialState.region); const [validating, setValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); const [saving, setSaving] = useState(false); @@ -53,6 +47,23 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const [bulkText, setBulkText] = useState(""); const [bulkResult, setBulkResult] = useState(null); // { success, failed } + // #6499 — on closed→open, pre-fill a unique default name and reset all + // credential/provider-specific fields so stale secrets cannot create a + // duplicate connection. Background refetches while the modal is open do not + // trigger this reset, so typed inputs are preserved. The API still enforces + // create-only (409); this is UX. + const wasOpenRef = useRef(false); + useEffect(() => { + const wasOpen = wasOpenRef.current; + wasOpenRef.current = isOpen; + const reset = buildAddApiKeyModalReset(wasOpen, isOpen, existingConnectionNames, defaultRegion); + if (!reset) return; + setFormData(reset.formData); + setAzureData(reset.azureData); + setAccountIdData(reset.accountIdData); + setRegion(reset.region); + }, [isOpen, existingConnectionNames, defaultRegion]); + const buildProviderSpecificData = () => { if (isOllamaLocal && formData.ollamaHostUrl.trim()) { return { baseUrl: formData.ollamaHostUrl.trim() }; @@ -452,6 +463,7 @@ AddApiKeyModal.propTypes = { name: PropTypes.string, })), error: PropTypes.string, + existingConnectionNames: PropTypes.arrayOf(PropTypes.string), onSave: PropTypes.func.isRequired, onBulkDone: PropTypes.func, onClose: PropTypes.func.isRequired, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js b/src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js index e21315c40..bebd24f99 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js @@ -19,6 +19,32 @@ export function defaultApiKeyConnectionName(existingConnectionNames = []) { } } +export function createAddApiKeyModalInitialState(existingConnectionNames = [], defaultRegion = "") { + return { + formData: { + name: defaultApiKeyConnectionName(existingConnectionNames), + apiKey: "", + defaultModel: "", + priority: 1, + proxyPoolId: "__none__", + ollamaHostUrl: "", + }, + azureData: { + azureEndpoint: "", + apiVersion: "2024-10-01-preview", + deployment: "", + organization: "", + }, + accountIdData: { accountId: "" }, + region: defaultRegion, + }; +} + +export function buildAddApiKeyModalReset(wasOpen, isOpen, existingConnectionNames = [], defaultRegion = "") { + if (!shouldResetAddApiKeyModal(wasOpen, isOpen)) return null; + return createAddApiKeyModalInitialState(existingConnectionNames, defaultRegion); +} + export function shouldResetAddApiKeyModal(previousIsOpen, nextIsOpen) { return !previousIsOpen && nextIsOpen; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index a9faf5df8..56299c66f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -49,7 +49,7 @@ export default function ProviderDetailPage() { }, [providerId]); const { getCaps } = useModelCaps(); const [connections, setConnections] = useState([]); - const [globalApiKeyConnectionNames, setGlobalApiKeyConnectionNames] = useState([]); + const [providerApiKeyConnectionNames, setProviderApiKeyConnectionNames] = useState([]); const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); const [proxyPools, setProxyPools] = useState([]); @@ -353,7 +353,11 @@ export default function ProviderDetailPage() { const allConnections = connectionsData.connections || []; const filtered = allConnections.filter(c => c.provider === providerId); setConnections(filtered); - setGlobalApiKeyConnectionNames(apiKeyConnectionNames(allConnections)); + // #6499 — the name-based collision scope in createProviderConnection is + // (provider, authType=apikey, name): provider-local. Derive default-name + // candidates from THIS provider's apikey connections only; using global + // names would pointlessly skip "main" just because another provider took it. + setProviderApiKeyConnectionNames(apiKeyConnectionNames(filtered)); } if (proxyPoolsRes.ok) { setProxyPools(proxyPoolsData.proxyPools || []); @@ -1954,8 +1958,7 @@ export default function ProviderDetailPage() { authHint={providerInfo?.authHint} website={providerInfo?.website} proxyPools={proxyPools} - existingConnectionNames={globalApiKeyConnectionNames} - existingConnectionCount={connections.length} + existingConnectionNames={providerApiKeyConnectionNames} error={addConnectionError} onSave={handleSaveApiKey} onBulkDone={fetchConnections} diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 18d3cb2da..f83c3bd76 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -139,7 +139,8 @@ export async function POST(request) { if (!apiKey && provider !== "ollama-local" && !isNoAuthProvider) { return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 }); } - const connectionName = name || displayName || AI_PROVIDERS[provider]?.name; + const rawConnectionName = name || displayName || AI_PROVIDERS[provider]?.name; + const connectionName = typeof rawConnectionName === "string" ? rawConnectionName.trim() : ""; if (!connectionName) { return NextResponse.json({ error: "Name is required" }, { status: 400 }); } @@ -207,20 +208,32 @@ export async function POST(request) { } // Bulk add sends createOnly so a name collision never silently overwrites - // an existing key — the repo throws PROVIDER_CONNECTION_NAME_CONFLICT and - // we surface a 409 instead of the default upsert. - const newConnection = await createProviderConnection({ - provider, - authType: isWebCookieProvider ? "cookie" : "apikey", - name: connectionName, - apiKey: apiKey || "", - priority: priority || 1, - globalPriority: globalPriority || null, - defaultModel: defaultModel || null, - providerSpecificData: mergedProviderSpecificData, - isActive: true, - testStatus: testStatus || "unknown", - }, { requireNewName: createOnly === true }); + // an existing key (requireNewName → PROVIDER_CONNECTION_NAME_CONFLICT → 409). + // #6499 — single dashboard add is always create-only: a duplicate + // (provider, apikey, name) must NOT silently upsert/overwrite + // (createOnly → PROVIDER_CONNECTION_ALREADY_EXISTS → 409). The repo throws + // atomically inside its transaction; the explicit update path is + // updateProviderConnection (PUT /api/providers/[id]). + let newConnection; + try { + newConnection = await createProviderConnection({ + provider, + authType: isWebCookieProvider ? "cookie" : "apikey", + name: connectionName, + apiKey: apiKey || "", + priority: priority || 1, + globalPriority: globalPriority || null, + defaultModel: defaultModel || null, + providerSpecificData: mergedProviderSpecificData, + isActive: true, + testStatus: testStatus || "unknown", + }, createOnly === true ? { requireNewName: true } : { createOnly: true }); + } catch (error) { + if (error?.code === "PROVIDER_CONNECTION_ALREADY_EXISTS" || error?.code === "PROVIDER_CONNECTION_NAME_CONFLICT") { + return NextResponse.json({ error: error.message, code: error.code }, { status: 409 }); + } + throw error; + } // Hide sensitive fields const result = sanitizeProviderConnection(newConnection); diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index fc771346f..12991b6ac 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -171,7 +171,7 @@ function reorderInTx(db, providerId) { }); } -export async function createProviderConnection(data, { shouldCommit, requireNewName } = {}) { +export async function createProviderConnection(data, { shouldCommit, requireNewName, createOnly = false } = {}) { const db = await getAdapter(); // OAuth flows can be cancelled while an upstream exchange is in flight. // Check after the async adapter lookup and immediately before the synchronous @@ -243,6 +243,14 @@ export async function createProviderConnection(data, { shouldCommit, requireNewN } if (existing) { + // #6499 — create-only (dashboard "add API key"): a duplicate (provider, + // apikey, name) must error, never silently upsert/overwrite. The explicit + // update path is updateProviderConnection (PUT /api/providers/[id]). + if (createOnly && data.authType === "apikey") { + const error = new Error(`A connection named "${data.name}" already exists for this provider`); + error.code = "PROVIDER_CONNECTION_ALREADY_EXISTS"; + throw error; + } const merged = { ...mergeProviderConnection(existing, data), updatedAt: now }; upsert(db, merged); result = merged; diff --git a/tests/unit/api-key-connection-collision-6499.test.js b/tests/unit/api-key-connection-collision-6499.test.js new file mode 100644 index 000000000..3567cd096 --- /dev/null +++ b/tests/unit/api-key-connection-collision-6499.test.js @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it, expect, afterEach, vi } from "vitest"; + +// #6499 — adding a second API-key connection for the same provider used to +// silently OVERWRITE the first: createProviderConnection upserts by +// (provider, authType=apikey, name), so a duplicate POST merged the new key +// onto the existing row with no warning. The POST /api/providers route now +// runs the create in create-only mode (atomic inside the repo transaction) and +// returns 409, leaving the original connection untouched; the explicit update +// path (PUT /api/providers/[id]) still applies changes. + +const originalDataDir = process.env.DATA_DIR; + +async function setupTestContext() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "durindoor-6499-")); + process.env.DATA_DIR = tempDir; + vi.resetModules(); + vi.doMock("next/server", () => ({ + NextResponse: { + json(body, init = {}) { + return new Response(JSON.stringify(body), { + status: init.status || 200, + headers: { "Content-Type": "application/json" }, + }); + }, + }, + })); + + const { POST } = await import("@/app/api/providers/route.js"); + const { PUT } = await import("@/app/api/providers/[id]/route.js"); + const { getProviderConnections, getProviderConnectionById } = await import("@/models/index.js"); + + return { + POST, + PUT, + getProviderConnections, + getProviderConnectionById, + cleanup() { + fs.rmSync(tempDir, { recursive: true, force: true }); + }, + }; +} + +function postRequest({ name, apiKey, provider = "groq" }) { + return new Request("https://durindoor.local/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey, name }), + }); +} + +function putRequest(id, body) { + return new Request(`https://durindoor.local/api/providers/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/providers - API-key connection name collision (#6499)", () => { + let cleanup = () => {}; + + afterEach(() => { + vi.doUnmock("next/server"); + vi.resetModules(); + cleanup(); + cleanup = () => {}; + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + }); + + // Single sequential flow covering the acceptance contract: POST new → 201, + // duplicate → 409 (original untouched), PUT → 200. Cold run: Next route + + // models + DB migrations take a while under Node 20. + it("creates new (201), rejects duplicate (409, original key unchanged), keeps PUT as explicit update (200)", async () => { + const ctx = await setupTestContext(); + cleanup = ctx.cleanup; + + // NEW — first use of the name creates the connection. + const first = await ctx.POST(postRequest({ name: "main", apiKey: "gsk-first" })); + expect(first.status).toBe(201); + const { connection: created } = await first.json(); + expect(created).toMatchObject({ provider: "groq", authType: "apikey", name: "main" }); + expect(await ctx.getProviderConnections({ provider: "groq" })).toHaveLength(1); + + // DUPLICATE — same (provider, apikey, name) must NOT silently overwrite. + const duplicate = await ctx.POST(postRequest({ name: "main", apiKey: "gsk-second" })); + expect(duplicate.status).toBe(409); + const dupBody = await duplicate.json(); + expect(dupBody.code).toBe("PROVIDER_CONNECTION_ALREADY_EXISTS"); + + // Overwrite prevention: still exactly one row, still the FIRST key. + let stored = await ctx.getProviderConnections({ provider: "groq" }); + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe(created.id); + expect(stored[0].apiKey).toBe("gsk-first"); + + // Whitespace-normalized duplicate: the route trims names, so " main " + // collides with "main" rather than creating a lookalike row. + const padded = await ctx.POST(postRequest({ name: " main ", apiKey: "gsk-padded" })); + expect(padded.status).toBe(409); + expect(await ctx.getProviderConnections({ provider: "groq" })).toHaveLength(1); + + // Same name on a DIFFERENT provider is a different collision scope → 201. + const other = await ctx.POST(postRequest({ name: "main", apiKey: "gsk-other", provider: "openrouter" })); + expect(other.status).toBe(201); + + // Non-string name is rejected with 400, not a 500 from `.trim`. + const badType = await ctx.POST(new Request("https://durindoor.local/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "groq", apiKey: "gsk-x", name: 123 }), + })); + expect(badType.status).toBe(400); + + // UPDATE — PUT by id remains the explicit update path. + const update = await ctx.PUT(putRequest(created.id, { apiKey: "gsk-rotated", defaultModel: "llama-3.3-70b" }), { + params: Promise.resolve({ id: created.id }), + }); + expect(update.status).toBe(200); + const updateBody = await update.json(); + expect(updateBody.connection.id).toBe(created.id); + expect(updateBody.connection.defaultModel).toBe("llama-3.3-70b"); + + stored = [await ctx.getProviderConnectionById(created.id)]; + expect(stored[0].apiKey).toBe("gsk-rotated"); + expect(stored[0].name).toBe("main"); + }, 30000); +}); diff --git a/tests/unit/api-key-connection-name.test.js b/tests/unit/api-key-connection-name.test.js index 376531aa2..b5e463d6b 100644 --- a/tests/unit/api-key-connection-name.test.js +++ b/tests/unit/api-key-connection-name.test.js @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; -import { apiKeyConnectionNames, allocateBulkConnectionName, bulkUsedNameSet, defaultApiKeyConnectionName, shouldResetAddApiKeyModal } from "../../src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js"; +import { + allocateBulkConnectionName, + apiKeyConnectionNames, + bulkUsedNameSet, + buildAddApiKeyModalReset, + defaultApiKeyConnectionName, +} from "../../src/app/(dashboard)/dashboard/providers/[id]/apiKeyConnectionName.js"; describe("API-key connection default names", () => { it("uses main for the first connection", () => { @@ -26,15 +32,26 @@ describe("API-key connection default names", () => { expect(defaultApiKeyConnectionName(undefined)).toBe("main"); }); - it("uses global API-key names so first hcnsec add avoids another provider's main", () => { - const existingNames = apiKeyConnectionNames([ + it("scopes names to one provider's apikey connections (the DB collision key is provider-local)", () => { + // #6499 — createProviderConnection collides on (provider, authType=apikey, + // name). Callers filter connections to the CURRENT provider before deriving + // a default; another provider's "main" must not force "main-2" here, and + // non-apikey rows on this provider must not either. + const allConnections = [ { provider: "openai", authType: "apikey", name: "main" }, - { provider: "anthropic", authType: "oauth", name: "main-2" }, - { provider: "hcnsec", authType: "cookie", name: "main-3" }, - ]); + { provider: "openai", authType: "oauth", name: "main-2" }, + { provider: "openai", authType: "cookie", name: "main-3" }, + { provider: "anthropic", authType: "apikey", name: "main" }, + ]; + const providerFilter = (provider) => allConnections.filter((c) => c.provider === provider); - expect(existingNames).toEqual(["main"]); - expect(defaultApiKeyConnectionName(existingNames)).toBe("main-2"); + // Current provider "openai": one apikey "main" → next default is "main-2". + expect(apiKeyConnectionNames(providerFilter("openai"))).toEqual(["main"]); + expect(defaultApiKeyConnectionName(apiKeyConnectionNames(providerFilter("openai")))).toBe("main-2"); + + // A provider with no apikey connections gets "main" again even though + // other providers already use it — the collision scope is provider-local. + expect(defaultApiKeyConnectionName(apiKeyConnectionNames(providerFilter("groq")))).toBe("main"); }); }); @@ -140,10 +157,12 @@ describe("bulk-add repo guard: requireNewName rejects name collision without ove }); }); -// Route-level: POST /api/providers must forward createOnly -> requireNewName -// and map the repo's PROVIDER_CONNECTION_NAME_CONFLICT to a 409. @/models is -// mocked (isolated via resetModules + doUnmock) so no broad provider setup is -// needed; the mock records the create options for the forwarding assertion. +// Route-level: POST /api/providers forwards bulk createOnly -> requireNewName +// and single dashboard adds -> createOnly, mapping both repo conflict codes +// (PROVIDER_CONNECTION_NAME_CONFLICT / PROVIDER_CONNECTION_ALREADY_EXISTS) to +// 409. @/models is mocked (isolated via resetModules + doUnmock) so no broad +// provider setup is needed; the mock records the create options for the +// forwarding assertion. describe("POST /api/providers createOnly plumbing", () => { function makeRequest(body) { return { json: async () => body }; @@ -188,7 +207,7 @@ describe("POST /api/providers createOnly plumbing", () => { expect(captured.opts).toEqual({ requireNewName: true }); }); - it("forwards requireNewName:false when createOnly is absent", async () => { + it("forwards createOnly:true (dashboard single add) to the repo", async () => { const captured = {}; const route = await importRouteWithModels((data) => ({ ...data, id: "c1" }), captured); @@ -196,15 +215,50 @@ describe("POST /api/providers createOnly plumbing", () => { provider: "openai", apiKey: "sk-NEW", name: "Key 2", })); expect(res.status).toBe(201); - expect(captured.opts).toEqual({ requireNewName: false }); + expect(captured.opts).toEqual({ createOnly: true }); + }); + + it("maps a dashboard duplicate (PROVIDER_CONNECTION_ALREADY_EXISTS) to 409", async () => { + const route = await importRouteWithModels(() => { + const err = new Error('A connection named "Key 1" already exists for this provider'); + err.code = "PROVIDER_CONNECTION_ALREADY_EXISTS"; + throw err; + }, {}); + + const res = await route.POST(makeRequest({ + provider: "openai", apiKey: "sk-DUP", name: "Key 1", + })); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.code).toBe("PROVIDER_CONNECTION_ALREADY_EXISTS"); }); }); describe("Add API-key modal reset guard", () => { - it("resets only when the modal transitions from closed to open", () => { - expect(shouldResetAddApiKeyModal(false, true)).toBe(true); - expect(shouldResetAddApiKeyModal(true, true)).toBe(false); - expect(shouldResetAddApiKeyModal(true, false)).toBe(false); - expect(shouldResetAddApiKeyModal(false, false)).toBe(false); + it("returns fresh default state only on closed→open transition", () => { + expect(buildAddApiKeyModalReset(false, true, ["main"], "us-east-1")).toMatchObject({ + formData: { + name: "main-2", + apiKey: "", + defaultModel: "", + priority: 1, + proxyPoolId: "__none__", + ollamaHostUrl: "", + }, + azureData: { + azureEndpoint: "", + apiVersion: "2024-10-01-preview", + deployment: "", + organization: "", + }, + accountIdData: { accountId: "" }, + region: "us-east-1", + }); + }); + + it("does not reset when not transitioning from closed to open", () => { + expect(buildAddApiKeyModalReset(true, true, ["main"], "us-east-1")).toBeNull(); + expect(buildAddApiKeyModalReset(true, false, ["main"], "us-east-1")).toBeNull(); + expect(buildAddApiKeyModalReset(false, false, ["main"], "us-east-1")).toBeNull(); }); });