openai_gateway_cyber_test.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. package handler
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/service"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. )
  11. // newTestGinContext builds a bare gin.Context backed by an httptest recorder.
  12. func newTestGinContext() *gin.Context {
  13. gin.SetMode(gin.TestMode)
  14. w := httptest.NewRecorder()
  15. c, _ := gin.CreateTestContext(w)
  16. return c
  17. }
  18. // TestRecordCyberPolicyIfMarked_NoMark verifies that when no cyber mark is set,
  19. // the function returns immediately and does NOT set the recorded flag.
  20. func TestRecordCyberPolicyIfMarked_NoMark(t *testing.T) {
  21. c := newTestGinContext()
  22. h := &OpenAIGatewayHandler{}
  23. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
  24. // Flag must NOT be set when there was no mark.
  25. require.False(t, c.GetBool(cyberPolicyRecordedKey),
  26. "cyberPolicyRecordedKey must remain false when no cyber mark is present")
  27. }
  28. // TestRecordCyberPolicyIfMarked_WithMark verifies that:
  29. // 1. When a cyber mark is present, the recorded flag is set (guard activated).
  30. // 2. A second call is a no-op (idempotent guard).
  31. // 3. Nil services do not panic.
  32. func TestRecordCyberPolicyIfMarked_WithMark(t *testing.T) {
  33. c := newTestGinContext()
  34. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
  35. Message: "flagged",
  36. Body: `{"error":{"code":"cyber_policy"}}`,
  37. UpstreamStatus: 400,
  38. })
  39. h := &OpenAIGatewayHandler{} // nil services — must not panic
  40. // First call: should set the flag.
  41. require.NotPanics(t, func() {
  42. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
  43. })
  44. require.True(t, c.GetBool(cyberPolicyRecordedKey),
  45. "cyberPolicyRecordedKey must be true after first call with a mark")
  46. // Second call: flag already set — must be a no-op (idempotent).
  47. require.NotPanics(t, func() {
  48. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
  49. })
  50. // Flag should still be true (not toggled or cleared).
  51. require.True(t, c.GetBool(cyberPolicyRecordedKey),
  52. "cyberPolicyRecordedKey must remain true after second call (guard)")
  53. }
  54. // TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog verifies the semantic:
  55. // when forwardErrored=false the function still sets the guard flag (mark present),
  56. // but the cyber usage row is NOT requested (only RecordCyberPolicyEvent fires).
  57. // Since services are nil here we only verify the guard flag and no panic.
  58. func TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog(t *testing.T) {
  59. c := newTestGinContext()
  60. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
  61. Message: "flagged",
  62. UpstreamStatus: 200,
  63. })
  64. h := &OpenAIGatewayHandler{}
  65. require.NotPanics(t, func() {
  66. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, nil, service.ChannelUsageFields{}, "")
  67. })
  68. require.True(t, c.GetBool(cyberPolicyRecordedKey))
  69. }
  70. // TestClearCyberPolicyTurnState verifies F1 at the handler level: after a turn
  71. // is finalized, both the mark and the recorded guard are reset so the next WS
  72. // turn detects/records independently.
  73. func TestClearCyberPolicyTurnState(t *testing.T) {
  74. c := newTestGinContext()
  75. h := &OpenAIGatewayHandler{}
  76. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn1", UpstreamStatus: 200})
  77. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
  78. require.True(t, c.GetBool(cyberPolicyRecordedKey))
  79. clearCyberPolicyTurnState(c)
  80. require.Nil(t, service.GetOpsCyberPolicy(c))
  81. require.False(t, c.GetBool(cyberPolicyRecordedKey))
  82. // turn2: a fresh cyber hit must be recordable again.
  83. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn2", UpstreamStatus: 200})
  84. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
  85. require.True(t, c.GetBool(cyberPolicyRecordedKey))
  86. require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
  87. }
  88. func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
  89. failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
  90. blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
  91. require.False(t, blocked, "the replacement account must receive the current turn")
  92. require.True(t, pending, "the cyber hit must still block later turns")
  93. blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
  94. require.False(t, blocked, "additional failover attempts must remain eligible")
  95. require.True(t, pending)
  96. blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
  97. require.True(t, blocked, "the next client turn must be blocked after failover finishes")
  98. require.False(t, pending)
  99. }
  100. func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
  101. c := newTestGinContext()
  102. h := &OpenAIGatewayHandler{}
  103. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
  104. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
  105. require.True(t, c.GetBool(cyberPolicyRecordedKey))
  106. clearCyberPolicyAttemptState(c, false)
  107. require.Nil(t, service.GetOpsCyberPolicy(c))
  108. require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
  109. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
  110. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
  111. require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
  112. require.True(t, c.GetBool(cyberPolicyRecordedKey))
  113. clearCyberPolicyAttemptState(c, true)
  114. require.Nil(t, service.GetOpsCyberPolicy(c))
  115. require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
  116. }
  117. // TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
  118. // auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
  119. // from upstream cyber_policy hits, and it must NOT touch moderation/violation.
  120. func TestBuildCyberSessionBlockedOpsEntry(t *testing.T) {
  121. entry := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
  122. RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
  123. })
  124. require.Equal(t, 403, entry.StatusCode)
  125. require.Equal(t, "cyber_policy_session_blocked", entry.ErrorType)
  126. require.Equal(t, "request", entry.ErrorPhase)
  127. require.True(t, entry.IsBusinessLimited)
  128. require.Equal(t, "gateway_local", entry.ErrorSource)
  129. require.Equal(t, "platform", entry.ErrorOwner)
  130. require.Empty(t, entry.ErrorBody, "no session block key → ErrorBody must be empty")
  131. entryWithKey := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
  132. RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
  133. SessionBlockKey: "abc123",
  134. })
  135. require.Equal(t, "session_block_key=abc123", entryWithKey.ErrorBody)
  136. }
  137. // TestRejectIfCyberSessionBlocked_FailOpen verifies fail-open paths: nil handler
  138. // services, no explicit session signal, and (implicitly) disabled switch all
  139. // pass the request through.
  140. func TestRejectIfCyberSessionBlocked_FailOpen(t *testing.T) {
  141. c := newTestGinContext()
  142. c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(`{}`))
  143. h := &OpenAIGatewayHandler{}
  144. require.False(t, h.rejectIfCyberSessionBlocked(c, nil, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil apiKey → pass")
  145. h2 := &OpenAIGatewayHandler{gatewayService: nil}
  146. key := &service.APIKey{ID: 1}
  147. require.False(t, h2.rejectIfCyberSessionBlocked(c, key, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil gateway service → pass")
  148. }
  149. func TestBuildCyberSessionBlockWritePlanCombinesExplicitAndTranscriptKeys(t *testing.T) {
  150. body := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
  151. c := newTestGinContext()
  152. c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(string(body)))
  153. c.Request.RemoteAddr = "203.0.113.44:12345"
  154. c.Request.Header.Set("User-Agent", "client/1.2.3")
  155. plan := buildCyberSessionBlockWritePlan(7, c, body)
  156. require.Len(t, plan.keys, 2)
  157. require.NotEmpty(t, plan.scopeKey)
  158. c.Request.Header.Set("session_id", "sess-explicit")
  159. plan = buildCyberSessionBlockWritePlan(7, c, body)
  160. require.Len(t, plan.keys, 3)
  161. require.NotEmpty(t, plan.scopeKey)
  162. }
  163. // TestRecordCyberPolicyIfMarked_BlockKeyPlumbed verifies the 6th param is
  164. // accepted and a non-empty key with nil gateway service does not panic
  165. // (write-side guards live in the service layer).
  166. func TestRecordCyberPolicyIfMarked_BlockKeyPlumbed(t *testing.T) {
  167. c := newTestGinContext()
  168. service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "x", UpstreamStatus: 400})
  169. h := &OpenAIGatewayHandler{}
  170. require.NotPanics(t, func() {
  171. h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, []byte(`{"input":"deadbeef"}`), service.ChannelUsageFields{}, "")
  172. })
  173. }
  174. // TestBuildCyberPolicyOpsErrorEntry_StatusCode verifies F6: the ops error log
  175. // records the status the codex client actually received (400 non-stream / 200 stream),
  176. // not a hardcoded 403.
  177. func TestBuildCyberPolicyOpsErrorEntry_StatusCode(t *testing.T) {
  178. for _, tc := range []struct {
  179. name string
  180. upstreamStatus int
  181. }{
  182. {"non_stream_400", 400},
  183. {"stream_200", 200},
  184. {"zero_value", 0},
  185. } {
  186. t.Run(tc.name, func(t *testing.T) {
  187. mark := &service.CyberPolicyMark{
  188. Code: "cyber_policy",
  189. Message: "blocked",
  190. UpstreamStatus: tc.upstreamStatus,
  191. }
  192. entry := buildCyberPolicyOpsErrorEntry(cyberPolicyOpsErrorMeta{
  193. RequestID: "req-1", Model: "gpt-5", RequestPath: "/openai/v1/responses",
  194. }, mark)
  195. require.Equal(t, tc.upstreamStatus, entry.StatusCode)
  196. require.Equal(t, "cyber_policy", entry.ErrorType)
  197. require.Equal(t, "request", entry.ErrorPhase)
  198. })
  199. }
  200. }