Files
sub2api/backend/internal/service/gateway_billing_header_test.go
T

154 lines
5.8 KiB
Go

package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
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 and recomputes 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." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"),
},
{
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 TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli;"}],"messages":[{"role":"user","content":"hello world"}]}`)
version := "2.1.22"
result := syncBillingHeaderVersion(body, "claude-cli/"+version)
require.Contains(t, gjson.GetBytes(result, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";")
require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version)))
require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw)
}
func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, endpoint := range []string{"messages", "count_tokens"} {
for _, tc := range []struct {
name string
mimic bool
identity bool
disableFP bool
}{
{name: "mimic_overrides_cached_version", mimic: true, identity: true},
{name: "mimic_without_identity", mimic: true},
{name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true},
{name: "passthrough_uses_cached_version", identity: true},
} {
t.Run(endpoint+"/"+tc.name, func(t *testing.T) {
resetGatewayForwardingSettingsCacheForTest(t)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`)
billing, err := buildBillingAttributionText(body, "2.1.81")
require.NoError(t, err)
body, err = sjson.SetBytes(body, "system.0.text", billing)
require.NoError(t, err)
cfg := &config.Config{}
svc := &GatewayService{cfg: cfg}
cachedUA := "claude-cli/2.9.0 (external, cli)"
if tc.identity {
svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{
UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(),
}})
}
if tc.disableFP {
svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableFingerprintUnification: "false",
}}, cfg)
}
account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
var req *http.Request
var wireBody []byte
if endpoint == "messages" {
req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", false, tc.mimic)
} else {
req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account,
body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic)
}
require.NoError(t, err)
defer func() { require.NoError(t, req.Body.Close()) }()
wantUA := cachedUA
if tc.mimic {
wantUA = claude.DefaultHeaders["User-Agent"]
}
require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent"))
version := ExtractCLIVersion(wantUA)
require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(),
"cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";")
actualBody, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, wireBody, actualBody)
})
}
}
}