| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- package service
- import (
- "context"
- "errors"
- "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,
- 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,
- 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")
- }
- func TestRecordCyberPolicyEvent_RespectsContentModerationScope(t *testing.T) {
- groupID := int64(7)
- tests := []struct {
- name string
- config string
- groupID *int64
- model string
- wantCalls []bool
- wantLogs int
- wantBanned bool
- }{
- {
- name: "excluded group",
- config: `{"all_groups":false,"group_ids":[8],"ban_threshold":1}`,
- groupID: &groupID,
- model: "gpt-5",
- wantLogs: 0,
- },
- {
- name: "ungrouped excluded by selected groups",
- config: `{"all_groups":false,"group_ids":[7],"ban_threshold":1}`,
- groupID: nil,
- model: "gpt-5",
- wantLogs: 0,
- },
- {
- name: "excluded model",
- config: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-4o"]},"ban_threshold":1}`,
- groupID: &groupID,
- model: "gpt-5",
- wantLogs: 0,
- },
- {
- name: "included group and model",
- config: `{"enabled":false,"mode":"off","sample_rate":0,"all_groups":false,"group_ids":[7],"model_filter":{"type":"include","models":["gpt-5"]},"ban_threshold":1}`,
- groupID: &groupID,
- model: "gpt-5",
- wantCalls: []bool{false},
- wantLogs: 1,
- wantBanned: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- repo := &banCountArgsTestRepo{}
- userRepo := &contentModerationTestUserRepo{user: &User{ID: 1, Role: RoleUser, Status: StatusActive}}
- svc := NewContentModerationService(
- &contentModerationTestSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: tt.config,
- }},
- repo, nil, nil, userRepo, nil, nil, nil,
- )
- svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
- UserID: 1,
- GroupID: tt.groupID,
- Model: tt.model,
- })
- if tt.wantCalls == nil {
- require.Empty(t, repo.snapshotCountCalls())
- } else {
- require.Equal(t, tt.wantCalls, repo.snapshotCountCalls())
- }
- require.Len(t, repo.snapshotLogs(), tt.wantLogs)
- require.Equal(t, tt.wantBanned, userRepo.user.Status == StatusDisabled)
- if tt.wantBanned {
- require.Len(t, userRepo.updated, 1)
- } else {
- require.Empty(t, userRepo.updated)
- }
- })
- }
- }
- func TestRecordCyberPolicyEvent_InitialRuntimeSnapshotLoadFailureSkipsEvent(t *testing.T) {
- repo := &banCountArgsTestRepo{}
- settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: `{invalid`,
- }}
- svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil)
- svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
- UserID: 1,
- Model: "gpt-5",
- })
- require.Empty(t, repo.snapshotCountCalls())
- require.Empty(t, repo.snapshotLogs())
- getValue, getMultiple := settingRepo.calls()
- require.Zero(t, getValue)
- require.GreaterOrEqual(t, getMultiple, 1)
- }
- func TestRecordCyberPolicyEvent_RuntimeSnapshotRefreshFailureKeepsStaleScope(t *testing.T) {
- repo := &banCountArgsTestRepo{}
- settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{
- SettingKeyRiskControlEnabled: "true",
- SettingKeyContentModerationConfig: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-5"]}}`,
- }}
- svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil)
- svc.runtimeCacheTTL = time.Minute
- _, err := svc.loadRuntimeSnapshot(context.Background())
- require.NoError(t, err)
- current := svc.runtimeSnapshot.Load()
- require.NotNil(t, current)
- expired := *current
- expired.loadedAt = time.Now().Add(-2 * time.Minute)
- svc.runtimeSnapshot.Store(&expired)
- settingRepo.failMultiple(errors.New("database unavailable"))
- svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
- UserID: 1,
- Model: "gpt-5",
- })
- require.Len(t, repo.snapshotLogs(), 1)
- require.Eventually(t, func() bool {
- _, calls := settingRepo.calls()
- return calls == 2
- }, time.Second, time.Millisecond)
- getValue, getMultiple := settingRepo.calls()
- require.Zero(t, getValue)
- require.Equal(t, 2, getMultiple)
- }
- // 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,
- 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, 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, 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, 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, "默认路径行为不变(现状回归)")
- }
- func (r *cyberOrderingTestRepo) UpdateLogOverturned(ctx context.Context, id int64) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.calls = append(r.calls, "update_overturned")
- return nil
- }
|