From 5193a595bf27748741bd2783c39229fb2f608c44 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:31:39 -0300 Subject: [PATCH] fix(dashboard): proxy modal stops pre-filling new scopes with an unrelated proxy (#4312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy assignments list returned by /api/settings/proxies/assignments is global, so its first entry belongs to some other scope. ProxyConfigModal picked `items.find(matchingScope) || items[0]`, so opening the proxy config for a freshly created provider/key (which has no assignment of its own) fell back to items[0] and pre-filled host/port/user/password from an unrelated proxy plus set hasOwnProxy=true — users reported a new provider already carried a proxy they never configured. Extracted the scope helpers into proxyAssignment.ts and added selectScopeAssignment which returns null (never items[0]) when the current scope has no assignment. The modal then shows the empty/custom state for new scopes. Both call sites now use it. TDD: src/shared/components/proxyAssignment.test.tsx (no-match -> null red->green for provider/key/global scopes; matching-scope + empty-list regression guards). Existing ProxyConfigModal component test stays green. --- src/shared/components/ProxyConfigModal.tsx | 31 ++++---------- .../components/proxyAssignment.test.tsx | 41 +++++++++++++++++++ src/shared/components/proxyAssignment.ts | 40 ++++++++++++++++++ 3 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 src/shared/components/proxyAssignment.test.tsx create mode 100644 src/shared/components/proxyAssignment.ts diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index ce5f0f3e0a..9e7f561dc8 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -4,6 +4,12 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; +import { + type ProxyAssignmentItem, + normalizeScopeId, + isSameScopeAssignment, + selectScopeAssignment, +} from "./proxyAssignment"; const ALL_PROXY_TYPES = [ { value: "http", label: "HTTP" }, @@ -33,12 +39,6 @@ type ProxyRegistryItem = { source?: string | null; }; -type ProxyAssignmentItem = { - proxyId?: string | null; - scope?: string | null; - scopeId?: string | null; -}; - type ProxyConfigModalProps = { isOpen: boolean; onClose: () => void; @@ -59,20 +59,6 @@ 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})` : ""; @@ -95,7 +81,7 @@ async function fetchAssignmentForScope(scope: string, scopeId: string | 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; + return selectScopeAssignment(items, scope, scopeId); } async function fetchRegistryProxy(proxyId: string, cachedProxies: ProxyRegistryItem[]) { @@ -198,8 +184,7 @@ export default function ProxyConfigModal({ if (assignmentRes.ok) { const assignmentPayload = await assignmentRes.json(); const items = Array.isArray(assignmentPayload?.items) ? assignmentPayload.items : []; - const target = - items.find((item) => isSameScopeAssignment(item, scope, scopeId)) || items[0]; + const target = selectScopeAssignment(items, scope, scopeId); if (target?.proxyId) { setSelectedProxyId(target.proxyId); setHasOwnProxy(true); diff --git a/src/shared/components/proxyAssignment.test.tsx b/src/shared/components/proxyAssignment.test.tsx new file mode 100644 index 0000000000..a6bfcbad5f --- /dev/null +++ b/src/shared/components/proxyAssignment.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { selectScopeAssignment } from "./proxyAssignment"; + +// Regression guard for the escalated bug "when I create a new provider it already +// comes with a proxy I never configured". The proxy assignments list is global, so +// its first entry belongs to some other scope. selectScopeAssignment must return null +// when the current scope has no assignment of its own — never fall back to items[0], +// which used to pre-fill freshly created providers/keys with an unrelated proxy. +const ASSIGNMENTS = [ + { proxyId: "socks5-acct", scope: "key", scopeId: "3c0031c1-existing-account" }, + { proxyId: "http-claude", scope: "provider", scopeId: "claude" }, +]; + +describe("selectScopeAssignment", () => { + it("returns null (not items[0]) for a scope with no assignment of its own", () => { + // A brand-new provider with no proxy assignment. + const result = selectScopeAssignment(ASSIGNMENTS, "provider", "newprovider"); + expect(result).toBeNull(); + }); + + it("returns null for a new account/key scope even when other proxies exist", () => { + const result = selectScopeAssignment(ASSIGNMENTS, "key", "brand-new-key"); + expect(result).toBeNull(); + }); + + it("returns the matching assignment when the scope owns one", () => { + const result = selectScopeAssignment(ASSIGNMENTS, "provider", "claude"); + expect(result?.proxyId).toBe("http-claude"); + }); + + it("treats the global scope (null scopeId) distinctly", () => { + const withGlobal = [{ proxyId: "g", scope: "global", scopeId: null }, ...ASSIGNMENTS]; + expect(selectScopeAssignment(withGlobal, "global", null)?.proxyId).toBe("g"); + // a provider scope still must not borrow the global assignment + expect(selectScopeAssignment(withGlobal, "provider", "unconfigured")).toBeNull(); + }); + + it("returns null for an empty assignments list", () => { + expect(selectScopeAssignment([], "provider", "anything")).toBeNull(); + }); +}); diff --git a/src/shared/components/proxyAssignment.ts b/src/shared/components/proxyAssignment.ts new file mode 100644 index 0000000000..b2d05e8090 --- /dev/null +++ b/src/shared/components/proxyAssignment.ts @@ -0,0 +1,40 @@ +/** + * Proxy-assignment scope helpers, extracted from ProxyConfigModal so the pure + * selection logic can be unit-tested without rendering the React component. + */ + +export type ProxyAssignmentItem = { + proxyId?: string | null; + scope?: string | null; + scopeId?: string | null; +}; + +export function normalizeScopeId(scopeId?: string | null) { + return !scopeId || scopeId === "__global__" ? null : scopeId; +} + +export function isSameScopeAssignment( + assignment: ProxyAssignmentItem, + scope: string, + scopeId: string | null +) { + return ( + assignment.scope === scope && normalizeScopeId(assignment.scopeId) === normalizeScopeId(scopeId) + ); +} + +/** + * Pick the proxy assignment that belongs to *this* scope, or `null` when none does. + * + * Must NOT fall back to `items[0]`: the assignments list is global, so the first + * entry belongs to some other scope (e.g. another account's proxy). Returning it + * for a scope with no assignment of its own made a freshly created provider/key + * appear pre-filled with an unrelated proxy the user never configured. (escalated bug) + */ +export function selectScopeAssignment( + items: ProxyAssignmentItem[], + scope: string, + scopeId: string | null +): ProxyAssignmentItem | null { + return items.find((item) => isSameScopeAssignment(item, scope, scopeId)) ?? null; +}