openai_cyber_session_block_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http/httptest"
  7. "strconv"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func newCyberBlockTestCtx(headers map[string]string, body string) (*gin.Context, []byte) {
  15. gin.SetMode(gin.TestMode)
  16. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  17. req := httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(body))
  18. for k, v := range headers {
  19. req.Header.Set(k, v)
  20. }
  21. c.Request = req
  22. return c, []byte(body)
  23. }
  24. func TestCyberSessionExplicitBlockKey(t *testing.T) {
  25. c1, b1 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
  26. k1 := CyberSessionExplicitBlockKey(101, c1, b1)
  27. require.NotEmpty(t, k1)
  28. // Same session, different apiKey → different key (isolation).
  29. c2, b2 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
  30. require.NotEqual(t, k1, CyberSessionExplicitBlockKey(202, c2, b2))
  31. // Same session + same apiKey → stable key.
  32. c3, b3 := newCyberBlockTestCtx(map[string]string{"session_id": "sess-abc"}, `{}`)
  33. require.Equal(t, k1, CyberSessionExplicitBlockKey(101, c3, b3))
  34. // prompt_cache_key in body counts as explicit.
  35. c4, b4 := newCyberBlockTestCtx(nil, `{"prompt_cache_key":"pck-1"}`)
  36. require.NotEmpty(t, CyberSessionExplicitBlockKey(101, c4, b4))
  37. // No explicit signal → empty key → caller must skip blocking entirely.
  38. c5, b5 := newCyberBlockTestCtx(nil, `{"input":"hello world"}`)
  39. require.Empty(t, CyberSessionExplicitBlockKey(101, c5, b5))
  40. // conversation_id header counts as explicit; key is stable and non-empty.
  41. c6, b6 := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
  42. k6 := CyberSessionExplicitBlockKey(101, c6, b6)
  43. require.NotEmpty(t, k6)
  44. c6b, b6b := newCyberBlockTestCtx(map[string]string{"conversation_id": "conv-xyz"}, `{}`)
  45. require.Equal(t, k6, CyberSessionExplicitBlockKey(101, c6b, b6b), "conversation_id key must be stable")
  46. }
  47. func TestCyberTranscriptBlockKeysRequireModelGeneratedHistory(t *testing.T) {
  48. first := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question one"}]}`)
  49. second := []byte(`{"instructions":"shared","input":[{"role":"user","content":"fixed environment"},{"role":"user","content":"question two"}]}`)
  50. firstKeys := CyberSessionTranscriptBlockKeys(77, first)
  51. secondKeys := CyberSessionTranscriptBlockKeys(77, second)
  52. require.Len(t, firstKeys, 1)
  53. require.Len(t, secondKeys, 1)
  54. require.NotEqual(t, firstKeys[0], secondKeys[0])
  55. hit := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
  56. continuation := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
  57. hitKeys := CyberSessionTranscriptBlockKeys(77, hit)
  58. require.Len(t, hitKeys, 2)
  59. require.Contains(t, CyberSessionTranscriptLookupKeys(77, continuation), hitKeys[1])
  60. }
  61. func TestCyberTranscriptBlockKeysWebSocketResponseCreate(t *testing.T) {
  62. body := []byte(`{"type":"response.create","response":{"prompt_cache_key":"ws-session","input":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}}`)
  63. c, _ := newCyberBlockTestCtx(nil, string(body))
  64. require.NotEmpty(t, CyberSessionExplicitBlockKey(88, c, body))
  65. require.Len(t, CyberSessionTranscriptBlockKeys(88, body), 2)
  66. }
  67. func TestCyberTranscriptLookupKeysAreBoundedAndKeepNewestOrder(t *testing.T) {
  68. messages := make([]map[string]string, maxOpenAICyberTranscriptLookupKeys+44)
  69. for i := range messages {
  70. messages[i] = map[string]string{"role": "user", "content": "message-" + strconv.Itoa(i)}
  71. }
  72. body, err := json.Marshal(map[string]any{"messages": messages})
  73. require.NoError(t, err)
  74. keys := CyberSessionTranscriptLookupKeys(77, body)
  75. require.Len(t, keys, maxOpenAICyberTranscriptLookupKeys)
  76. firstRetainedBody, err := json.Marshal(map[string]any{"messages": messages[:45]})
  77. require.NoError(t, err)
  78. firstRetainedPrefix := CyberSessionTranscriptLookupKeys(77, firstRetainedBody)
  79. require.Equal(t, firstRetainedPrefix[len(firstRetainedPrefix)-1], keys[0])
  80. fullKey := CyberSessionTranscriptBlockKeys(77, body)[0]
  81. require.Equal(t, fullKey, keys[len(keys)-1])
  82. }
  83. // --- fakes ---
  84. type fakeCyberBlockStore struct {
  85. blocked map[string]bool
  86. scopes map[string]bool
  87. findCalls int
  88. }
  89. var _ CyberSessionBlockStore = (*fakeCyberBlockStore)(nil)
  90. func (f *fakeCyberBlockStore) SetCyberSessionBlocked(_ context.Context, scopeKey string, keys []string, _ time.Duration) error {
  91. if f.blocked == nil {
  92. f.blocked = map[string]bool{}
  93. }
  94. for _, key := range keys {
  95. f.blocked[key] = true
  96. }
  97. if scopeKey != "" {
  98. if f.scopes == nil {
  99. f.scopes = map[string]bool{}
  100. }
  101. f.scopes[scopeKey] = true
  102. }
  103. return nil
  104. }
  105. func (f *fakeCyberBlockStore) IsCyberSessionScopeActive(_ context.Context, scopeKey string) (bool, error) {
  106. return f.scopes[scopeKey], nil
  107. }
  108. func (f *fakeCyberBlockStore) FindCyberSessionBlocked(_ context.Context, keys []string) (string, error) {
  109. f.findCalls++
  110. for _, key := range keys {
  111. if f.blocked[key] {
  112. return key, nil
  113. }
  114. }
  115. return "", nil
  116. }
  117. // fakeSettingRepo is a minimal SettingRepository stub for unit tests.
  118. // Only GetValue is exercised by GetCyberSessionBlockRuntime; all other methods
  119. // panic so accidental calls are caught immediately.
  120. type fakeSettingRepo struct {
  121. vals map[string]string
  122. }
  123. func (r *fakeSettingRepo) GetValue(_ context.Context, key string) (string, error) {
  124. v, ok := r.vals[key]
  125. if !ok {
  126. return "", ErrSettingNotFound
  127. }
  128. return v, nil
  129. }
  130. func (r *fakeSettingRepo) Get(_ context.Context, _ string) (*Setting, error) {
  131. panic("fakeSettingRepo.Get not implemented")
  132. }
  133. func (r *fakeSettingRepo) Set(_ context.Context, _, _ string) error {
  134. panic("fakeSettingRepo.Set not implemented")
  135. }
  136. func (r *fakeSettingRepo) GetMultiple(_ context.Context, _ []string) (map[string]string, error) {
  137. panic("fakeSettingRepo.GetMultiple not implemented")
  138. }
  139. func (r *fakeSettingRepo) SetMultiple(_ context.Context, _ map[string]string) error {
  140. panic("fakeSettingRepo.SetMultiple not implemented")
  141. }
  142. func (r *fakeSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
  143. panic("fakeSettingRepo.GetAll not implemented")
  144. }
  145. func (r *fakeSettingRepo) Delete(_ context.Context, _ string) error {
  146. panic("fakeSettingRepo.Delete not implemented")
  147. }
  148. var _ SettingRepository = (*fakeSettingRepo)(nil)
  149. // comboCacheAndStore implements both GatewayCache (no-op stubs) and
  150. // CyberSessionBlockStore (delegates to fakeCyberBlockStore) so it can be
  151. // injected as s.cache and successfully type-asserted to CyberSessionBlockStore.
  152. type comboCacheAndStore struct {
  153. store fakeCyberBlockStore
  154. }
  155. var _ GatewayCache = (*comboCacheAndStore)(nil)
  156. var _ CyberSessionBlockStore = (*comboCacheAndStore)(nil)
  157. func (c *comboCacheAndStore) GetSessionAccountID(_ context.Context, _ int64, _ string) (int64, error) {
  158. return 0, errors.New("stub")
  159. }
  160. func (c *comboCacheAndStore) SetSessionAccountID(_ context.Context, _ int64, _ string, _ int64, _ time.Duration) error {
  161. return nil
  162. }
  163. func (c *comboCacheAndStore) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
  164. return nil
  165. }
  166. func (c *comboCacheAndStore) DeleteSessionAccountID(_ context.Context, _ int64, _ string) error {
  167. return nil
  168. }
  169. func (c *comboCacheAndStore) SetGrokVideoPendingBilling(_ context.Context, _ string, _ []byte, _ time.Duration) error {
  170. return nil
  171. }
  172. func (c *comboCacheAndStore) GetGrokVideoPendingBilling(_ context.Context, _ string) ([]byte, error) {
  173. return nil, nil
  174. }
  175. func (c *comboCacheAndStore) ClaimGrokVideoBilled(_ context.Context, _ string, _ time.Duration) (bool, error) {
  176. return true, nil
  177. }
  178. func (c *comboCacheAndStore) ReleaseGrokVideoBilled(_ context.Context, _ string) error {
  179. return nil
  180. }
  181. func (c *comboCacheAndStore) SetReasoningContent(_ context.Context, _ string, _ string, _ time.Duration) error {
  182. return nil
  183. }
  184. func (c *comboCacheAndStore) GetReasoningContent(_ context.Context, _ string) (string, error) {
  185. return "", ErrReasoningContentNotFound
  186. }
  187. func (c *comboCacheAndStore) SetCyberSessionBlocked(ctx context.Context, scopeKey string, keys []string, ttl time.Duration) error {
  188. return c.store.SetCyberSessionBlocked(ctx, scopeKey, keys, ttl)
  189. }
  190. func (c *comboCacheAndStore) IsCyberSessionScopeActive(ctx context.Context, scopeKey string) (bool, error) {
  191. return c.store.IsCyberSessionScopeActive(ctx, scopeKey)
  192. }
  193. func (c *comboCacheAndStore) FindCyberSessionBlocked(ctx context.Context, keys []string) (string, error) {
  194. return c.store.FindCyberSessionBlocked(ctx, keys)
  195. }
  196. // --- tests ---
  197. // TestIsCyberSessionBlocked_EmptyKeyAndNilService covers the fail-open paths:
  198. // empty key, nil service, store missing → always false / no panic.
  199. func TestFindCyberSessionBlocked_EmptyAndNilService(t *testing.T) {
  200. var nilSvc *OpenAIGatewayService
  201. require.Empty(t, nilSvc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
  202. require.NotPanics(t, func() { nilSvc.MarkCyberSessionBlocked(context.Background(), "", []string{"k"}) })
  203. svc := &OpenAIGatewayService{}
  204. require.Empty(t, svc.FindCyberSessionBlockedForRequest(context.Background(), 1, nil, nil, "", ""))
  205. }
  206. // TestCyberSessionBlock_RoundTrip exercises the type-assertion success path:
  207. // mark a session blocked via a combo cache+store, then confirm IsCyberSessionBlocked
  208. // returns true, and an unrelated key returns false.
  209. func TestCyberSessionBlock_RoundTrip(t *testing.T) {
  210. // SettingService with only settingRepo set — GetCyberSessionBlockRuntime needs
  211. // nothing else (cfg/proxyRepo/etc. are not touched by this code path).
  212. settingSvc := &SettingService{
  213. settingRepo: &fakeSettingRepo{
  214. vals: map[string]string{
  215. SettingKeyCyberSessionBlockEnabled: "true",
  216. SettingKeyCyberSessionBlockTTLSeconds: "60",
  217. },
  218. },
  219. }
  220. combo := &comboCacheAndStore{}
  221. svc := &OpenAIGatewayService{
  222. cache: combo,
  223. settingService: settingSvc,
  224. }
  225. ctx := context.Background()
  226. const testKey = "deadbeef1234"
  227. c, body := newCyberBlockTestCtx(map[string]string{"session_id": "sess-roundtrip"}, `{}`)
  228. explicitKey := CyberSessionExplicitBlockKey(1, c, body)
  229. require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
  230. svc.MarkCyberSessionBlocked(ctx, "", []string{explicitKey, testKey})
  231. require.Equal(t, explicitKey, svc.FindCyberSessionBlockedForRequest(ctx, 1, c, body, "203.0.113.1", "client/1.0"))
  232. }
  233. func TestFindCyberSessionBlockedForRequestUsesScopeForTranscript(t *testing.T) {
  234. settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{
  235. SettingKeyCyberSessionBlockEnabled: "true",
  236. SettingKeyCyberSessionBlockTTLSeconds: "60",
  237. }}}
  238. combo := &comboCacheAndStore{}
  239. svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc}
  240. ctx := context.Background()
  241. hitBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
  242. nextBody := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"different trigger"},{"role":"assistant","content":"blocked"},{"role":"user","content":"continue"}]}`)
  243. nextCtx, _ := newCyberBlockTestCtx(nil, string(nextBody))
  244. const clientIP = "203.0.113.20"
  245. const userAgent = "Codex CLI 1.2.3"
  246. blockKey := CyberSessionTranscriptBlockKeys(9, hitBody)[1]
  247. // Without an active source scope, transcript candidates are never blocks.
  248. require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, userAgent))
  249. scopeKey := CyberSessionScopeKey(9, clientIP, userAgent)
  250. svc.MarkCyberSessionBlocked(ctx, scopeKey, []string{blockKey})
  251. require.Equal(t, blockKey, svc.FindCyberSessionBlockedForRequest(ctx, 9, nextCtx, nextBody, clientIP, "Codex CLI 1.2.4"))
  252. }
  253. func TestFindCyberSessionBlockedForRequestFailsClosedOnScopedTranscriptOverflow(t *testing.T) {
  254. settingSvc := &SettingService{settingRepo: &fakeSettingRepo{vals: map[string]string{
  255. SettingKeyCyberSessionBlockEnabled: "true",
  256. SettingKeyCyberSessionBlockTTLSeconds: "60",
  257. }}}
  258. combo := &comboCacheAndStore{}
  259. svc := &OpenAIGatewayService{cache: combo, settingService: settingSvc}
  260. ctx := context.Background()
  261. const apiKeyID = int64(9)
  262. const clientIP = "203.0.113.20"
  263. const userAgent = "Codex CLI 1.2.3"
  264. messages := make([]map[string]string, maxOpenAICyberTranscriptLookupKeys+1)
  265. for i := range messages {
  266. messages[i] = map[string]string{"role": "user", "content": "message-" + strconv.Itoa(i)}
  267. }
  268. body, err := json.Marshal(map[string]any{"messages": messages})
  269. require.NoError(t, err)
  270. c, _ := newCyberBlockTestCtx(nil, string(body))
  271. require.Empty(t, svc.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent),
  272. "overflow alone must not bypass the scope gate")
  273. combo.store.scopes = map[string]bool{CyberSessionScopeKey(apiKeyID, clientIP, userAgent): true}
  274. require.Equal(t, cyberSessionTranscriptLookupOverflowBlockKey,
  275. svc.FindCyberSessionBlockedForRequest(ctx, apiKeyID, c, body, clientIP, userAgent))
  276. require.Zero(t, combo.store.findCalls, "overflow must not issue an unbounded Redis lookup")
  277. }
  278. func TestCyberSessionScopeKeyNormalizesUserAgentVersion(t *testing.T) {
  279. base := CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.3")
  280. require.NotEmpty(t, base)
  281. require.Equal(t, base, CyberSessionScopeKey(7, "203.0.113.10", "Codex CLI 1.2.4"))
  282. require.NotEqual(t, base, CyberSessionScopeKey(8, "203.0.113.10", "Codex CLI 1.2.3"))
  283. require.NotEqual(t, base, CyberSessionScopeKey(7, "203.0.113.11", "Codex CLI 1.2.3"))
  284. }