openai_codex_account_identity_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. "github.com/tidwall/gjson"
  12. )
  13. type codexAccountIdentityRepoStub struct {
  14. AccountRepository
  15. account *Account
  16. }
  17. func (s *codexAccountIdentityRepoStub) GetByID(_ context.Context, _ int64) (*Account, error) {
  18. return s.account, nil
  19. }
  20. func TestCodexRequestBodyIdentityNamespaceIsStablePerOAuthAccount(t *testing.T) {
  21. body := []byte(`{"model":"gpt-5.6-codex","prompt_cache_key":"client-session","client_metadata":{"x-codex-installation-id":"client-installation","session_id":"client-session","thread_id":"client-thread","x-codex-window-id":"client-window","x-codex-turn-metadata":"{\"installation_id\":\"client-installation\",\"session_id\":\"client-session\",\"thread_id\":\"client-thread\",\"turn_id\":\"client-turn\",\"window_id\":\"client-window\"}"}}`)
  22. account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}}
  23. account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}}
  24. first, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77)
  25. require.NoError(t, err)
  26. require.True(t, changed)
  27. firstAgain, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account11, 77)
  28. require.NoError(t, err)
  29. require.True(t, changed)
  30. second, changed, err := applyCodexAccountIdentityClientMetadataRaw(body, account19, 77)
  31. require.NoError(t, err)
  32. require.True(t, changed)
  33. require.JSONEq(t, string(first), string(firstAgain))
  34. paths := []string{
  35. "prompt_cache_key",
  36. "client_metadata.x-codex-installation-id",
  37. "client_metadata.session_id",
  38. "client_metadata.thread_id",
  39. "client_metadata.x-codex-window-id",
  40. }
  41. for _, path := range paths {
  42. require.NotEqual(t, gjson.GetBytes(body, path).String(), gjson.GetBytes(first, path).String(), path)
  43. require.NotEqual(t, gjson.GetBytes(first, path).String(), gjson.GetBytes(second, path).String(), path)
  44. }
  45. require.Equal(t, gjson.GetBytes(first, "prompt_cache_key").String(), gjson.GetBytes(first, "client_metadata.session_id").String())
  46. var embeddedFirst map[string]any
  47. var embeddedSecond map[string]any
  48. require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(first, "client_metadata.x-codex-turn-metadata").String()), &embeddedFirst))
  49. require.NoError(t, json.Unmarshal([]byte(gjson.GetBytes(second, "client_metadata.x-codex-turn-metadata").String()), &embeddedSecond))
  50. for _, field := range []string{"installation_id", "session_id", "thread_id", "turn_id", "window_id"} {
  51. require.NotEqual(t, embeddedFirst[field], embeddedSecond[field], field)
  52. }
  53. }
  54. func TestCodexAccountIdentityNamespaceUsesStableCredentialSource(t *testing.T) {
  55. firstRow := &Account{ID: 8, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}}
  56. secondRow := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "shared-upstream-account"}}
  57. require.Equal(t, codexAccountIdentityNamespace(firstRow), codexAccountIdentityNamespace(secondRow))
  58. firstUser := &Account{ID: 20, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}}
  59. sameUser := &Account{ID: 21, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-1"}}
  60. secondUser := &Account{ID: 22, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "team-account", "chatgpt_user_id": "user-2"}}
  61. require.Equal(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(sameUser))
  62. require.NotEqual(t, codexAccountIdentityNamespace(firstUser), codexAccountIdentityNamespace(secondUser))
  63. seed := "11111111-1111-4111-8111-111111111111"
  64. seeded := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Extra: map[string]any{codexFingerprintSeedExtraKey: seed}}
  65. require.Equal(t, "seed:"+seed, codexAccountIdentityNamespace(seeded))
  66. // Local row IDs repeat across independent deployments, so they are not a
  67. // safe fallback for upstream identity.
  68. require.Empty(t, codexAccountIdentityNamespace(&Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
  69. setupTokenA := &Account{ID: 30, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}}
  70. setupTokenADuplicate := &Account{ID: 31, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-a"}}
  71. setupTokenB := &Account{ID: 32, Platform: PlatformOpenAI, Type: AccountTypeSetupToken, Credentials: map[string]any{"access_token": "setup-token-b"}}
  72. setupNamespace := codexAccountIdentityNamespace(setupTokenA)
  73. require.NotEmpty(t, setupNamespace)
  74. require.NotContains(t, setupNamespace, "setup-token-a")
  75. require.Equal(t, setupNamespace, codexAccountIdentityNamespace(setupTokenADuplicate))
  76. require.NotEqual(t, setupNamespace, codexAccountIdentityNamespace(setupTokenB))
  77. }
  78. func TestCodexAccountIdentitySourceResolvesShadowAndOverwritesFailoverContext(t *testing.T) {
  79. gin.SetMode(gin.TestMode)
  80. recorder := httptest.NewRecorder()
  81. c, _ := gin.CreateTestContext(recorder)
  82. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  83. parentID := int64(11)
  84. parent := &Account{ID: parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{
  85. "chatgpt_account_id": "team-account",
  86. "chatgpt_user_id": "user-1",
  87. }}
  88. shadow := &Account{ID: 111, ParentAccountID: &parentID, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  89. service := &OpenAIGatewayService{accountRepo: &codexAccountIdentityRepoStub{account: parent}}
  90. resolved, err := service.prepareCodexAccountIdentitySource(context.Background(), c, shadow)
  91. require.NoError(t, err)
  92. require.Same(t, parent, resolved)
  93. require.Same(t, parent, codexAccountIdentitySource(c, shadow))
  94. req, err := service.buildUpstreamRequest(
  95. context.Background(), c, shadow,
  96. []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`),
  97. "token", true, "client-session", true,
  98. )
  99. require.NoError(t, err)
  100. require.Equal(t, isolateOpenAIUpstreamSessionID(0, parent, "client-session"), req.Header.Get("session_id"))
  101. next := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{
  102. "chatgpt_account_id": "other-account",
  103. "chatgpt_user_id": "user-2",
  104. }}
  105. resolved, err = service.prepareCodexAccountIdentitySource(context.Background(), c, next)
  106. require.NoError(t, err)
  107. require.Same(t, next, resolved)
  108. require.Same(t, next, codexAccountIdentitySource(c, shadow))
  109. }
  110. func TestBuildOpenAIWSHeadersNamespacesCodexIdentityByOAuthAccount(t *testing.T) {
  111. gin.SetMode(gin.TestMode)
  112. recorder := httptest.NewRecorder()
  113. c, _ := gin.CreateTestContext(recorder)
  114. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  115. c.Set("api_key_id", int64(77))
  116. c.Request.Header.Set("x-codex-installation-id", "client-installation")
  117. c.Request.Header.Set("thread-id", "client-thread")
  118. c.Request.Header.Set("x-codex-window-id", "client-window")
  119. c.Request.Header.Set("x-client-request-id", "client-request")
  120. account11 := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-11"}}
  121. account19 := &Account{ID: 19, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-account-19"}}
  122. service := &OpenAIGatewayService{}
  123. build := func(account *Account) http.Header {
  124. headers, _, err := service.buildOpenAIWSHeaders(
  125. context.Background(), c, account, "token",
  126. OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2},
  127. true, "", "", "client-session", "", "",
  128. )
  129. require.NoError(t, err)
  130. return headers
  131. }
  132. first := build(account11)
  133. firstAgain := build(account11)
  134. second := build(account19)
  135. for _, header := range []string{"session_id", "x-codex-installation-id", "thread-id", "x-codex-window-id", "x-client-request-id"} {
  136. require.NotEmpty(t, first.Get(header), header)
  137. require.Equal(t, first.Get(header), firstAgain.Get(header), header)
  138. require.NotEqual(t, first.Get(header), second.Get(header), header)
  139. }
  140. httpRequest, err := service.buildUpstreamRequest(
  141. context.Background(), c, account11,
  142. []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`),
  143. "token", true, "client-session", true,
  144. )
  145. require.NoError(t, err)
  146. require.Equal(t, httpRequest.Header.Get("session_id"), first.Get("session_id"), "HTTP and WS must derive the same identity from the raw client key")
  147. }
  148. func TestBuildUpstreamRequestNamespacesCodexIdentityByOAuthAccount(t *testing.T) {
  149. gin.SetMode(gin.TestMode)
  150. svc := &OpenAIGatewayService{}
  151. body := []byte(`{"model":"gpt-5.6-codex","stream":true,"prompt_cache_key":"client-session"}`)
  152. build := func(accountID int64, chatgptAccountID string) http.Header {
  153. t.Helper()
  154. recorder := httptest.NewRecorder()
  155. c, _ := gin.CreateTestContext(recorder)
  156. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  157. c.Set("api_key", &APIKey{ID: 77})
  158. c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.0")
  159. c.Request.Header.Set("x-codex-installation-id", "client-installation")
  160. c.Request.Header.Set("x-codex-window-id", "client-window")
  161. c.Request.Header.Set("session-id", "client-session")
  162. c.Request.Header.Set("thread-id", "client-thread")
  163. c.Request.Header.Set("x-client-request-id", "client-request")
  164. c.Request.Header.Set("x-codex-turn-metadata", `{"installation_id":"client-installation","session_id":"client-session","thread_id":"client-thread","turn_id":"client-turn","window_id":"client-window"}`)
  165. account := &Account{
  166. ID: accountID,
  167. Platform: PlatformOpenAI,
  168. Type: AccountTypeOAuth,
  169. Credentials: map[string]any{
  170. "chatgpt_account_id": chatgptAccountID,
  171. },
  172. }
  173. req, err := svc.buildUpstreamRequest(
  174. context.Background(), c, account, body, "oauth-token", true, "client-session", true,
  175. )
  176. require.NoError(t, err)
  177. return req.Header
  178. }
  179. first := build(11, "chatgpt-account-11")
  180. firstAgain := build(11, "chatgpt-account-11")
  181. second := build(19, "chatgpt-account-19")
  182. identityHeaders := []string{
  183. "x-codex-installation-id",
  184. "x-codex-window-id",
  185. "session-id",
  186. "session_id",
  187. "conversation_id",
  188. "thread-id",
  189. "x-client-request-id",
  190. "x-codex-turn-metadata",
  191. }
  192. checked := 0
  193. for _, header := range identityHeaders {
  194. if first.Get(header) == "" && second.Get(header) == "" {
  195. continue
  196. }
  197. checked++
  198. require.NotEmpty(t, first.Get(header), header)
  199. require.Equal(t, first.Get(header), firstAgain.Get(header), "same account must retain stable identity: %s", header)
  200. require.NotEqual(t, first.Get(header), second.Get(header), "account failover must rotate upstream identity: %s", header)
  201. }
  202. require.GreaterOrEqual(t, checked, 5, "test must exercise the real outbound identity surface")
  203. }