Files
sub2api/backend/internal/securityaudit/prompt_service_test.go
T
shaw bfbe113f5e fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁 (#4887)
根因:prompt audit 是共享 TOTP_ENCRYPTION_KEY 加密器的功能中唯一不校验
EncryptionKeyConfigured 的落点。未配置固定密钥的部署每次重启自动生成新
密钥,v162 保存的节点 Token 密文在升级重启后永久无法解密,Reload 中
ActiveFromStorage 整体失败导致快照永远装不上:管理端 GET 回退默认 v1
(v166 起为 503),而保存路径直读数据库做 CAS 版本对比,必然冲突——
配置既看不见也改不掉。PR #4893 仅改变了报错形态,未修复根因。

修复:
- ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记
  TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入
  Token 即可自愈(密文保留,密钥恢复后自动复原)
- blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧
  被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警
- Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份
  一致的门控),错误码 prompt_audit_encryption_key_required
- token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入
- 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s
  刷新刷屏)
2026-07-28 09:31:36 +08:00

127 lines
4.4 KiB
Go

package securityaudit
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
type staticSettingRepository struct {
values map[string]string
}
func (r staticSettingRepository) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
func (r staticSettingRepository) GetValue(context.Context, string) (string, error) {
return "", service.ErrSettingNotFound
}
func (r staticSettingRepository) Set(context.Context, string, string) error { return nil }
func (r staticSettingRepository) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
result := make(map[string]string, len(keys))
for _, key := range keys {
result[key] = r.values[key]
}
return result, nil
}
func (r staticSettingRepository) SetMultiple(context.Context, map[string]string) error { return nil }
func (r staticSettingRepository) GetAll(context.Context) (map[string]string, error) {
return r.values, nil
}
func (r staticSettingRepository) Delete(context.Context, string) error { return nil }
func TestPromptServiceHasExplicitIdempotentLifecycle(t *testing.T) {
config := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
service := NewPromptService(
config,
NewPostgreSQLRepository(nil),
NewRedisPayloadStore(nil),
NewOpenAICompatibleScanner(),
NewAtomicMetrics(),
)
require.Nil(t, service.cancel, "construction must not start background work")
require.NoError(t, service.Start(context.Background()))
require.NotNil(t, service.cancel)
require.NoError(t, service.Start(context.Background()), "Start must be idempotent")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
require.NoError(t, service.Shutdown(ctx))
require.Nil(t, service.cancel)
require.NoError(t, service.Shutdown(ctx), "Shutdown must be idempotent")
}
func TestPromptServiceStartReportsDependencyFailureWithoutPanic(t *testing.T) {
service := &PromptService{}
require.Error(t, service.Start(context.Background()))
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, service.Shutdown(ctx))
}
func TestPromptServiceRejectsInvalidDeleteConfirmationClaims(t *testing.T) {
now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)
start, end := now.Add(-time.Hour), now.Add(time.Hour)
filter := EventFilter{Decision: string(EventCritical), StartAt: &start, EndAt: &end}
const snapshotMaxID int64 = 10
filterHash := FilterHash(filter, snapshotMaxID)
validClaims := deleteClaims{
FilterHash: filterHash, SnapshotMaxID: snapshotMaxID, AdminID: 7,
IssuedAt: now, ExpiresAt: now.Add(5 * time.Minute),
}
claimsToken := func(claims deleteClaims) string {
raw, err := json.Marshal(claims)
require.NoError(t, err)
return string(raw)
}
validRequest := DeleteByFilterRequest{
Filter: filter, SnapshotMaxID: snapshotMaxID, FilterHash: filterHash,
ConfirmationToken: claimsToken(validClaims), Confirm: true,
}
tests := []struct {
name string
request DeleteByFilterRequest
adminID int64
}{
{name: "confirm false", request: func() DeleteByFilterRequest { value := validRequest; value.Confirm = false; return value }(), adminID: 7},
{name: "malformed token", request: func() DeleteByFilterRequest {
value := validRequest
value.ConfirmationToken = "not-json"
return value
}(), adminID: 7},
{name: "different administrator", request: validRequest, adminID: 8},
{name: "filter hash mismatch", request: func() DeleteByFilterRequest {
value := validRequest
value.FilterHash = strings.Repeat("b", 64)
return value
}(), adminID: 7},
{name: "snapshot mismatch", request: func() DeleteByFilterRequest { value := validRequest; value.SnapshotMaxID++; return value }(), adminID: 7},
{name: "expired", request: func() DeleteByFilterRequest {
value := validRequest
claims := validClaims
claims.ExpiresAt = now
value.ConfirmationToken = claimsToken(claims)
return value
}(), adminID: 7},
}
service := &PromptService{config: &fakeConfigStore{}, clock: fixedClock{now: now}}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := service.DeleteByFilter(context.Background(), test.request, test.adminID)
require.Error(t, err)
require.Nil(t, result)
})
}
}