sub2api/backend/internal/service/admin_service_credentials_merge_test.go
haruka 0f8e2d0934 fix(security): 屏蔽 admin 账号接口返回的敏感凭证字段
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>
2026-05-08 03:44:04 +08:00

118 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//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)
}