fix(frontend): 编辑弹窗回退旧 credentials 结构以兼容旧后端

新代码仅依赖 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) <noreply@anthropic.com>
This commit is contained in:
haruka 2026-05-17 03:02:08 +08:00
parent 0f8e2d0934
commit 3ca232ad06
2 changed files with 158 additions and 7 deletions

View File

@ -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

View File

@ -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_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()
})
})