From 4495a4390867024ea5861861e7dee5cbfb978bf1 Mon Sep 17 00:00:00 2001 From: terence71-glitch Date: Sun, 24 May 2026 19:46:22 +0200 Subject: [PATCH] fix(proxy): save dashboard custom proxies in registry (#2661) Integrated into release/v3.8.3 --- src/lib/db/proxies.ts | 15 +- src/shared/components/ProxyConfigModal.tsx | 254 ++++++++++-- src/shared/validation/schemas.ts | 1 + tests/integration/proxy-registry-flow.test.ts | 2 + tests/unit/db-proxies-crud.test.ts | 4 + .../components/ProxyConfigModal.test.tsx | 387 ++++++++++++++++++ 6 files changed, 623 insertions(+), 40 deletions(-) create mode 100644 tests/unit/shared/components/ProxyConfigModal.test.tsx diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index dc23194906..3565ff77ce 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -16,6 +16,7 @@ interface ProxyRegistryRecord { region: string | null; notes: string | null; status: string; + source: string; createdAt: string; updatedAt: string; } @@ -39,6 +40,7 @@ interface ProxyPayload { region?: string | null; notes?: string | null; status?: string; + source?: string; } interface LegacyProxyConfig { @@ -75,6 +77,7 @@ function mapProxyRow(row: unknown): ProxyRegistryRecord { region: typeof r.region === "string" ? r.region : null, notes: typeof r.notes === "string" ? r.notes : null, status: typeof r.status === "string" ? r.status : "active", + source: typeof r.source === "string" ? r.source : "manual", createdAt: typeof r.created_at === "string" ? r.created_at : "", updatedAt: typeof r.updated_at === "string" ? r.updated_at : "", }; @@ -169,7 +172,7 @@ export async function listProxies(options?: { includeSecrets?: boolean }) { const db = getDbInstance(); const rows = db .prepare( - "SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC" + "SELECT id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC" ) .all(); @@ -182,7 +185,7 @@ export async function getProxyById(id: string, options?: { includeSecrets?: bool const db = getDbInstance(); const row = db .prepare( - "SELECT id, name, type, host, port, username, password, region, notes, status, created_at, updated_at FROM proxy_registry WHERE id = ?" + "SELECT id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at FROM proxy_registry WHERE id = ?" ) .get(id); if (!row) return null; @@ -197,8 +200,8 @@ export async function createProxy(payload: ProxyPayload) { db.prepare( `INSERT INTO proxy_registry - (id, name, type, host, port, username, password, region, notes, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, payload.name, @@ -210,6 +213,7 @@ export async function createProxy(payload: ProxyPayload) { payload.region || null, payload.notes || null, payload.status || "active", + payload.source || "manual", now, now ); @@ -272,7 +276,7 @@ export async function updateProxy(id: string, payload: Partial) { db.prepare( `UPDATE proxy_registry - SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, updated_at = ? + SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, source = ?, updated_at = ? WHERE id = ?` ).run( merged.name, @@ -284,6 +288,7 @@ export async function updateProxy(id: string, payload: Partial) { merged.region || null, merged.notes || null, merged.status || "active", + merged.source || "manual", merged.updatedAt, id ); diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index de62005111..65ec150e52 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -17,6 +17,23 @@ const PROXY_TYPES = SOCKS5_UI_ENABLED type ProxyConfigLevel = "global" | "provider" | "combo" | "key"; +type ProxyRegistryItem = { + id: string; + name?: string; + type?: string; + host?: string; + port?: number | string; + username?: string | null; + password?: string | null; + source?: string | null; +}; + +type ProxyAssignmentItem = { + proxyId?: string | null; + scope?: string | null; + scopeId?: string | null; +}; + type ProxyConfigModalProps = { isOpen: boolean; onClose: () => void; @@ -26,6 +43,86 @@ type ProxyConfigModalProps = { onSaved?: () => void; }; +const DASHBOARD_CUSTOM_PROXY_SOURCE = "dashboard-custom"; +const DASHBOARD_CUSTOM_PROXY_NOTES = "Created from the dashboard Custom proxy tab."; + +function getAssignmentScope(level: ProxyConfigLevel) { + return level === "key" ? "account" : level; +} + +function getAssignmentScopeId(level: ProxyConfigLevel, levelId?: string) { + return level === "global" ? null : levelId || null; +} + +function normalizeScopeId(scopeId?: string | null) { + return !scopeId || scopeId === "__global__" ? null : scopeId; +} + +function isSameScopeAssignment( + assignment: ProxyAssignmentItem, + scope: string, + scopeId: string | null +) { + return ( + assignment.scope === scope && normalizeScopeId(assignment.scopeId) === normalizeScopeId(scopeId) + ); +} + +function getCustomProxyName(level: ProxyConfigLevel, levelId?: string, levelLabel?: string) { + const label = levelLabel || levelId || ""; + const suffix = label ? ` (${label})` : ""; + if (level === "global") return "Custom Global Proxy"; + if (level === "key") return `Custom Account Proxy${suffix}`; + if (level === "combo") return `Custom Combo Proxy${suffix}`; + return `Custom Provider Proxy${suffix}`; +} + +async function readJson(response: Response) { + return response.json().catch(() => ({})); +} + +async function fetchAssignmentForScope(scope: string, scopeId: string | null) { + const params = new URLSearchParams({ scope }); + if (scopeId) params.set("scopeId", scopeId); + + const res = await fetch(`/api/settings/proxies/assignments?${params}`); + if (!res.ok) return null; + + const payload = await readJson(res); + const items: ProxyAssignmentItem[] = Array.isArray(payload?.items) ? payload.items : []; + return items.find((item) => isSameScopeAssignment(item, scope, scopeId)) || items[0] || null; +} + +async function fetchRegistryProxy(proxyId: string, cachedProxies: ProxyRegistryItem[]) { + const cached = cachedProxies.find((proxy) => proxy.id === proxyId); + if (cached) return cached; + + const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(proxyId)}`); + if (!res.ok) return null; + return (await readJson(res)) as ProxyRegistryItem; +} + +async function fetchProxyUsage(proxyId: string) { + const res = await fetch(`/api/settings/proxies?id=${encodeURIComponent(proxyId)}&whereUsed=1`); + if (!res.ok) return []; + + const payload = await readJson(res); + const assignments: ProxyAssignmentItem[] = Array.isArray(payload?.assignments) + ? payload.assignments + : []; + const seen = new Set(); + return assignments.filter((assignment) => { + const key = `${assignment.scope || ""}:${normalizeScopeId(assignment.scopeId) || ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function isRedactedSecret(value?: string | null) { + return value === "***"; +} + export default function ProxyConfigModal({ isOpen, onClose, @@ -36,7 +133,7 @@ export default function ProxyConfigModal({ }: ProxyConfigModalProps) { const t = useTranslations("proxyConfigModal"); const [mode, setMode] = useState("saved"); - const [savedProxies, setSavedProxies] = useState([]); + const [savedProxies, setSavedProxies] = useState([]); const [selectedProxyId, setSelectedProxyId] = useState(""); const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http"); const [host, setHost] = useState(""); @@ -68,29 +165,53 @@ export default function ProxyConfigModal({ const loadProxy = async () => { try { let hasSavedAssignment = false; + let registryItems: ProxyRegistryItem[] = []; const registryRes = await fetch("/api/settings/proxies"); if (registryRes.ok) { const registryPayload = await registryRes.json(); - setSavedProxies(Array.isArray(registryPayload?.items) ? registryPayload.items : []); + registryItems = Array.isArray(registryPayload?.items) ? registryPayload.items : []; + setSavedProxies(registryItems); } else { setSavedProxies([]); } - const scope = level === "key" ? "account" : level; + const scope = getAssignmentScope(level); const assignmentParams = new URLSearchParams({ scope }); - if (level !== "global" && levelId) { - assignmentParams.set("scopeId", levelId); + const scopeId = getAssignmentScopeId(level, levelId); + if (scopeId) { + assignmentParams.set("scopeId", scopeId); } const assignmentRes = await fetch(`/api/settings/proxies/assignments?${assignmentParams}`); if (assignmentRes.ok) { const assignmentPayload = await assignmentRes.json(); const items = Array.isArray(assignmentPayload?.items) ? assignmentPayload.items : []; - const target = items[0]; + const target = + items.find((item) => isSameScopeAssignment(item, scope, scopeId)) || items[0]; if (target?.proxyId) { - setMode("saved"); setSelectedProxyId(target.proxyId); setHasOwnProxy(true); hasSavedAssignment = true; + const assignedProxy = registryItems.find((item) => item.id === target.proxyId); + if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) { + const normalizedType = String(assignedProxy.type || "http").toLowerCase(); + const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType); + setMode("custom"); + setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http"); + setHost(assignedProxy.host || ""); + setPort(String(assignedProxy.port || "")); + setUsername( + isRedactedSecret(assignedProxy.username) ? "" : assignedProxy.username || "" + ); + setPassword( + isRedactedSecret(assignedProxy.password) ? "" : assignedProxy.password || "" + ); + setShowAuth(!!(assignedProxy.username || assignedProxy.password)); + if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) { + setFormError(t("errorSocks5Hidden")); + } + } else { + setMode("saved"); + } } else { setMode("custom"); setSelectedProxyId(""); @@ -119,8 +240,8 @@ export default function ProxyConfigModal({ } if (!hasSavedAssignment) setMode("custom"); } else { - resetFields(); if (!hasSavedAssignment) { + resetFields(); setHasOwnProxy(false); } } @@ -176,7 +297,8 @@ export default function ProxyConfigModal({ setFormError(null); setSaving(true); try { - const scope = level === "key" ? "account" : level; + const scope = getAssignmentScope(level); + const scopeId = getAssignmentScopeId(level, levelId); let res; if (mode === "saved") { res = await fetch("/api/settings/proxies/assignments", { @@ -184,7 +306,7 @@ export default function ProxyConfigModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, - scopeId: level === "global" ? null : levelId, + scopeId, proxyId: selectedProxyId, }), }); @@ -195,42 +317,103 @@ export default function ProxyConfigModal({ await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" }); } } else { - const clearAssignmentRes = await fetch("/api/settings/proxies/assignments", { + const trimmedHost = String(host || "").trim(); + const normalizedPort = Number(String(port || "").trim() || getDefaultPort(proxyType)); + const normalizedUsername = String(username || "").trim(); + const normalizedPassword = String(password || "").trim(); + const proxy = { + name: getCustomProxyName(level, levelId, levelLabel), + type: proxyType, + host: trimmedHost, + port: normalizedPort, + status: "active", + source: DASHBOARD_CUSTOM_PROXY_SOURCE, + notes: DASHBOARD_CUSTOM_PROXY_NOTES, + }; + const createPayload: Record = { ...proxy }; + + if (username !== "***" && normalizedUsername) { + createPayload.username = normalizedUsername; + } + if (password !== "***" && normalizedPassword) { + createPayload.password = normalizedPassword; + } + + const existingAssignment = await fetchAssignmentForScope(scope, scopeId); + let safeExistingProxyId: string | null = null; + if (existingAssignment?.proxyId) { + const existingProxy = await fetchRegistryProxy(existingAssignment.proxyId, savedProxies); + if (existingProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) { + const usage = await fetchProxyUsage(existingAssignment.proxyId); + if ( + usage.length === 1 && + usage.some((assignment) => isSameScopeAssignment(assignment, scope, scopeId)) + ) { + safeExistingProxyId = existingAssignment.proxyId; + } + } + } + + if (safeExistingProxyId) { + const updatePayload: Record = { + id: safeExistingProxyId, + ...proxy, + }; + if (username !== "***") { + updatePayload.username = normalizedUsername; + } + if (password !== "***") { + updatePayload.password = normalizedPassword; + } + res = await fetch("/api/settings/proxies", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updatePayload), + }); + } else { + res = await fetch("/api/settings/proxies", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(createPayload), + }); + } + + const registryPayload = await readJson(res); + if (!res.ok) { + setFormError(registryPayload?.error?.message || t("errorSaveProxy")); + return; + } + + const registryProxyId = registryPayload?.id || safeExistingProxyId; + if (!registryProxyId) { + setFormError(t("errorSaveProxy")); + return; + } + + res = await fetch("/api/settings/proxies/assignments", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, - scopeId: level === "global" ? null : levelId, - proxyId: null, + scopeId, + proxyId: registryProxyId, }), }); - const clearAssignmentPayload = await clearAssignmentRes.json().catch(() => ({})); - if (!clearAssignmentRes.ok) { - setFormError(clearAssignmentPayload?.error?.message || t("errorClearSavedProxy")); - return; - } - const proxy = { - type: proxyType, - host: String(host || "").trim(), - port: String(port || "").trim() || getDefaultPort(proxyType), - username: String(username || "").trim(), - password: String(password || "").trim(), - }; - res = await fetch("/api/settings/proxy", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ level, id: levelId, proxy }), - }); + if (res.ok) { + const clearParams = new URLSearchParams({ level }); + if (levelId) clearParams.set("id", levelId); + await fetch(`/api/settings/proxy?${clearParams.toString()}`, { method: "DELETE" }); + } } - const payload = await res.json().catch(() => ({})); + const payload = await readJson(res); if (!res.ok) { setFormError(payload?.error?.message || t("errorSaveProxy")); return; } setHasOwnProxy(true); if (mode === "custom") { - setSelectedProxyId(""); + setSelectedProxyId(payload?.assignment?.proxyId || selectedProxyId || ""); } onSaved?.(); onClose(); @@ -246,13 +429,14 @@ export default function ProxyConfigModal({ setFormError(null); setSaving(true); try { - const scope = level === "key" ? "account" : level; + const scope = getAssignmentScope(level); + const scopeId = getAssignmentScopeId(level, levelId); await fetch("/api/settings/proxies/assignments", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope, - scopeId: level === "global" ? null : levelId, + scopeId, proxyId: null, }), }); @@ -298,7 +482,7 @@ export default function ProxyConfigModal({ setTesting(false); return; } - const found = (savedProxies as any[]).find((p: any) => p.id === selectedProxyId); + const found = savedProxies.find((p) => p.id === selectedProxyId); if (!found) { setFormError(t("errorProxyNotFound")); setTesting(false); diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index ceec89a032..0113e89619 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1334,6 +1334,7 @@ export const createProxyRegistrySchema = z region: z.string().trim().max(64).nullable().optional(), notes: z.string().trim().max(1000).nullable().optional(), status: z.enum(["active", "inactive"]).optional().default("active"), + source: z.enum(["manual", "oneproxy", "dashboard-custom"]).optional(), }) .strict(); diff --git a/tests/integration/proxy-registry-flow.test.ts b/tests/integration/proxy-registry-flow.test.ts index 48510dea5d..ec414bb2b9 100644 --- a/tests/integration/proxy-registry-flow.test.ts +++ b/tests/integration/proxy-registry-flow.test.ts @@ -51,12 +51,14 @@ test("integration: proxy registry full flow works and enforces safe delete", asy type: "http", host: "flow.local", port: 8080, + source: "dashboard-custom", }), }) ); assert.equal(createRes.status, 201); const createdProxy = (await createRes.json()) as any; assert.ok(createdProxy.id); + assert.equal(createdProxy.source, "dashboard-custom"); const assignRes = await proxyAssignmentsRoute.PUT( new Request("http://localhost/api/settings/proxies/assignments", { diff --git a/tests/unit/db-proxies-crud.test.ts b/tests/unit/db-proxies-crud.test.ts index 7275096871..2209f199a7 100644 --- a/tests/unit/db-proxies-crud.test.ts +++ b/tests/unit/db-proxies-crud.test.ts @@ -50,10 +50,12 @@ test("proxy CRUD redacts secrets by default and preserves stored credentials on username: "user-a", password: "pass-a", region: "sa-east-1", + source: "dashboard-custom", }); assert.equal(created.username, "***"); assert.equal(created.password, "***"); + assert.equal(created.source, "dashboard-custom"); const withSecrets = await proxiesDb.getProxyById(created.id, { includeSecrets: true }); const updated = await proxiesDb.updateProxy(created.id, { @@ -71,9 +73,11 @@ test("proxy CRUD redacts secrets by default and preserves stored credentials on assert.equal(updated.notes, "updated"); assert.equal(updatedWithSecrets.username, "user-a"); assert.equal(updatedWithSecrets.password, "pass-a"); + assert.equal(updatedWithSecrets.source, "dashboard-custom"); assert.equal(listed.length, 1); assert.equal(listed[0].username, "***"); assert.equal(listed[0].password, "***"); + assert.equal(listed[0].source, "dashboard-custom"); }); test("proxy assignments resolve by account, provider and global scope", async () => { diff --git a/tests/unit/shared/components/ProxyConfigModal.test.tsx b/tests/unit/shared/components/ProxyConfigModal.test.tsx new file mode 100644 index 0000000000..3889ef45f1 --- /dev/null +++ b/tests/unit/shared/components/ProxyConfigModal.test.tsx @@ -0,0 +1,387 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => { + if (!values) return key; + return Object.entries(values).reduce( + (message, [name, value]) => message.replace(`{${name}}`, String(value)), + key + ); + }, +})); + +type FetchCall = { + url: string; + method: string; + body: any; +}; + +type MockResponse = { + status?: number; + body?: unknown; +}; + +const cleanupCallbacks: Array<() => void> = []; +let fetchCalls: FetchCall[] = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response; +} + +function parseBody(init?: RequestInit) { + if (typeof init?.body !== "string") return null; + try { + return JSON.parse(init.body); + } catch { + return init.body; + } +} + +function installFetchMock(handler: (url: string, init?: RequestInit) => MockResponse) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const method = String(init?.method || "GET").toUpperCase(); + const body = parseBody(init); + fetchCalls.push({ url, method, body }); + const response = handler(url, init); + return jsonResponse(response.body ?? {}, response.status ?? 200); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +async function renderProxyConfigModal(props?: Partial>) { + const { default: ProxyConfigModal } = await import("@/shared/components/ProxyConfigModal"); + const container = makeContainer(); + const root: Root = createRoot(container); + cleanupCallbacks.push(() => root.unmount()); + + await act(async () => { + root.render( + + ); + }); + await waitForModalToLoad(container); + return { container, root }; +} + +async function waitForModalToLoad(container: HTMLElement) { + for (let i = 0; i < 20; i++) { + await flushEffects(); + if (!container.textContent?.includes("loading")) return; + } +} + +function getInput(container: HTMLElement, placeholder: string) { + const input = Array.from(container.querySelectorAll("input")).find( + (item) => item.getAttribute("placeholder") === placeholder + ); + expect(input).toBeTruthy(); + return input as HTMLInputElement; +} + +async function clickButton(container: HTMLElement, text: string) { + const expected = text.toLowerCase(); + const buttons = Array.from(container.querySelectorAll("button")); + const getText = (item: HTMLButtonElement) => item.textContent?.trim().toLowerCase() || ""; + const button = + buttons.find((item) => getText(item) === expected) || + buttons.find( + (item) => getText(item).endsWith(expected) && !getText(item).includes("savedproxy") + ) || + buttons.find((item) => getText(item).includes(expected)); + expect(button).toBeTruthy(); + await act(async () => { + button?.click(); + }); + await flushEffects(); +} + +async function waitForCall(predicate: (call: FetchCall) => boolean) { + for (let i = 0; i < 20; i++) { + await flushEffects(); + if (fetchCalls.some(predicate)) return; + } +} + +async function setInputValue(input: HTMLInputElement, value: string) { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flushEffects(); +} + +function defaultProxyConfigResponses(url: string): MockResponse | null { + if (url === "/api/settings/proxies") { + return { body: { items: [], total: 0 } }; + } + if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) { + return { body: { items: [], total: 0 } }; + } + if (url.startsWith("/api/settings/proxy?level=provider")) { + return { body: { level: "provider", id: "claude", proxy: null } }; + } + if (url === "/api/settings/proxy") { + return { body: {} }; + } + return null; +} + +describe("ProxyConfigModal custom registry saves", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + fetchCalls = []; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("creates a dashboard-custom registry proxy, assigns it, and clears matching legacy config", async () => { + installFetchMock((url, init) => { + const method = String(init?.method || "GET").toUpperCase(); + const body = parseBody(init); + + if (method === "POST" && url === "/api/settings/proxies") { + return { status: 201, body: { id: "custom-proxy-1", ...body } }; + } + if (method === "PUT" && url === "/api/settings/proxies/assignments") { + return { body: { success: true, assignment: { proxyId: body.proxyId } } }; + } + if (method === "DELETE" && url === "/api/settings/proxy?level=provider&id=claude") { + return { body: { success: true } }; + } + + return defaultProxyConfigResponses(url) || { status: 404, body: {} }; + }); + + const { container } = await renderProxyConfigModal(); + + await setInputValue(getInput(container, "hostPlaceholder"), "custom.local"); + await setInputValue(getInput(container, "8080"), "3128"); + await clickButton(container, "save"); + await waitForCall( + (call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments" + ); + + const createCall = fetchCalls.find( + (call) => call.method === "POST" && call.url === "/api/settings/proxies" + ); + expect(createCall?.body).toMatchObject({ + name: "Custom Provider Proxy (Claude)", + type: "http", + host: "custom.local", + port: 3128, + status: "active", + source: "dashboard-custom", + }); + + expect( + fetchCalls.some((call) => call.method === "PUT" && call.url === "/api/settings/proxy") + ).toBe(false); + expect( + fetchCalls.some( + (call) => + call.method === "PUT" && + call.url === "/api/settings/proxies/assignments" && + call.body.proxyId === "custom-proxy-1" && + call.body.scope === "provider" && + call.body.scopeId === "claude" + ) + ).toBe(true); + expect( + fetchCalls.some( + (call) => + call.method === "DELETE" && call.url === "/api/settings/proxy?level=provider&id=claude" + ) + ).toBe(true); + }); + + it("updates an existing scope-owned dashboard-custom proxy instead of creating a duplicate", async () => { + installFetchMock((url, init) => { + const method = String(init?.method || "GET").toUpperCase(); + const body = parseBody(init); + + if (method === "GET" && url === "/api/settings/proxies") { + return { + body: { + items: [ + { + id: "custom-proxy-1", + name: "Custom Provider Proxy (Claude)", + type: "http", + host: "old.local", + port: 8080, + source: "dashboard-custom", + }, + ], + total: 1, + }, + }; + } + if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) { + return { + body: { + items: [ + { proxyId: "other-proxy", scope: "provider", scopeId: "other-provider" }, + { proxyId: "custom-proxy-1", scope: "provider", scopeId: "claude" }, + ], + total: 1, + }, + }; + } + if (url === "/api/settings/proxies?id=custom-proxy-1&whereUsed=1") { + return { + body: { + count: 1, + assignments: [{ proxyId: "custom-proxy-1", scope: "provider", scopeId: "claude" }], + }, + }; + } + if (method === "PATCH" && url === "/api/settings/proxies") { + return { body: { id: "custom-proxy-1", ...body } }; + } + if (method === "PUT" && url === "/api/settings/proxies/assignments") { + return { body: { success: true, assignment: { proxyId: body.proxyId } } }; + } + if (method === "DELETE" && url === "/api/settings/proxy?level=provider&id=claude") { + return { body: { success: true } }; + } + + return defaultProxyConfigResponses(url) || { status: 404, body: {} }; + }); + + const { container } = await renderProxyConfigModal(); + + await setInputValue(getInput(container, "hostPlaceholder"), "updated.local"); + await clickButton(container, "authOptional"); + await setInputValue(getInput(container, "usernamePlaceholder"), "***"); + await setInputValue(getInput(container, "passwordPlaceholder"), "***"); + await clickButton(container, "save"); + await waitForCall( + (call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments" + ); + + expect( + fetchCalls.some((call) => call.method === "POST" && call.url === "/api/settings/proxies") + ).toBe(false); + + const updateCall = fetchCalls.find( + (call) => call.method === "PATCH" && call.url === "/api/settings/proxies" + ); + expect(updateCall?.body).toMatchObject({ + id: "custom-proxy-1", + host: "updated.local", + source: "dashboard-custom", + }); + expect(updateCall?.body).not.toHaveProperty("username"); + expect(updateCall?.body).not.toHaveProperty("password"); + }); + + it("creates a new dashboard-custom proxy when current assignment is a reusable manual proxy", async () => { + installFetchMock((url, init) => { + const method = String(init?.method || "GET").toUpperCase(); + const body = parseBody(init); + + if (method === "GET" && url === "/api/settings/proxies") { + return { + body: { + items: [ + { + id: "manual-proxy-1", + name: "Shared Manual Proxy", + type: "http", + host: "shared.local", + port: 8080, + source: "manual", + }, + ], + total: 1, + }, + }; + } + if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) { + return { + body: { + items: [{ proxyId: "manual-proxy-1", scope: "provider", scopeId: "claude" }], + total: 1, + }, + }; + } + if (method === "POST" && url === "/api/settings/proxies") { + return { status: 201, body: { id: "custom-proxy-2", ...body } }; + } + if (method === "PUT" && url === "/api/settings/proxies/assignments") { + return { body: { success: true, assignment: { proxyId: body.proxyId } } }; + } + if (method === "DELETE" && url === "/api/settings/proxy?level=provider&id=claude") { + return { body: { success: true } }; + } + + return defaultProxyConfigResponses(url) || { status: 404, body: {} }; + }); + + const { container } = await renderProxyConfigModal(); + + await clickButton(container, "custom"); + await setInputValue(getInput(container, "hostPlaceholder"), "custom.local"); + await clickButton(container, "save"); + await waitForCall( + (call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments" + ); + + expect( + fetchCalls.some((call) => call.method === "PATCH" && call.url === "/api/settings/proxies") + ).toBe(false); + expect( + fetchCalls.some( + (call) => + call.method === "PUT" && + call.url === "/api/settings/proxies/assignments" && + call.body.proxyId === "custom-proxy-2" + ) + ).toBe(true); + }); +});