Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
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
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
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user