Files
MailUI4Agents/plugins/homeagent-mail-bridge/relay_key_test.go
JianFeeeee 13fcb00acc feat: relay-key 共用模块(三桥 + homeagent)
Sha256 clamp relay_key 过 160 字节上限,避免服务端 400
被 worker 当暂时失败让位,导致邮件驱动会话无本地 UI 静默挂死。

新增 relay_key.go / relay-key.js + 15 个纯函数测试。
2026-09-06 15:16:34 +08:00

245 lines
7.3 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 main
// relay_key.go 的测试 —— 逐条对齐 Node 侧 test/relay-key.test.mjs。
//
// 事故背景生产实测pi 会话里 bash 的 relay_key 突然超过服务端 160 字节
// 列宽,返回 400。真实会话文件里 toolCallId 有两种形态:
//
// toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
// toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
//
// 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。
// 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的
// worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。
import (
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"unicode/utf8"
)
// ─── truncateToBytes ───
func TestTruncateToBytes_UnderLimit(t *testing.T) {
if got := truncateToBytes("abcdef", 10); got != "abcdef" {
t.Fatalf("未超限应原样返回,得到 %q", got)
}
if got := truncateToBytes("abcdef", 6); got != "abcdef" {
t.Fatalf("恰好等于上限应原样返回,得到 %q", got)
}
}
func TestTruncateToBytes_ASCII(t *testing.T) {
if got := truncateToBytes("abcdef", 3); got != "abc" {
t.Fatalf("want abc, got %q", got)
}
}
func TestTruncateToBytes_NoHalfRune(t *testing.T) {
// "中文" = 6 字节。上限 4 时不能切出 "中" + 半个 "文"
got := truncateToBytes("中文", 4)
if got != "中" {
t.Fatalf("want 中, got %q", got)
}
if !utf8.ValidString(got) {
t.Fatal("截断结果不是合法 UTF-8")
}
}
func TestTruncateToBytes_AllLimitsStayValid(t *testing.T) {
s := "会话abc标识def中文gh"
for limit := 0; limit <= len(s)+2; limit++ {
got := truncateToBytes(s, limit)
if len(got) > limit {
t.Fatalf("limit=%d 时超了:%d 字节", limit, len(got))
}
if !utf8.ValidString(got) {
t.Fatalf("limit=%d 时切出了半个字符", limit)
}
}
}
func TestTruncateToBytes_ZeroOrNegative(t *testing.T) {
if got := truncateToBytes("abc", 0); got != "" {
t.Fatalf("上限 0 应返回空串,得到 %q", got)
}
if got := truncateToBytes("abc", -5); got != "" {
t.Fatalf("负上限应返回空串,得到 %q", got)
}
}
// ─── ClampRelayKey ───
func TestClampRelayKey_NormalKeyUnchanged(t *testing.T) {
// homeagent 侧真实的键形状
key := "homeagent:53e4c9ea-878c-4479-b2f7-58b4be36cf96"
if len(key) > RelayKeyMaxBytes {
t.Fatalf("这个键本来就该合规,长度 %d", len(key))
}
if got := ClampRelayKey(key); got != key {
t.Fatalf("合规的键必须原样返回(否则升级前后算出不同键),得到 %q", got)
}
}
func TestClampRelayKey_ExactlyAtLimit(t *testing.T) {
key := strings.Repeat("k", RelayKeyMaxBytes)
if got := ClampRelayKey(key); got != key {
t.Fatal("恰好等于上限时应原样返回(边界不能差一)")
}
}
func TestClampRelayKey_OneByteOver(t *testing.T) {
key := strings.Repeat("k", RelayKeyMaxBytes+1)
got := ClampRelayKey(key)
if got == key {
t.Fatal("超一个字节就该收敛")
}
if len(got) > RelayKeyMaxBytes {
t.Fatalf("收敛后仍然超限:%d 字节", len(got))
}
}
func TestClampRelayKey_RealWorldLengths(t *testing.T) {
// 生产实测 437 ~ 13601 字节都出现过
for _, n := range []int{437, 1000, 5493, 13601} {
key := "homeagent:toolu_bdrk_01X~sig1:" + strings.Repeat("A", n)
got := ClampRelayKey(key)
if len(got) > RelayKeyMaxBytes {
t.Fatalf("n=%d 时超了:%d 字节", n, len(got))
}
}
}
func TestClampRelayKey_Deterministic(t *testing.T) {
key := "sess:" + strings.Repeat("x", 500)
if ClampRelayKey(key) != ClampRelayKey(key) {
t.Fatal("同一输入必须得到同一输出(幂等键的根本要求)")
}
}
// 这正是不能直接截断的理由:两个键前 160 字节完全相同,
// 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。
func TestClampRelayKey_NoCollisionOnSharedPrefix(t *testing.T) {
common := strings.Repeat("a", 300)
k1 := common + ":call-1"
k2 := common + ":call-2"
if ClampRelayKey(k1) == ClampRelayKey(k2) {
t.Fatal("前缀相同尾部不同的两个键不能撞成一个")
}
}
func TestClampRelayKey_KeepsReadablePrefix(t *testing.T) {
prefix := "homeagent:53e4c9ea-878c-4479-b2f7-58b4be36cf96"
got := ClampRelayKey(prefix + ":" + strings.Repeat("A", 900))
if !strings.HasPrefix(got, prefix) {
t.Fatalf("收敛结果应保留可读前缀(日志里还能 grep得到 %q", got)
}
if !strings.Contains(got, ":sha256:") {
t.Fatalf("收敛结果应带 sha256 后缀,得到 %q", got)
}
}
func TestClampRelayKey_HashIsOfOriginalKey(t *testing.T) {
key := "sess:" + strings.Repeat("y", 400)
sum := sha256.Sum256([]byte(key))
want := hex.EncodeToString(sum[:])
if !strings.HasSuffix(ClampRelayKey(key), ":sha256:"+want) {
t.Fatal("哈希必须是原始键的完整 sha256不是截断后的")
}
}
func TestClampRelayKey_CJKNoHalfRune(t *testing.T) {
key := "会话标识:" + strings.Repeat("中", 300)
got := ClampRelayKey(key)
if len(got) > RelayKeyMaxBytes {
t.Fatalf("超限:%d 字节", len(got))
}
if !utf8.ValidString(got) {
t.Fatal("切出了半个字符")
}
}
func TestClampRelayKey_TinyLimitFallsBackToHash(t *testing.T) {
key := strings.Repeat("z", 500)
got := clampRelayKeyTo(key, 20)
if len(got) > 20 {
t.Fatalf("超限:%d 字节", len(got))
}
if got != clampRelayKeyTo(key, 20) {
t.Fatal("退化路径也必须确定")
}
}
func TestClampRelayKey_Empty(t *testing.T) {
if got := ClampRelayKey(""); got != "" {
t.Fatalf("空键应返回空串,得到 %q", got)
}
}
// ─── IsPermanentFailure ───
func TestIsPermanentFailure_400(t *testing.T) {
// 事故的核心:原来 400 被当暂时失败让位,那条 bash 无人批准就执行了
if !IsPermanentFailure(400) {
t.Fatal("400 必须是永久失败")
}
}
func TestIsPermanentFailure_409(t *testing.T) {
// 这条链上没有人类,永远不会有人点头
if !IsPermanentFailure(409) {
t.Fatal("409 必须是永久失败")
}
}
func TestIsPermanentFailure_401(t *testing.T) {
// 实测opencode 拿着已撤销的密钥重试了 18 小时2690 次 401
if !IsPermanentFailure(401) {
t.Fatal("401 必须是永久失败:密钥无效要人去后台登记")
}
}
func TestIsPermanentFailure_Other4xx(t *testing.T) {
for _, s := range []int{403, 404, 422} {
if !IsPermanentFailure(s) {
t.Fatalf("%d 应当是永久失败", s)
}
}
}
func TestIsPermanentFailure_TimeoutAndRateLimit(t *testing.T) {
if IsPermanentFailure(408) {
t.Fatal("408 是暂时失败(超时,等一会儿可能成功)")
}
if IsPermanentFailure(429) {
t.Fatal("429 是暂时失败(限流,等一会儿可能成功)")
}
}
func TestIsPermanentFailure_5xx(t *testing.T) {
for _, s := range []int{500, 502, 503, 504} {
if IsPermanentFailure(s) {
t.Fatalf("%d 是暂时失败(服务端的问题)", s)
}
}
}
func TestIsPermanentFailure_NoStatus(t *testing.T) {
// 网络层错误DNS 解析失败、连接被拒,都没有 HTTP 状态码
if IsPermanentFailure(0) {
t.Fatal("无 status 应按暂时处理")
}
if IsPermanentFailure(-1) {
t.Fatal("负 status 应按暂时处理")
}
}
func TestIsPermanentFailure_Success(t *testing.T) {
// 本不该走到这里,但不能误判成永久
if IsPermanentFailure(200) || IsPermanentFailure(302) {
t.Fatal("2xx / 3xx 不算永久失败")
}
}