Files
sub2api/backend/internal/service/openai_ws_forwarder_test.go
T
DaydreamCodingandClaude Opus 4.8 b62b573f7f feat(openai): cyber_policy 硬阻断全链路透传、审计与计费
上游对单次请求下发 error.code=cyber_policy 硬阻断时,网关在所有端点
(/v1/responses、/v1/chat/completions、/v1/messages、WebSocket)及流式/
非流式路径下,将该结果原样透传给客户端,绝不 failover、换号或同步拦截;
命中后异步完成审计与计费:

- 风控中心记录 cyber_policy 留痕并发送通知邮件,落库先于发信,SMTP 阻塞
  不影响留痕
- ops 错误请求记录,状态码对齐客户端实际接收(流式 200 / 非流式 400)
- 用量明细标记 request_type=cyber,按上游真实 token 计费,HTTP 与
  WebSocket 计费口径统一,零 token 命中不误扣
- 会话级自动屏蔽(管理员开关,默认关):命中的会话在可配 TTL 内本地拦截
  不再发往上游,仅屏蔽该会话不影响同 Key 其他会话
- 封号计数排除开关:可选让 cyber 命中不计入自动封号,命中当次不判定且
  历史行在违规计数中一并排除

WebSocket 多轮连接下 cyber 标记按 turn 生命周期管理,逐轮独立检测与记录;
透传的错误响应不被兜底逻辑追加内容污染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:47:01 +08:00

130 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestIsOpenAIWSTokenEvent_TerminalEventsExcluded 覆盖 isOpenAIWSTokenEvent 的回归用例。
// 重点验证终止事件(response.completed / response.done)不再被当作 token event,
// 否则当上游没有可识别的 delta 时,firstTokenMs 会被填到终止时刻,
// 等于把"总耗时"误报为"首 token 延迟"(issue #2651)。
func TestIsOpenAIWSTokenEvent_TerminalEventsExcluded(t *testing.T) {
cases := []struct {
name string
eventType string
want bool
}{
{name: "empty", eventType: "", want: false},
{name: "whitespace_trimmed_empty", eventType: " ", want: false},
{name: "response.created", eventType: "response.created", want: false},
{name: "response.in_progress", eventType: "response.in_progress", want: false},
{name: "response.output_item.added", eventType: "response.output_item.added", want: false},
{name: "response.output_item.done", eventType: "response.output_item.done", want: false},
{name: "terminal_response.completed", eventType: "response.completed", want: false},
{name: "terminal_response.done", eventType: "response.done", want: false},
{name: "terminal_response.completed_padded", eventType: " response.completed ", want: false},
{name: "terminal_response.done_padded", eventType: " response.done ", want: false},
{name: "delta_text", eventType: "response.output_text.delta", want: true},
{name: "delta_audio_transcript", eventType: "response.audio_transcript.delta", want: true},
{name: "delta_function_call_arguments", eventType: "response.function_call_arguments.delta", want: true},
{name: "output_text_done", eventType: "response.output_text.done", want: true},
{name: "output_text_annotation_added", eventType: "response.output_text.annotation.added", want: true},
{name: "output_audio_done", eventType: "response.output_audio.done", want: true},
{name: "reasoning_summary_delta", eventType: "response.reasoning_summary_text.delta", want: true},
{name: "unrelated_event_error", eventType: "error", want: false},
{name: "unknown_event_without_match", eventType: "response.reasoning_summary_part.added", want: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got := isOpenAIWSTokenEvent(tc.eventType)
require.Equal(t, tc.want, got, "isOpenAIWSTokenEvent(%q)", tc.eventType)
})
}
}
// TestOpenAIWSCyberPolicyMark_ResponseFailed 验证 WS 路径 response.failed cyber_policy 标记逻辑。
//
// 全量转发循环(forwardOpenAIWSV2 / sendAndRelay)依赖真实 WebSocket 连接,
// 无法在单元测试中驱动。本测试通过直接调用转发循环内使用的两个函数
// detectOpenAICyberPolicy + MarkOpsCyberPolicy,覆盖「从 response.failed 帧
// 到 gin context 写入」的完整调用序列,等同于循环体内对应代码段的逻辑验证。
// 全量 WS 端到端覆盖由后续集成测试(Task 12 handler 编排)承担。
func TestOpenAIWSCyberPolicyMark_ResponseFailed(t *testing.T) {
// 构造一个真实的 response.failed 帧(cyber_policy 命中路径)。
payload := []byte(`{"type":"response.failed","response":{"id":"resp_abc","status":"failed","error":{"code":"cyber_policy","message":"Request blocked by content policy."}}}`)
// 验证 detectOpenAICyberPolicy 能从 response.error.code 路径识别。
hit, code, msg := detectOpenAICyberPolicy(payload)
require.True(t, hit, "detectOpenAICyberPolicy should return true for cyber_policy payload")
require.Equal(t, "cyber_policy", code)
require.Equal(t, "Request blocked by content policy.", msg)
// 构造 gin test context,模拟转发循环调用 MarkOpsCyberPolicy。
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
usage := OpenAIUsage{InputTokens: 42, OutputTokens: 7}
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
Body: truncateString(string(payload), 4096),
UpstreamStatus: 200,
UpstreamInTok: usage.InputTokens,
UpstreamOutTok: usage.OutputTokens,
})
mark := GetOpsCyberPolicy(c)
require.NotNil(t, mark, "GetOpsCyberPolicy should return non-nil after MarkOpsCyberPolicy")
require.Equal(t, "cyber_policy", mark.Code)
require.Equal(t, "Request blocked by content policy.", mark.Message)
require.Equal(t, 200, mark.UpstreamStatus)
require.Equal(t, 42, mark.UpstreamInTok)
require.Equal(t, 7, mark.UpstreamOutTok)
// 验证幂等性:再次标记不覆盖首个。
MarkOpsCyberPolicy(c, CyberPolicyMark{Code: "cyber_policy", Message: "second call"})
require.Equal(t, "Request blocked by content policy.", GetOpsCyberPolicy(c).Message, "second MarkOpsCyberPolicy call must not overwrite first")
}
// TestOpenAIWSCyberPolicyMark_NonCyberPayload 验证非 cyber_policy 的 response.failed 不触发标记。
func TestOpenAIWSCyberPolicyMark_NonCyberPayload(t *testing.T) {
payload := []byte(`{"type":"response.failed","response":{"id":"resp_xyz","status":"failed","error":{"code":"server_error","message":"Internal error"}}}`)
hit, _, _ := detectOpenAICyberPolicy(payload)
require.False(t, hit, "detectOpenAICyberPolicy should return false for non-cyber_policy error code")
}
// TestIsOpenAIWSTokenEvent_DisjointWithTerminal 守护「token 事件集合与终止事件集合互斥」的不变量。
// firstTokenMs 的计算依赖于 isTokenEvent && !isTerminalEvent;
// 若两者再次出现交集,则 issue #2651 描述的 latency 误报会重现。
func TestIsOpenAIWSTokenEvent_DisjointWithTerminal(t *testing.T) {
terminalEvents := []string{
"response.completed",
"response.done",
"response.failed",
"response.incomplete",
"response.cancelled",
"response.canceled",
}
for _, ev := range terminalEvents {
ev := ev
t.Run(ev, func(t *testing.T) {
require.True(t, isOpenAIWSTerminalEvent(ev), "expected terminal event %q to be classified as terminal", ev)
require.False(t, isOpenAIWSTokenEvent(ev), "terminal event %q must NOT be classified as token event (issue #2651)", ev)
})
}
}