content_moderation_cyber_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "strings"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // cyberOrderingTestRepo records the sequence of repo calls to verify F7 ordering.
  13. type cyberOrderingTestRepo struct {
  14. mu sync.Mutex
  15. calls []string
  16. emailSents []bool // EmailSent value captured at each CreateLog call
  17. }
  18. func (r *cyberOrderingTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error {
  19. r.mu.Lock()
  20. defer r.mu.Unlock()
  21. r.calls = append(r.calls, "create")
  22. if log != nil {
  23. r.emailSents = append(r.emailSents, log.EmailSent)
  24. log.ID = 1 // simulate DB-assigned ID so UpdateLogEmailSent guard passes
  25. }
  26. return nil
  27. }
  28. func (r *cyberOrderingTestRepo) UpdateLogEmailSent(ctx context.Context, id int64, sent bool) error {
  29. r.mu.Lock()
  30. defer r.mu.Unlock()
  31. r.calls = append(r.calls, "update_email_sent")
  32. return nil
  33. }
  34. func (r *cyberOrderingTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
  35. return nil, nil, nil
  36. }
  37. func (r *cyberOrderingTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
  38. return 0, nil
  39. }
  40. func (r *cyberOrderingTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) {
  41. return &ContentModerationCleanupResult{}, nil
  42. }
  43. func (r *cyberOrderingTestRepo) snapshot() []string {
  44. r.mu.Lock()
  45. defer r.mu.Unlock()
  46. out := make([]string, len(r.calls))
  47. copy(out, r.calls)
  48. return out
  49. }
  50. func (r *cyberOrderingTestRepo) snapshotEmailSents() []bool {
  51. r.mu.Lock()
  52. defer r.mu.Unlock()
  53. out := make([]bool, len(r.emailSents))
  54. copy(out, r.emailSents)
  55. return out
  56. }
  57. func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) {
  58. repo := &contentModerationTestRepo{}
  59. svc := NewContentModerationService(
  60. &contentModerationTestSettingRepo{values: map[string]string{
  61. SettingKeyRiskControlEnabled: "false",
  62. }},
  63. repo,
  64. nil,
  65. nil,
  66. nil,
  67. nil,
  68. nil,
  69. nil,
  70. )
  71. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  72. UserID: 1,
  73. UserEmail: "u@x.com",
  74. Model: "gpt-5",
  75. Endpoint: "/v1/responses",
  76. UpstreamMessage: "flagged",
  77. UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
  78. UpstreamStatus: 400,
  79. })
  80. require.Empty(t, repo.snapshotLogs(), "CreateLog must NOT be called when risk_control_enabled is off")
  81. }
  82. func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) {
  83. repo := &contentModerationTestRepo{}
  84. svc := NewContentModerationService(
  85. &contentModerationTestSettingRepo{values: map[string]string{
  86. SettingKeyRiskControlEnabled: "true",
  87. }},
  88. repo,
  89. nil,
  90. nil,
  91. nil,
  92. nil,
  93. nil,
  94. nil, // emailService=nil: email path safely skipped
  95. )
  96. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  97. UserID: 1,
  98. UserEmail: "u@x.com",
  99. Model: "gpt-5",
  100. Endpoint: "/v1/responses",
  101. UpstreamMessage: "flagged",
  102. UpstreamBody: `{"error":{"code":"cyber_policy"}}`,
  103. UpstreamStatus: 400,
  104. })
  105. logs := repo.snapshotLogs()
  106. require.Len(t, logs, 1)
  107. log := logs[0]
  108. require.Equal(t, "cyber_policy", log.Action)
  109. require.True(t, log.Flagged)
  110. require.Equal(t, "cyber_policy", log.HighestCategory)
  111. require.Contains(t, log.Error, "flagged")
  112. require.False(t, log.AutoBanned)
  113. // emailService is nil, so EmailSent must be false
  114. require.False(t, log.EmailSent)
  115. // UserID pointer must be set
  116. require.NotNil(t, log.UserID)
  117. require.Equal(t, int64(1), *log.UserID)
  118. // score for cyber_policy is always 1.0
  119. require.Equal(t, 1.0, log.HighestScore)
  120. // mode must be post_upstream
  121. require.Equal(t, "post_upstream", log.Mode)
  122. // provider
  123. require.Equal(t, "openai", log.Provider)
  124. // model
  125. require.Equal(t, "gpt-5", log.Model)
  126. // endpoint
  127. require.Equal(t, "/v1/responses", log.Endpoint)
  128. // violation count >= 1 (side-effects ran)
  129. require.GreaterOrEqual(t, log.ViolationCount, 1)
  130. // Error field should also contain the upstream body JSON
  131. require.True(t, strings.Contains(log.Error, "cyber_policy") || strings.Contains(log.Error, "flagged"),
  132. "Error should mention flagged or cyber_policy")
  133. }
  134. func TestRecordCyberPolicyEvent_RespectsContentModerationScope(t *testing.T) {
  135. groupID := int64(7)
  136. tests := []struct {
  137. name string
  138. config string
  139. groupID *int64
  140. model string
  141. wantCalls []bool
  142. wantLogs int
  143. wantBanned bool
  144. }{
  145. {
  146. name: "excluded group",
  147. config: `{"all_groups":false,"group_ids":[8],"ban_threshold":1}`,
  148. groupID: &groupID,
  149. model: "gpt-5",
  150. wantLogs: 0,
  151. },
  152. {
  153. name: "ungrouped excluded by selected groups",
  154. config: `{"all_groups":false,"group_ids":[7],"ban_threshold":1}`,
  155. groupID: nil,
  156. model: "gpt-5",
  157. wantLogs: 0,
  158. },
  159. {
  160. name: "excluded model",
  161. config: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-4o"]},"ban_threshold":1}`,
  162. groupID: &groupID,
  163. model: "gpt-5",
  164. wantLogs: 0,
  165. },
  166. {
  167. name: "included group and model",
  168. config: `{"enabled":false,"mode":"off","sample_rate":0,"all_groups":false,"group_ids":[7],"model_filter":{"type":"include","models":["gpt-5"]},"ban_threshold":1}`,
  169. groupID: &groupID,
  170. model: "gpt-5",
  171. wantCalls: []bool{false},
  172. wantLogs: 1,
  173. wantBanned: true,
  174. },
  175. }
  176. for _, tt := range tests {
  177. t.Run(tt.name, func(t *testing.T) {
  178. repo := &banCountArgsTestRepo{}
  179. userRepo := &contentModerationTestUserRepo{user: &User{ID: 1, Role: RoleUser, Status: StatusActive}}
  180. svc := NewContentModerationService(
  181. &contentModerationTestSettingRepo{values: map[string]string{
  182. SettingKeyRiskControlEnabled: "true",
  183. SettingKeyContentModerationConfig: tt.config,
  184. }},
  185. repo, nil, nil, userRepo, nil, nil, nil,
  186. )
  187. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  188. UserID: 1,
  189. GroupID: tt.groupID,
  190. Model: tt.model,
  191. })
  192. if tt.wantCalls == nil {
  193. require.Empty(t, repo.snapshotCountCalls())
  194. } else {
  195. require.Equal(t, tt.wantCalls, repo.snapshotCountCalls())
  196. }
  197. require.Len(t, repo.snapshotLogs(), tt.wantLogs)
  198. require.Equal(t, tt.wantBanned, userRepo.user.Status == StatusDisabled)
  199. if tt.wantBanned {
  200. require.Len(t, userRepo.updated, 1)
  201. } else {
  202. require.Empty(t, userRepo.updated)
  203. }
  204. })
  205. }
  206. }
  207. func TestRecordCyberPolicyEvent_InitialRuntimeSnapshotLoadFailureSkipsEvent(t *testing.T) {
  208. repo := &banCountArgsTestRepo{}
  209. settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{
  210. SettingKeyRiskControlEnabled: "true",
  211. SettingKeyContentModerationConfig: `{invalid`,
  212. }}
  213. svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil)
  214. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  215. UserID: 1,
  216. Model: "gpt-5",
  217. })
  218. require.Empty(t, repo.snapshotCountCalls())
  219. require.Empty(t, repo.snapshotLogs())
  220. getValue, getMultiple := settingRepo.calls()
  221. require.Zero(t, getValue)
  222. require.GreaterOrEqual(t, getMultiple, 1)
  223. }
  224. func TestRecordCyberPolicyEvent_RuntimeSnapshotRefreshFailureKeepsStaleScope(t *testing.T) {
  225. repo := &banCountArgsTestRepo{}
  226. settingRepo := &contentModerationRuntimeSettingRepo{values: map[string]string{
  227. SettingKeyRiskControlEnabled: "true",
  228. SettingKeyContentModerationConfig: `{"all_groups":true,"model_filter":{"type":"include","models":["gpt-5"]}}`,
  229. }}
  230. svc := NewContentModerationService(settingRepo, repo, nil, nil, nil, nil, nil, nil)
  231. svc.runtimeCacheTTL = time.Minute
  232. _, err := svc.loadRuntimeSnapshot(context.Background())
  233. require.NoError(t, err)
  234. current := svc.runtimeSnapshot.Load()
  235. require.NotNil(t, current)
  236. expired := *current
  237. expired.loadedAt = time.Now().Add(-2 * time.Minute)
  238. svc.runtimeSnapshot.Store(&expired)
  239. settingRepo.failMultiple(errors.New("database unavailable"))
  240. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  241. UserID: 1,
  242. Model: "gpt-5",
  243. })
  244. require.Len(t, repo.snapshotLogs(), 1)
  245. require.Eventually(t, func() bool {
  246. _, calls := settingRepo.calls()
  247. return calls == 2
  248. }, time.Second, time.Millisecond)
  249. getValue, getMultiple := settingRepo.calls()
  250. require.Zero(t, getValue)
  251. require.Equal(t, 2, getMultiple)
  252. }
  253. // TestRecordCyberPolicyEvent_CreateLogBeforeEmail verifies F7: the moderation
  254. // log is persisted BEFORE email delivery, and EmailSent is patched afterwards —
  255. // SMTP hangs can no longer swallow the audit record.
  256. //
  257. // Note on email ordering: EmailService is a concrete type with no injectable
  258. // send interface, so SMTP-success cannot be simulated in unit tests.
  259. // With emailService=nil the email block is skipped and UpdateLogEmailSent is not
  260. // called (correct: logPersisted && emailSent guard). The test therefore asserts
  261. // the two invariants that ARE observable without real SMTP:
  262. // 1. CreateLog runs first (calls[0]=="create").
  263. // 2. The log is stored with EmailSent=false (not pre-set to true).
  264. //
  265. // The update_email_sent path is covered by integration/e2e tests where a real
  266. // (or test-double) SMTP endpoint is available.
  267. func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) {
  268. repo := &cyberOrderingTestRepo{}
  269. svc := NewContentModerationService(
  270. &contentModerationTestSettingRepo{values: map[string]string{
  271. SettingKeyRiskControlEnabled: "true",
  272. }},
  273. repo,
  274. nil,
  275. nil,
  276. nil,
  277. nil,
  278. nil,
  279. nil, // emailService=nil: email path safely skipped; see doc comment above
  280. )
  281. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  282. RequestID: "req-1",
  283. UserID: 7,
  284. UserEmail: "u@example.com",
  285. Model: "gpt-5",
  286. UpstreamMessage: "blocked",
  287. })
  288. calls := repo.snapshot()
  289. require.GreaterOrEqual(t, len(calls), 1, "CreateLog must be called")
  290. require.Equal(t, "create", calls[0], "CreateLog must run first (F7: log-before-email)")
  291. // EmailSent must be false when the log is first persisted (new code sets it
  292. // false before CreateLog; email result is patched via UpdateLogEmailSent).
  293. emailSents := repo.snapshotEmailSents()
  294. require.NotEmpty(t, emailSents, "CreateLog must have captured EmailSent value")
  295. require.False(t, emailSents[0], "log must be stored with EmailSent=false initially (F7)")
  296. // With emailService=nil, no email is sent, so UpdateLogEmailSent must NOT
  297. // be called (logPersisted && emailSent guard correctly suppresses the patch).
  298. require.NotContains(t, calls, "update_email_sent",
  299. "UpdateLogEmailSent must not be called when no email was sent")
  300. }
  301. // banCountArgsTestRepo 在 contentModerationTestRepo 基础上记录
  302. // CountFlaggedByUserSince 收到的 excludeCyberPolicy 参数,供透传断言。
  303. type banCountArgsTestRepo struct {
  304. contentModerationTestRepo
  305. argsMu sync.Mutex
  306. countCalls []bool
  307. }
  308. func (r *banCountArgsTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time, excludeCyberPolicy bool) (int, error) {
  309. r.argsMu.Lock()
  310. r.countCalls = append(r.countCalls, excludeCyberPolicy)
  311. r.argsMu.Unlock()
  312. return r.contentModerationTestRepo.CountFlaggedByUserSince(ctx, userID, since, excludeCyberPolicy)
  313. }
  314. func (r *banCountArgsTestRepo) snapshotCountCalls() []bool {
  315. r.argsMu.Lock()
  316. defer r.argsMu.Unlock()
  317. out := make([]bool, len(r.countCalls))
  318. copy(out, r.countCalls)
  319. return out
  320. }
  321. func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) {
  322. repo := &banCountArgsTestRepo{}
  323. svc := NewContentModerationService(
  324. &contentModerationTestSettingRepo{values: map[string]string{}},
  325. repo, nil, nil, nil, nil, nil, nil,
  326. )
  327. userID := int64(42)
  328. cfgExclude := defaultContentModerationConfig()
  329. cfgExclude.CyberPolicyExcludeFromBanCount = true
  330. svc.applyFlaggedAccountSideEffects(context.Background(), cfgExclude, &ContentModerationLog{Flagged: true, UserID: &userID})
  331. cfgDefault := defaultContentModerationConfig() // 默认 false
  332. svc.applyFlaggedAccountSideEffects(context.Background(), cfgDefault, &ContentModerationLog{Flagged: true, UserID: &userID})
  333. require.Equal(t, []bool{true, false}, repo.snapshotCountCalls(),
  334. "applyFlaggedAccountSideEffects 必须把 cfg.CyberPolicyExcludeFromBanCount 透传给 COUNT 查询")
  335. }
  336. func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing.T) {
  337. repo := &banCountArgsTestRepo{}
  338. svc := NewContentModerationService(
  339. &contentModerationTestSettingRepo{values: map[string]string{
  340. SettingKeyRiskControlEnabled: "true",
  341. SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`,
  342. }},
  343. repo, nil, nil, nil, nil, nil, nil,
  344. )
  345. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  346. UserID: 1,
  347. UserEmail: "u@x.com",
  348. Model: "gpt-5",
  349. Endpoint: "/v1/responses",
  350. UpstreamMessage: "flagged",
  351. UpstreamStatus: 400,
  352. })
  353. require.Empty(t, repo.snapshotCountCalls(), "开关开时不得执行封号计数查询")
  354. logs := repo.snapshotLogs()
  355. require.Len(t, logs, 1, "风控日志必须照记")
  356. require.True(t, logs[0].Flagged, "日志仍标记 Flagged=true(列表可见可筛)")
  357. require.Equal(t, "cyber_policy", logs[0].Action)
  358. require.Equal(t, 0, logs[0].ViolationCount, "不参与计数时 ViolationCount 保持 0")
  359. require.False(t, logs[0].AutoBanned)
  360. }
  361. func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) {
  362. repo := &banCountArgsTestRepo{}
  363. svc := NewContentModerationService(
  364. &contentModerationTestSettingRepo{values: map[string]string{
  365. SettingKeyRiskControlEnabled: "true",
  366. }},
  367. repo, nil, nil, nil, nil, nil, nil,
  368. )
  369. svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
  370. UserID: 1,
  371. UserEmail: "u@x.com",
  372. Model: "gpt-5",
  373. Endpoint: "/v1/responses",
  374. UpstreamMessage: "flagged",
  375. UpstreamStatus: 400,
  376. })
  377. require.Equal(t, []bool{false}, repo.snapshotCountCalls(),
  378. "默认配置必须执行计数查询且不排除 cyber 行")
  379. logs := repo.snapshotLogs()
  380. require.Len(t, logs, 1)
  381. require.GreaterOrEqual(t, logs[0].ViolationCount, 1, "默认路径行为不变(现状回归)")
  382. }
  383. func (r *cyberOrderingTestRepo) UpdateLogOverturned(ctx context.Context, id int64) error {
  384. r.mu.Lock()
  385. defer r.mu.Unlock()
  386. r.calls = append(r.calls, "update_overturned")
  387. return nil
  388. }