Files
sub2api/backend/internal/service/identity_service_version_floor_test.go
T
alfadb 3cb2381bdc fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
claude-fable-5-1 已登记进 DefaultModels,但 OAuth 账号实际调用仍然全部失败:

  400 claude_code_version_too_old
  "Claude Code 2.1.220 does not support this model;
   version 2.1.251 or newer is required."

两个独立的原因,缺一不可:

1. CLICurrentVersion 停留在 2.1.220,低于上游对该模型的客户端版本闸门。
   升到 2.1.258(当前官方 CLI 最新版,npm @anthropic-ai/claude-code)。

2. 只升常量对所有已有账号无效。上游请求实际发送的 User-Agent 来自账号级
   持久指纹(IdentityService,Redis fingerprint:<accountID>),不是
   DefaultHeaders;defaultFingerprint 只在首次创建指纹时使用。
   GetOrCreateFingerprint 的缓存命中路径只有两条改写分支:UA 畸形时自愈,
   或客户端送来更新版本时升级。指纹 7 天 TTL 但活跃账号每 24h 续期,实际
   近乎永不过期,系统内也没有重置入口——客户端版本比缓存旧时(例如缓存
   2.1.220、客户端 2.1.75),存量账号会永远对上游声称 2.1.220。

   生产实测:升级 CLICurrentVersion 并部署后仍报同样的 400,读 Redis 确认
   出问题账号的缓存值为 "claude-cli/2.1.220 (external, cli)"。

修复:新增 floorClaudeCLIUserAgentVersion,把 CLICurrentVersion 作为缓存
指纹里 claude-cli 版本的下限,缓存命中与首次创建两条路径共用。

  - 只对 claude-cli 产品生效,其它客户端一律不动
  - 只升不降:版本等于或高于下限(含客户端上报的更新版本)不做任何改动
  - 只替换版本号段,UA 其余部分原样保留,例如
    "(external, claude-desktop-3p, agent-sdk/0.3.100)" 不受影响
  - 与既有的"客户端更新版本"升级分支相互独立、二者取更新者
  - X-Stainless-* 维持既有 merge 语义,不被下限逻辑覆盖

cc_version 无需单独处理:billing attribution block 的版本已由
syncBillingHeaderVersion 跟随实际发送的 User-Agent 同步。

测试:identity_service_version_floor_test.go 覆盖 helper 的 8 种 UA 形态与
5 个 GetOrCreateFingerprint 场景,其中一个直接复现线上故障(缓存 2.1.220 +
客户端 2.1.75 → 写回 2.1.258,Stainless 字段保持不变)。
constants_cli_version_test.go 锁定常量与 DefaultHeaders 的 UA 同源,并断言
不低于 Fable 5.1 的闸门下限。回退实现后对应用例确认转红。

identity_service_user_agent_validation_test.go 中 3 个把版本钉死在旧字面量
的既有用例改为钉 CLICurrentVersion 或更高版本——它们断言的正是本次有意改变
的行为,各自的原始意图(哨兵拒绝、合法升级生效、健康缓存不重写)保持不变。
2026-09-07 16:34:35 +08:00

147 lines
5.8 KiB
Go

package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
)
// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。
func TestFloorClaudeCLIUserAgentVersion(t *testing.T) {
floorUA := "claude-cli/" + claude.CLICurrentVersion
cases := []struct {
name string
ua string
want string
wantChanged bool
}{
// 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。
{"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true},
// 只替换版本号段,括号内的真实客户端形态原样保留。
{"old_version_with_desktop_3p_suffix",
"claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)",
floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true},
// 等于下限:不得改动。
{"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false},
// 高于下限:只升不降,不得把客户端上报的更新版本压回去。
{"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false},
// 非 claude-cli 产品:一律不动。
{"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false},
// 空串 / 畸形:一律不动。
{"empty", "", "", false},
{"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false},
{"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, changed := floorClaudeCLIUserAgentVersion(tc.ua)
require.Equal(t, tc.want, got)
require.Equal(t, tc.wantChanged, changed)
})
}
}
// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本
// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。
// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并
// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251),
// 仅升 CLICurrentVersion 对存量账号完全无效。
func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 147,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存")
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent)
// X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。
require.Equal(t, "0.91.1", fp.StainlessPackageVersion)
}
// 缓存版本高于下限:不得被降级,也不得触发多余写入。
func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) {
ua := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: ua,
ClientID: "cid-1",
StainlessPackageVersion: "0.91.1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级")
require.Zero(t, cache.setCalls)
}
// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。
func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) {
newUA := "claude-cli/2.9.0 (external, cli)"
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.220 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA))
require.NoError(t, err)
require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回")
require.Equal(t, 1, cache.setCalls)
}
// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。
func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) {
cache := &stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: "claude-cli/2.1.22 (external, cli)",
ClientID: "cid-1",
UpdatedAt: time.Now().Unix(),
}}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.223 (external, cli)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}
// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。
func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) {
cache := &stubIdentityCache{}
svc := NewIdentityService(cache)
fp, err := svc.GetOrCreateFingerprint(
context.Background(), 1,
headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"),
)
require.NoError(t, err)
require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent)
require.Equal(t, 1, cache.setCalls)
}