Account.Credentials 是 JSONB map,混合存放可编辑的非敏感配置(base_url、 model_mapping、project_id 等)与敏感秘钥(OAuth access/refresh/id token、 API key、AWS secret、Vertex private key 等)。当前所有 admin 账号接口直接 透传该 map,token 经由浏览器 DevTools、抓包、日志等途径泄漏。 - service 包新增 SensitiveCredentialKeys 清单与 MergePreservingSensitiveCreds 作为单一权威定义。 - dto 层 RedactCredentials 在响应里剥离敏感子键,输出 credentials_status (has_<key> 布尔标识)告知前端存在性,不暴露原值。 - AccountFromServiceShallow 接入脱敏,覆盖 list、get、create、update、 refresh、batch、bulk-update、OAuth 创建等 9 个 handler。 - service.UpdateAccount 改为合并语义:incoming 没传敏感键则保留 existing, 让前端"全对象 PUT"流程在脱敏后无感工作;显式提供新 token 仍会覆盖。 - 前端 EditAccountModal 修复脱敏后会崩的两处兜底:apikey 必填检查和 Vertex SA JSON 存在性校验改读 credentials_status.has_*。 - 导出端点 /admin/accounts/data 走独立的 DataAccount 结构,按设计保留 完整 credentials 作为管理员备份路径。 测试:RedactCredentials 单元测试、mapper 端到端 JSON 断言(确认序列化 后无 token 子串)、UpdateAccount 合并语义三种场景(保留 / 覆盖 / 空 map 跳过)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.2 KiB
Go
118 lines
3.2 KiB
Go
//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)
|
||
}
|