openai_oauth_service_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. package repository
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "net/url"
  8. "testing"
  9. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. "github.com/stretchr/testify/suite"
  14. )
  15. type OpenAIOAuthServiceSuite struct {
  16. suite.Suite
  17. ctx context.Context
  18. srv *httptest.Server
  19. svc *openaiOAuthService
  20. received chan url.Values
  21. }
  22. func (s *OpenAIOAuthServiceSuite) SetupTest() {
  23. s.ctx = context.Background()
  24. s.received = make(chan url.Values, 1)
  25. }
  26. func (s *OpenAIOAuthServiceSuite) TearDownTest() {
  27. if s.srv != nil {
  28. s.srv.Close()
  29. s.srv = nil
  30. }
  31. }
  32. func (s *OpenAIOAuthServiceSuite) setupServer(handler http.HandlerFunc) {
  33. s.srv = newLocalTestServer(s.T(), handler)
  34. s.svc = &openaiOAuthService{tokenURL: s.srv.URL}
  35. }
  36. func (s *OpenAIOAuthServiceSuite) TestExchangeCode_DefaultRedirectURI() {
  37. errCh := make(chan string, 1)
  38. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  39. if r.Method != http.MethodPost {
  40. errCh <- "method mismatch"
  41. w.WriteHeader(http.StatusBadRequest)
  42. return
  43. }
  44. if err := r.ParseForm(); err != nil {
  45. errCh <- "ParseForm failed"
  46. w.WriteHeader(http.StatusBadRequest)
  47. return
  48. }
  49. if got := r.PostForm.Get("grant_type"); got != "authorization_code" {
  50. errCh <- "grant_type mismatch"
  51. w.WriteHeader(http.StatusBadRequest)
  52. return
  53. }
  54. if got := r.PostForm.Get("client_id"); got != openai.ClientID {
  55. errCh <- "client_id mismatch"
  56. w.WriteHeader(http.StatusBadRequest)
  57. return
  58. }
  59. if got := r.PostForm.Get("code"); got != "code" {
  60. errCh <- "code mismatch"
  61. w.WriteHeader(http.StatusBadRequest)
  62. return
  63. }
  64. if got := r.PostForm.Get("redirect_uri"); got != openai.DefaultRedirectURI {
  65. errCh <- "redirect_uri mismatch"
  66. w.WriteHeader(http.StatusBadRequest)
  67. return
  68. }
  69. if got := r.PostForm.Get("code_verifier"); got != "ver" {
  70. errCh <- "code_verifier mismatch"
  71. w.WriteHeader(http.StatusBadRequest)
  72. return
  73. }
  74. wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
  75. if got := r.Header.Get("User-Agent"); got != wantUA {
  76. errCh <- "user-agent mismatch"
  77. w.WriteHeader(http.StatusBadRequest)
  78. return
  79. }
  80. if got := r.Header.Get("originator"); got != wantOriginator {
  81. errCh <- "originator mismatch"
  82. w.WriteHeader(http.StatusBadRequest)
  83. return
  84. }
  85. w.Header().Set("Content-Type", "application/json")
  86. _, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`)
  87. }))
  88. resp, err := s.svc.ExchangeCode(s.ctx, "code", "ver", "", "", "")
  89. require.NoError(s.T(), err, "ExchangeCode")
  90. select {
  91. case msg := <-errCh:
  92. require.Fail(s.T(), msg)
  93. default:
  94. }
  95. require.Equal(s.T(), "at", resp.AccessToken)
  96. require.Equal(s.T(), "rt", resp.RefreshToken)
  97. }
  98. func (s *OpenAIOAuthServiceSuite) TestRefreshToken_FormFields() {
  99. errCh := make(chan string, 1)
  100. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  101. if err := r.ParseForm(); err != nil {
  102. errCh <- "ParseForm failed"
  103. w.WriteHeader(http.StatusBadRequest)
  104. return
  105. }
  106. if got := r.PostForm.Get("grant_type"); got != "refresh_token" {
  107. errCh <- "grant_type mismatch"
  108. w.WriteHeader(http.StatusBadRequest)
  109. return
  110. }
  111. if got := r.PostForm.Get("refresh_token"); got != "rt" {
  112. errCh <- "refresh_token mismatch"
  113. w.WriteHeader(http.StatusBadRequest)
  114. return
  115. }
  116. if got := r.PostForm.Get("client_id"); got != openai.ClientID {
  117. errCh <- "client_id mismatch"
  118. w.WriteHeader(http.StatusBadRequest)
  119. return
  120. }
  121. if got := r.PostForm.Get("scope"); got != openai.RefreshScopes {
  122. errCh <- "scope mismatch"
  123. w.WriteHeader(http.StatusBadRequest)
  124. return
  125. }
  126. wantUA, wantOriginator := service.CodexCanonicalAuthIdentity()
  127. if got := r.Header.Get("User-Agent"); got != wantUA {
  128. errCh <- "user-agent mismatch"
  129. w.WriteHeader(http.StatusBadRequest)
  130. return
  131. }
  132. if got := r.Header.Get("originator"); got != wantOriginator {
  133. errCh <- "originator mismatch"
  134. w.WriteHeader(http.StatusBadRequest)
  135. return
  136. }
  137. w.Header().Set("Content-Type", "application/json")
  138. _, _ = io.WriteString(w, `{"access_token":"at2","refresh_token":"rt2","token_type":"bearer","expires_in":3600}`)
  139. }))
  140. resp, err := s.svc.RefreshToken(s.ctx, "rt", "")
  141. require.NoError(s.T(), err, "RefreshToken")
  142. select {
  143. case msg := <-errCh:
  144. require.Fail(s.T(), msg)
  145. default:
  146. }
  147. require.Equal(s.T(), "at2", resp.AccessToken)
  148. require.Equal(s.T(), "rt2", resp.RefreshToken)
  149. }
  150. // TestRefreshToken_DefaultsToOpenAIClientID 验证未指定 client_id 时默认使用 OpenAI ClientID,
  151. // 且只发送一次请求(不再盲猜多个 client_id)。
  152. func (s *OpenAIOAuthServiceSuite) TestRefreshToken_DefaultsToOpenAIClientID() {
  153. var seenClientIDs []string
  154. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  155. if err := r.ParseForm(); err != nil {
  156. w.WriteHeader(http.StatusBadRequest)
  157. return
  158. }
  159. clientID := r.PostForm.Get("client_id")
  160. seenClientIDs = append(seenClientIDs, clientID)
  161. w.Header().Set("Content-Type", "application/json")
  162. _, _ = io.WriteString(w, `{"access_token":"at","refresh_token":"rt","token_type":"bearer","expires_in":3600}`)
  163. }))
  164. resp, err := s.svc.RefreshToken(s.ctx, "rt", "")
  165. require.NoError(s.T(), err, "RefreshToken")
  166. require.Equal(s.T(), "at", resp.AccessToken)
  167. // 只发送了一次请求,使用默认的 OpenAI ClientID
  168. require.Equal(s.T(), []string{openai.ClientID}, seenClientIDs)
  169. }
  170. func (s *OpenAIOAuthServiceSuite) TestRefreshToken_UseProvidedClientID() {
  171. const customClientID = "custom-client-id"
  172. var seenClientIDs []string
  173. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  174. if err := r.ParseForm(); err != nil {
  175. w.WriteHeader(http.StatusBadRequest)
  176. return
  177. }
  178. clientID := r.PostForm.Get("client_id")
  179. seenClientIDs = append(seenClientIDs, clientID)
  180. if clientID != customClientID {
  181. w.WriteHeader(http.StatusBadRequest)
  182. return
  183. }
  184. w.Header().Set("Content-Type", "application/json")
  185. _, _ = io.WriteString(w, `{"access_token":"at-custom","refresh_token":"rt-custom","token_type":"bearer","expires_in":3600}`)
  186. }))
  187. resp, err := s.svc.RefreshTokenWithClientID(s.ctx, "rt", "", customClientID)
  188. require.NoError(s.T(), err, "RefreshTokenWithClientID")
  189. require.Equal(s.T(), "at-custom", resp.AccessToken)
  190. require.Equal(s.T(), "rt-custom", resp.RefreshToken)
  191. require.Equal(s.T(), []string{customClientID}, seenClientIDs)
  192. }
  193. func (s *OpenAIOAuthServiceSuite) TestNonSuccessStatus_IncludesBody() {
  194. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  195. w.WriteHeader(http.StatusBadRequest)
  196. _, _ = io.WriteString(w, "bad")
  197. }))
  198. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  199. require.Error(s.T(), err)
  200. require.ErrorContains(s.T(), err, "status 400")
  201. require.ErrorContains(s.T(), err, "bad")
  202. }
  203. func (s *OpenAIOAuthServiceSuite) TestRequestError_ClosedServer() {
  204. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
  205. s.srv.Close()
  206. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  207. require.Error(s.T(), err)
  208. require.ErrorContains(s.T(), err, "request failed")
  209. }
  210. func (s *OpenAIOAuthServiceSuite) TestExchangeCode_RequestErrorWithoutProxyReturnsProxyHint() {
  211. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
  212. s.srv.Close()
  213. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  214. require.Error(s.T(), err)
  215. require.Equal(s.T(), "OPENAI_OAUTH_PROXY_REQUIRED", infraerrors.Reason(err))
  216. require.Contains(s.T(), infraerrors.Message(err), "no proxy is configured")
  217. }
  218. func (s *OpenAIOAuthServiceSuite) TestContextCancel() {
  219. started := make(chan struct{})
  220. block := make(chan struct{})
  221. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  222. close(started)
  223. <-block
  224. }))
  225. ctx, cancel := context.WithCancel(s.ctx)
  226. done := make(chan error, 1)
  227. go func() {
  228. _, err := s.svc.ExchangeCode(ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  229. done <- err
  230. }()
  231. <-started
  232. cancel()
  233. close(block)
  234. err := <-done
  235. require.Error(s.T(), err)
  236. }
  237. func (s *OpenAIOAuthServiceSuite) TestExchangeCode_UsesProvidedRedirectURI() {
  238. want := "http://localhost:9999/cb"
  239. errCh := make(chan string, 1)
  240. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  241. _ = r.ParseForm()
  242. if got := r.PostForm.Get("redirect_uri"); got != want {
  243. errCh <- "redirect_uri mismatch"
  244. w.WriteHeader(http.StatusBadRequest)
  245. return
  246. }
  247. w.Header().Set("Content-Type", "application/json")
  248. _, _ = io.WriteString(w, `{"access_token":"at","token_type":"bearer","expires_in":1}`)
  249. }))
  250. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", want, "", "")
  251. require.NoError(s.T(), err, "ExchangeCode")
  252. select {
  253. case msg := <-errCh:
  254. require.Fail(s.T(), msg)
  255. default:
  256. }
  257. }
  258. func (s *OpenAIOAuthServiceSuite) TestExchangeCode_UseProvidedClientID() {
  259. wantClientID := "custom-exchange-client-id"
  260. errCh := make(chan string, 1)
  261. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  262. _ = r.ParseForm()
  263. if got := r.PostForm.Get("client_id"); got != wantClientID {
  264. errCh <- "client_id mismatch"
  265. w.WriteHeader(http.StatusBadRequest)
  266. return
  267. }
  268. w.Header().Set("Content-Type", "application/json")
  269. _, _ = io.WriteString(w, `{"access_token":"at","token_type":"bearer","expires_in":1}`)
  270. }))
  271. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", wantClientID)
  272. require.NoError(s.T(), err, "ExchangeCode")
  273. select {
  274. case msg := <-errCh:
  275. require.Fail(s.T(), msg)
  276. default:
  277. }
  278. }
  279. func (s *OpenAIOAuthServiceSuite) TestTokenURL_CanBeOverriddenWithQuery() {
  280. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  281. _ = r.ParseForm()
  282. s.received <- r.PostForm
  283. w.Header().Set("Content-Type", "application/json")
  284. _, _ = io.WriteString(w, `{"access_token":"at","token_type":"bearer","expires_in":1}`)
  285. }))
  286. s.svc.tokenURL = s.srv.URL + "?x=1"
  287. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  288. require.NoError(s.T(), err, "ExchangeCode")
  289. select {
  290. case <-s.received:
  291. default:
  292. require.Fail(s.T(), "expected server to receive request")
  293. }
  294. }
  295. func (s *OpenAIOAuthServiceSuite) TestExchangeCode_SuccessButInvalidJSON() {
  296. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  297. w.Header().Set("Content-Type", "application/json")
  298. w.WriteHeader(http.StatusOK)
  299. _, _ = io.WriteString(w, "not-valid-json")
  300. }))
  301. _, err := s.svc.ExchangeCode(s.ctx, "code", "ver", openai.DefaultRedirectURI, "", "")
  302. require.Error(s.T(), err, "expected error for invalid JSON response")
  303. }
  304. func (s *OpenAIOAuthServiceSuite) TestRefreshToken_NonSuccessStatus() {
  305. s.setupServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  306. w.WriteHeader(http.StatusUnauthorized)
  307. _, _ = io.WriteString(w, "unauthorized")
  308. }))
  309. _, err := s.svc.RefreshToken(s.ctx, "rt", "")
  310. require.Error(s.T(), err, "expected error for non-2xx status")
  311. require.ErrorContains(s.T(), err, "status 401")
  312. }
  313. func TestNewOpenAIOAuthClient_DefaultTokenURL(t *testing.T) {
  314. client := NewOpenAIOAuthClient()
  315. svc, ok := client.(*openaiOAuthService)
  316. require.True(t, ok)
  317. require.Equal(t, openai.TokenURL, svc.tokenURL)
  318. }
  319. func TestOpenAIOAuthServiceSuite(t *testing.T) {
  320. suite.Run(t, new(OpenAIOAuthServiceSuite))
  321. }