From 3ca232ad0611b883ed25ad4e878c399abcfa5851 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 17 May 2026 03:02:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E7=BC=96=E8=BE=91=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=9B=9E=E9=80=80=E6=97=A7=20credentials=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E4=BB=A5=E5=85=BC=E5=AE=B9=E6=97=A7=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新代码仅依赖 credentials_status 会导致两种灰度场景被误判为未配置: - 新前端 + 旧后端:旧后端未返回 credentials_status,前端读不到已脱敏的 api_key / service_account_json,阻止保存。 - 旧前端 + 新后端:旧前端也读不到已脱敏字段(旧前端不在本 PR 范围)。 修复: - API key 判断改为 credentials_status?.has_api_key ?? Boolean(currentCredentials.api_key) - Vertex SA 判断:有 credentials_status 用 status,否则回退读 credentials.service_account_json / service_account 补充测试覆盖: - apikey/Vertex SA 各自的新后端脱敏响应、旧后端未脱敏响应、 两者皆缺时阻止保存。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/account/EditAccountModal.vue | 21 ++- .../__tests__/EditAccountModal.spec.ts | 144 ++++++++++++++++++ 2 files changed, 158 insertions(+), 7 deletions(-) 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() + }) })