Files
sub2api/backend/internal/service/openai_encrypted_content_lineage_test.go
T
feeeei 0e08c39999 fix(openai-ws): remember rejected encrypted content across turns
invalid_encrypted_content 恢复此前只清理当次请求体:同一失效密文随客户
端维护的会话历史在后续每一轮重新出现,反复触发"整包被拒→剥离→重试/
重连"(#6382 生产观测 2h 内 251 次 recovery、273 次 WS 重连),既是
QoS 放大器也是内存压力放大器。

按会话记录被上游拒绝过的 encrypted_content SHA-256 摘要(lineage):
- OpenAIWSStateStore 新增会话级摘要绑定,TTL 复用 session sticky,
  单会话 512 摘要为存储自保护,超出仅退化为既有单次 recovery
- 写点覆盖 forward WSv2 recovery、forward 非 WS 400 重试、ingress
  error 事件、bridge 400/流内 error;读点在 forward 进场与 ingress/
  bridge turn 循环入口按摘要预剥离,新密文摘要不同不受影响
- 只收剥离端可处理的项类型(reasoning/compaction/compaction_summary),
  避免记入永远剥不掉的摘要后每轮空转解码
- replay 历史序列同步剥离,避免与已剥离的当前 input 项错位导致
  prefix 复用失配

含端到端集成测试:turn1 上游拒绝即记录,turn2 上游收到的请求不再携带
失效密文且 reasoning 骨架保留。
2026-08-31 01:02:13 +08:00

217 lines
8.0 KiB
Go

