claude_oauth_service_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. package repository
  2. import (
  3. "context"
  4. "encoding/json"
  5. "io"
  6. "net/http"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  10. "github.com/imroc/req/v3"
  11. "github.com/stretchr/testify/require"
  12. "github.com/stretchr/testify/suite"
  13. )
  14. type ClaudeOAuthServiceSuite struct {
  15. suite.Suite
  16. client *claudeOAuthService
  17. }
  18. // requestCapture holds captured request data for assertions in the main goroutine.
  19. type requestCapture struct {
  20. path string
  21. method string
  22. cookies []*http.Cookie
  23. body []byte
  24. bodyJSON map[string]any
  25. contentType string
  26. }
  27. func newTestReqClient(rt http.RoundTripper) *req.Client {
  28. c := req.C()
  29. c.GetClient().Transport = rt
  30. return c
  31. }
  32. func (s *ClaudeOAuthServiceSuite) TestGetOrganizationUUID() {
  33. tests := []struct {
  34. name string
  35. handler http.HandlerFunc
  36. wantErr bool
  37. errContain string
  38. wantUUID string
  39. validate func(captured requestCapture)
  40. }{
  41. {
  42. name: "success",
  43. handler: func(w http.ResponseWriter, r *http.Request) {
  44. w.Header().Set("Content-Type", "application/json")
  45. _, _ = w.Write([]byte(`[{"uuid":"org-1"}]`))
  46. },
  47. wantUUID: "org-1",
  48. validate: func(captured requestCapture) {
  49. require.Equal(s.T(), "/api/organizations", captured.path, "unexpected path")
  50. require.Len(s.T(), captured.cookies, 1, "expected 1 cookie")
  51. require.Equal(s.T(), "sessionKey", captured.cookies[0].Name)
  52. require.Equal(s.T(), "sess", captured.cookies[0].Value)
  53. },
  54. },
  55. {
  56. name: "non_200_returns_error",
  57. handler: func(w http.ResponseWriter, r *http.Request) {
  58. w.WriteHeader(http.StatusUnauthorized)
  59. _, _ = w.Write([]byte("unauthorized"))
  60. },
  61. wantErr: true,
  62. errContain: "401",
  63. },
  64. {
  65. name: "invalid_json_returns_error",
  66. handler: func(w http.ResponseWriter, r *http.Request) {
  67. w.Header().Set("Content-Type", "application/json")
  68. _, _ = w.Write([]byte("not-json"))
  69. },
  70. wantErr: true,
  71. },
  72. }
  73. for _, tt := range tests {
  74. s.Run(tt.name, func() {
  75. var captured requestCapture
  76. rt := newInProcessTransport(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  77. captured.path = r.URL.Path
  78. captured.cookies = r.Cookies()
  79. tt.handler(w, r)
  80. }), nil)
  81. client, ok := NewClaudeOAuthClient().(*claudeOAuthService)
  82. require.True(s.T(), ok, "type assertion failed")
  83. s.client = client
  84. s.client.baseURL = "http://in-process"
  85. s.client.clientFactory = func(string) (*req.Client, error) { return newTestReqClient(rt), nil }
  86. got, err := s.client.GetOrganizationUUID(context.Background(), "sess", "")
  87. if tt.wantErr {
  88. require.Error(s.T(), err)
  89. if tt.errContain != "" {
  90. require.ErrorContains(s.T(), err, tt.errContain)
  91. }
  92. return
  93. }
  94. require.NoError(s.T(), err)
  95. require.Equal(s.T(), tt.wantUUID, got)
  96. if tt.validate != nil {
  97. tt.validate(captured)
  98. }
  99. })
  100. }
  101. }
  102. func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
  103. tests := []struct {
  104. name string
  105. handler http.HandlerFunc
  106. wantErr bool
  107. wantCode string
  108. validate func(captured requestCapture)
  109. }{
  110. {
  111. name: "parses_redirect_uri",
  112. handler: func(w http.ResponseWriter, r *http.Request) {
  113. w.Header().Set("Content-Type", "application/json")
  114. _ = json.NewEncoder(w).Encode(map[string]string{
  115. "redirect_uri": oauth.RedirectURI + "?code=AUTH&state=STATE",
  116. })
  117. },
  118. wantCode: "AUTH#STATE",
  119. validate: func(captured requestCapture) {
  120. require.True(s.T(), strings.HasPrefix(captured.path, "/v1/oauth/") && strings.HasSuffix(captured.path, "/authorize"), "unexpected path: %s", captured.path)
  121. require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
  122. require.Len(s.T(), captured.cookies, 1, "expected 1 cookie")
  123. require.Equal(s.T(), "sess", captured.cookies[0].Value)
  124. require.Equal(s.T(), "org-1", captured.bodyJSON["organization_uuid"])
  125. require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
  126. require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
  127. require.Equal(s.T(), "st", captured.bodyJSON["state"])
  128. },
  129. },
  130. {
  131. name: "missing_code_returns_error",
  132. handler: func(w http.ResponseWriter, r *http.Request) {
  133. w.Header().Set("Content-Type", "application/json")
  134. _ = json.NewEncoder(w).Encode(map[string]string{
  135. "redirect_uri": oauth.RedirectURI + "?state=STATE", // no code
  136. })
  137. },
  138. wantErr: true,
  139. },
  140. }
  141. for _, tt := range tests {
  142. s.Run(tt.name, func() {
  143. var captured requestCapture
  144. rt := newInProcessTransport(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  145. captured.path = r.URL.Path
  146. captured.method = r.Method
  147. captured.cookies = r.Cookies()
  148. captured.body, _ = io.ReadAll(r.Body)
  149. _ = json.Unmarshal(captured.body, &captured.bodyJSON)
  150. tt.handler(w, r)
  151. }), nil)
  152. client, ok := NewClaudeOAuthClient().(*claudeOAuthService)
  153. require.True(s.T(), ok, "type assertion failed")
  154. s.client = client
  155. s.client.baseURL = "http://in-process"
  156. s.client.clientFactory = func(string) (*req.Client, error) { return newTestReqClient(rt), nil }
  157. code, err := s.client.GetAuthorizationCode(context.Background(), "sess", "org-1", oauth.ScopeInference, "cc", "st", "")
  158. if tt.wantErr {
  159. require.Error(s.T(), err)
  160. return
  161. }
  162. require.NoError(s.T(), err)
  163. require.Equal(s.T(), tt.wantCode, code)
  164. if tt.validate != nil {
  165. tt.validate(captured)
  166. }
  167. })
  168. }
  169. }
  170. func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
  171. tests := []struct {
  172. name string
  173. handler http.HandlerFunc
  174. code string
  175. isSetupToken bool
  176. wantErr bool
  177. wantResp *oauth.TokenResponse
  178. validate func(captured requestCapture)
  179. }{
  180. {
  181. name: "sends_state_when_embedded",
  182. handler: func(w http.ResponseWriter, r *http.Request) {
  183. w.Header().Set("Content-Type", "application/json")
  184. _ = json.NewEncoder(w).Encode(oauth.TokenResponse{
  185. AccessToken: "at",
  186. TokenType: "bearer",
  187. ExpiresIn: 3600,
  188. RefreshToken: "rt",
  189. Scope: "s",
  190. })
  191. },
  192. code: "AUTH#STATE2",
  193. isSetupToken: false,
  194. wantResp: &oauth.TokenResponse{
  195. AccessToken: "at",
  196. RefreshToken: "rt",
  197. },
  198. validate: func(captured requestCapture) {
  199. require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
  200. require.True(s.T(), strings.HasPrefix(captured.contentType, "application/json"), "unexpected content-type")
  201. require.Equal(s.T(), "AUTH", captured.bodyJSON["code"])
  202. require.Equal(s.T(), "STATE2", captured.bodyJSON["state"])
  203. require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
  204. require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
  205. require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
  206. // Regular OAuth should not include expires_in
  207. require.Nil(s.T(), captured.bodyJSON["expires_in"], "regular OAuth should not include expires_in")
  208. },
  209. },
  210. {
  211. name: "setup_token_omits_expires_in",
  212. handler: func(w http.ResponseWriter, r *http.Request) {
  213. w.Header().Set("Content-Type", "application/json")
  214. _ = json.NewEncoder(w).Encode(oauth.TokenResponse{
  215. AccessToken: "at",
  216. TokenType: "bearer",
  217. ExpiresIn: 31536000,
  218. })
  219. },
  220. code: "AUTH",
  221. isSetupToken: true,
  222. wantResp: &oauth.TokenResponse{
  223. AccessToken: "at",
  224. },
  225. validate: func(captured requestCapture) {
  226. require.Nil(s.T(), captured.bodyJSON["expires_in"], "setup token should not include expires_in")
  227. },
  228. },
  229. {
  230. name: "non_200_returns_error",
  231. handler: func(w http.ResponseWriter, r *http.Request) {
  232. w.WriteHeader(http.StatusBadRequest)
  233. _, _ = w.Write([]byte("bad request"))
  234. },
  235. code: "AUTH",
  236. isSetupToken: false,
  237. wantErr: true,
  238. },
  239. }
  240. for _, tt := range tests {
  241. s.Run(tt.name, func() {
  242. var captured requestCapture
  243. rt := newInProcessTransport(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  244. captured.method = r.Method
  245. captured.contentType = r.Header.Get("Content-Type")
  246. captured.body, _ = io.ReadAll(r.Body)
  247. _ = json.Unmarshal(captured.body, &captured.bodyJSON)
  248. tt.handler(w, r)
  249. }), nil)
  250. client, ok := NewClaudeOAuthClient().(*claudeOAuthService)
  251. require.True(s.T(), ok, "type assertion failed")
  252. s.client = client
  253. s.client.tokenURL = "http://in-process/token"
  254. s.client.clientFactory = func(string) (*req.Client, error) { return newTestReqClient(rt), nil }
  255. resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "", tt.isSetupToken)
  256. if tt.wantErr {
  257. require.Error(s.T(), err)
  258. return
  259. }
  260. require.NoError(s.T(), err)
  261. require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken)
  262. require.Equal(s.T(), tt.wantResp.RefreshToken, resp.RefreshToken)
  263. if tt.validate != nil {
  264. tt.validate(captured)
  265. }
  266. })
  267. }
  268. }
  269. func (s *ClaudeOAuthServiceSuite) TestRefreshToken() {
  270. tests := []struct {
  271. name string
  272. handler http.HandlerFunc
  273. wantErr bool
  274. wantResp *oauth.TokenResponse
  275. validate func(captured requestCapture)
  276. }{
  277. {
  278. name: "sends_json_format",
  279. handler: func(w http.ResponseWriter, r *http.Request) {
  280. w.Header().Set("Content-Type", "application/json")
  281. _ = json.NewEncoder(w).Encode(oauth.TokenResponse{
  282. AccessToken: "new_access_token",
  283. TokenType: "bearer",
  284. ExpiresIn: 28800,
  285. RefreshToken: "new_refresh_token",
  286. Scope: "user:profile user:inference",
  287. })
  288. },
  289. wantResp: &oauth.TokenResponse{
  290. AccessToken: "new_access_token",
  291. RefreshToken: "new_refresh_token",
  292. },
  293. validate: func(captured requestCapture) {
  294. require.Equal(s.T(), http.MethodPost, captured.method, "expected POST")
  295. // 验证使用 JSON 格式(不是 form 格式)
  296. require.True(s.T(), strings.HasPrefix(captured.contentType, "application/json"),
  297. "expected JSON content-type, got: %s", captured.contentType)
  298. // 验证 JSON body 内容
  299. require.Equal(s.T(), "refresh_token", captured.bodyJSON["grant_type"])
  300. require.Equal(s.T(), "rt", captured.bodyJSON["refresh_token"])
  301. require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
  302. },
  303. },
  304. {
  305. name: "returns_new_refresh_token",
  306. handler: func(w http.ResponseWriter, r *http.Request) {
  307. w.Header().Set("Content-Type", "application/json")
  308. _ = json.NewEncoder(w).Encode(oauth.TokenResponse{
  309. AccessToken: "at",
  310. TokenType: "bearer",
  311. ExpiresIn: 28800,
  312. RefreshToken: "rotated_rt", // Anthropic rotates refresh tokens
  313. })
  314. },
  315. wantResp: &oauth.TokenResponse{
  316. AccessToken: "at",
  317. RefreshToken: "rotated_rt",
  318. },
  319. },
  320. {
  321. name: "non_200_returns_error",
  322. handler: func(w http.ResponseWriter, r *http.Request) {
  323. w.WriteHeader(http.StatusUnauthorized)
  324. _, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
  325. },
  326. wantErr: true,
  327. },
  328. }
  329. for _, tt := range tests {
  330. s.Run(tt.name, func() {
  331. var captured requestCapture
  332. rt := newInProcessTransport(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  333. captured.method = r.Method
  334. captured.contentType = r.Header.Get("Content-Type")
  335. captured.body, _ = io.ReadAll(r.Body)
  336. _ = json.Unmarshal(captured.body, &captured.bodyJSON)
  337. tt.handler(w, r)
  338. }), nil)
  339. client, ok := NewClaudeOAuthClient().(*claudeOAuthService)
  340. require.True(s.T(), ok, "type assertion failed")
  341. s.client = client
  342. s.client.tokenURL = "http://in-process/token"
  343. s.client.clientFactory = func(string) (*req.Client, error) { return newTestReqClient(rt), nil }
  344. resp, err := s.client.RefreshToken(context.Background(), "rt", "")
  345. if tt.wantErr {
  346. require.Error(s.T(), err)
  347. return
  348. }
  349. require.NoError(s.T(), err)
  350. require.Equal(s.T(), tt.wantResp.AccessToken, resp.AccessToken)
  351. require.Equal(s.T(), tt.wantResp.RefreshToken, resp.RefreshToken)
  352. if tt.validate != nil {
  353. tt.validate(captured)
  354. }
  355. })
  356. }
  357. }
  358. func TestClaudeOAuthServiceSuite(t *testing.T) {
  359. suite.Run(t, new(ClaudeOAuthServiceSuite))
  360. }