509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
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_key,credentials_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_json,credentials_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()
|
||
})
|
||
})
|