package service
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestCollectOpenAIEncryptedContentDigestsRaw(t *testing.T) {
t.Parallel()
digests := collectOpenAIEncryptedContentDigestsRaw([]byte(`{"input":[
{"type":"reasoning","encrypted_content":"cipher-a"},
{"type":"compaction","encrypted_content":"cipher-b"},
{"type":"input_text","text":"hello"},
{"type":"reasoning"},
{"type":"message","encrypted_content":"cipher-alien"}
]}`))
// 剥离端处理不了的类型(message)不收摘要,否则该摘要永远命中却剥不掉。
require.Equal(t, []string{
openAIEncryptedContentDigest("cipher-a"),
openAIEncryptedContentDigest("cipher-b"),
}, digests)
require.Nil(t, collectOpenAIEncryptedContentDigestsRaw([]byte(`{"model":"gpt-5"}`)))
}
func TestStripOpenAIInvalidEncryptedContentItems(t *testing.T) {
t.Parallel()
invalid := map[string]struct{}{
openAIEncryptedContentDigest("stale-cipher"): {},
}
t.Run("only_strips_matching_items", func(t *testing.T) {
t.Parallel()
reqBody := map[string]any{
"input": []any{
map[string]any{"type": "reasoning", "id": "rs_1", "encrypted_content": "stale-cipher", "summary": []any{}},
map[string]any{"type": "reasoning", "id": "rs_2", "encrypted_content": "fresh-cipher"},
map[string]any{"type": "input_text", "text": "hello"},
},
}
stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid)
require.Equal(t, 1, stripped)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 3)
first, ok := input[0].(map[string]any)
require.True(t, ok)
_, hasEncrypted := first["encrypted_content"]
require.False(t, hasEncrypted, "命中项应剥离 encrypted_content")
require.Equal(t, "rs_1", first["id"], "reasoning 骨架应保留")
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "fresh-cipher", second["encrypted_content"], "新密文不得误删")
})
t.Run("compaction_removed_entirely", func(t *testing.T) {
t.Parallel()
reqBody := map[string]any{
"input": []any{
map[string]any{"type": "compaction", "encrypted_content": "stale-cipher"},
map[string]any{"type": "input_text", "text": "hello"},
},
}
stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid)
require.Equal(t, 1, stripped)
input, ok := reqBody["input"].([]any)
require.True(t, ok)
require.Len(t, input, 1)
survivor, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "input_text", survivor["type"])
})
t.Run("no_hit_no_change", func(t *testing.T) {
t.Parallel()
reqBody := map[string]any{
"input": []any{
map[string]any{"type": "reasoning", "encrypted_content": "fresh-cipher"},
},
}
require.Zero(t, stripOpenAIInvalidEncryptedContentItems(reqBody, invalid))
})
}
func TestStripOpenAIInvalidEncryptedContentFromReplayItems(t *testing.T) {
t.Parallel()
invalid := map[string]struct{}{
openAIEncryptedContentDigest("stale-cipher"): {},
}
t.Run("rewrites_hit_items_and_shares_the_rest", func(t *testing.T) {
t.Parallel()
original := json.RawMessage(`{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"}`)
clean := json.RawMessage(`{"type":"input_text","text":"hello"}`)
compaction := json.RawMessage(`{"type":"compaction","encrypted_content":"stale-cipher"}`)
items := []json.RawMessage{original, clean, compaction}
next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid)
require.Equal(t, 2, stripped)
require.Len(t, next, 2, "compaction 命中项应整项删除")
var rewritten map[string]any
require.NoError(t, json.Unmarshal(next[0], &rewritten))
require.Equal(t, "rs_1", rewritten["id"])
_, hasEncrypted := rewritten["encrypted_content"]
require.False(t, hasEncrypted)
// 未命中项正文共享;原命中项正文不被修改(整体替换语义)。
require.Same(t, &clean[0], &next[1][0])
require.Contains(t, string(original), "stale-cipher")
})
t.Run("miss_returns_original_header", func(t *testing.T) {
t.Parallel()
items := []json.RawMessage{json.RawMessage(`{"type":"reasoning","encrypted_content":"fresh-cipher"}`)}
next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid)
require.Zero(t, stripped)
require.Same(t, &items[0][0], &next[0][0])
})
}
func TestStripOpenAIInvalidEncryptedContentRaw(t *testing.T) {
t.Parallel()
invalid := map[string]struct{}{
openAIEncryptedContentDigest("stale-cipher"): {},
}
payload := []byte(`{"model":"gpt-5","input":[
{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"},
{"type":"reasoning","id":"rs_2","encrypted_content":"fresh-cipher"},
{"type":"input_text","text":"hello"}
],"stream":true}`)
stripped, count, err := stripOpenAIInvalidEncryptedContentRaw(payload, invalid)
require.NoError(t, err)
require.Equal(t, 1, count)
var decoded map[string]any
require.NoError(t, json.Unmarshal(stripped, &decoded))
require.Equal(t, "gpt-5", decoded["model"])
input, ok := decoded["input"].([]any)
require.True(t, ok)
require.Len(t, input, 3)
first, ok := input[0].(map[string]any)
require.True(t, ok)
_, hasEncrypted := first["encrypted_content"]
require.False(t, hasEncrypted)
second, ok := input[1].(map[string]any)
require.True(t, ok)
require.Equal(t, "fresh-cipher", second["encrypted_content"])
t.Run("miss_fast_path_returns_original", func(t *testing.T) {
t.Parallel()
cleanPayload := []byte(`{"input":[{"type":"reasoning","encrypted_content":"fresh-cipher"}]}`)
unchanged, count, err := stripOpenAIInvalidEncryptedContentRaw(cleanPayload, invalid)
require.NoError(t, err)
require.Zero(t, count)
require.Same(t, &cleanPayload[0], &unchanged[0], "未命中时应原样返回,不重编码")
})
}
func TestOpenAIWSStateStoreInvalidEncryptedContentLineage(t *testing.T) {
t.Parallel()
t.Run("mark_get_roundtrip_with_merge", func(t *testing.T) {
t.Parallel()
store := NewOpenAIWSStateStore(nil)
require.False(t, store.HasAnySessionInvalidEncryptedContent())
require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a"))
store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d1", "d2"}, time.Minute)
store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d2", "d3"}, time.Minute)
require.True(t, store.HasAnySessionInvalidEncryptedContent())
digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-a")
require.Len(t, digests, 3)
for _, digest := range []string{"d1", "d2", "d3"} {
require.Contains(t, digests, digest)
}
// 组隔离:另一组同名会话不可见。
require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(2, "session-a"))
// 返回值是拷贝,调用方修改不得影响存储。
digests["d4"] = struct{}{}
require.Len(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a"), 3)
})
t.Run("expired_binding_not_returned", func(t *testing.T) {
t.Parallel()
raw := NewOpenAIWSStateStore(nil)
raw.MarkSessionInvalidEncryptedContent(1, "session-b", []string{"d1"}, time.Minute)
store, ok := raw.(*defaultOpenAIWSStateStore)
require.True(t, ok)
store.sessionInvalidEncryptedMu.Lock()
binding := store.sessionInvalidEncrypted["1:session-b"]
binding.expiresAt = time.Now().Add(-time.Second)
store.sessionInvalidEncrypted["1:session-b"] = binding
store.sessionInvalidEncryptedMu.Unlock()
require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-b"))
})
t.Run("per_session_capacity_degrades_gracefully", func(t *testing.T) {
t.Parallel()
store := NewOpenAIWSStateStore(nil)
oversized := make([]string, 0, openAIWSInvalidEncryptedDigestsPerSession+10)
for i := range openAIWSInvalidEncryptedDigestsPerSession + 10 {
oversized = append(oversized, openAIEncryptedContentDigest(fmt.Sprintf("cipher-%d", i)))
}
store.MarkSessionInvalidEncryptedContent(1, "session-c", oversized, time.Minute)
digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-c")
require.Len(t, digests, openAIWSInvalidEncryptedDigestsPerSession)
})
}