diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 059813c9..029c5819 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3344,11 +3344,14 @@ const handleSubmit = async () => { // Handle API key // 后端响应已脱敏:currentCredentials 不会再包含 api_key 原文。 - // 用户填入新值则覆盖;留空且后端已存在旧 key(看 credentials_status)则不带 api_key 字段, - // 由后端合并保留;两者都无才报错。 + // 用户填入新值则覆盖;留空时优先看 credentials_status.has_api_key; + // 若后端尚未升级(无 credentials_status),回退读旧结构 currentCredentials.api_key。 + // 两者都无才报错。 + const hasExistingApiKey = + props.account.credentials_status?.has_api_key ?? Boolean(currentCredentials.api_key) if (editApiKey.value.trim()) { newCredentials.api_key = editApiKey.value.trim() - } else if (!props.account.credentials_status?.has_api_key) { + } else if (!hasExistingApiKey) { appStore.showError(t('admin.accounts.apiKeyIsRequired')) return } @@ -3433,10 +3436,14 @@ const handleSubmit = async () => { return } - // SA JSON 已脱敏不再随 credentials 返回,存在性改读 credentials_status。 - const hasExistingServiceAccountJson = - props.account.credentials_status?.has_service_account_json || - props.account.credentials_status?.has_service_account + // SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。 + // 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。 + const credentialsStatus = props.account.credentials_status + const hasExistingServiceAccountJson = credentialsStatus + ? Boolean( + credentialsStatus.has_service_account_json || credentialsStatus.has_service_account + ) + : Boolean(currentCredentials.service_account_json || currentCredentials.service_account) if (!hasExistingServiceAccountJson) { appStore.showError(t('admin.accounts.vertexSaJsonRequired')) return diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 04486154..db7ea653 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -141,6 +141,32 @@ function buildAccount() { } 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: { @@ -237,4 +263,122 @@ describe('EditAccountModal', () => { 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() + }) })