| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- package handler
- import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // newTestGinContext builds a bare gin.Context backed by an httptest recorder.
- func newTestGinContext() *gin.Context {
- gin.SetMode(gin.TestMode)
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- return c
- }
- // TestRecordCyberPolicyIfMarked_NoMark verifies that when no cyber mark is set,
- // the function returns immediately and does NOT set the recorded flag.
- func TestRecordCyberPolicyIfMarked_NoMark(t *testing.T) {
- c := newTestGinContext()
- h := &OpenAIGatewayHandler{}
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
- // Flag must NOT be set when there was no mark.
- require.False(t, c.GetBool(cyberPolicyRecordedKey),
- "cyberPolicyRecordedKey must remain false when no cyber mark is present")
- }
- // TestRecordCyberPolicyIfMarked_WithMark verifies that:
- // 1. When a cyber mark is present, the recorded flag is set (guard activated).
- // 2. A second call is a no-op (idempotent guard).
- // 3. Nil services do not panic.
- func TestRecordCyberPolicyIfMarked_WithMark(t *testing.T) {
- c := newTestGinContext()
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
- Message: "flagged",
- Body: `{"error":{"code":"cyber_policy"}}`,
- UpstreamStatus: 400,
- })
- h := &OpenAIGatewayHandler{} // nil services — must not panic
- // First call: should set the flag.
- require.NotPanics(t, func() {
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
- })
- require.True(t, c.GetBool(cyberPolicyRecordedKey),
- "cyberPolicyRecordedKey must be true after first call with a mark")
- // Second call: flag already set — must be a no-op (idempotent).
- require.NotPanics(t, func() {
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
- })
- // Flag should still be true (not toggled or cleared).
- require.True(t, c.GetBool(cyberPolicyRecordedKey),
- "cyberPolicyRecordedKey must remain true after second call (guard)")
- }
- // TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog verifies the semantic:
- // when forwardErrored=false the function still sets the guard flag (mark present),
- // but the cyber usage row is NOT requested (only RecordCyberPolicyEvent fires).
- // Since services are nil here we only verify the guard flag and no panic.
- func TestRecordCyberPolicyIfMarked_ForwardSuccessSkipsUsageLog(t *testing.T) {
- c := newTestGinContext()
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{
- Message: "flagged",
- UpstreamStatus: 200,
- })
- h := &OpenAIGatewayHandler{}
- require.NotPanics(t, func() {
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false /* forwardErrored=false */, nil, service.ChannelUsageFields{}, "")
- })
- require.True(t, c.GetBool(cyberPolicyRecordedKey))
- }
- // TestClearCyberPolicyTurnState verifies F1 at the handler level: after a turn
- // is finalized, both the mark and the recorded guard are reset so the next WS
- // turn detects/records independently.
- func TestClearCyberPolicyTurnState(t *testing.T) {
- c := newTestGinContext()
- h := &OpenAIGatewayHandler{}
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn1", UpstreamStatus: 200})
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
- require.True(t, c.GetBool(cyberPolicyRecordedKey))
- clearCyberPolicyTurnState(c)
- require.Nil(t, service.GetOpsCyberPolicy(c))
- require.False(t, c.GetBool(cyberPolicyRecordedKey))
- // turn2: a fresh cyber hit must be recordable again.
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "turn2", UpstreamStatus: 200})
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", false, nil, service.ChannelUsageFields{}, "")
- require.True(t, c.GetBool(cyberPolicyRecordedKey))
- require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
- }
- func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
- failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
- blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
- require.False(t, blocked, "the replacement account must receive the current turn")
- require.True(t, pending, "the cyber hit must still block later turns")
- blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
- require.False(t, blocked, "additional failover attempts must remain eligible")
- require.True(t, pending)
- blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
- require.True(t, blocked, "the next client turn must be blocked after failover finishes")
- require.False(t, pending)
- }
- func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
- c := newTestGinContext()
- h := &OpenAIGatewayHandler{}
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
- require.True(t, c.GetBool(cyberPolicyRecordedKey))
- clearCyberPolicyAttemptState(c, false)
- require.Nil(t, service.GetOpsCyberPolicy(c))
- require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
- require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
- require.True(t, c.GetBool(cyberPolicyRecordedKey))
- clearCyberPolicyAttemptState(c, true)
- require.Nil(t, service.GetOpsCyberPolicy(c))
- require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
- }
- // TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
- // auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
- // from upstream cyber_policy hits, and it must NOT touch moderation/violation.
- func TestBuildCyberSessionBlockedOpsEntry(t *testing.T) {
- entry := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
- RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
- })
- require.Equal(t, 403, entry.StatusCode)
- require.Equal(t, "cyber_policy_session_blocked", entry.ErrorType)
- require.Equal(t, "request", entry.ErrorPhase)
- require.True(t, entry.IsBusinessLimited)
- require.Equal(t, "gateway_local", entry.ErrorSource)
- require.Equal(t, "platform", entry.ErrorOwner)
- require.Empty(t, entry.ErrorBody, "no session block key → ErrorBody must be empty")
- entryWithKey := buildCyberSessionBlockedOpsEntry(cyberPolicyOpsErrorMeta{
- RequestID: "req-9", Model: "gpt-5", RequestPath: "/openai/v1/responses",
- SessionBlockKey: "abc123",
- })
- require.Equal(t, "session_block_key=abc123", entryWithKey.ErrorBody)
- }
- // TestRejectIfCyberSessionBlocked_FailOpen verifies fail-open paths: nil handler
- // services, no explicit session signal, and (implicitly) disabled switch all
- // pass the request through.
- func TestRejectIfCyberSessionBlocked_FailOpen(t *testing.T) {
- c := newTestGinContext()
- c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(`{}`))
- h := &OpenAIGatewayHandler{}
- require.False(t, h.rejectIfCyberSessionBlocked(c, nil, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil apiKey → pass")
- h2 := &OpenAIGatewayHandler{gatewayService: nil}
- key := &service.APIKey{ID: 1}
- require.False(t, h2.rejectIfCyberSessionBlocked(c, key, []byte(`{}`), "gpt-5", cyberBlockFormatResponses), "nil gateway service → pass")
- }
- func TestBuildCyberSessionBlockWritePlanCombinesExplicitAndTranscriptKeys(t *testing.T) {
- body := []byte(`{"messages":[{"role":"user","content":"setup"},{"role":"assistant","content":"ready"},{"role":"user","content":"trigger"}]}`)
- c := newTestGinContext()
- c.Request = httptest.NewRequest("POST", "/openai/v1/responses", strings.NewReader(string(body)))
- c.Request.RemoteAddr = "203.0.113.44:12345"
- c.Request.Header.Set("User-Agent", "client/1.2.3")
- plan := buildCyberSessionBlockWritePlan(7, c, body)
- require.Len(t, plan.keys, 2)
- require.NotEmpty(t, plan.scopeKey)
- c.Request.Header.Set("session_id", "sess-explicit")
- plan = buildCyberSessionBlockWritePlan(7, c, body)
- require.Len(t, plan.keys, 3)
- require.NotEmpty(t, plan.scopeKey)
- }
- // TestRecordCyberPolicyIfMarked_BlockKeyPlumbed verifies the 6th param is
- // accepted and a non-empty key with nil gateway service does not panic
- // (write-side guards live in the service layer).
- func TestRecordCyberPolicyIfMarked_BlockKeyPlumbed(t *testing.T) {
- c := newTestGinContext()
- service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "x", UpstreamStatus: 400})
- h := &OpenAIGatewayHandler{}
- require.NotPanics(t, func() {
- h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, []byte(`{"input":"deadbeef"}`), service.ChannelUsageFields{}, "")
- })
- }
- // TestBuildCyberPolicyOpsErrorEntry_StatusCode verifies F6: the ops error log
- // records the status the codex client actually received (400 non-stream / 200 stream),
- // not a hardcoded 403.
- func TestBuildCyberPolicyOpsErrorEntry_StatusCode(t *testing.T) {
- for _, tc := range []struct {
- name string
- upstreamStatus int
- }{
- {"non_stream_400", 400},
- {"stream_200", 200},
- {"zero_value", 0},
- } {
- t.Run(tc.name, func(t *testing.T) {
- mark := &service.CyberPolicyMark{
- Code: "cyber_policy",
- Message: "blocked",
- UpstreamStatus: tc.upstreamStatus,
- }
- entry := buildCyberPolicyOpsErrorEntry(cyberPolicyOpsErrorMeta{
- RequestID: "req-1", Model: "gpt-5", RequestPath: "/openai/v1/responses",
- }, mark)
- require.Equal(t, tc.upstreamStatus, entry.StatusCode)
- require.Equal(t, "cyber_policy", entry.ErrorType)
- require.Equal(t, "request", entry.ErrorPhase)
- })
- }
- }
|