| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- package handler
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "sync/atomic"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/securityaudit"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "go.uber.org/zap"
- "go.uber.org/zap/zaptest/observer"
- )
- func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) {
- require.True(t, cachesSecurityAuditCompletion("http"))
- require.True(t, cachesSecurityAuditCompletion(""))
- require.False(t, cachesSecurityAuditCompletion("first_turn"))
- require.False(t, cachesSecurityAuditCompletion("subsequent_turn"))
- require.True(t, isSecurityAuditWebSocketStage("first_turn"))
- require.True(t, isSecurityAuditWebSocketStage("subsequent_turn"))
- require.False(t, isSecurityAuditWebSocketStage("http"))
- }
- func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := &turnCountingEngine{mode: securityaudit.ModeAsync}
- coordinator := securityaudit.NewCoordinator(nil, engine)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- subject := middleware2.AuthSubject{UserID: 7, Concurrency: 1}
- first := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test",
- []byte(`{"type":"response.create","response":{"input":"benign"}}`), "first_turn")
- require.NotNil(t, first)
- require.True(t, first.AllowNextStage)
- require.Equal(t, int64(1), engine.enqueues.Load())
- _, cached := c.Get(securityAuditCompletedContextKey)
- require.False(t, cached, "WebSocket stages must not set the HTTP completion cache")
- // Even if an HTTP path previously cached completion on this Context, WS turns
- // must still audit every response.create payload.
- c.Set(securityAuditCompletedContextKey, true)
- second := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test",
- []byte(`{"type":"response.create","response":{"input":"malicious follow-up"}}`), "subsequent_turn")
- require.NotNil(t, second)
- require.Equal(t, int64(2), engine.enqueues.Load(), "subsequent WebSocket turns must be audited again")
- }
- func TestRunSecurityAuditDeduplicatesRepeatedPayloadWithinWebSocketTurn(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
- coordinator := securityaudit.NewCoordinator(nil, engine)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
- c.Set(securityAuditWSTurnContextKey, 2)
- first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- require.NotNil(t, first)
- require.NotNil(t, second)
- require.True(t, first.AllowNextStage)
- require.True(t, second.AllowNextStage)
- require.Equal(t, int64(1), engine.evaluates.Load())
- // The cache holds only one successful same-turn result.
- entry, exists := c.Get(securityAuditWSDedupeContextKey)
- require.True(t, exists)
- require.IsType(t, securityAuditWSDedupeEntry{}, entry)
- c.Set(securityAuditWSTurnContextKey, 3)
- runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- require.Equal(t, int64(2), engine.evaluates.Load())
- }
- func TestRunSecurityAuditDoesNotCacheFailedWebSocketDecision(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := &turnCountingEngine{
- mode: securityaudit.ModeBlocking,
- decisions: []*securityaudit.PromptDecision{
- {Kind: securityaudit.DecisionUnavailable, AllowNextStage: false},
- {Kind: securityaudit.DecisionAllow, AllowNextStage: true},
- },
- }
- coordinator := securityaudit.NewCoordinator(nil, engine)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Set(securityAuditWSTurnContextKey, 2)
- payload := []byte(`{"type":"response.create","response":{"input":"retry me"}}`)
- first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- _, cachedAfterFailure := c.Get(securityAuditWSDedupeContextKey)
- second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- require.False(t, first.AllowNextStage)
- require.False(t, cachedAfterFailure)
- require.True(t, second.AllowNextStage)
- require.Equal(t, int64(2), engine.evaluates.Load())
- }
- func TestRunSecurityAuditDoesNotCacheFlaggedWebSocketDecision(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := &turnCountingEngine{
- mode: securityaudit.ModeBlocking,
- decisions: []*securityaudit.PromptDecision{
- {Kind: securityaudit.DecisionFlag, AllowNextStage: true},
- {Kind: securityaudit.DecisionAllow, AllowNextStage: true},
- },
- }
- coordinator := securityaudit.NewCoordinator(nil, engine)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Set(securityAuditWSTurnContextKey, 2)
- payload := []byte(`{"type":"response.create","response":{"input":"retry flagged"}}`)
- first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- _, cachedAfterFlag := c.Get(securityAuditWSDedupeContextKey)
- second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- require.Equal(t, securityaudit.DecisionFlag, first.Kind)
- require.True(t, first.AllowNextStage)
- require.False(t, cachedAfterFlag)
- require.Equal(t, securityaudit.DecisionAllow, second.Kind)
- require.Equal(t, int64(2), engine.evaluates.Load())
- }
- func TestRunSecurityAuditLogsWebSocketChecksAndCacheHits(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
- coordinator := securityaudit.NewCoordinator(nil, engine)
- core, logs := observer.New(zap.InfoLevel)
- reqLog := zap.New(core)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
- c.Set(securityAuditWSTurnContextKey, 2)
- payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
- runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
- startLogs := logs.FilterMessage("security_audit.gateway_check_start").All()
- require.Len(t, startLogs, 1)
- require.Equal(t, false, startLogs[0].ContextMap()["cached"])
- doneLogs := logs.FilterMessage("security_audit.gateway_check_done").All()
- require.Len(t, doneLogs, 2)
- require.Equal(t, false, doneLogs[0].ContextMap()["cached"])
- require.Equal(t, true, doneLogs[1].ContextMap()["cached"])
- require.Equal(t, "allow", doneLogs[1].ContextMap()["decision"])
- require.Equal(t, "subsequent_turn", doneLogs[1].ContextMap()["stage"])
- require.Equal(t, int64(1), engine.evaluates.Load())
- }
- type turnCountingEngine struct {
- mode securityaudit.Mode
- enqueues atomic.Int64
- evaluates atomic.Int64
- decisions []*securityaudit.PromptDecision
- }
- func (e *turnCountingEngine) EffectiveMode() securityaudit.Mode { return e.mode }
- func (e *turnCountingEngine) Enqueue(context.Context, securityaudit.Request) error {
- e.enqueues.Add(1)
- return nil
- }
- func (e *turnCountingEngine) Evaluate(context.Context, securityaudit.Request) (*securityaudit.PromptDecision, error) {
- call := e.evaluates.Add(1)
- if int(call) <= len(e.decisions) {
- return e.decisions[call-1], nil
- }
- return &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}, nil
- }
|