Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
181 lines
7.2 KiB
Go
181 lines
7.2 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|