Merge pull request #2271 from StarryKira/fix/redact-account-credentials

fix(security): 屏蔽 admin 账号接口返回的敏感凭证字段
This commit is contained in:
Wesley Liddick 2026-05-19 16:15:36 +08:00 committed by GitHub
commit a929e285ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 661 additions and 27 deletions

View File

@ -43,6 +43,9 @@ type DataProxy struct {
Status string `json:"status"`
}
// DataAccount 是管理员显式备份导出使用的账号结构,故意不走 dto.Account 的脱敏路径,
// Credentials 原文返回。这是"管理员备份"这一显式行为的一部分;如未来需要导出脱敏版本,
// 应新增独立结构而非修改这里。
type DataAccount struct {
Name string `json:"name"`
Notes *string `json:"notes,omitempty"`

View File

@ -0,0 +1,67 @@
package dto
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) {
src := &service.Account{
ID: 42,
Name: "demo",
Platform: "anthropic",
Type: "oauth",
Credentials: map[string]any{
"access_token": "at-secret",
"refresh_token": "rt-secret",
"id_token": "id-secret",
"api_key": "sk-secret",
"base_url": "https://api.example.com",
"model_mapping": map[string]any{"foo": "bar"},
},
}
got := AccountFromServiceShallow(src)
require.NotNil(t, got)
// 敏感键不在 Credentials 里
require.NotContains(t, got.Credentials, "access_token")
require.NotContains(t, got.Credentials, "refresh_token")
require.NotContains(t, got.Credentials, "id_token")
require.NotContains(t, got.Credentials, "api_key")
// 非敏感键保留
require.Equal(t, "https://api.example.com", got.Credentials["base_url"])
require.Equal(t, map[string]any{"foo": "bar"}, got.Credentials["model_mapping"])
// 状态 map 标记敏感键存在
require.True(t, got.CredentialsStatus["has_access_token"])
require.True(t, got.CredentialsStatus["has_refresh_token"])
require.True(t, got.CredentialsStatus["has_id_token"])
require.True(t, got.CredentialsStatus["has_api_key"])
// JSON 序列化校验:响应体里不会出现敏感子串
raw, err := json.Marshal(got)
require.NoError(t, err)
require.NotContains(t, string(raw), "rt-secret")
require.NotContains(t, string(raw), "at-secret")
require.NotContains(t, string(raw), "sk-secret")
require.NotContains(t, string(raw), "id-secret")
// 状态标识应序列化进 JSON
require.Contains(t, string(raw), "credentials_status")
require.Contains(t, string(raw), "has_refresh_token")
// 原始 service.Account 不应被改动
require.Equal(t, "rt-secret", src.Credentials["refresh_token"])
}
func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) {
src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"}
got := AccountFromServiceShallow(src)
require.NotNil(t, got)
require.Nil(t, got.Credentials)
require.Nil(t, got.CredentialsStatus)
}

View File

@ -0,0 +1,44 @@
// Package dto provides data transfer objects for HTTP handlers.
package dto
import "github.com/Wei-Shaw/sub2api/internal/service"
// RedactCredentials 复制一份 in剥离 service.SensitiveCredentialKeys 列出的所有敏感子键,
// 并产出一个 has_<key> 状态 map 表示哪些敏感键存在且非零值。
//
// 输入 nil 时返回 nil, nil避免响应里出现空对象
// 不修改入参;调用方拿到的 out 可安全序列化进 JSON 返回前端。
func RedactCredentials(in map[string]any) (out map[string]any, status map[string]bool) {
if in == nil {
return nil, nil
}
out = make(map[string]any, len(in))
for k, v := range in {
if service.IsSensitiveCredentialKey(k) {
if isCredentialValuePresent(v) {
if status == nil {
status = make(map[string]bool, 4)
}
status["has_"+k] = true
}
continue
}
out[k] = v
}
return out, status
}
// isCredentialValuePresent 判断值是否"存在且非零"。空字符串、nil、false 均视为未配置;
// 其余非零类型(数字、对象、字符串等)视为已配置。
func isCredentialValuePresent(v any) bool {
switch x := v.(type) {
case nil:
return false
case string:
return x != ""
case bool:
return x
default:
return true
}
}

View File

