mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
fix(proxy): save dashboard custom proxies in registry (#2661)
Integrated into release/v3.8.3
This commit is contained in:
@@ -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<ProxyPayload>) {
|
||||
|
||||
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<ProxyPayload>) {
|
||||
merged.region || null,
|
||||
merged.notes || null,
|
||||
merged.status || "active",
|
||||
merged.source || "manual",
|
||||
merged.updatedAt,
|
||||
id
|
||||
);
|
||||
|
||||
@@ -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<string>();
|
||||
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<ProxyRegistryItem[]>([]);
|
||||
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<string, unknown> = { ...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<string, unknown> = {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
387
tests/unit/shared/components/ProxyConfigModal.test.tsx
Normal file
387
tests/unit/shared/components/ProxyConfigModal.test.tsx
Normal file
@@ -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<string, unknown>) => {
|
||||
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<React.ComponentProps<any>>) {
|
||||
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(
|
||||
<ProxyConfigModal
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
level="provider"
|
||||
levelId="claude"
|
||||
levelLabel="Claude"
|
||||
onSaved={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user