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

888 lines
30 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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
const { updateAccountMock, checkMixedChannelRiskMock, authIsSimpleMode } = vi.hoisted(() => ({
updateAccountMock: vi.fn(),
checkMixedChannelRiskMock: vi.fn(),
authIsSimpleMode: { value: true }
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn()
})
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
get isSimpleMode() {
return authIsSimpleMode.value
}
})
}))
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>
`
})
const GroupSelectorStub = defineComponent({
name: 'GroupSelector',
props: {
modelValue: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue'],
template: `
<div data-testid="group-selector">
<button
type="button"
data-testid="set-shadow-group"
@click="$emit('update:modelValue', [7])"
>
group
</button>
</div>
`
})
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 buildOpenAISparkShadowAccount() {
const account = buildAccount()
return {
...account,
id: 4,
name: 'OpenAI Spark Shadow',
type: 'oauth',
parent_account_id: 1,
credentials: {
access_token: 'parent-access-token',
refresh_token: 'parent-refresh-token',
api_key: 'sk-parent',
base_url: 'https://api.openai.com',
model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
},
compact_model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-compact'
}
},
group_ids: []
} 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 buildAntigravityAccount(projectId = 'configured-project') {
return {
id: 3,
name: 'Antigravity OAuth',
notes: '',
platform: 'antigravity',
type: 'oauth',
credentials: {
antigravity_project_id: projectId,
model_mapping: {
'gemini-2.5-flash': 'gemini-2.5-flash'
}
},
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 buildGrokOAuthAccount() {
return {
id: 5,
name: 'Grok OAuth',
notes: '',
platform: 'grok',
type: 'oauth',
credentials: {
refresh_token: 'grok-rt',
base_url: 'https://api.x.ai/v1',
model_mapping: {
'grok-latest': 'grok-4.3'
}
},
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 buildOpenAISetupTokenAccount() {
return {
...buildAccount(),
type: 'setup-token',
extra: {
openai_oauth_responses_websockets_v2_mode: 'ctx_pool',
openai_oauth_responses_websockets_v2_enabled: true
}
} 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: GroupSelectorStub,
ModelWhitelistSelector: ModelWhitelistSelectorStub
}
}
})
}
describe('EditAccountModal', () => {
beforeEach(() => {
authIsSimpleMode.value = true
})
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('loads and submits the per-account OpenAI long-context billing toggle', async () => {
const account = buildAccount()
account.extra = {
openai_long_context_billing_enabled: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const toggle = wrapper.get('[data-testid="openai-long-context-billing-toggle"]')
expect(toggle.attributes('aria-checked')).toBe('true')
await toggle.trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_long_context_billing_enabled).toBe(false)
})
it('loads and submits Grok OAuth model mapping edits', async () => {
const account = buildGrokOAuthAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
expect(wrapper.text()).toContain('Imagine Image')
expect(wrapper.text()).toContain('Imagine Video')
const inputWithValue = (value: string) => {
const input = wrapper
.findAll('input')
.find((input) => (input.element as HTMLInputElement).value === value)
expect(input).toBeTruthy()
return input!
}
await inputWithValue('grok-latest').setValue('grok')
await inputWithValue('grok-4.3').setValue('grok-build-0.1')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.model_mapping).toEqual({
grok: 'grok-build-0.1'
})
})
it('only submits model mapping credentials when saving an OpenAI spark shadow account', async () => {
authIsSimpleMode.value = false
const account = buildOpenAISparkShadowAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="set-shadow-group"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
const payload = updateAccountMock.mock.calls[0]?.[1]
expect(payload?.group_ids).toEqual([7])
expect(payload?.credentials).toEqual({
model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark'
},
compact_model_mapping: {
'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark-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('submits OpenAI quota auto-pause thresholds in extra', async () => {
const account = buildAccount()
account.extra = {
auto_pause_5h_threshold: 0.9,
auto_pause_7d_threshold: 0.8
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="auto-pause-5h-threshold"]').setValue('95')
await wrapper.get('[data-testid="auto-pause-7d-threshold"]').setValue('96')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_5h_threshold).toBe(0.95)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_threshold).toBe(0.96)
})
it('submits OpenAI quota auto-pause disable flag in extra', async () => {
// Toggling the per-account disable flag must persist as auto_pause_5h_disabled
// so an admin can exempt one account from auto-pause even when a global default
// threshold is configured (otherwise leaving the threshold blank would silently
// fall back to the global default).
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="auto-pause-5h-disabled"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_5h_disabled).toBe(true)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_disabled).toBeUndefined()
})
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('disables text generation protocol when only embeddings requests are accepted', async () => {
const account = buildAccount()
account.credentials.openai_capabilities = ['embeddings']
account.extra = {
openai_responses_mode: 'force_responses',
openai_responses_supported: true
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const responsesModeSelect = wrapper.get<HTMLSelectElement>(
'[data-testid="openai-responses-mode-select"]'
)
expect(responsesModeSelect.element.disabled).toBe(true)
expect(wrapper.find('[data-testid="openai-responses-mode-not-applicable"]').exists()).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([
'embeddings'
])
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 Codex image tool force-inject mode as 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-tool-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')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
})
it('submits Codex image tool no-injection mode without strip policy', async () => {
const account = buildAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-tool-disabled"]').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(false)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
})
it('submits Codex image tool block mode as strip policy and clears bridge override', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_bridge: 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-tool-block"]').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_explicit_tool_policy).toBe('strip')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge')
})
it('loads strip policy as block mode and clears both keys when reset to inherit', async () => {
const account = buildAccount()
account.extra = {
codex_image_generation_explicit_tool_policy: 'strip'
}
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('button[data-testid="codex-image-tool-inherit"]').trigger('click')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_explicit_tool_policy')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge')
})
it('setup-token account can select and submit OAuth WS mode', async () => {
const account = buildOpenAISetupTokenAccount()
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
await wrapper.get('[data-testid="edit-openai-ws-mode-select"]').setValue('http_bridge')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_oauth_responses_websockets_v2_mode).toBe('http_bridge')
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_oauth_responses_websockets_v2_enabled).toBe(true)
})
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()
})
it('loads and submits Antigravity configured project fallback', async () => {
const account = buildAntigravityAccount('configured-project')
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const input = wrapper.get<HTMLInputElement>('[data-testid="antigravity-project-id-input"]')
expect(input.element.value).toBe('configured-project')
await input.setValue(' updated-project ')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.antigravity_project_id).toBe(
'updated-project'
)
})
it('clears Antigravity configured project fallback when input is empty', async () => {
const account = buildAntigravityAccount('configured-project')
updateAccountMock.mockReset()
checkMixedChannelRiskMock.mockReset()
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
updateAccountMock.mockResolvedValue(account)
const wrapper = mountModal(account)
const input = wrapper.get<HTMLInputElement>('[data-testid="antigravity-project-id-input"]')
await input.setValue('')
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
expect(updateAccountMock).toHaveBeenCalledTimes(1)
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).not.toHaveProperty(
'antigravity_project_id'
)
})
})