fix: restore security hardening compatibility and validation

Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
This commit is contained in:
elky
2026-09-07 21:14:27 +08:00
parent a5c3699ae9
commit a90d564931
191 changed files with 6785 additions and 1643 deletions
@@ -205,7 +205,7 @@
v-if="!isEndpointConfigReadOnly"
v-model:open="endpointRulesExpanded[endpoint.id]"
>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<!-- 有规则时显示可折叠的触发器 -->
<CollapsibleTrigger
v-if="getTotalRulesCount(endpoint) > 0"
@@ -236,7 +236,18 @@
请求/响应规则
</span>
<div class="flex-1" />
<div class="flex items-center gap-1 shrink-0">
<div class="flex w-full flex-wrap items-center justify-end gap-1 sm:w-auto">
<Button
v-if="getTotalRulesCount(endpoint) > 0"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
:title="legacyT('查看已保存规则的原值')"
@click="revealRulesEndpointId = endpoint.id"
>
<Eye class="w-3 h-3 mr-1" />
{{ legacyT('查看原值') }}
</Button>
<Button
v-if="hasRulePanelChanges(endpoint)"
variant="ghost"
@@ -1016,6 +1027,12 @@
</template>
</Dialog>
<EndpointRulesRevealDialog
:model-value="modelValue && revealRulesEndpointId !== null"
:endpoint-id="revealRulesEndpointId"
@update:model-value="revealRulesEndpointId = null"
/>
<!-- 删除端点确认弹窗 -->
<AlertDialog
:model-value="deleteConfirmOpen"
@@ -1052,13 +1069,14 @@ import {
PopoverTrigger,
PopoverContent,
} from '@/components/ui'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft } from 'lucide-vue-next'
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio, CheckCircle, Save, Filter, HelpCircle, GripVertical, Globe, Code2, AlignLeft, Eye } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import { useI18n } from '@/i18n'
import AlertDialog from '@/components/common/AlertDialog.vue'
import EndpointConditionEditor from './EndpointConditionEditor.vue'
import EndpointRulesRevealDialog from './EndpointRulesRevealDialog.vue'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from './endpoint-default-paths'
import {
@@ -1444,6 +1462,7 @@ function handleBodyRuleDragEnd(endpointId: string) {
// 状态
const addingEndpoint = ref(false)
const savingEndpointId = ref<string | null>(null)
const revealRulesEndpointId = ref<string | null>(null)
const resettingDefaultRulesEndpointId = ref<string | null>(null)
const deletingEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
@@ -1544,7 +1563,7 @@ function validateJsonCondition(rule: Record<string, unknown>, label: string, ind
if (!isJsonObject(raw)) return formatJsonRuleError(label, index, 'condition 必须是对象')
const shapeError = validateJsonConditionShape(raw, formatJsonRuleFieldLabel(label, index, 'condition'))
if (shapeError) return shapeError
const editable = conditionToEditable(raw as BodyRule['condition'])
const editable = conditionToEditable(raw as NonNullable<BodyRule['condition']> & Record<string, unknown>)
const err = validateEditableCondition(editable)
return err ? formatJsonRuleError(label, index, err) : null
}
@@ -3307,7 +3326,8 @@ onMounted(() => {
})
// 监听 props 变化
watch(() => props.modelValue, (open) => {
watch(() => [props.modelValue, props.provider?.id] as const, ([open]) => {
revealRulesEndpointId.value = null
bodyRuleHelpOpenEndpointId.value = null
ruleSelectOpen.value = {}
responseRuleSelectOpen.value = {}
@@ -3338,6 +3358,8 @@ watch(() => props.modelValue, (open) => {
} else {
// 关闭对话框时完全清空新端点表单
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
endpointEditStates.value = {}
localEndpoints.value = []
}
}, { immediate: true })
@@ -3416,7 +3438,16 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
if (Object.keys(payload).length === 0) return
await updateEndpoint(endpoint.id, payload)
const submittedState = JSON.stringify(state)
const submittedJsonDraft = endpointRulesJsonDraft.value[endpoint.id]
const updatedEndpoint = await updateEndpoint(endpoint.id, payload)
if (endpointEditStates.value[endpoint.id] === state) {
localEndpoints.value = localEndpoints.value.map(current => current.id === endpoint.id ? updatedEndpoint : current)
if (JSON.stringify(state) === submittedState
&& endpointRulesJsonDraft.value[endpoint.id] === submittedJsonDraft) {
resetEndpointChanges(updatedEndpoint)
}
}
success(legacyT('端点已更新'))
emit('endpointUpdated')
} catch (error: unknown) {
@@ -0,0 +1,89 @@
<template>
<Dialog
:model-value="modelValue"
:title="legacyT('查看规则原值')"
size="2xl"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="space-y-3">
<p class="text-sm text-muted-foreground">
{{ legacyT('仅展示已保存的规则,可能包含密钥;不会覆盖未保存的编辑。关闭后清除明文。') }}
</p>
<div
v-if="loading"
role="status"
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
>
<Loader2 class="h-4 w-4 animate-spin" />
{{ legacyT('加载中...') }}
</div>
<p
v-else-if="failed"
role="alert"
class="text-sm text-destructive"
>
{{ legacyT('加载规则失败,请关闭后重试') }}
</p>
<Textarea
v-else-if="rulesJson"
:model-value="rulesJson"
:aria-label="legacyT('原始规则 JSON')"
readonly
spellcheck="false"
class="min-h-[320px] font-mono text-xs leading-relaxed"
/>
</div>
<template #footer>
<Button
variant="outline"
@click="emit('update:modelValue', false)"
>
{{ legacyT('关闭') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { Button, Dialog, Textarea } from '@/components/ui'
import { revealEndpointRules } from '@/api/endpoints'
import { useI18n } from '@/i18n'
const props = defineProps<{
modelValue: boolean
endpointId: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const { legacyT } = useI18n()
const rulesJson = ref('')
const loading = ref(false)
const failed = ref(false)
watch(() => [props.modelValue, props.endpointId] as const, async ([open, endpointId], _previous, onCleanup) => {
rulesJson.value = ''
failed.value = false
loading.value = false
if (!open || !endpointId) return
const controller = new AbortController()
onCleanup(() => {
controller.abort()
rulesJson.value = ''
})
loading.value = true
try {
const rules = await revealEndpointRules(endpointId, controller.signal)
if (!controller.signal.aborted) {
rulesJson.value = JSON.stringify(rules, null, 2)
}
} catch {
if (!controller.signal.aborted) failed.value = true
} finally {
if (!controller.signal.aborted) loading.value = false
}
}, { immediate: true })
</script>
@@ -239,15 +239,15 @@ async function loadRelated() {
related_limit: 8,
per_item_limit: 100
}
if (!props.isAdmin && target.source.kind === 'provider') {
const dimension = target.source.kind
if (!props.isAdmin && dimension === 'provider') {
throw new Error('公开健康监控不支持 provider 详情')
}
const data = props.isAdmin
? await getHealthRelatedMonitor(params)
: await getPublicHealthRelatedMonitor({
...params,
dimension: target.source.kind
})
: dimension === 'provider'
? null
: await getPublicHealthRelatedMonitor({ ...params, dimension })
if (seq === requestSeq) {
related.value = data
}
@@ -117,7 +117,7 @@
<div
class="space-y-4 transition-opacity duration-150"
:class="mode === 'oauth' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'oauth' ? '' : undefined"
:inert="mode !== 'oauth' ? true : undefined"
:aria-hidden="mode !== 'oauth'"
>
<!-- Windsurf: 浏览器 session/poll 授权 -->
@@ -612,7 +612,7 @@
v-if="isClaudeCodeProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'cookie' ? '' : undefined"
:inert="mode !== 'cookie' ? true : undefined"
:aria-hidden="mode !== 'cookie'"
>
<label
@@ -648,7 +648,7 @@
<div
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'import' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'import' ? '' : undefined"
:inert="mode !== 'import' ? true : undefined"
:aria-hidden="mode !== 'import'"
>
<div
@@ -780,7 +780,7 @@
v-if="isCodexProvider"
class="flex flex-col gap-3 justify-center transition-opacity duration-150"
:class="mode === 'agent_identity' ? 'opacity-100' : 'opacity-0 pointer-events-none'"
:inert="mode !== 'agent_identity' ? '' : undefined"
:inert="mode !== 'agent_identity' ? true : undefined"
:aria-hidden="mode !== 'agent_identity'"
>
<Textarea
@@ -94,7 +94,7 @@
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">启用代理</span>
<Switch
:model-value="formData[group.toggleKey] || false"
:model-value="formData[group.toggleKey] === true"
@update:model-value="handleProxyToggle(group.toggleKey, $event)"
/>
</div>
@@ -107,7 +107,7 @@
>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
:model-value="formData.proxy_node_id || ''"
:model-value="stringFieldValue('proxy_node_id')"
trigger-class="h-8"
@update:model-value="(v: string) => { formData.proxy_node_id = v; handleFieldChange('proxy_node_id', v) }"
/>
@@ -146,7 +146,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -156,7 +156,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -182,7 +182,7 @@
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@@ -192,7 +192,7 @@
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
@@ -201,7 +201,7 @@
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
@update:model-value="handleFieldChange(field.key, $event)"
>
<SelectTrigger>
@@ -221,7 +221,7 @@
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:model-value="stringFieldValue(field.key)"
:placeholder="field.placeholder"
rows="3"
@update:model-value="handleFieldChange(field.key, $event)"
@@ -417,6 +417,11 @@ const architecturesLoaded = ref(false)
const selectedArchitectureId = ref('new_api')
const selectedAuthType = ref('')
const formData = ref<Record<string, unknown>>({})
function stringFieldValue(key: string): string {
const value = formData.value[key]
return typeof value === 'string' || typeof value === 'number' ? String(value) : ''
}
const quotaAlert = ref<QuotaAlertConfig>({
enabled: false,
threshold_amount: 0,
@@ -439,11 +444,11 @@ const currentSchema = computed<CredentialsSchema | null>(() => {
if (selectedAuthType.value && arch.supported_auth_types.length > 1) {
const authType = arch.supported_auth_types.find((t) => t.type === selectedAuthType.value)
if (authType?.credentials_schema) {
return authType.credentials_schema as CredentialsSchema
return authType.credentials_schema
}
}
return (arch?.credentials_schema as CredentialsSchema) ?? null
return arch?.credentials_schema ?? null
})
// 表单是否可以验证(必填字段已填写)
@@ -464,7 +469,7 @@ const canVerify = computed(() => {
const error = validateFromSchema(schema, dataToValidate)
if (error) return false
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
return !!effectiveBaseUrl
})
@@ -503,6 +508,7 @@ function handleAuthTypeChange() {
}
function handleFieldChange(fieldKey: string, value: unknown) {
formData.value[fieldKey] = value
formChanged.value = true
// 执行 schema 定义的字段钩子
@@ -533,7 +539,7 @@ function resetFormData() {
// 初始化表单数据
const data: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as Record<string, unknown>)['x-default-value'] ?? ''
data[key] = prop['x-default-value'] ?? ''
}
// 代理相关默认值
data.proxy_enabled = false
@@ -575,7 +581,7 @@ async function handleVerify() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -672,7 +678,7 @@ async function handleSave() {
return
}
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
const effectiveBaseUrl = stringFieldValue('base_url') || props.providerWebsite
if (!effectiveBaseUrl) {
showError('请填写 API 地址')
return
@@ -129,7 +129,7 @@
<ProviderKeyIdentityBlock
:api-key="key"
:masked-secret-label="getProviderMaskedSecretLabel(key, provider.provider_type)"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type) : null"
:oauth-plan-label="key.oauth_plan_type ? formatOAuthPlanType(key.oauth_plan_type ?? '') : null"
:oauth-plan-class="key.oauth_plan_type ? getOAuthPlanTypeClass(key.oauth_plan_type) : ''"
:oauth-org-badge="getOAuthOrgBadge(key)"
:kiro-subscription-label="shouldShowKiroSubscriptionBadge(key) ? getKiroSubscriptionBadgeLabel(key) : null"
@@ -153,7 +153,7 @@
</div>
<ProviderKeyActionCluster
:api-key="key"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
:recoverable="isKeyRecoverable(key)"
:recover-title="getRecoverKeyTitle(key)"
:circuit-breaker-title="getKeyCircuitBreakerTitle(key)"
@@ -161,7 +161,7 @@
:health-score-bar-class="getHealthScoreBarColor(key.health_score || 0)"
:health-score-text-class="getHealthScoreColor(key.health_score || 0)"
:proxy-popover-open="proxyPopoverOpenKeyId === key.id"
:proxy-node-name="getKeyProxyNodeName(key)"
:proxy-node-name="getKeyProxyNodeName(key) ?? undefined"
:saving-proxy="savingProxyKeyId === key.id"
:toggling="togglingKeyId === key.id"
@recover="handleRecoverKey(key)"
@@ -847,7 +847,7 @@
v-if="open && oauthAccountDialogOpen && provider"
:open="oauthAccountDialogOpen"
:provider-id="provider.id"
:provider-type="provider.provider_type"
:provider-type="provider.provider_type ?? null"
@close="oauthAccountDialogOpen = false"
@saved="handleKeyChanged"
/>
@@ -910,10 +910,10 @@
<AntigravityQuotaDialog
v-if="antigravityQuotaDialogKey"
:open="antigravityQuotaDialogOpen"
:metadata="antigravityQuotaDialogKey.upstream_metadata"
:metadata="antigravityQuotaDialogKey.upstream_metadata ?? null"
:quota-snapshot="antigravityQuotaDialogKey.status_snapshot?.quota ?? null"
:key-name="antigravityQuotaDialogKey.name || legacyT('未命名密钥')"
:provider-id="providerId"
:provider-id="providerId ?? undefined"
:key-id="antigravityQuotaDialogKey.id"
@update:open="antigravityQuotaDialogOpen = $event"
/>
@@ -1554,7 +1554,7 @@ async function downloadRefreshToken(key: EndpointAPIKey) {
try {
const data = await exportKey(key.id)
const providerType = provider.value?.provider_type || 'unknown'
const safeName = (data.email || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const safeName = ((typeof data.email === 'string' && data.email) || key.name || key.id.slice(0, 8)).replace(/[^a-zA-Z0-9_\-@.]/g, '_')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -1693,7 +1693,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
title: legacyT('清除账号异常标记'),
message: formatClearOAuthInvalidConfirmMessage(key),
confirmText: legacyT('确认清除'),
variant: 'default',
variant: 'info',
})
if (!confirmed) return
@@ -2474,7 +2474,7 @@ function isKiroBannedKey(key: EndpointAPIKey): boolean {
}
// 格式化封禁/禁止时间(后端返回秒级时间戳,Kiro/Antigravity 通用)
function formatBanTimestamp(timestamp: number | undefined): string {
function formatBanTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
return date.toLocaleString(getI18nLocale(), {
@@ -2516,7 +2516,7 @@ function formatKiroUsage(value: number | undefined): string {
}
// 格式化 Kiro 重置时间
function formatKiroResetTime(timestamp: number | undefined): string {
function formatKiroResetTime(timestamp: number | null | undefined): string {
if (!timestamp) return ''
// timestamp 可能是毫秒或秒,需要判断
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000
@@ -2568,7 +2568,7 @@ function shouldShowKiroSubscriptionBadge(key: EndpointAPIKey): boolean {
const kiroLabel = getKiroSubscriptionBadgeLabel(key)
if (!kiroLabel) return false
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type)
const oauthPlanLabel = formatOAuthPlanType(key.oauth_plan_type ?? '')
if (!oauthPlanLabel) return true
return oauthPlanLabel.trim().toLowerCase() !== kiroLabel.trim().toLowerCase()
@@ -2887,6 +2887,8 @@ async function autoRefreshQuotaInBackground(): Promise<boolean> {
}
async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
const providerId = props.providerId
if (!providerId) return
antigravityQuotaDialogKey.value = key
antigravityQuotaDialogOpen.value = true
@@ -2895,7 +2897,8 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
if (refreshingQuota.value) return
refreshingQuota.value = true
try {
const result = await refreshProviderQuota(props.providerId)
const result = await refreshProviderQuota(providerId)
if (providerId !== props.providerId) return
applyQuotaResults(result.results)
// 更新弹窗引用的 key 数据
const updated = allKeys.value.find(({ key: k }) => k.id === key.id)
@@ -535,7 +535,7 @@ function buildSettingsPayload(
cache_ttl_minutes: source.cache_ttl_minutes,
max_probe_interval_minutes: source.max_probe_interval_minutes,
is_active: source.is_active,
note: source.note.trim() || null,
note: source.note?.trim() || null,
...((source.proxy_node_id || includeEmptyProxy)
? { proxy_node_id: source.proxy_node_id || null }
: {}),
@@ -658,4 +658,4 @@ async function submitImport(): Promise<void> {
importing.value = false
}
}
</script>
</script>
@@ -98,7 +98,7 @@
<div class="space-y-1.5">
<Label class="text-xs">代理节点</Label>
<ProxyNodeSelect
:model-value="settings.proxy_node_id"
:model-value="settings.proxy_node_id ?? ''"
trigger-class="h-10"
@update:model-value="updateSetting('proxy_node_id', $event)"
/>
@@ -106,7 +106,7 @@
<div class="space-y-1.5">
<Label class="text-xs">备注</Label>
<Input
:model-value="settings.note"
:model-value="settings.note ?? ''"
class="h-10"
placeholder="可选"
@update:model-value="updateSetting('note', String($event))"
@@ -186,4 +186,4 @@ function updateSetting<Key extends keyof ImportSettings>(
): void {
emit('update:settings', { ...props.settings, [key]: value })
}
</script>
</script>
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, ref, type App, type ComponentPublicInstance } from 'vue'
import EndpointFormDialog from '../EndpointFormDialog.vue'
import type { ProviderEndpoint, ProviderWithEndpointsSummary } from '@/api/endpoints'
const api = vi.hoisted(() => ({
createEndpoint: vi.fn(),
getDefaultBodyRules: vi.fn().mockResolvedValue({ body_rules: [] }),
updateEndpoint: vi.fn(),
deleteEndpoint: vi.fn(),
}))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/api/admin', () => ({ adminApi: { getApiFormats: vi.fn().mockResolvedValue({ formats: [] }) } }))
vi.mock('@/composables/useToast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() }) }))
vi.mock('@/stores/proxy-nodes', () => ({ useProxyNodesStore: () => ({ nodes: [], ensureLoaded: vi.fn() }) }))
vi.mock('../ProxyNodeSelect.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointConditionEditor.vue', () => ({ default: { render: () => null } }))
vi.mock('../EndpointRulesRevealDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/common/AlertDialog.vue', () => ({ default: { render: () => null } }))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = defineComponent({
inheritAttrs: false,
setup: (_props, { slots }) => () => h('div', [slots.default?.(), slots.footer?.()]),
})
return Object.fromEntries([
'Dialog', 'Button', 'Input', 'Textarea', 'Label', 'Badge', 'Select', 'SelectTrigger',
'SelectValue', 'SelectContent', 'SelectItem', 'Switch', 'Collapsible', 'CollapsibleTrigger',
'CollapsibleContent', 'Popover', 'PopoverTrigger', 'PopoverContent',
].map(name => [name, passthrough]))
})
interface DialogState {
localEndpoints: ProviderEndpoint[]
endpointEditStates: Record<string, unknown>
endpointRulesJsonDirty: Record<string, boolean>
endpointRulesJsonDraft: Record<string, string>
enterEndpointRulesJsonMode: (endpoint: ProviderEndpoint) => void
updateEndpointRulesJsonDraft: (endpointId: string, value: string) => void
getEndpointEditRules: (endpointId: string) => Array<{ value: string, retainValue: boolean }>
updateEndpointRuleField: (endpointId: string, index: number, field: 'value', value: string) => void
hasRulePanelChanges: (endpoint: ProviderEndpoint) => boolean
saveEndpoint: (endpoint: ProviderEndpoint) => Promise<void>
}
const endpoint: ProviderEndpoint = {
id: 'endpoint-1', provider_id: 'provider-1', provider_name: 'Custom', api_format: 'openai:chat',
base_url: 'https://example.test', is_active: true,
header_rules: [{ action: 'set', key: 'x-auth', value: '***', has_value: true }],
body_rules: [], config: {}, max_retries: 0, total_keys: 0, active_keys: 0,
created_at: '2026-09-07T00:00:00Z', updated_at: '2026-09-07T00:00:00Z',
}
const mounted: Array<{ app: App, root: HTMLElement }> = []
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
async function mountDialog() {
const props = reactive({
modelValue: true,
provider: { id: 'provider-1', provider_type: 'custom', name: 'Custom' } as ProviderWithEndpointsSummary,
endpoints: [structuredClone(endpoint)],
})
const component = ref<ComponentPublicInstance>()
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({ setup: () => () => h(EndpointFormDialog, { ...props, ref: component }) }))
app.mount(root)
mounted.push({ app, root })
await settle()
const { setupState: state } = component.value!.$ as unknown as { setupState: DialogState }
return { props, state }
}
beforeEach(() => {
api.updateEndpoint.mockReset().mockResolvedValue(structuredClone(endpoint))
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule saving', () => {
it('resets dirty state from the saved projection and preserves the new secret marker', async () => {
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'replacement-secret')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
await state.saveEndpoint(state.localEndpoints[0])
await settle()
expect(api.updateEndpoint).toHaveBeenCalledWith(endpoint.id, {
header_rules: [{ action: 'set', key: 'x-auth', value: 'replacement-secret' }],
})
expect(state.getEndpointEditRules(endpoint.id)[0]).toMatchObject({ value: '***', retainValue: true })
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
})
it('does not overwrite newer edits when a save finishes', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'first-edit')
const saving = state.saveEndpoint(state.localEndpoints[0])
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'newer-edit')
resolveSave(structuredClone(endpoint))
await saving
expect(state.getEndpointEditRules(endpoint.id)[0].value).toBe('newer-edit')
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(true)
})
it('refreshes JSON mode with the saved projection instead of retaining submitted plaintext', async () => {
const { state } = await mountDialog()
state.enterEndpointRulesJsonMode(state.localEndpoints[0])
state.updateEndpointRulesJsonDraft(endpoint.id, JSON.stringify({
header_rules: [{ action: 'set', key: 'x-auth', value: 'json-secret' }],
body_rules: [], response_header_rules: [],
}))
await state.saveEndpoint(state.localEndpoints[0])
const savedDraft = state.endpointRulesJsonDraft[endpoint.id]
expect(savedDraft).not.toContain('json-secret')
expect(JSON.parse(savedDraft).header_rules[0]).toMatchObject({ value: '***', has_value: true })
expect(state.endpointRulesJsonDirty[endpoint.id]).toBe(false)
expect(state.hasRulePanelChanges(state.localEndpoints[0])).toBe(false)
})
it('clears secret drafts on close and ignores the stale save response', async () => {
let resolveSave!: (saved: ProviderEndpoint) => void
api.updateEndpoint.mockImplementationOnce(() => new Promise(resolve => { resolveSave = resolve }))
const { props, state } = await mountDialog()
state.updateEndpointRuleField(endpoint.id, 0, 'value', 'unsaved-secret')
const saving = state.saveEndpoint(state.localEndpoints[0])
props.modelValue = false
await settle()
resolveSave(structuredClone(endpoint))
await saving
expect(state.endpointEditStates).toEqual({})
expect(state.localEndpoints).toEqual([])
})
})
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, reactive, type App } from 'vue'
import EndpointRulesRevealDialog from '../EndpointRulesRevealDialog.vue'
import type { ProviderEndpointRules } from '@/api/endpoints'
const api = vi.hoisted(() => ({ revealEndpointRules: vi.fn() }))
vi.mock('@/api/endpoints', () => api)
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Dialog: defineComponent({
props: { modelValue: Boolean },
setup: (props, { slots }) => () => props.modelValue
? h('section', [slots.default?.(), slots.footer?.()]) : null,
}),
Button: defineComponent({ setup: (_props, { slots }) => () => h('button', slots.default?.()) }),
Textarea: defineComponent({
props: { modelValue: String },
setup: (props) => () => h('textarea', { value: props.modelValue }),
}),
}
})
const mounted: Array<{ app: App, root: HTMLElement }> = []
const savedRules: ProviderEndpointRules = {
header_rules: [{ action: 'set', key: 'x-auth', value: 'request-secret' }],
body_rules: [{ action: 'set', path: 'auth.token', value: 'body-secret' }],
response_header_rules: [{ action: 'set', key: 'x-auth', value: 'response-secret' }],
}
async function settle() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
await nextTick()
}
}
function mountDialog(open = true) {
const props = reactive({ modelValue: open, endpointId: 'endpoint-1' as string | null })
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
setup: () => () => h(EndpointRulesRevealDialog, {
...props,
'onUpdate:modelValue': (value: boolean) => { props.modelValue = value },
}),
}))
app.mount(root)
mounted.push({ app, root })
return { props, root, app }
}
beforeEach(() => {
api.revealEndpointRules.mockReset().mockResolvedValue(savedRules)
})
afterEach(() => {
for (const { app, root } of mounted.splice(0)) {
app.unmount()
root.remove()
}
})
describe('endpoint rule reveal', () => {
it('fetches only on demand and displays saved rules read-only', async () => {
const { props, root } = mountDialog(false)
await settle()
expect(api.revealEndpointRules).not.toHaveBeenCalled()
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledOnce()
expect(api.revealEndpointRules.mock.calls[0][0]).toBe('endpoint-1')
const textarea = root.querySelector('textarea')!
expect(JSON.parse(textarea.value)).toEqual(savedRules)
expect(textarea.readOnly).toBe(true)
})
it('clears plaintext on close and fetches again instead of caching', async () => {
const { props, root } = mountDialog()
await settle()
const signal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
root.querySelector('button')!.click()
await settle()
expect(signal.aborted).toBe(true)
expect(root.querySelector('textarea')).toBeNull()
api.revealEndpointRules.mockResolvedValue({ ...savedRules, body_rules: [] })
props.modelValue = true
await settle()
expect(api.revealEndpointRules).toHaveBeenCalledTimes(2)
expect(JSON.parse(root.querySelector('textarea')!.value).body_rules).toEqual([])
})
it('ignores an old endpoint response after switching endpoints', async () => {
let resolveFirst!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve }))
const { props, root } = mountDialog()
await settle()
const oldSignal = api.revealEndpointRules.mock.calls[0][1] as AbortSignal
props.endpointId = 'endpoint-2'
api.revealEndpointRules.mockResolvedValue({ header_rules: [], body_rules: [], response_header_rules: [] })
await settle()
expect(oldSignal.aborted).toBe(true)
resolveFirst(savedRules)
await settle()
expect(JSON.parse(root.querySelector('textarea')!.value).header_rules).toEqual([])
})
it('ignores an in-flight response after the dialog closes', async () => {
let resolveRequest!: (rules: ProviderEndpointRules) => void
api.revealEndpointRules.mockImplementationOnce(() => new Promise(resolve => { resolveRequest = resolve }))
const { props, root } = mountDialog()
await settle()
props.modelValue = false
await settle()
resolveRequest(savedRules)
await settle()
expect(root.querySelector('textarea')).toBeNull()
expect(root.innerHTML).not.toContain('secret')
})
it('does not expose API error details in the dialog', async () => {
api.revealEndpointRules.mockRejectedValue(new Error('Bearer upstream-secret'))
const { root } = mountDialog()
await settle()
expect(root.querySelector('[role="alert"]')).not.toBeNull()
expect(root.innerHTML).not.toContain('upstream-secret')
})
})
@@ -132,11 +132,17 @@ describe('ModelMappingDialog', () => {
error: null,
warning: null,
})
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-1',
provider_model_name: 'provider-model-1',
provider_model_mappings: [],
} as Model
}
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -186,12 +192,18 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
provider_model_mappings: [],
} as Model
}
const open = ref(false)
const root = document.createElement('div')
document.body.appendChild(root)
@@ -239,7 +251,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -249,7 +267,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [responsesEndpoint.id],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -304,7 +322,13 @@ describe('ModelMappingDialog', () => {
})
it('preserves an edited compact scope when endpoint capabilities are unavailable', async () => {
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -314,7 +338,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: ['endpoint-responses'],
operations: ['compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -369,7 +393,13 @@ describe('ModelMappingDialog', () => {
base_url: 'https://api.example.com/v1',
is_active: true,
} as ProviderEndpoint
const model = {
const model: Model = {
provider_id: 'provider-1',
global_model_id: 'global-model-1',
is_active: true,
is_available: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
id: 'model-sol',
provider_model_name: 'gpt-5.6-sol',
global_model_display_name: 'GPT-5.6 Sol',
@@ -379,7 +409,7 @@ describe('ModelMappingDialog', () => {
endpoint_ids: [endpoint.id],
operations: ['Compact'],
}],
} as Model
}
const editingGroup: AliasGroup = {
model,
apiFormatsKey: '',
@@ -5,29 +5,45 @@ import { renderToString } from '@vue/server-renderer'
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
import ModelMappingTab from '../provider-tabs/ModelMappingTab.vue'
const provider = {
const provider: ProviderWithEndpointsSummary = {
id: 'provider-demo',
name: 'Demo Provider',
provider_type: 'custom',
is_active: true,
active_keys: 0,
api_formats: [],
} as ProviderWithEndpointsSummary
provider_priority: 0,
keep_priority_on_conversion: false,
enable_format_conversion: true,
total_endpoints: 0,
active_endpoints: 0,
total_keys: 0,
total_models: 0,
active_models: 0,
global_model_ids: [],
avg_health_score: null,
unhealthy_endpoints: 0,
endpoint_health_details: [],
ops_configured: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
describe('ModelMappingTab response contracts', () => {
it('keeps the module visible when a legacy or malformed preview reaches the component', async () => {
const props: InstanceType<typeof ModelMappingTab>['$props'] = {
provider,
models: [],
endpoints: [],
providerKeys: [],
loading: false,
}
Reflect.set(props, 'mappingPreview', {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
})
const app = createSSRApp({
render: () => h(ModelMappingTab, {
provider,
models: [],
endpoints: [],
providerKeys: [],
mappingPreview: {
message: '演示模式:该接口暂未模拟',
demo_mode: true,
},
loading: false,
}),
render: () => h(ModelMappingTab, props),
})
const html = await renderToString(app)
@@ -582,8 +582,8 @@ describe('OAuthAccountDialog authorization and import', () => {
'批量授权完成:成功 0 个(新增 0 个,替换 0 个),失败 4 个;#2 invalid cookie;#3 expired cookie',
'错误',
)
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls.at(-1)?.[0]).not.toContain('third safe reason')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('must-not-leak')
expect(toastMocks.error.mock.calls[toastMocks.error.mock.calls.length - 1]?.[0]).not.toContain('third safe reason')
expect(toastMocks.warning).not.toHaveBeenCalled()
})
@@ -85,7 +85,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyActionCluster>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -47,7 +47,7 @@ function createProviderKey(overrides: Partial<EndpointAPIKey> = {}): EndpointAPI
}
}
function mount(props: Record<string, unknown>) {
function mount(props: InstanceType<typeof ProviderKeyIdentityBlock>['$props']) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(defineComponent({
@@ -277,7 +277,7 @@ export function getVisibleCodexResetCreditItems(
if (!snapshot || !Array.isArray(credits)) return []
return credits
.map((item) => {
.map((item): CodexResetCreditDisplayCandidate | null => {
if (!codexResetCreditStatusIsDisplayable(item)) return null
const remainingSeconds = codexResetCreditRemainingSeconds(item, snapshot, nowUnixSecs)
if (remainingSeconds === null || remainingSeconds <= 0) return null
@@ -468,7 +468,8 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
apiFormat = group.apiFormats[0]
} else if (group.apiFormats.length === 0) {
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
apiFormat = group.model.effective_api_format || group.model.api_format
const formats = group.model.effective_config?.api_formats ?? group.model.config?.api_formats ?? []
if (formats.length === 1) apiFormat = formats[0]
}
const result = await testModel(
@@ -456,7 +456,7 @@ function hasRequestPricing(model: Model): boolean {
function hasVideoPricing(model: Model): boolean {
const priceByResolution = model.effective_config?.billing?.video?.price_per_second_by_resolution
|| model.config?.billing?.video?.price_per_second_by_resolution
return priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
return !!priceByResolution && typeof priceByResolution === 'object' && Object.keys(priceByResolution).length > 0
}
// 获取视频计费的显示文本