@ -0,0 +1,97 @@
package dto
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestRedactCredentials_NilInput(t *testing.T) {
out, status := RedactCredentials(nil)
require.Nil(t, out)
require.Nil(t, status)
}
func TestRedactCredentials_StripsSensitiveKeysAndReportsStatus(t *testing.T) {
in := map[string]any{
"refresh_token": "rt-secret",
"access_token": "at-secret",
"api_key": "sk-secret",
"aws_secret_access_key": "aws-secret",
"service_account_json": map[string]any{"private_key": "..."},
"private_key": "raw-key",
// 非敏感
"base_url": "https://api.example.com",
"model_mapping": map[string]any{"foo": "bar"},
"project_id": "proj-1",
"expires_at": int64(123456),
}
out, status := RedactCredentials(in)
require.NotContains(t, out, "refresh_token")
require.NotContains(t, out, "access_token")
require.NotContains(t, out, "api_key")
require.NotContains(t, out, "aws_secret_access_key")
require.NotContains(t, out, "service_account_json")
require.NotContains(t, out, "private_key")
require.Equal(t, "https://api.example.com", out["base_url"])
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
require.Equal(t, "proj-1", out["project_id"])
require.Equal(t, int64(123456), out["expires_at"])
require.True(t, status["has_refresh_token"])
require.True(t, status["has_access_token"])
require.True(t, status["has_api_key"])
require.True(t, status["has_aws_secret_access_key"])
require.True(t, status["has_service_account_json"])
require.True(t, status["has_private_key"])
// 状态 map 不应携带非敏感键的 has_*
require.NotContains(t, status, "has_base_url")
require.NotContains(t, status, "has_project_id")
}
func TestRedactCredentials_EmptyValuesNotMarkedPresent(t *testing.T) {
in := map[string]any{
"refresh_token": "",
"access_token": nil,
"api_key": false,
"id_token": "actual-id",
}
out, status := RedactCredentials(in)
require.Empty(t, out, "敏感键即使为空也不应出现在 redacted output")
require.False(t, status["has_refresh_token"])
require.False(t, status["has_access_token"])
require.False(t, status["has_api_key"])
require.True(t, status["has_id_token"])
}
func TestRedactCredentials_DoesNotMutateInput(t *testing.T) {
in := map[string]any{
"refresh_token": "secret",
"base_url": "x",
}
_, _ = RedactCredentials(in)
require.Equal(t, "secret", in["refresh_token"], "原始 map 不应被修改")
require.Equal(t, "x", in["base_url"])
}
func TestRedactCredentials_AllKnownSensitiveKeys(t *testing.T) {
keys := []string{
"access_token", "refresh_token", "id_token",
"api_key", "session_key", "cookie",
"aws_secret_access_key", "aws_session_token",
"service_account_json", "service_account", "private_key",
}
in := make(map[string]any, len(keys))
for _, k := range keys {
in[k] = "filled"
}
out, status := RedactCredentials(in)
require.Empty(t, out)
for _, k := range keys {
require.True(t, status["has_"+k], "key %s 应在 status 中标记为已配置", k)
}
}

View File

