package handler import ( "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, "", 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, "", 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, "", 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 */, "", 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, "", 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, "", service.ChannelUsageFields{}, "") require.True(t, c.GetBool(cyberPolicyRecordedKey)) require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message) } // 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") } // 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, "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) }) } }