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:
parent
0f8e2d0934
commit
3ca232ad06
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user