Files
sub2api/backend/internal/service/gateway_billing_header_test.go
shaw e51c9e50b5 feat: sync billing header cc_version with User-Agent and add opt-in CCH signing
- Sync cc_version in x-anthropic-billing-header with the fingerprint
  User-Agent version, preserving the message-derived suffix
- Implement xxHash64-based CCH signing to replace the cch=00000
  placeholder with a computed hash
- Add admin toggle (enable_cch_signing) under gateway forwarding settings,
  disabled by default
2026-04-08 16:11:19 +08:00

166 lines
6.2 KiB
Go

package service
import (
"fmt"
"testing"
"github.com/cespare/xxhash/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSyncBillingHeaderVersion(t *testing.T) {
tests := []struct {
name string
body string
userAgent string
wantSub string // substring expected in result
unchanged bool // expect body to remain the same
}{
{
name: "replaces cc_version preserving message-derived suffix",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
userAgent: "claude-cli/2.1.22 (external, cli)",
wantSub: "cc_version=2.1.22.df2",
},
{
name: "no billing header in system",
body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
userAgent: "claude-cli/2.1.22",
unchanged: true,
},
{
name: "no system field",
body: `{"messages":[]}`,
userAgent: "claude-cli/2.1.22",
unchanged: true,
},
{
name: "user-agent without version",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
userAgent: "Mozilla/5.0",
unchanged: true,
},
{
name: "empty user-agent",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
userAgent: "",
unchanged: true,
},
{
name: "version already matches",
body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
userAgent: "claude-cli/2.1.22",
unchanged: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
if tt.unchanged {
assert.Equal(t, tt.body, string(result), "body should remain unchanged")
} else {
assert.Contains(t, string(result), tt.wantSub)
// Ensure old semver is gone
assert.NotContains(t, string(result), "cc_version=2.1.81")
}
})
}
}
func TestSignBillingHeaderCCH(t *testing.T) {
t.Run("replaces placeholder with hash", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
result := signBillingHeaderCCH(body)
// Should not have the placeholder anymore
assert.NotContains(t, string(result), "cch=00000")
// Should have a 5 hex-char cch value
billingText := gjson.GetBytes(result, "system.0.text").String()
require.Contains(t, billingText, "cch=")
assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText)
})
t.Run("no placeholder - body unchanged", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`)
result := signBillingHeaderCCH(body)
assert.Equal(t, string(body), string(result))
})
t.Run("no billing header - body unchanged", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`)
result := signBillingHeaderCCH(body)
assert.Equal(t, string(body), string(result))
})
t.Run("cch=00000 in user content is not touched", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`)
result := signBillingHeaderCCH(body)
// Billing header should be signed
billingText := gjson.GetBytes(result, "system.0.text").String()
assert.NotContains(t, billingText, "cch=00000")
// User message should keep its literal cch=00000
userText := gjson.GetBytes(result, "messages.0.content.0.text").String()
assert.Contains(t, userText, "cch=00000")
})
t.Run("signing is deterministic", func(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
r1 := signBillingHeaderCCH(body)
body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
r2 := signBillingHeaderCCH(body2)
assert.Equal(t, string(r1), string(r2))
})
t.Run("matches reference algorithm", func(t *testing.T) {
// Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
result := signBillingHeaderCCH(body)
billingText := gjson.GetBytes(result, "system.0.text").String()
assert.Contains(t, billingText, "cch="+expectedCCH+";")
})
}
func TestXXHash64Seeded(t *testing.T) {
t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) {
inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"}
for _, s := range inputs {
data := []byte(s)
expected := xxhash.Sum64(data)
got := xxHash64Seeded(data, 0)
assert.Equal(t, expected, got, "mismatch for input %q", s)
}
})
t.Run("large input matches cespare", func(t *testing.T) {
data := make([]byte, 256)
for i := range data {
data[i] = byte(i)
}
expected := xxhash.Sum64(data)
got := xxHash64Seeded(data, 0)
assert.Equal(t, expected, got)
})
t.Run("deterministic with custom seed", func(t *testing.T) {
data := []byte("hello world")
h1 := xxHash64Seeded(data, cchSeed)
h2 := xxHash64Seeded(data, cchSeed)
assert.Equal(t, h1, h2)
})
t.Run("different seeds produce different results", func(t *testing.T) {
data := []byte("test data for hashing")
h1 := xxHash64Seeded(data, 0)
h2 := xxHash64Seeded(data, cchSeed)
assert.NotEqual(t, h1, h2)
})
}