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
|
// Handle API key
|
||||||
// 后端响应已脱敏:currentCredentials 不会再包含 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()) {
|
if (editApiKey.value.trim()) {
|
||||||
newCredentials.api_key = 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'))
|
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -3433,10 +3436,14 @@ const handleSubmit = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// SA JSON 已脱敏不再随 credentials 返回,存在性改读 credentials_status。
|
// SA JSON 已脱敏不再随 credentials 返回,存在性优先读 credentials_status。
|
||||||
const hasExistingServiceAccountJson =
|
// 若后端尚未升级(无 credentials_status),回退读旧结构 service_account_json / service_account。
|
||||||
props.account.credentials_status?.has_service_account_json ||
|
const credentialsStatus = props.account.credentials_status
|
||||||
props.account.credentials_status?.has_service_account
|
const hasExistingServiceAccountJson = credentialsStatus
|
||||||
|
? Boolean(
|
||||||
|
credentialsStatus.has_service_account_json || credentialsStatus.has_service_account
|
||||||
|
)
|
||||||
|
: Boolean(currentCredentials.service_account_json || currentCredentials.service_account)
|
||||||
if (!hasExistingServiceAccountJson) {
|
if (!hasExistingServiceAccountJson) {
|
||||||
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
|
appStore.showError(t('admin.accounts.vertexSaJsonRequired'))
|
||||||
return
|
return
|
||||||
|
|||||||
@ -141,6 +141,32 @@ function buildAccount() {
|
|||||||
} as any
|
} 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()) {
|
function mountModal(account = buildAccount()) {
|
||||||
return mount(EditAccountModal, {
|
return mount(EditAccountModal, {
|
||||||
props: {
|
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?.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_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