Merge pull request #2271 from StarryKira/fix/redact-account-credentials
fix(security): 屏蔽 admin 账号接口返回的敏感凭证字段
This commit is contained in:
commit
a929e285ce
@ -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"`
|
||||
|
||||
67
backend/internal/handler/dto/account_mapper_redact_test.go
Normal file
67
backend/internal/handler/dto/account_mapper_redact_test.go
Normal 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)
|
||||
}
|
||||
44
backend/internal/handler/dto/credentials_redact.go
Normal file
44
backend/internal/handler/dto/credentials_redact.go
Normal 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
|
||||
}
|
||||
}
|
||||
97
backend/internal/handler/dto/credentials_redact_test.go
Normal file
97
backend/internal/handler/dto/credentials_redact_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 / 私钥
|
||||
// 的存在性通过 CredentialsStatus(has_<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"`
|
||||
|
||||
|
||||
50
backend/internal/service/account_credentials_redact.go
Normal file
50
backend/internal/service/account_credentials_redact.go
Normal 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
|
||||
}
|
||||
90
backend/internal/service/account_credentials_redact_test.go
Normal file
90
backend/internal/service/account_credentials_redact_test.go
Normal 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"))
|
||||
}
|
||||
@ -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:{},此时也必须落库。
|
||||
|
||||
117
backend/internal/service/admin_service_credentials_merge_test.go
Normal file
117
backend/internal/service/admin_service_credentials_merge_test.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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_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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 }>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user