Files
sub2api/backend/internal/service/content_moderation_cyber_test.go
T
DaydreamCodingandClaude Opus 4.8 b62b573f7f feat(openai): cyber_policy 硬阻断全链路透传、审计与计费
上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点
(/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/
非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截;
命中后异步完成审计与计费:

- 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞
  不影响留痕
- ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400)
- 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与
  WebSocket 计费口径统一,零 token 命中不误扣
- 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截
  不再发往上游,仅屏蔽该会话不影响同 Key 其他会话
- 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且
  历史行在违规计数中一并排除

WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录;
透传的错误响应不被兜底逻辑追加内容污染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:47:01 +08:00

303 lines
9.9 KiB
Go

package service
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
)
// cyberOrderingTestRepo records the sequence of repo calls to verify F7 ordering.
type cyberOrderingTestRepo struct {
mu sync.Mutex
calls []string
emailSents []bool // EmailSent value captured at each CreateLog call
}
func (r *cyberOrderingTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, "create")
if log != nil {
r.emailSents = append(r.emailSents, log.EmailSent)
log.ID = 1 // simulate DB-assigned ID so UpdateLogEmailSent guard passes
}
return nil
}
func (r *cyberOrderingTestRepo) UpdateLogEmailSent(ctx context.Context, id int64, sent bool) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, "update_email_sent")
return nil
}
func (r *cyberOrderingTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
return nil, nil, nil
}
func (r *cyberOrderingTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
return 0, nil
}
func (r *cyberOrderingTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) {
return &ContentModerationCleanupResult{}, nil
}
func (r *cyberOrderingTestRepo) snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.calls))
copy(out, r.calls)
return out
}
func (r *cyberOrderingTestRepo) snapshotEmailSents() []bool {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]bool, len(r.emailSents))
copy(out, r.emailSents)
return out
}
func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) {
repo := &contentModerationTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "false",
}},
repo,
nil,
nil,
nil,
nil,
nil,
)
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
UserID: 1,
UserEmail: "u@x.com",
Model: "gpt-5",
Endpoint: "/v1/responses",
UpstreamMessage: "flagged",
UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
UpstreamStatus: 400,
})
require.Empty(t, repo.snapshotLogs(), "CreateLog must NOT be called when risk_control_enabled is off")
}
func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) {
repo := &contentModerationTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
}},
repo,
nil,
nil,
nil,
nil,
nil, // emailService=nil: email path safely skipped
)
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
UserID: 1,
UserEmail: "u@x.com",
Model: "gpt-5",
Endpoint: "/v1/responses",
UpstreamMessage: "flagged",
UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
UpstreamStatus: 400,
})
logs := repo.snapshotLogs()
require.Len(t, logs, 1)
log := logs[0]
require.Equal(t, "cyber_policy", log.Action)
require.True(t, log.Flagged)
require.Equal(t, "cyber_policy", log.HighestCategory)
require.Contains(t, log.Error, "flagged")
require.False(t, log.AutoBanned)
// emailService is nil, so EmailSent must be false
require.False(t, log.EmailSent)
// UserID pointer must be set
require.NotNil(t, log.UserID)
require.Equal(t, int64(1), *log.UserID)
// score for cyber_policy is always 1.0
require.Equal(t, 1.0, log.HighestScore)
// mode must be post_upstream
require.Equal(t, "post_upstream", log.Mode)
// provider
require.Equal(t, "openai", log.Provider)
// model
require.Equal(t, "gpt-5", log.Model)
// endpoint
require.Equal(t, "/v1/responses", log.Endpoint)
// violation count >= 1 (side-effects ran)
require.GreaterOrEqual(t, log.ViolationCount, 1)
// Error field should also contain the upstream body JSON
require.True(t, strings.Contains(log.Error, "cyber_policy") || strings.Contains(log.Error, "flagged"),
"Error should mention flagged or cyber_policy")
}
// TestRecordCyberPolicyEvent_CreateLogBeforeEmail verifies F7: the moderation
// log is persisted BEFORE email delivery, and EmailSent is patched afterwards —
// SMTP hangs can no longer swallow the audit record.
//
// Note on email ordering: EmailService is a concrete type with no injectable
// send interface, so SMTP-success cannot be simulated in unit tests.
// With emailService=nil the email block is skipped and UpdateLogEmailSent is not
// called (correct: logPersisted && emailSent guard). The test therefore asserts
// the two invariants that ARE observable without real SMTP:
// 1. CreateLog runs first (calls[0]=="create").
// 2. The log is stored with EmailSent=false (not pre-set to true).
//
// The update_email_sent path is covered by integration/e2e tests where a real
// (or test-double) SMTP endpoint is available.
func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) {
repo := &cyberOrderingTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
}},
repo,
nil,
nil,
nil,
nil,
nil, // emailService=nil: email path safely skipped; see doc comment above
)
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
RequestID: "req-1",
UserID: 7,
UserEmail: "u@example.com",
Model: "gpt-5",
UpstreamMessage: "blocked",
})
calls := repo.snapshot()
require.GreaterOrEqual(t, len(calls), 1, "CreateLog must be called")
require.Equal(t, "create", calls[0], "CreateLog must run first (F7: log-before-email)")
// EmailSent must be false when the log is first persisted (new code sets it
// false before CreateLog; email result is patched via UpdateLogEmailSent).
emailSents := repo.snapshotEmailSents()
require.NotEmpty(t, emailSents, "CreateLog must have captured EmailSent value")
require.False(t, emailSents[0], "log must be stored with EmailSent=false initially (F7)")
// With emailService=nil, no email is sent, so UpdateLogEmailSent must NOT
// be called (logPersisted && emailSent guard correctly suppresses the patch).
require.NotContains(t, calls, "update_email_sent",
"UpdateLogEmailSent must not be called when no email was sent")
}
// banCountArgsTestRepo 在 contentModerationTestRepo 基础上记录
// CountFlaggedByUserSince 收到的 excludeCyberPolicy 参数,供透传断言。
type banCountArgsTestRepo struct {
contentModerationTestRepo
argsMu sync.Mutex
countCalls []bool
}
func (r *banCountArgsTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
r.argsMu.Lock()
r.countCalls = append(r.countCalls, excludeCyberPolicy)
r.argsMu.Unlock()
return r.contentModerationTestRepo.CountFlaggedByUserSince(ctx, userID, since, excludeCyberPolicy)
}
func (r *banCountArgsTestRepo) snapshotCountCalls() []bool {
r.argsMu.Lock()
defer r.argsMu.Unlock()
out := make([]bool, len(r.countCalls))
copy(out, r.countCalls)
return out
}
func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) {
repo := &banCountArgsTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{}},
repo, nil, nil, nil, nil, nil,
)
userID := int64(42)
cfgExclude := defaultContentModerationConfig()
cfgExclude.CyberPolicyExcludeFromBanCount = true
svc.applyFlaggedAccountSideEffects(context.Background(), cfgExclude, &ContentModerationLog{Flagged: true, UserID: &userID})
cfgDefault := defaultContentModerationConfig() // 默认 false
svc.applyFlaggedAccountSideEffects(context.Background(), cfgDefault, &ContentModerationLog{Flagged: true, UserID: &userID})
require.Equal(t, []bool{true, false}, repo.snapshotCountCalls(),
"applyFlaggedAccountSideEffects 必须把 cfg.CyberPolicyExcludeFromBanCount 透传给 COUNT 查询")
}
func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing.T) {
repo := &banCountArgsTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`,
}},
repo, nil, nil, nil, nil, nil,
)
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
UserID: 1,
UserEmail: "u@x.com",
Model: "gpt-5",
Endpoint: "/v1/responses",
UpstreamMessage: "flagged",
UpstreamStatus: 400,
})
require.Empty(t, repo.snapshotCountCalls(), "开关开时不得执行封号计数查询")
logs := repo.snapshotLogs()
require.Len(t, logs, 1, "风控日志必须照记")
require.True(t, logs[0].Flagged, "日志仍标记 Flagged=true(列表可见可筛)")
require.Equal(t, "cyber_policy", logs[0].Action)
require.Equal(t, 0, logs[0].ViolationCount, "不参与计数时 ViolationCount 保持 0")
require.False(t, logs[0].AutoBanned)
}
func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) {
repo := &banCountArgsTestRepo{}
svc := NewContentModerationService(
&contentModerationTestSettingRepo{values: map[string]string{
SettingKeyRiskControlEnabled: "true",
}},
repo, nil, nil, nil, nil, nil,
)
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
UserID: 1,
UserEmail: "u@x.com",
Model: "gpt-5",
Endpoint: "/v1/responses",
UpstreamMessage: "flagged",
UpstreamStatus: 400,
})
require.Equal(t, []bool{false}, repo.snapshotCountCalls(),
"默认配置必须执行计数查询且不排除 cyber 行")
logs := repo.snapshotLogs()
require.Len(t, logs, 1)
require.GreaterOrEqual(t, logs[0].ViolationCount, 1, "默认路径行为不变(现状回归)")
}