sub2api/frontend/src/components/account/__tests__/EditAccountModal.spec.ts

509 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
const { updateAccountMock, checkMixedChannelRiskMock } = vi.hoisted(() => ({
updateAccountMock: vi.fn(),
checkMixedChannelRiskMock: vi.fn()
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
isSimpleMode: true
})
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
update: updateAccountMock,
checkMixedChannelRisk: checkMixedChannelRiskMock
},
settings: {
getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }),
getSettings: vi.fn().mockResolvedValue({})
},
tlsFingerprintProfiles: {
list: vi.fn().mockResolvedValue([])
}
}
}))
vi.mock('@/api/admin/accounts', () => ({
getAntigravityDefaultModelMapping: vi.fn()
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
import EditAccountModal from '../EditAccountModal.vue'
const BaseDialogStub = defineComponent({
name: 'BaseDialog',
props: {
show: {
type: Boolean,
default: false
}
},
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
})
const ModelWhitelistSelectorStub = defineComponent({
name: 'ModelWhitelistSelector',
props: {
modelValue: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<div>
<button
type="button"
data-testid="rewrite-to-snapshot"
@click="$emit('update:modelValue', ['gpt-5.2-2025-12-11'])"
>
rewrite
</button>
<span data-testid="model-whitelist-value">
{{ Array.isArray(modelValue) ? modelValue.join(',') : '' }}
</span>
</div>
`
})
const SelectStub = defineComponent({
name: 'SelectStub',
props: {
modelValue: {
type: [String, Number, Boolean, null],
default: ''
},
options: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<select
v-bind="$attrs"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
`
})
function buildAccount() {
return {
id: 1,
name: 'OpenAI Key',
notes: '',
platform: 'openai',
type: 'apikey',
credentials: {
api_key: 'sk-test',
base_url: 'https://api.openai.com',
model_mapping: {
'gpt-5.2': 'gpt-5.2'
}
},
extra: {},
proxy_id: null,
concurrency: 1,
priority: 1,
rate_multiplier: 1,
status: 'active',
group_ids: [],
expires_at: null,
auto_pause_on_expired: false
} as any
}
function buildVertexAccount() {
return {
id: 2,
name: 'Vertex SA',
notes: '',
platform: 'gemini',
type: 'service_account',
credentials: {
service_account_json: '{"type":"service_account","client_email":"sa@example.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\\nMIIE\\n-----END PRIVATE KEY-----\\n"}',
project_id: 'demo-project',
client_email: 'sa@example.iam.gserviceaccount.com',
location: 'us-central1',
tier_id: 'vertex'
},
extra: {},
proxy_id: null,
concurrency: 1,
priority: 1,
rate_multiplier: 1,
status: 'active',
group_ids: [],
expires_at: null,
auto_pause_on_expired: false
} as any
}
function mountModal(account = buildAccount()) {
return mount(EditAccountModal, {
props: {
show: true,
account,
proxies: [],
groups: []
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
Select: SelectStub,
Icon: true,
ProxySelector: true,
GroupSelector: true,
ModelWhitelistSelector: ModelWhitelistSelectorStub
}
}
})
}
describe('EditAccountModal', () => {
it('reopening the same account rehydrates the OpenAI whitelist from props', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2')
await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click')
expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2-2025-12-11')
await wrapper.setProps({ show: false })
await wrapper.setProps({ show: true })
expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({
'gpt-5.2': 'gpt-5.2'
})
})
it('preserves model mappings when editing the whitelist', async () => {
const account = buildAccount()
account.credentials.model_mapping = {
'gpt-5.2': 'gpt-5.2',
'gpt-latest': 'gpt-5.2'
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.get('[data-testid="model-whitelist-value"]').text()).toBe('gpt-5.2')
await wrapper.get('[data-testid="rewrite-to-snapshot"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({
'gpt-5.2-2025-12-11': 'gpt-5.2-2025-12-11',
'gpt-latest': 'gpt-5.2'
})
})
it('submits OpenAI compact mode and compact-only model mapping', async () => {
const account = buildAccount()
account.extra = {
openai_compact_mode: 'force_on'
}
account.credentials = {
...account.credentials,
compact_model_mapping: {
'gpt-5.4': 'gpt-5.4-openai-compact'
}
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_compact_mode).toBe('force_on')
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.compact_model_mapping).toEqual({
'gpt-5.4': 'gpt-5.4-openai-compact'
})
})
it('submits OpenAI APIKey Responses support override mode', async () => {
const account = buildAccount()
account.extra = {
openai_responses_mode: 'force_chat_completions',
openai_responses_supported: false
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="openai-responses-mode-select"]').setValue('force_responses')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_mode).toBe('force_responses')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(false)
})
it('clears OpenAI APIKey Responses override when set back to auto', async () => {
const account = buildAccount()
account.extra = {
openai_responses_mode: 'force_chat_completions',
openai_responses_supported: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="openai-responses-mode-select"]').setValue('auto')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_responses_mode')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true)
})
it('submits OpenAI APIKey endpoint capabilities from credentials', async () => {
const account = buildAccount()
account.credentials.openai_capabilities = ['chat_completions']
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.findAll('input[type="checkbox"]').some((input) => (input.element as HTMLInputElement).checked)).toBe(true)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([
'chat_completions'
])
})
it('keeps at least one OpenAI APIKey endpoint capability selected', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const chatCheckbox = wrapper.get<HTMLInputElement>(
'[data-testid="openai-endpoint-capability-chat_completions"]'
)
const embeddingsCheckbox = wrapper.get<HTMLInputElement>(
'[data-testid="openai-endpoint-capability-embeddings"]'
)
expect(chatCheckbox.element.checked).toBe(true)
expect(embeddingsCheckbox.element.checked).toBe(true)
await embeddingsCheckbox.setValue(false)
expect(chatCheckbox.element.checked).toBe(true)
expect(embeddingsCheckbox.element.checked).toBe(false)
await chatCheckbox.setValue(false)
expect(chatCheckbox.element.checked).toBe(true)
expect(embeddingsCheckbox.element.checked).toBe(false)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([
'chat_completions'
])
})
it('submits account-level Codex image generation bridge override', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_bridge: false,
codex_image_generation_bridge_enabled: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled')
})
it('allows saving apikey account when backend redacted api_key but credentials_status reports it exists', async () => {
// 新前端 + 新后端响应已脱敏credentials 里没有 api_keycredentials_status.has_api_key=true
const account = buildAccount()
account.credentials = {
base_url: 'https://api.openai.com',
model_mapping: { 'gpt-5.2': 'gpt-5.2' }
}
account.credentials_status = { has_api_key: true }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
// 用户未输入新 key 时payload 不应带 api_key由后端合并保留旧值
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty('api_key')
})
it('allows saving apikey account against legacy backend without credentials_status', async () => {
// 新前端 + 旧后端credentials_status 缺失,但 credentials.api_key 仍是明文,应允许保存
const account = buildAccount()
// 显式确保没有 credentials_status
expect(account.credentials_status).toBeUndefined()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
// 旧后端响应未脱敏,原 api_key 会随 currentCredentials 一起传回去(旧行为,等价于无操作)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.api_key).toBe('sk-test')
})
it('blocks apikey save when neither credentials_status nor legacy api_key indicates existence', async () => {
const account = buildAccount()
account.credentials = {
base_url: 'https://api.openai.com'
}
// 既没有 credentials_status 也没有旧的 api_key
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).not.toHaveBeenCalled()
})
it('allows saving Vertex SA account when backend redacted service_account_json but credentials_status reports it exists', async () => {
// 新前端 + 新后端响应已脱敏credentials 里没有 service_account_jsoncredentials_status.has_service_account_json=true
const account = buildVertexAccount()
account.credentials = {
project_id: 'demo-project',
client_email: 'sa@example.iam.gserviceaccount.com',
location: 'us-central1',
tier_id: 'vertex'
}
account.credentials_status = { has_service_account_json: true }
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.project_id).toBe('demo-project')
})
it('allows saving Vertex SA account against legacy backend without credentials_status', async () => {
// 新前端 + 旧后端credentials_status 缺失,但 credentials.service_account_json 仍是明文,应允许保存
const account = buildVertexAccount()
expect(account.credentials_status).toBeUndefined()
expect(account.credentials.service_account_json).toBeTruthy()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
})
it('blocks Vertex SA save when neither credentials_status nor legacy json indicates existence', async () => {
const account = buildVertexAccount()
account.credentials = {
project_id: 'demo-project',
client_email: 'sa@example.iam.gserviceaccount.com',
location: 'us-central1',
tier_id: 'vertex'
}
// 既没有 credentials_status 也没有旧的 service_account_json
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
const wrapper = mountModal(account)
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).not.toHaveBeenCalled()
})
})