From 76a82bcfc966022b5a9465cfa78c07df8b9ffb9a Mon Sep 17 00:00:00 2001 From: Nezumi-2711 Date: Tue, 14 Jul 2026 17:09:56 +0700 Subject: [PATCH] fix: update the logic code for combos pages --- src/app/(dashboard)/dashboard/combos/page.js | 49 ++--- .../(dashboard)/dashboard/providers/page.js | 19 +- src/app/api/models/connected/route.js | 98 ++++++++- src/app/api/models/custom/route.js | 32 ++- src/app/api/provider-nodes/[id]/route.js | 19 ++ src/app/api/provider-nodes/route.js | 19 ++ src/app/api/providers/[id]/route.js | 25 ++- src/app/api/providers/route.js | 10 +- tests/unit/connected-models-route.test.js | 199 ++++++++++++++++++ tests/unit/custom-models-route.test.js | 78 +++++++ .../provider-connection-admin-access.test.js | 118 +++++++++++ 11 files changed, 615 insertions(+), 51 deletions(-) create mode 100644 tests/unit/connected-models-route.test.js create mode 100644 tests/unit/custom-models-route.test.js create mode 100644 tests/unit/provider-connection-admin-access.test.js diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index b1bc45df..3c6f78cb 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -7,7 +7,6 @@ import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; @@ -17,7 +16,7 @@ export default function CombosPage() { const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [editingCombo, setEditingCombo] = useState(null); - const [activeProviders, setActiveProviders] = useState([]); + const [connectedModels, setConnectedModels] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); const [modelCaps, setModelCaps] = useState({}); const [confirmState, setConfirmState] = useState(null); @@ -29,23 +28,19 @@ export default function CombosPage() { async function fetchData() { try { - const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ + const [combosRes, settingsRes, modelsRes] = await Promise.all([ fetch("/api/combos"), - fetch("/api/providers"), fetch("/api/settings"), - fetch("/api/models"), + fetch("/api/models/connected", { cache: "no-store" }), ]); const combosData = await combosRes.json(); - const providersData = await providersRes.json(); const settingsData = settingsRes.ok ? await settingsRes.json() : {}; // Only LLM combos here - webSearch/webFetch combos belong to media-providers/web if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm")); - if (providersRes.ok) { - setActiveProviders(providersData.connections || []); - } if (modelsRes.ok) { const md = await modelsRes.json(); + setConnectedModels(md.models || []); // Build fullModel -> caps map for badge lookup const map = {}; for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps; @@ -59,6 +54,11 @@ export default function CombosPage() { } } + // The Models page is the source of truth for eligible models. Administrators + // can still see disabled rows there to manage them, but disabled models must + // not be added to new or edited combos because they cannot serve requests. + const selectableModels = connectedModels.filter((model) => !model.disabled); + const handleCreate = async (data) => { try { const res = await fetch("/api/combos", { @@ -190,7 +190,7 @@ export default function CombosPage() { key={combo.id} combo={combo} modelCaps={modelCaps} - activeProviders={activeProviders} + availableModels={selectableModels} copied={copied} onCopy={copy} onEdit={() => setEditingCombo(combo)} @@ -208,7 +208,7 @@ export default function CombosPage() { isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onSave={handleCreate} - activeProviders={activeProviders} + availableModels={selectableModels} /> {/* Edit Modal - Use key to force remount and reset state */} @@ -218,7 +218,7 @@ export default function CombosPage() { combo={editingCombo} onClose={() => setEditingCombo(null)} onSave={(data) => handleUpdate(editingCombo.id, data)} - activeProviders={activeProviders} + availableModels={selectableModels} /> {/* Confirm Delete Modal */} @@ -240,7 +240,7 @@ const STRATEGY_OPTIONS = [ { value: "fusion", label: "Fusion — panel + judge" }, ]; -function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { +function ComboCard({ combo, modelCaps = {}, availableModels = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { const [showJudgeSelect, setShowJudgeSelect] = useState(false); const current = strategy.fallbackStrategy || "fallback"; const judge = strategy.judgeModel || ""; @@ -344,7 +344,7 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy isOpen={showJudgeSelect} onClose={() => setShowJudgeSelect(false)} onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - activeProviders={activeProviders} + availableModels={availableModels} title="Select Judge Model" addedModelValues={judge ? [judge] : []} closeOnSelect={true} @@ -451,14 +451,13 @@ function ModelItem({ id, index, model, isFirst, isLast, onEdit, onMoveUp, onMove ); } -function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindFilter = null }) { +function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [], kindFilter = null }) { // Initialize state with combo values - key prop on parent handles reset on remount const [name, setName] = useState(combo?.name || ""); const [models, setModels] = useState(combo?.models || []); const [showModelSelect, setShowModelSelect] = useState(false); const [saving, setSaving] = useState(false); const [nameError, setNameError] = useState(""); - const [modelAliases, setModelAliases] = useState({}); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -479,21 +478,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF } }; - const fetchModalData = async () => { - try { - const aliasesRes = await fetch("/api/models/alias"); - if (!aliasesRes.ok) return; - const aliasesData = await aliasesRes.json(); - setModelAliases(aliasesData.aliases || {}); - } catch (error) { - console.error("Error fetching modal data:", error); - } - }; - - useEffect(() => { - if (isOpen) fetchModalData(); - }, [isOpen]); - const validateName = (value) => { if (!value.trim()) { setNameError("Name is required"); @@ -642,8 +626,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF onClose={() => setShowModelSelect(false)} onSelect={handleAddModel} onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} + availableModels={availableModels} title="Add Model to Combo" kindFilter={kindFilter} addedModelValues={models} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 958c4f10..6db6c9e8 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -110,6 +110,7 @@ export default function ProvidersPage() { const searchQuery = useHeaderSearchStore((s) => s.query); const registerSearch = useHeaderSearchStore((s) => s.register); const unregisterSearch = useHeaderSearchStore((s) => s.unregister); + const isAdmin = user?.role === "admin"; useEffect(() => { registerSearch("Search providers..."); @@ -335,8 +336,10 @@ export default function ProvidersPage() { freeEntries.length > 0 || freeTierEntries.length > 0 || apikeyEntries.length > 0 || - compatibleProviders.length > 0 || - anthropicCompatibleProviders.length > 0; + (isAdmin && ( + compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0 + )); return (
@@ -349,7 +352,8 @@ export default function ProvidersPage() {
)} - {/* Custom Providers (OpenAI/Anthropic Compatible) — dynamic */} + {/* Custom provider configuration is administered centrally. */} + {isAdmin && (

@@ -400,6 +404,7 @@ export default function ProvidersPage() {

)}
+ )} {/* OAuth Providers */} {oauthEntries.length > 0 && ( @@ -577,7 +582,7 @@ export default function ProvidersPage() { */} - setShowAddCompatibleModal(false)} @@ -585,8 +590,8 @@ export default function ProvidersPage() { setProviderNodes((prev) => [...prev, node]); setShowAddCompatibleModal(false); }} - /> - } + {isAdmin && setShowAddAnthropicCompatibleModal(false)} @@ -594,7 +599,7 @@ export default function ProvidersPage() { setProviderNodes((prev) => [...prev, node]); setShowAddAnthropicCompatibleModal(false); }} - /> + />} {/* Test Results Modal */} {testResults && ( diff --git a/src/app/api/models/connected/route.js b/src/app/api/models/connected/route.js index bbaa19f6..703b8c7f 100644 --- a/src/app/api/models/connected/route.js +++ b/src/app/api/models/connected/route.js @@ -1,9 +1,21 @@ import { NextResponse } from "next/server"; -import { getModelAliases, getProviderConnections } from "@/models"; +import { + getCustomModels, + getModelAliases, + getProviderConnections, + getProviderNodes, +} from "@/models"; +import { getUsers } from "@/lib/db"; import { disableModels, enableModels, getDisabledModels } from "@/lib/disabledModelsDb"; import { requireAdminUser, requireUsageDashboardUser } from "@/lib/auth/currentUser"; import { AI_MODELS } from "@/shared/constants/models"; -import { AI_PROVIDERS, getProviderAlias, getProviderByAlias } from "@/shared/constants/providers"; +import { + AI_PROVIDERS, + getProviderAlias, + getProviderByAlias, + isAnthropicCompatibleProvider, + isOpenAICompatibleProvider, +} from "@/shared/constants/providers"; import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; export const dynamic = "force-dynamic"; @@ -34,6 +46,20 @@ function getProviderLabel(providerAlias) { }; } +function getCompatibleProviderLabel(providerId, node, connection) { + const isAnthropic = node?.type === "anthropic-compatible" + || isAnthropicCompatibleProvider(providerId); + + return { + id: providerId, + alias: providerId, + name: node?.name || connection?.providerSpecificData?.nodeName + || (isAnthropic ? "Anthropic Compatible" : "OpenAI Compatible"), + color: isAnthropic ? "#D97757" : "#10A37F", + textIcon: isAnthropic ? "AC" : "OC", + }; +} + function getForbiddenResponse(error) { if (error.message === "Unauthorized") { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); @@ -49,10 +75,13 @@ export async function GET() { try { const user = await requireUsageDashboardUser(); - const [connections, disabledModels, modelAliases] = await Promise.all([ + const [connections, customModels, disabledModels, modelAliases, providerNodes, users] = await Promise.all([ getProviderConnections(), + getCustomModels(), getDisabledModels(), getModelAliases(), + getProviderNodes(), + getUsers(), ]); const connectionCountByAlias = new Map(); @@ -64,7 +93,7 @@ export async function GET() { } } - const models = AI_MODELS + const staticModels = AI_MODELS .filter((model) => connectionCountByAlias.has(model.provider)) .map((model) => { const providerAlias = getProviderAlias(model.provider) || model.provider; @@ -84,7 +113,66 @@ export async function GET() { reasoning: caps.reasoning, }, }; - }) + }); + + // Compatible providers are dynamic and therefore absent from AI_MODELS. + // Their catalog is the explicit list maintained by an administrator on the + // provider detail page. The provider-node ID is retained as the alias so + // combo model values route directly to the correct credential pool. + const adminOwnerIds = new Set( + users + .filter((entry) => entry.role === "admin" && entry.isActive !== false) + .map((entry) => entry.id), + ); + const nodeById = new Map(providerNodes.map((node) => [node.id, node])); + const viableCompatibleConnections = new Map(); + for (const connection of connections) { + if (!isViableConnection(connection)) continue; + if (!adminOwnerIds.has(connection.ownerId)) continue; + if ( + !isOpenAICompatibleProvider(connection.provider) + && !isAnthropicCompatibleProvider(connection.provider) + ) { + continue; + } + if (!viableCompatibleConnections.has(connection.provider)) { + viableCompatibleConnections.set(connection.provider, connection); + } + } + + const compatibleModels = []; + for (const [providerId, connection] of viableCompatibleConnections) { + const provider = getCompatibleProviderLabel(providerId, nodeById.get(providerId), connection); + const disabled = disabledModels[providerId] || []; + + for (const customModel of customModels) { + const kind = customModel.kind || customModel.type || "llm"; + if (customModel.providerAlias !== providerId || kind !== "llm" || !customModel.id) continue; + + const modelId = String(customModel.id).trim(); + if (!modelId) continue; + + const fullModel = `${providerId}/${modelId}`; + const caps = getCapabilitiesForModel(providerId, modelId); + compatibleModels.push({ + provider, + providerAlias: providerId, + model: modelId, + name: customModel.name || modelId, + fullModel, + alias: modelAliases[fullModel] || modelId, + disabled: disabled.includes(modelId), + isCustom: true, + caps: { + vision: caps.vision, + search: caps.search, + reasoning: caps.reasoning, + }, + }); + } + } + + const models = [...staticModels, ...compatibleModels] .filter((model) => user.role === "admin" || !model.disabled) .sort((a, b) => ( a.provider.name.localeCompare(b.provider.name) diff --git a/src/app/api/models/custom/route.js b/src/app/api/models/custom/route.js index 76e14c96..c10dd2e2 100644 --- a/src/app/api/models/custom/route.js +++ b/src/app/api/models/custom/route.js @@ -1,8 +1,30 @@ import { NextResponse } from "next/server"; -import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models"; +import { + getCustomModels, + addCustomModel, + deleteCustomModel, +} from "@/models"; +import { requireAdminUser } from "@/lib/auth/currentUser"; export const dynamic = "force-dynamic"; +function getAccessErrorResponse(error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + return null; +} + +async function requireCustomModelCatalogAdmin() { + // Custom-model records are a shared catalog, not connection-owned data. + // Restrict mutations to administrators for every provider so one dashboard + // user cannot alter models available to other users. + await requireAdminUser(); +} + // GET /api/models/custom - List all custom models export async function GET() { try { @@ -21,9 +43,13 @@ export async function POST(request) { if (!providerAlias || !id) { return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); } + await requireCustomModelCatalogAdmin(); const added = await addCustomModel({ providerAlias, id, type: type || "llm", name }); return NextResponse.json({ success: true, added }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error adding custom model:", error); return NextResponse.json({ error: "Failed to add custom model" }, { status: 500 }); } @@ -39,9 +65,13 @@ export async function DELETE(request) { if (!providerAlias || !id) { return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); } + await requireCustomModelCatalogAdmin(); await deleteCustomModel({ providerAlias, id, type }); return NextResponse.json({ success: true }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error deleting custom model:", error); return NextResponse.json({ error: "Failed to delete custom model" }, { status: 500 }); } diff --git a/src/app/api/provider-nodes/[id]/route.js b/src/app/api/provider-nodes/[id]/route.js index a758d0ab..ab8411fb 100644 --- a/src/app/api/provider-nodes/[id]/route.js +++ b/src/app/api/provider-nodes/[id]/route.js @@ -1,9 +1,21 @@ import { NextResponse } from "next/server"; import { deleteProviderConnectionsByProvider, deleteProviderNode, getProviderConnections, getProviderNodeById, updateProviderConnection, updateProviderNode } from "@/models"; +import { requireAdminUser } from "@/lib/auth/currentUser"; + +function getAccessErrorResponse(error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + return null; +} // PUT /api/provider-nodes/[id] - Update provider node export async function PUT(request, { params }) { try { + await requireAdminUser(); const { id } = await params; const body = await request.json(); const { name, prefix, apiType, baseUrl } = body; @@ -75,6 +87,9 @@ export async function PUT(request, { params }) { return NextResponse.json({ node: updated }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error updating provider node:", error); return NextResponse.json({ error: "Failed to update provider node" }, { status: 500 }); } @@ -83,6 +98,7 @@ export async function PUT(request, { params }) { // DELETE /api/provider-nodes/[id] - Delete provider node and its connections export async function DELETE(request, { params }) { try { + await requireAdminUser(); const { id } = await params; const node = await getProviderNodeById(id); @@ -95,6 +111,9 @@ export async function DELETE(request, { params }) { return NextResponse.json({ success: true }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error deleting provider node:", error); return NextResponse.json({ error: "Failed to delete provider node" }, { status: 500 }); } diff --git a/src/app/api/provider-nodes/route.js b/src/app/api/provider-nodes/route.js index 92c89196..32cb2fc1 100644 --- a/src/app/api/provider-nodes/route.js +++ b/src/app/api/provider-nodes/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createProviderNode, getProviderNodes } from "@/models"; +import { requireAdminUser } from "@/lib/auth/currentUser"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers"; import { generateId } from "@/shared/utils"; @@ -17,12 +18,26 @@ const CUSTOM_EMBEDDING_DEFAULTS = { baseUrl: "https://api.openai.com/v1", }; +function getAccessErrorResponse(error) { + if (error.message === "Unauthorized") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (error.message === "Forbidden") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } + return null; +} + // GET /api/provider-nodes - List all provider nodes export async function GET() { try { + await requireAdminUser(); const nodes = await getProviderNodes(); return NextResponse.json({ nodes }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error fetching provider nodes:", error); return NextResponse.json({ error: "Failed to fetch provider nodes" }, { status: 500 }); } @@ -31,6 +46,7 @@ export async function GET() { // POST /api/provider-nodes - Create provider node export async function POST(request) { try { + await requireAdminUser(); const body = await request.json(); const { name, prefix, apiType, baseUrl, type } = body; @@ -98,6 +114,9 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 }); } catch (error) { + const accessError = getAccessErrorResponse(error); + if (accessError) return accessError; + console.log("Error creating provider node:", error); return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); } diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js index 0d980f22..93a958ea 100644 --- a/src/app/api/providers/[id]/route.js +++ b/src/app/api/providers/[id]/route.js @@ -6,6 +6,21 @@ import { deleteProviderConnection, } from "@/models"; import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; +import { + isAnthropicCompatibleProvider, + isCustomEmbeddingProvider, + isOpenAICompatibleProvider, +} from "@/shared/constants/providers"; + +function isAdministratorManagedProvider(provider) { + return isOpenAICompatibleProvider(provider) + || isAnthropicCompatibleProvider(provider) + || isCustomEmbeddingProvider(provider); +} + +function canMutateConnection(user, connection) { + return !isAdministratorManagedProvider(connection.provider) || user.role === "admin"; +} function normalizeProxyConfig(body = {}) { const hasAnyProxyField = @@ -92,7 +107,7 @@ export async function GET(request, { params }) { export async function PUT(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { user, ownerId } = await getProviderConnectionAccess(); const body = await request.json(); const { name, @@ -111,6 +126,9 @@ export async function PUT(request, { params }) { if (!existing) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + if (!canMutateConnection(user, existing)) { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } const proxyConfig = normalizeProxyConfig(body); if (proxyConfig.error) { @@ -184,12 +202,15 @@ export async function PUT(request, { params }) { export async function DELETE(request, { params }) { try { const { id } = await params; - const { ownerId } = await getProviderConnectionAccess(); + const { user, ownerId } = await getProviderConnectionAccess(); const existing = await getProviderConnectionById(id, ownerId); if (!existing) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } + if (!canMutateConnection(user, existing)) { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } const deleted = await deleteProviderConnection(id); if (!deleted) { diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index a1b5b054..045bec41 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -111,17 +111,21 @@ export async function POST(request) { // Dual-auth providers (e.g. codebuddy-cn, xai) live under category "oauth" but also // accept an API key via authModes — they aren't in APIKEY_PROVIDERS, so allow them here. const supportsApiKeyMode = !!AI_PROVIDERS[provider]?.authModes?.includes("apikey"); + const isCompatibleProvider = isOpenAICompatibleProvider(provider) + || isAnthropicCompatibleProvider(provider) + || isCustomEmbeddingProvider(provider); const isValidProvider = APIKEY_PROVIDERS[provider] || FREE_TIER_PROVIDERS[provider] || supportsApiKeyMode || isWebCookieProvider || - isOpenAICompatibleProvider(provider) || - isAnthropicCompatibleProvider(provider) || - isCustomEmbeddingProvider(provider); + isCompatibleProvider; if (!provider || !isValidProvider) { return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); } + if (isCompatibleProvider && user.role !== "admin") { + return NextResponse.json({ error: "Administrator access required" }, { status: 403 }); + } if (!apiKey && provider !== "ollama-local") { return NextResponse.json({ error: `${isWebCookieProvider ? "Cookie value" : "API Key"} is required` }, { status: 400 }); } diff --git a/tests/unit/connected-models-route.test.js b/tests/unit/connected-models-route.test.js new file mode 100644 index 00000000..ed76e46b --- /dev/null +++ b/tests/unit/connected-models-route.test.js @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getModelAliases = vi.fn(); +const getProviderConnections = vi.fn(); +const getCustomModels = vi.fn(); +const getProviderNodes = vi.fn(); +const getUsers = vi.fn(); +const getDisabledModels = vi.fn(); +const requireUsageDashboardUser = vi.fn(); +const getCapabilitiesForModel = vi.fn(); + +vi.mock("@/models", () => ({ + getCustomModels, + getModelAliases, + getProviderConnections, + getProviderNodes, +})); + +vi.mock("@/lib/db", () => ({ getUsers })); + +vi.mock("@/lib/disabledModelsDb", () => ({ getDisabledModels })); +vi.mock("@/lib/auth/currentUser", () => ({ + requireUsageDashboardUser, +})); +vi.mock("@/shared/constants/models", () => ({ + AI_MODELS: [ + { provider: "alpha", model: "enabled", name: "Enabled model" }, + { provider: "alpha", model: "disabled", name: "Disabled model" }, + { provider: "beta", model: "inactive", name: "Inactive provider model" }, + ], +})); +vi.mock("@/shared/constants/providers", () => { + const providers = { + alpha: { id: "alpha", alias: "alpha-alias", name: "Alpha", color: "#111111" }, + beta: { id: "beta", alias: "beta-alias", name: "Beta", color: "#222222" }, + }; + + return { + AI_PROVIDERS: providers, + getProviderAlias: (providerId) => providers[providerId]?.alias || providerId, + getProviderByAlias: (providerId) => providers[providerId], + isOpenAICompatibleProvider: (providerId) => providerId.startsWith("openai-compatible-"), + isAnthropicCompatibleProvider: (providerId) => providerId.startsWith("anthropic-compatible-"), + }; +}); +vi.mock("open-sse/providers/capabilities.js", () => ({ getCapabilitiesForModel })); + +const { GET } = await import("../../src/app/api/models/connected/route.js"); + +describe("GET /api/models/connected", () => { + beforeEach(() => { + getModelAliases.mockReset(); + getProviderConnections.mockReset(); + getCustomModels.mockReset(); + getProviderNodes.mockReset(); + getUsers.mockReset(); + getDisabledModels.mockReset(); + requireUsageDashboardUser.mockReset(); + getCapabilitiesForModel.mockReset(); + + getModelAliases.mockResolvedValue({ "alpha/enabled": "preferred-alpha" }); + getDisabledModels.mockResolvedValue({ "alpha-alias": ["disabled"] }); + getCustomModels.mockResolvedValue([]); + getProviderNodes.mockResolvedValue([]); + getUsers.mockResolvedValue([{ id: "admin", role: "admin", isActive: true }]); + getCapabilitiesForModel.mockReturnValue({ vision: false, search: true, reasoning: true }); + getProviderConnections.mockResolvedValue([ + { provider: "alpha", isActive: true, apiKey: "secret" }, + { provider: "beta", isActive: false, apiKey: "secret" }, + ]); + }); + + it("returns every connected-provider model to an administrator, including disabled rows", async () => { + requireUsageDashboardUser.mockResolvedValue({ id: "admin", role: "admin" }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).toEqual([ + expect.objectContaining({ + fullModel: "alpha/disabled", + providerAlias: "alpha-alias", + disabled: true, + }), + expect.objectContaining({ + fullModel: "alpha/enabled", + alias: "preferred-alpha", + disabled: false, + caps: { vision: false, search: true, reasoning: true }, + }), + ]); + expect(body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ fullModel: "beta/inactive" }), + ])); + }); + + it("excludes disabled models for non-administrators", async () => { + requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).toEqual([ + expect.objectContaining({ fullModel: "alpha/enabled", disabled: false }), + ]); + }); + + it("includes administrator-managed compatible-provider models for non-administrators", async () => { + const providerId = "openai-compatible-test-node"; + requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" }); + getProviderConnections.mockResolvedValue([ + { + provider: providerId, + isActive: true, + apiKey: "admin-secret", + ownerId: "admin", + providerSpecificData: { nodeName: "Company Gateway" }, + }, + ]); + getProviderNodes.mockResolvedValue([ + { id: providerId, type: "openai-compatible", name: "Company Gateway" }, + ]); + getCustomModels.mockResolvedValue([ + { providerAlias: providerId, id: "gpt-company", name: "Company GPT", type: "llm" }, + { providerAlias: providerId, id: "company-embed", name: "Company Embed", type: "embedding" }, + ]); + getModelAliases.mockResolvedValue({ [`${providerId}/gpt-company`]: "company-chat" }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).toEqual([ + expect.objectContaining({ + provider: expect.objectContaining({ id: providerId, name: "Company Gateway" }), + providerAlias: providerId, + model: "gpt-company", + name: "Company GPT", + fullModel: `${providerId}/gpt-company`, + alias: "company-chat", + isCustom: true, + }), + ]); + expect(body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ model: "company-embed" }), + ])); + }); + + it("does not expose disabled compatible-provider models to non-administrators", async () => { + const providerId = "anthropic-compatible-test-node"; + requireUsageDashboardUser.mockResolvedValue({ id: "member", role: "user" }); + getProviderConnections.mockResolvedValue([ + { provider: providerId, isActive: true, apiKey: "admin-secret", ownerId: "admin" }, + ]); + getProviderNodes.mockResolvedValue([ + { id: providerId, type: "anthropic-compatible", name: "Company Anthropic" }, + ]); + getCustomModels.mockResolvedValue([ + { providerAlias: providerId, id: "claude-company", type: "llm" }, + ]); + getDisabledModels.mockResolvedValue({ [providerId]: ["claude-company"] }); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ fullModel: `${providerId}/claude-company` }), + ])); + }); + + it("does not treat a non-admin compatible-provider connection as shared", async () => { + const providerId = "openai-compatible-user-node"; + requireUsageDashboardUser.mockResolvedValue({ id: "member-b", role: "user" }); + getUsers.mockResolvedValue([ + { id: "admin", role: "admin", isActive: true }, + { id: "member-a", role: "user", isActive: true }, + ]); + getProviderConnections.mockResolvedValue([ + { provider: providerId, isActive: true, apiKey: "member-secret", ownerId: "member-a" }, + ]); + getProviderNodes.mockResolvedValue([ + { id: providerId, type: "openai-compatible", name: "Member Gateway" }, + ]); + getCustomModels.mockResolvedValue([ + { providerAlias: providerId, id: "member-only-model", type: "llm" }, + ]); + + const response = await GET(); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.models).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ fullModel: `${providerId}/member-only-model` }), + ])); + }); +}); \ No newline at end of file diff --git a/tests/unit/custom-models-route.test.js b/tests/unit/custom-models-route.test.js new file mode 100644 index 00000000..02fd9e18 --- /dev/null +++ b/tests/unit/custom-models-route.test.js @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getCustomModels = vi.fn(); +const addCustomModel = vi.fn(); +const deleteCustomModel = vi.fn(); +const requireAdminUser = vi.fn(); + +vi.mock("@/models", () => ({ + getCustomModels, + addCustomModel, + deleteCustomModel, +})); +vi.mock("@/lib/auth/currentUser", () => ({ requireAdminUser })); + +const { GET, POST, DELETE } = await import("../../src/app/api/models/custom/route.js"); + +describe("/api/models/custom", () => { + beforeEach(() => { + getCustomModels.mockReset(); + addCustomModel.mockReset(); + deleteCustomModel.mockReset(); + requireAdminUser.mockReset(); + }); + + it("keeps the shared catalog readable to authenticated model selectors", async () => { + getCustomModels.mockResolvedValue([{ providerAlias: "openai", id: "gpt-test", type: "llm" }]); + + const response = await GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + models: [{ providerAlias: "openai", id: "gpt-test", type: "llm" }], + }); + expect(requireAdminUser).not.toHaveBeenCalled(); + }); + + it("rejects a non-admin adding a shared custom model", async () => { + requireAdminUser.mockRejectedValue(new Error("Forbidden")); + + const response = await POST(new Request("http://localhost/api/models/custom", { + method: "POST", + body: JSON.stringify({ providerAlias: "openai", id: "gpt-test", type: "llm" }), + })); + + expect(response.status).toBe(403); + expect(addCustomModel).not.toHaveBeenCalled(); + }); + + it("allows an admin to add a shared custom model", async () => { + requireAdminUser.mockResolvedValue({ id: "admin", role: "admin" }); + addCustomModel.mockResolvedValue(true); + + const response = await POST(new Request("http://localhost/api/models/custom", { + method: "POST", + body: JSON.stringify({ providerAlias: "openai", id: "gpt-test", type: "llm" }), + })); + + expect(response.status).toBe(200); + expect(addCustomModel).toHaveBeenCalledWith({ + providerAlias: "openai", + id: "gpt-test", + type: "llm", + name: undefined, + }); + }); + + it("rejects a non-admin deleting a shared custom model", async () => { + requireAdminUser.mockRejectedValue(new Error("Forbidden")); + + const response = await DELETE(new Request( + "http://localhost/api/models/custom?providerAlias=openai&id=gpt-test&type=llm", + { method: "DELETE" }, + )); + + expect(response.status).toBe(403); + expect(deleteCustomModel).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/provider-connection-admin-access.test.js b/tests/unit/provider-connection-admin-access.test.js new file mode 100644 index 00000000..309cb80e --- /dev/null +++ b/tests/unit/provider-connection-admin-access.test.js @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getProviderConnectionById = vi.fn(); +const getProxyPoolById = vi.fn(); +const updateProviderConnection = vi.fn(); +const deleteProviderConnection = vi.fn(); +const getProviderConnectionAccess = vi.fn(); + +vi.mock("@/models", () => ({ + getProviderConnectionById, + getProxyPoolById, + updateProviderConnection, + deleteProviderConnection, +})); +vi.mock("@/lib/providers/connectionAccess", () => ({ getProviderConnectionAccess })); +vi.mock("@/shared/constants/providers", () => ({ + isOpenAICompatibleProvider: (provider) => provider.startsWith("openai-compatible-"), + isAnthropicCompatibleProvider: (provider) => provider.startsWith("anthropic-compatible-"), + isCustomEmbeddingProvider: (provider) => provider.startsWith("custom-embedding-"), +})); + +const { PUT, DELETE } = await import("../../src/app/api/providers/[id]/route.js"); + +const memberAccess = { + user: { id: "member", role: "user" }, + ownerId: "member", +}; +const adminAccess = { + user: { id: "admin", role: "admin" }, + ownerId: null, +}; + +describe("provider connection administrator-managed access", () => { + beforeEach(() => { + getProviderConnectionById.mockReset(); + getProxyPoolById.mockReset(); + updateProviderConnection.mockReset(); + deleteProviderConnection.mockReset(); + getProviderConnectionAccess.mockReset(); + }); + + it("prevents a member from updating their legacy compatible connection", async () => { + getProviderConnectionAccess.mockResolvedValue(memberAccess); + getProviderConnectionById.mockResolvedValue({ + id: "legacy-compatible", + provider: "openai-compatible-chat-node", + ownerId: "member", + }); + + const response = await PUT(new Request("http://localhost/api/providers/legacy-compatible", { + method: "PUT", + body: JSON.stringify({ name: "Changed" }), + }), { params: Promise.resolve({ id: "legacy-compatible" }) }); + + expect(response.status).toBe(403); + expect(updateProviderConnection).not.toHaveBeenCalled(); + }); + + it("prevents a member from deleting their legacy compatible connection", async () => { + getProviderConnectionAccess.mockResolvedValue(memberAccess); + getProviderConnectionById.mockResolvedValue({ + id: "legacy-compatible", + provider: "anthropic-compatible-node", + ownerId: "member", + }); + + const response = await DELETE(new Request("http://localhost/api/providers/legacy-compatible", { + method: "DELETE", + }), { params: Promise.resolve({ id: "legacy-compatible" }) }); + + expect(response.status).toBe(403); + expect(deleteProviderConnection).not.toHaveBeenCalled(); + }); + + it("allows an administrator to delete a compatible connection", async () => { + getProviderConnectionAccess.mockResolvedValue(adminAccess); + getProviderConnectionById.mockResolvedValue({ + id: "compatible", + provider: "custom-embedding-node", + ownerId: "admin", + }); + deleteProviderConnection.mockResolvedValue(true); + + const response = await DELETE(new Request("http://localhost/api/providers/compatible", { + method: "DELETE", + }), { params: Promise.resolve({ id: "compatible" }) }); + + expect(response.status).toBe(200); + expect(deleteProviderConnection).toHaveBeenCalledWith("compatible"); + }); + + it("preserves member control over their non-compatible connection", async () => { + getProviderConnectionAccess.mockResolvedValue(memberAccess); + getProviderConnectionById.mockResolvedValue({ + id: "openai-connection", + provider: "openai", + ownerId: "member", + providerSpecificData: {}, + authType: "apikey", + }); + updateProviderConnection.mockResolvedValue({ + id: "openai-connection", + provider: "openai", + name: "Changed", + }); + + const response = await PUT(new Request("http://localhost/api/providers/openai-connection", { + method: "PUT", + body: JSON.stringify({ name: "Changed" }), + }), { params: Promise.resolve({ id: "openai-connection" }) }); + + expect(response.status).toBe(200); + expect(updateProviderConnection).toHaveBeenCalledWith("openai-connection", { + name: "Changed", + providerSpecificData: {}, + }); + }); +});