選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 

193 行
6.9 KiB

  1. package service
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/QuantumNous/new-api/common"
  12. )
  13. func TestRefreshCodexOAuthTokenReportsHTTPStatusForNonJSONError(t *testing.T) {
  14. t.Parallel()
  15. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  16. w.WriteHeader(http.StatusUnauthorized)
  17. _, _ = w.Write([]byte("upstream unavailable"))
  18. }))
  19. defer server.Close()
  20. _, err := refreshCodexOAuthToken(context.Background(), server.Client(), server.URL, "client-id", "refresh-token")
  21. if err == nil {
  22. t.Fatal("expected refresh failure")
  23. }
  24. if !strings.Contains(err.Error(), "status=401") {
  25. t.Fatalf("expected error to contain upstream status, got %q", err)
  26. }
  27. }
  28. func TestRefreshCodexOAuthTokenSendsRefreshGrantAndParsesResponse(t *testing.T) {
  29. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  30. if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
  31. t.Fatalf("unexpected request: method=%s content-type=%q", r.Method, r.Header.Get("Content-Type"))
  32. }
  33. if err := r.ParseForm(); err != nil {
  34. t.Fatalf("parse form: %v", err)
  35. }
  36. if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("client_id") != "client-id" || r.Form.Get("refresh_token") != "refresh-token" {
  37. t.Fatalf("unexpected refresh form: %#v", r.Form)
  38. }
  39. _, _ = w.Write([]byte(`{"access_token":"access","refresh_token":"next-refresh","expires_in":60}`))
  40. }))
  41. defer server.Close()
  42. before := time.Now()
  43. result, err := refreshCodexOAuthToken(context.Background(), server.Client(), server.URL, "client-id", " refresh-token ")
  44. if err != nil {
  45. t.Fatalf("refresh token: %v", err)
  46. }
  47. if result.AccessToken != "access" || result.RefreshToken != "next-refresh" || !result.ExpiresAt.After(before.Add(59*time.Second)) {
  48. t.Fatalf("unexpected refresh result: %#v", result)
  49. }
  50. }
  51. func TestExchangeCodexAuthorizationCodeSendsPKCEForm(t *testing.T) {
  52. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  53. if err := r.ParseForm(); err != nil {
  54. t.Fatalf("parse form: %v", err)
  55. }
  56. if r.Form.Get("grant_type") != "authorization_code" || r.Form.Get("code") != "code" || r.Form.Get("code_verifier") != "verifier" || r.Form.Get("redirect_uri") != "http://localhost/callback" {
  57. t.Fatalf("unexpected authorization-code form: %#v", r.Form)
  58. }
  59. _, _ = w.Write([]byte(`{"access_token":"access","refresh_token":"refresh","expires_in":60}`))
  60. }))
  61. defer server.Close()
  62. result, err := exchangeCodexAuthorizationCode(context.Background(), server.Client(), server.URL, "client-id", " code ", " verifier ", "http://localhost/callback")
  63. if err != nil {
  64. t.Fatalf("exchange authorization code: %v", err)
  65. }
  66. if result.AccessToken != "access" || result.RefreshToken != "refresh" {
  67. t.Fatalf("unexpected exchange result: %#v", result)
  68. }
  69. }
  70. func TestExtractCodexClaimsFromJWT(t *testing.T) {
  71. claims := map[string]any{
  72. "email": " user@example.com ",
  73. codexJWTClaimPath: map[string]any{
  74. "chatgpt_account_id": " account-123 ",
  75. },
  76. }
  77. token := newTestJWT(t, claims)
  78. accountID, ok := ExtractCodexAccountIDFromJWT(token)
  79. if !ok || accountID != "account-123" {
  80. t.Fatalf("expected trimmed account ID, got %q, %t", accountID, ok)
  81. }
  82. email, ok := ExtractEmailFromJWT(token)
  83. if !ok || email != "user@example.com" {
  84. t.Fatalf("expected trimmed email, got %q, %t", email, ok)
  85. }
  86. }
  87. func TestExtractCodexClaimsRejectMalformedOrEmptyValues(t *testing.T) {
  88. if _, ok := ExtractCodexAccountIDFromJWT("not-a-jwt"); ok {
  89. t.Fatal("malformed token must not yield account ID")
  90. }
  91. if _, ok := ExtractEmailFromJWT(newTestJWT(t, map[string]any{"email": " "})); ok {
  92. t.Fatal("empty email must not be accepted")
  93. }
  94. if _, ok := ExtractCodexAccountIDFromJWT(newTestJWT(t, map[string]any{
  95. codexJWTClaimPath: map[string]any{"chatgpt_account_id": ""},
  96. })); ok {
  97. t.Fatal("empty account ID must not be accepted")
  98. }
  99. }
  100. func TestCreateCodexOAuthAuthorizationFlowUsesPKCEAndState(t *testing.T) {
  101. flow, err := CreateCodexOAuthAuthorizationFlow()
  102. if err != nil {
  103. t.Fatalf("create authorization flow: %v", err)
  104. }
  105. if len(flow.State) != 32 {
  106. t.Fatalf("expected 32-character state, got %q", flow.State)
  107. }
  108. if flow.Verifier == "" || flow.Challenge == "" {
  109. t.Fatal("expected PKCE verifier and challenge")
  110. }
  111. u, err := url.Parse(flow.AuthorizeURL)
  112. if err != nil {
  113. t.Fatalf("parse authorize URL: %v", err)
  114. }
  115. q := u.Query()
  116. if q.Get("state") != flow.State || q.Get("code_challenge") != flow.Challenge {
  117. t.Fatalf("authorize URL does not include generated state and challenge: %s", flow.AuthorizeURL)
  118. }
  119. if q.Get("code_challenge_method") != "S256" || q.Get("redirect_uri") != codexOAuthRedirectURI {
  120. t.Fatalf("unexpected PKCE or redirect parameters: %s", flow.AuthorizeURL)
  121. }
  122. }
  123. func TestFetchCodexWhamUsageSendsRequiredHeaders(t *testing.T) {
  124. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  125. if r.URL.Path != "/backend-api/wham/usage" {
  126. t.Fatalf("unexpected path: %s", r.URL.Path)
  127. }
  128. if r.Header.Get("Authorization") != "Bearer access-token" {
  129. t.Fatalf("unexpected authorization: %q", r.Header.Get("Authorization"))
  130. }
  131. if r.Header.Get("chatgpt-account-id") != "account-123" || r.Header.Get("originator") != "codex_cli_rs" {
  132. t.Fatalf("missing Codex headers: %#v", r.Header)
  133. }
  134. w.Header().Set("Content-Type", "application/json")
  135. _, _ = w.Write([]byte(`{"limit": 1}`))
  136. }))
  137. defer server.Close()
  138. status, body, err := FetchCodexWhamUsage(context.Background(), server.Client(), server.URL+"/", " access-token ", " account-123 ")
  139. if err != nil {
  140. t.Fatalf("fetch usage: %v", err)
  141. }
  142. if status != http.StatusOK || string(body) != `{"limit": 1}` {
  143. t.Fatalf("unexpected usage response: status=%d body=%q", status, body)
  144. }
  145. }
  146. func TestFetchCodexWhamUsageRejectsMissingInputs(t *testing.T) {
  147. tests := []struct {
  148. name string
  149. client *http.Client
  150. baseURL string
  151. accessKey string
  152. accountID string
  153. }{
  154. {name: "nil client", baseURL: "https://example.com", accessKey: "token", accountID: "account"},
  155. {name: "empty base URL", client: http.DefaultClient, accessKey: "token", accountID: "account"},
  156. {name: "empty access token", client: http.DefaultClient, baseURL: "https://example.com", accountID: "account"},
  157. {name: "empty account ID", client: http.DefaultClient, baseURL: "https://example.com", accessKey: "token"},
  158. }
  159. for _, tt := range tests {
  160. t.Run(tt.name, func(t *testing.T) {
  161. if _, _, err := FetchCodexWhamUsage(context.Background(), tt.client, tt.baseURL, tt.accessKey, tt.accountID); err == nil {
  162. t.Fatal("expected input validation error")
  163. }
  164. })
  165. }
  166. }
  167. func newTestJWT(t *testing.T, claims map[string]any) string {
  168. t.Helper()
  169. payload, err := common.Marshal(claims)
  170. if err != nil {
  171. t.Fatalf("marshal claims: %v", err)
  172. }
  173. return "header." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
  174. }