security_audit_media_submit_test.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. package handler
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "sync"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/securityaudit"
  12. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  13. "github.com/Wei-Shaw/sub2api/internal/service"
  14. "github.com/gin-gonic/gin"
  15. "github.com/stretchr/testify/require"
  16. )
  17. type handlerPromptEngine struct {
  18. mu sync.Mutex
  19. mode securityaudit.Mode
  20. decision *securityaudit.PromptDecision
  21. err error
  22. evaluated int
  23. enqueued int
  24. requests []securityaudit.Request
  25. }
  26. func (e *handlerPromptEngine) EffectiveMode() securityaudit.Mode { return e.mode }
  27. func (e *handlerPromptEngine) Enqueue(_ context.Context, req securityaudit.Request) error {
  28. e.mu.Lock()
  29. defer e.mu.Unlock()
  30. e.enqueued++
  31. e.requests = append(e.requests, req.Clone())
  32. return e.err
  33. }
  34. func (e *handlerPromptEngine) Evaluate(_ context.Context, req securityaudit.Request) (*securityaudit.PromptDecision, error) {
  35. e.mu.Lock()
  36. defer e.mu.Unlock()
  37. e.evaluated++
  38. e.requests = append(e.requests, req.Clone())
  39. return e.decision, e.err
  40. }
  41. func (e *handlerPromptEngine) snapshot() (evaluated, enqueued int, requests []securityaudit.Request) {
  42. e.mu.Lock()
  43. defer e.mu.Unlock()
  44. requests = make([]securityaudit.Request, len(e.requests))
  45. copy(requests, e.requests)
  46. return e.evaluated, e.enqueued, requests
  47. }
  48. func securityAuditMediaTestMiddleware(c *gin.Context) {
  49. groupID := int64(3)
  50. user := &service.User{ID: 7, Username: "media-user", Email: "media@example.test"}
  51. c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
  52. ID: 9, UserID: 7, User: user, Name: "media-key", GroupID: &groupID,
  53. Group: &service.Group{ID: groupID, Name: "media-group", Platform: service.PlatformOpenAI, AllowImageGeneration: true},
  54. })
  55. c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 7, Concurrency: 2})
  56. c.Next()
  57. }
  58. func blockingHandlerPromptEngine() *handlerPromptEngine {
  59. return &handlerPromptEngine{mode: securityaudit.ModeBlocking, decision: &securityaudit.PromptDecision{
  60. Kind: securityaudit.DecisionBlock, ErrorCode: securityaudit.ErrorCodeBlocked, AllowNextStage: false,
  61. }}
  62. }
  63. func TestAsyncImagePromptGuardRunsBeforeTaskCreation(t *testing.T) {
  64. gin.SetMode(gin.TestMode)
  65. store := &asyncImageMemoryStore{tasks: map[string]*service.ImageTaskRecord{}}
  66. tasks := service.NewImageTaskServiceWithUploader(store, nil, time.Hour, time.Minute)
  67. engine := blockingHandlerPromptEngine()
  68. openAI := &OpenAIGatewayHandler{securityAuditCoordinator: securityaudit.NewCoordinator(nil, engine)}
  69. h := &AsyncImageHandler{tasks: tasks, openAI: openAI}
  70. executions := 0
  71. h.execute = func(string, *gin.Context) { executions++ }
  72. router := gin.New()
  73. router.Use(securityAuditMediaTestMiddleware)
  74. router.POST("/v1/images/generations/async", h.Submit)
  75. request := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", strings.NewReader(`{"model":"gpt-image-2","prompt":"blocked async prompt"}`))
  76. request.Header.Set("Content-Type", "application/json")
  77. recorder := httptest.NewRecorder()
  78. router.ServeHTTP(recorder, request)
  79. require.Equal(t, http.StatusForbidden, recorder.Code)
  80. require.Contains(t, recorder.Body.String(), securityaudit.ErrorCodeBlocked)
  81. require.Empty(t, store.tasks, "no asynchronous task may exist after a blocking decision")
  82. require.Zero(t, executions)
  83. evaluated, _, requests := engine.snapshot()
  84. require.Equal(t, 1, evaluated)
  85. require.Len(t, requests, 1)
  86. require.Contains(t, string(requests[0].Body), "blocked async prompt")
  87. }
  88. func TestAsyncImageSuccessfulPrecheckIsNotRepeatedByDetachedExecution(t *testing.T) {
  89. gin.SetMode(gin.TestMode)
  90. store := &asyncImageMemoryStore{tasks: map[string]*service.ImageTaskRecord{}}
  91. tasks := service.NewImageTaskServiceWithUploader(store, nil, time.Hour, time.Minute)
  92. engine := &handlerPromptEngine{mode: securityaudit.ModeBlocking, decision: &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}}
  93. openAI := &OpenAIGatewayHandler{securityAuditCoordinator: securityaudit.NewCoordinator(nil, engine)}
  94. h := &AsyncImageHandler{tasks: tasks, openAI: openAI}
  95. var executionMu sync.Mutex
  96. repeatedDecision := false
  97. h.execute = func(_ string, c *gin.Context) {
  98. apiKey, _ := middleware2.GetAPIKeyFromContext(c)
  99. subject, _ := middleware2.GetAuthSubjectFromContext(c)
  100. decision := openAI.checkSecurityAudit(c, nil, apiKey, subject, service.ContentModerationProtocolOpenAIImages, "gpt-image-2", []byte(`{"prompt":"must not rescan"}`))
  101. executionMu.Lock()
  102. repeatedDecision = decision != nil
  103. executionMu.Unlock()
  104. c.JSON(http.StatusOK, gin.H{"created": 1, "data": []any{}})
  105. }
  106. router := gin.New()
  107. router.Use(securityAuditMediaTestMiddleware)
  108. router.POST("/v1/images/generations/async", h.Submit)
  109. request := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", strings.NewReader(`{"model":"gpt-image-2","prompt":"allowed async prompt"}`))
  110. request.Header.Set("Content-Type", "application/json")
  111. recorder := httptest.NewRecorder()
  112. router.ServeHTTP(recorder, request)
  113. require.Equal(t, http.StatusAccepted, recorder.Code)
  114. require.Eventually(t, func() bool {
  115. store.mu.RLock()
  116. defer store.mu.RUnlock()
  117. for _, task := range store.tasks {
  118. if task.Status == service.ImageTaskStatusCompleted {
  119. return true
  120. }
  121. }
  122. return false
  123. }, time.Second, 10*time.Millisecond)
  124. evaluated, _, _ := engine.snapshot()
  125. require.Equal(t, 1, evaluated)
  126. executionMu.Lock()
  127. require.False(t, repeatedDecision)
  128. executionMu.Unlock()
  129. }
  130. func TestBatchImagePromptGuardRunsBeforePersistenceOrBilling(t *testing.T) {
  131. gin.SetMode(gin.TestMode)
  132. engine := blockingHandlerPromptEngine()
  133. openAI := &OpenAIGatewayHandler{securityAuditCoordinator: securityaudit.NewCoordinator(nil, engine)}
  134. h := &BatchImageHandler{openAI: openAI}
  135. router := gin.New()
  136. router.Use(securityAuditMediaTestMiddleware)
  137. router.POST("/v1/images/batches", h.Submit)
  138. body := map[string]any{
  139. "model": "gemini-image-test",
  140. "items": []map[string]any{{
  141. "custom_id": "one", "prompt": "blocked batch prompt",
  142. "reference_images": []map[string]any{{"mime_type": "image/png", "data": []byte("BINARY_CANARY")}},
  143. }},
  144. }
  145. raw, err := json.Marshal(body)
  146. require.NoError(t, err)
  147. request := httptest.NewRequest(http.MethodPost, "/v1/images/batches", strings.NewReader(string(raw)))
  148. request.Header.Set("Content-Type", "application/json")
  149. recorder := httptest.NewRecorder()
  150. require.NotPanics(t, func() { router.ServeHTTP(recorder, request) }, "nil service would panic if Submit were reached")
  151. require.Equal(t, http.StatusForbidden, recorder.Code)
  152. evaluated, _, requests := engine.snapshot()
  153. require.Equal(t, 1, evaluated)
  154. require.Len(t, requests, 1)
  155. require.Contains(t, string(requests[0].Body), "blocked batch prompt")
  156. require.NotContains(t, string(requests[0].Body), "BINARY_CANARY")
  157. require.NotContains(t, string(requests[0].Body), "QklOQVJZX0NBTkFSWQ==")
  158. }
  159. func TestSecurityAuditBlockingFailuresLeaveAllDownstreamCountersAtZero(t *testing.T) {
  160. gin.SetMode(gin.TestMode)
  161. for _, kind := range []securityaudit.DecisionKind{securityaudit.DecisionBlock, securityaudit.DecisionUnavailable, securityaudit.DecisionInvalid} {
  162. t.Run(string(kind), func(t *testing.T) {
  163. promptDecision := promptGuardDecision(kind)
  164. engine := &handlerPromptEngine{mode: securityaudit.ModeBlocking, decision: &securityaudit.PromptDecision{
  165. Kind: kind, ErrorCode: promptDecision.ErrorCode, AllowNextStage: false,
  166. }}
  167. coordinator := securityaudit.NewCoordinator(nil, engine)
  168. recorder := httptest.NewRecorder()
  169. c, _ := gin.CreateTestContext(recorder)
  170. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-test","messages":[{"role":"user","content":"guard me"}]}`))
  171. groupID := int64(3)
  172. apiKey := &service.APIKey{ID: 9, UserID: 7, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI}}
  173. subject := middleware2.AuthSubject{UserID: 7, Concurrency: 2}
  174. decision := runSecurityAudit(c, nil, coordinator, nil, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "gpt-test", []byte(`{"messages":[{"role":"user","content":"guard me"}]}`), "http")
  175. require.NotNil(t, decision)
  176. require.False(t, decision.AllowNextStage)
  177. require.False(t, recorder.Result().Header.Get("Content-Type") != "", "Guard evaluation itself must not start SSE/HTTP output")
  178. accountSelections, billingChecks, billingPreconsumes, upstreamDispatches := 0, 0, 0, 0
  179. if decision.AllowNextStage {
  180. accountSelections++
  181. billingChecks++
  182. billingPreconsumes++
  183. upstreamDispatches++
  184. }
  185. require.Zero(t, accountSelections)
  186. require.Zero(t, billingChecks)
  187. require.Zero(t, billingPreconsumes)
  188. require.Zero(t, upstreamDispatches)
  189. (&OpenAIGatewayHandler{}).openAISecurityAuditError(c, decision)
  190. require.Equal(t, promptDecision.HTTPStatus, recorder.Code)
  191. })
  192. }
  193. }