account_mode1_protection_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  15. "github.com/gin-gonic/gin"
  16. "github.com/stretchr/testify/require"
  17. )
  18. func mode1TestAccount(id int64) *Account {
  19. return &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 4,
  20. Extra: map[string]any{"codex_fingerprint_seed": newCodexFingerprintSeed(), "codex_fingerprint_mode": "device", "enable_tls_fingerprint": true, "tls_fingerprint_builtin": "nodejs24",
  21. "anti_degrade": map[string]any{"enabled": true, "mode": "mode1", "policy_version": 2, "max_concurrency": 4}}}
  22. }
  23. func TestMode1IdentityIsUniquePersistentAndDoesNotMergeThreads(t *testing.T) {
  24. ctx := context.Background()
  25. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{}}
  26. admin := &adminServiceImpl{accountRepo: repo}
  27. svc := NewAntiDegradeService(admin)
  28. seen := map[string]bool{}
  29. for id := int64(1); id <= 32; id++ {
  30. repo.accounts[id] = &Account{ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Concurrency: 2, Extra: map[string]any{"custom": "keep"}}
  31. a, err := svc.ApplyAntiDegradeMode(ctx, id, AntiDegradeMode1)
  32. require.NoError(t, err)
  33. seed := requireValidCodexFingerprintSeed(t, a.Extra)
  34. require.False(t, seen[seed])
  35. seen[seed] = true
  36. // Simulate the JSON representation stored by the database and a new service instance.
  37. stored, err := json.Marshal(a)
  38. require.NoError(t, err)
  39. var loaded Account
  40. require.NoError(t, json.Unmarshal(stored, &loaded))
  41. repo.accounts[id] = &loaded
  42. updated, err := admin.UpdateAccount(ctx, id, &UpdateAccountInput{Name: "renamed", Extra: map[string]any{"custom": "edited"}})
  43. require.NoError(t, err)
  44. require.Equal(t, seed, requireValidCodexFingerprintSeed(t, updated.Extra))
  45. require.True(t, isMode1ProtectionEnabled(updated))
  46. for _, session := range []string{"session-a", "session-b"} {
  47. headers := http.Header{}
  48. headers.Set("session-id", session)
  49. headers.Set("thread-id", "thread-"+session)
  50. ids := resolveCodexFingerprintIDsFromRequest(updated, headers)
  51. applyCodexFingerprintHeaders(headers, ids)
  52. require.NotEmpty(t, headers.Get("x-codex-installation-id"))
  53. require.Equal(t, session, headers.Get("session-id"))
  54. require.Equal(t, "thread-"+session, headers.Get("thread-id"))
  55. }
  56. a, err = NewAntiDegradeService(admin).RevertAntiDegrade(ctx, id)
  57. require.NoError(t, err)
  58. require.False(t, antiDegradeEnabled(a))
  59. require.Equal(t, seed, requireValidCodexFingerprintSeed(t, a.Extra))
  60. require.Equal(t, "edited", a.Extra["custom"])
  61. a, err = svc.ApplyAntiDegradeMode(ctx, id, AntiDegradeMode1)
  62. require.NoError(t, err)
  63. require.Equal(t, seed, requireValidCodexFingerprintSeed(t, a.Extra))
  64. }
  65. }
  66. func TestMode1RuntimeRejectsConfigurationDrift(t *testing.T) {
  67. a := mode1TestAccount(1)
  68. p, err := resolveMode1TLSProfile(a)
  69. require.NoError(t, err)
  70. require.NotNil(t, p)
  71. a.Concurrency = 100
  72. require.Equal(t, 100, a.Mode1EffectiveConcurrency())
  73. a.Concurrency = 1
  74. require.Equal(t, 1, a.Mode1EffectiveConcurrency())
  75. a.Extra["enable_tls_fingerprint"] = false
  76. p, err = resolveMode1TLSProfile(a)
  77. require.NoError(t, err)
  78. require.Nil(t, p)
  79. a.Extra["enable_tls_fingerprint"] = true
  80. delete(a.Extra, "codex_fingerprint_seed")
  81. _, err = resolveMode1TLSProfile(a)
  82. require.Error(t, err)
  83. }
  84. func TestMode1InvalidMarkerDoesNotDowngrade(t *testing.T) {
  85. for _, version := range []any{"2", 999, nil} {
  86. a := mode1TestAccount(1)
  87. mode1Marker(a)["policy_version"] = version
  88. p, err := resolveMode1TLSProfile(a)
  89. require.Error(t, err)
  90. require.Nil(t, p)
  91. }
  92. }
  93. func TestMode1DifferentAccountsWithDuplicatedLegacyDeviceStillDiffer(t *testing.T) {
  94. a, b := mode1TestAccount(1), mode1TestAccount(2)
  95. a.Extra["openai_device_id"] = "legacy-shared"
  96. b.Extra["openai_device_id"] = "legacy-shared"
  97. require.Equal(t, a.GetOpenAIDeviceID(), b.GetOpenAIDeviceID())
  98. seedA, _ := codexFingerprintSeed(a.Extra)
  99. seedB, _ := codexFingerprintSeed(b.Extra)
  100. require.NotEqual(t, resolveConvergedInstallationID(a, seedA), resolveConvergedInstallationID(b, seedB))
  101. require.Equal(t, resolveConvergedInstallationID(a, seedA), resolveConvergedInstallationID(a, seedA))
  102. }
  103. func TestMode1PartialAndBulkUpdatesCannotDisablePolicy(t *testing.T) {
  104. a := mode1TestAccount(1)
  105. repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{1: a}}
  106. admin := &adminServiceImpl{accountRepo: repo}
  107. err := admin.UpdateAccountExtra(context.Background(), 1, map[string]any{"anti_degrade": nil})
  108. require.ErrorContains(t, err, "专用")
  109. _, err = admin.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{AccountIDs: []int64{1}, Extra: map[string]any{"anti_degrade": nil}})
  110. require.ErrorContains(t, err, "模式一")
  111. require.True(t, isMode1ProtectionEnabled(repo.accounts[1]))
  112. require.Empty(t, repo.bulkUpdates)
  113. }
  114. type mode1HTTPRecorder struct {
  115. normal, tls int
  116. profile *tlsfingerprint.Profile
  117. cap int
  118. err error
  119. }
  120. func (r *mode1HTTPRecorder) Do(_ *http.Request, _ string, _ int64, cap int) (*http.Response, error) {
  121. r.normal++
  122. r.cap = cap
  123. if r.err != nil {
  124. return nil, r.err
  125. }
  126. return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("ok"))}, nil
  127. }
  128. func (r *mode1HTTPRecorder) DoWithTLS(_ *http.Request, _ string, _ int64, cap int, p *tlsfingerprint.Profile) (*http.Response, error) {
  129. r.tls++
  130. r.profile = p
  131. r.cap = cap
  132. if r.err != nil {
  133. return nil, r.err
  134. }
  135. return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("ok"))}, nil
  136. }
  137. func TestMode1HTTPAndAccountTestUseSameTLSWithoutFallback(t *testing.T) {
  138. ctx := context.Background()
  139. a := mode1TestAccount(1)
  140. a.Concurrency = 100
  141. u := &mode1HTTPRecorder{}
  142. gw := &OpenAIGatewayService{httpUpstream: u}
  143. req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://localhost/responses", nil)
  144. r, err := gw.doOpenAIUpstream(req, "", a)
  145. require.NoError(t, err)
  146. r.Body.Close()
  147. require.Equal(t, 100, u.cap)
  148. require.NotNil(t, u.profile)
  149. profileKey := u.profile.CacheKey()
  150. tester := &AccountTestService{httpUpstream: u}
  151. r, err = tester.doOpenAIAccountTestUpstream(req, "", a, false)
  152. require.NoError(t, err)
  153. r.Body.Close()
  154. require.Equal(t, profileKey, u.profile.CacheKey())
  155. u.err = errors.New("TLS failed")
  156. _, err = gw.doOpenAIUpstream(req, "", a)
  157. require.Error(t, err)
  158. require.Zero(t, u.normal)
  159. gw.cfg = &config.Config{}
  160. u.err = nil
  161. r, err = gw.doOpenAIUpstream(req, "", a)
  162. require.NoError(t, err)
  163. r.Body.Close()
  164. require.Equal(t, 1, u.normal)
  165. require.Equal(t, 100, u.cap)
  166. }
  167. func TestMode1UsesExistingPluginRouting(t *testing.T) {
  168. manager := &PluginManager{}
  169. manager.route.Store(&pluginRoute{pluginID: 1, rolloutPercent: 100, unavailable: "test"})
  170. u := &mode1HTTPRecorder{}
  171. gw := &OpenAIGatewayService{httpUpstream: u, pluginManager: manager}
  172. req, _ := http.NewRequest(http.MethodPost, "https://localhost/responses", nil)
  173. _, err := gw.doOpenAIUpstream(req, "", mode1TestAccount(1))
  174. require.Error(t, err) // the configured plugin is actually unavailable
  175. require.NotContains(t, err.Error(), "未声明支持模式一")
  176. require.Zero(t, u.tls)
  177. require.Zero(t, u.normal)
  178. }
  179. func TestMode1IntegrityPreservesReasoningToolsAndBudget(t *testing.T) {
  180. original := []byte(`{"model":"test-model","reasoning":{"effort":"high"},"input":[{"type":"reasoning","encrypted_content":"opaque"},{"type":"function_call_output","call_id":"call-1","output":"result"}],"max_output_tokens":2000,"tools":[{"type":"function","name":"read"}]}`)
  181. require.NoError(t, validateMode1RequestIntegrity(original, original))
  182. for _, field := range []string{"reasoning", "input", "max_output_tokens", "tools", "model"} {
  183. var payload map[string]any
  184. require.NoError(t, json.Unmarshal(original, &payload))
  185. delete(payload, field)
  186. out, _ := json.Marshal(payload)
  187. require.ErrorContains(t, validateMode1RequestIntegrity(original, out), field)
  188. }
  189. }
  190. type mode1ForwardRecorder struct {
  191. httpUpstreamRecorder
  192. template *tlsfingerprint.Profile
  193. ordinary int
  194. }
  195. func (r *mode1ForwardRecorder) Do(req *http.Request, p string, id int64, n int) (*http.Response, error) {
  196. r.ordinary++
  197. return r.httpUpstreamRecorder.Do(req, p, id, n)
  198. }
  199. func (r *mode1ForwardRecorder) DoWithTLS(req *http.Request, p string, id int64, n int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  200. r.template = profile
  201. return r.httpUpstreamRecorder.Do(req, p, id, n)
  202. }
  203. func TestMode1ForwardPreservesPayloadThroughNormalAndPassthrough(t *testing.T) {
  204. for _, passthrough := range []bool{false, true} {
  205. t.Run(fmt.Sprint(passthrough), func(t *testing.T) {
  206. gin.SetMode(gin.TestMode)
  207. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  208. body := []byte(`{"model":"gpt-5.2","stream":true,"reasoning":{"effort":"high"},"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Return OK"}]}]}`)
  209. c.Request = httptest.NewRequest("POST", "/v1/responses", bytes.NewReader(body))
  210. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
  211. if !passthrough {
  212. c.Request.Header.Set("User-Agent", "local-test/1.0")
  213. }
  214. c.Request.Header.Set("session-id", "test-session")
  215. c.Request.Header.Set("thread-id", "test-thread")
  216. u := &mode1ForwardRecorder{httpUpstreamRecorder: httpUpstreamRecorder{resp: &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.output_text.delta\",\"delta\":\"OK\"}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"test\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"OK\"}]}]}}\n\ndata: [DONE]\n\n"))}}}
  217. a := mode1TestAccount(1)
  218. a.Name = "mode1"
  219. a.Schedulable = true
  220. a.Credentials = map[string]any{"access_token": "local-dummy", "chatgpt_account_id": "dummy"}
  221. a.Extra["openai_passthrough"] = passthrough
  222. cfg := &config.Config{}
  223. cfg.Gateway.TLSFingerprint.Enabled = true
  224. gw := &OpenAIGatewayService{cfg: cfg, httpUpstream: u, toolCorrector: NewCodexToolCorrector()}
  225. _, err := gw.Forward(context.Background(), c, a, body)
  226. require.NoError(t, err)
  227. require.Zero(t, u.ordinary)
  228. require.NotNil(t, u.template)
  229. require.NoError(t, validateMode1RequestIntegrity(body, u.lastBody))
  230. require.NotEmpty(t, u.lastReq.Header.Get("x-codex-installation-id"))
  231. require.NotEmpty(t, u.lastReq.Header.Get("session-id"))
  232. })
  233. }
  234. }