@ -198,13 +198,15 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if a == nil {
return nil
}
redactedCreds, credsStatus := RedactCredentials(a.Credentials)
out := &Account{
ID: a.ID,
Name: a.Name,
Notes: a.Notes,
Platform: a.Platform,
Type: a.Type,
Credentials: a.Credentials,
Credentials: redactedCreds,
CredentialsStatus: credsStatus,
Extra: a.Extra,
ProxyID: a.ProxyID,
Concurrency: a.Concurrency,

View File

@ -149,25 +149,28 @@ type AdminGroup struct {
}
type Account struct {
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
// Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥
// 的存在性通过 CredentialsStatushas_<key>)暴露,原始值不返回前端。
Credentials map[string]any `json:"credentials"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"`

View File

@ -0,0 +1,50 @@
package service
// SensitiveCredentialKeys 列出 Account.Credentials JSON map 中绝不允许返回到前端的子键。
// dto 层做响应脱敏、service 层做更新合并都引用此清单——新增凭证类型时务必同步。
var SensitiveCredentialKeys = []string{
// OAuth
"access_token", "refresh_token", "id_token",
// API Key 类
"api_key", "session_key", "cookie",
// 云服务凭据
"aws_secret_access_key", "aws_session_token",
"service_account_json", "service_account", "private_key",
}
var sensitiveCredentialKeySet = func() map[string]struct{} {
m := make(map[string]struct{}, len(SensitiveCredentialKeys))
for _, k := range SensitiveCredentialKeys {
m[k] = struct{}{}
}
return m
}()
// IsSensitiveCredentialKey 判断指定键是否为敏感凭证子键。
func IsSensitiveCredentialKey(key string) bool {
_, ok := sensitiveCredentialKeySet[key]
return ok
}
// MergePreservingSensitiveCreds 把 incoming 写入 existing 之上,但敏感子键采用"incoming 没提供就保留 existing"
// 的语义。返回新的 map不修改入参。
//
// 用途:前端编辑账号通常采用"全对象 PUT"模式;脱敏后前端 spread 旧 credentials 时不会带上敏感键,
// 直接覆盖会清空已有 token。此函数保证
// - 非敏感键:完全由 incoming 决定(用户可以编辑、删除非敏感字段)。
// - 敏感键incoming 显式提供则覆盖(用户主动旋转 token否则保留 existing。
func MergePreservingSensitiveCreds(existing, incoming map[string]any) map[string]any {
out := make(map[string]any, len(incoming)+len(SensitiveCredentialKeys))
for k, v := range incoming {
out[k] = v
}
for _, key := range SensitiveCredentialKeys {
if _, hasIncoming := incoming[key]; hasIncoming {
continue
}
if existingVal, ok := existing[key]; ok {
out[key] = existingVal
}
}
return out
}

View File

@ -0,0 +1,90 @@
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestMergePreservingSensitiveCreds_PreservesSensitiveWhenIncomingMissing(t *testing.T) {
existing := map[string]any{
"refresh_token": "rt-old",
"access_token": "at-old",
"api_key": "sk-old",
"base_url": "https://old.example.com",
}
incoming := map[string]any{
"base_url": "https://new.example.com",
"model_mapping": map[string]any{"foo": "bar"},
}
out := MergePreservingSensitiveCreds(existing, incoming)
require.Equal(t, "rt-old", out["refresh_token"], "incoming 没传 refresh_token应保留 existing")
require.Equal(t, "at-old", out["access_token"])
require.Equal(t, "sk-old", out["api_key"])
require.Equal(t, "https://new.example.com", out["base_url"], "非敏感键由 incoming 决定")
require.Equal(t, map[string]any{"foo": "bar"}, out["model_mapping"])
}
func TestMergePreservingSensitiveCreds_OverwritesWhenIncomingProvidesSensitive(t *testing.T) {
existing := map[string]any{
"refresh_token": "rt-old",
"api_key": "sk-old",
}
incoming := map[string]any{
"refresh_token": "rt-new",
// 显式没传 api_key —— 应保留
}
out := MergePreservingSensitiveCreds(existing, incoming)
require.Equal(t, "rt-new", out["refresh_token"], "incoming 显式传入应覆盖")
require.Equal(t, "sk-old", out["api_key"], "incoming 没传应保留")
}
func TestMergePreservingSensitiveCreds_DoesNotMutateInputs(t *testing.T) {
existing := map[string]any{"refresh_token": "rt"}
incoming := map[string]any{"base_url": "x"}
_ = MergePreservingSensitiveCreds(existing, incoming)
require.Equal(t, "rt", existing["refresh_token"])
require.NotContains(t, existing, "base_url")
require.Equal(t, "x", incoming["base_url"])
require.NotContains(t, incoming, "refresh_token")
}
func TestMergePreservingSensitiveCreds_NilInputs(t *testing.T) {
out := MergePreservingSensitiveCreds(nil, map[string]any{"base_url": "x"})
require.Equal(t, "x", out["base_url"])
require.NotContains(t, out, "refresh_token")
out2 := MergePreservingSensitiveCreds(map[string]any{"refresh_token": "rt"}, nil)
require.Equal(t, "rt", out2["refresh_token"])
}
func TestMergePreservingSensitiveCreds_NonSensitiveDeletionAllowed(t *testing.T) {
existing := map[string]any{
"refresh_token": "rt",
"base_url": "https://old",
"project_id": "p1",
}
incoming := map[string]any{
"base_url": "https://new",
// 不带 project_id —— 等同删除(非敏感键由 incoming 决定)
}
out := MergePreservingSensitiveCreds(existing, incoming)
require.Equal(t, "rt", out["refresh_token"], "敏感键保留")
require.Equal(t, "https://new", out["base_url"])
require.NotContains(t, out, "project_id", "非敏感键 incoming 不传 = 删除")
}
func TestIsSensitiveCredentialKey(t *testing.T) {
require.True(t, IsSensitiveCredentialKey("refresh_token"))
require.True(t, IsSensitiveCredentialKey("api_key"))
require.True(t, IsSensitiveCredentialKey("private_key"))
require.False(t, IsSensitiveCredentialKey("base_url"))
require.False(t, IsSensitiveCredentialKey(""))
require.False(t, IsSensitiveCredentialKey("model_mapping"))
}

View File

@ -2472,7 +2472,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
account.Notes = normalizeAccountNotes(input.Notes)
}
if len(input.Credentials) > 0 {
account.Credentials = input.Credentials
// 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏,
// 全对象 PUT 编辑时不会再带回 token避免覆盖时清空已有凭证。
account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials)
}
// Extra 使用 map需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。

View File

@ -0,0 +1,117 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
type updateAccountCredsRepoStub struct {
mockAccountRepoForGemini
account *Account
updateCalls int
}
func (r *updateAccountCredsRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) {
return r.account, nil
}
func (r *updateAccountCredsRepoStub) Update(ctx context.Context, account *Account) error {
r.updateCalls++
r.account = account
return nil
}
func TestUpdateAccount_PreservesSensitiveCredsWhenIncomingOmits(t *testing.T) {
accountID := int64(202)
repo := &updateAccountCredsRepoStub{
account: &Account{
ID: accountID,
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"refresh_token": "rt-existing",
"access_token": "at-existing",
"id_token": "id-existing",
"base_url": "https://old.example.com",
},
},
}
svc := &adminServiceImpl{accountRepo: repo}
// 模拟前端编辑:仅修改 base_url没有传 token脱敏后前端 spread 拿不到敏感键)
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Credentials: map[string]any{
"base_url": "https://new.example.com",
},
})
require.NoError(t, err)
require.NotNil(t, updated)
require.Equal(t, 1, repo.updateCalls)
// 敏感键应保留
require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"])
require.Equal(t, "at-existing", repo.account.Credentials["access_token"])
require.Equal(t, "id-existing", repo.account.Credentials["id_token"])
// 非敏感键被替换
require.Equal(t, "https://new.example.com", repo.account.Credentials["base_url"])
}
func TestUpdateAccount_ExplicitNewTokenOverwrites(t *testing.T) {
accountID := int64(203)
repo := &updateAccountCredsRepoStub{
account: &Account{
ID: accountID,
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"refresh_token": "rt-old",
"api_key": "sk-old",
},
},
}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Credentials: map[string]any{
"refresh_token": "rt-new",
// api_key 没传 → 应保留旧值
},
})
require.NoError(t, err)
require.NotNil(t, updated)
require.Equal(t, "rt-new", repo.account.Credentials["refresh_token"])
require.Equal(t, "sk-old", repo.account.Credentials["api_key"])
}
func TestUpdateAccount_EmptyCredentialsSkipsUpdate(t *testing.T) {
accountID := int64(204)
repo := &updateAccountCredsRepoStub{
account: &Account{
ID: accountID,
Platform: PlatformAnthropic,
Type: AccountTypeOAuth,
Status: StatusActive,
Credentials: map[string]any{
"refresh_token": "rt-existing",
},
},
}
svc := &adminServiceImpl{accountRepo: repo}
_, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Credentials: map[string]any{}, // len == 0 → 闸门跳过
Name: "renamed",
})
require.NoError(t, err)
require.Equal(t, "rt-existing", repo.account.Credentials["refresh_token"], "空 credentials 不应触碰已有 token")
require.Equal(t, "renamed", repo.account.Name)
}

View File

@ -3386,13 +3386,15 @@ const handleSubmit = async () => {
}
// Handle API key
// currentCredentials 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()) {
// User provided a new API key
newCredentials.api_key = editApiKey.value.trim()
} else if (currentCredentials.api_key) {
// Preserve existing api_key
newCredentials.api_key = currentCredentials.api_key
} else {
} else if (!hasExistingApiKey) {
appStore.showError(t('admin.accounts.apiKeyIsRequired'))
return
}
@ -3477,7 +3479,15 @@ const handleSubmit = async () => {
return
}
if (!currentCredentials.service_account_json && !currentCredentials.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: {
@ -304,4 +330,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()
})
})

View File

@ -796,7 +796,12 @@ export interface Account {
notes?: string | null
platform: AccountPlatform
type: AccountType
// 后端响应里 credentials 已脱敏access_token / refresh_token / id_token /
// api_key / session_key / cookie / aws_secret_access_key / aws_session_token /
// service_account_json / service_account / private_key 不会出现,
// 改为通过 credentials_status.has_<key> 暴露存在性。
credentials?: Record<string, unknown>
credentials_status?: Record<string, boolean>
// Extra fields including Codex usage, OpenAI compact capability, and model-level rate limits.
extra?: (CodexUsageSnapshot & OpenAICompactState & {
model_rate_limits?: Record<string, { rate_limited_at: string; rate_limit_reset_at: string }>