openai_subscription_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "net/http/httptest"
  7. "testing"
  8. "time"
  9. "github.com/imroc/req/v3"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func TestFetchChatGPTSubscriptionExpiresAt(t *testing.T) {
  13. const wantExpiresAt = "2026-06-10T02:52:15Z"
  14. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  15. require.Equal(t, "/backend-api/subscriptions", r.URL.Path)
  16. require.Equal(t, "acc_123", r.URL.Query().Get("account_id"))
  17. require.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
  18. w.Header().Set("Content-Type", "application/json")
  19. _ = json.NewEncoder(w).Encode(map[string]any{
  20. "plan_type": "plus",
  21. "active_until": wantExpiresAt,
  22. "will_renew": true,
  23. "id": "sub_123",
  24. })
  25. }))
  26. defer server.Close()
  27. oldURL := chatGPTSubscriptionsURL
  28. chatGPTSubscriptionsURL = server.URL + "/backend-api/subscriptions"
  29. t.Cleanup(func() { chatGPTSubscriptionsURL = oldURL })
  30. got := fetchChatGPTSubscriptionExpiresAt(context.Background(), func(proxyURL string) (*req.Client, error) {
  31. return req.C().SetTimeout(5 * time.Second), nil
  32. }, "access-token", "", "acc_123")
  33. require.Equal(t, wantExpiresAt, got)
  34. }
  35. func TestFetchChatGPTAccountInfo_SkipsExpiredWorkspaceCandidate(t *testing.T) {
  36. expiredAt := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
  37. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  38. require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path)
  39. require.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
  40. w.Header().Set("Content-Type", "application/json")
  41. _ = json.NewEncoder(w).Encode(map[string]any{
  42. "accounts": map[string]any{
  43. "org-expired-workspace": map[string]any{
  44. "account": map[string]any{
  45. "plan_type": "self_serve_business_usage_based",
  46. "is_default": true,
  47. },
  48. "entitlement": map[string]any{
  49. "expires_at": expiredAt,
  50. },
  51. },
  52. "personal-account": map[string]any{
  53. "account": map[string]any{
  54. "plan_type": "free",
  55. },
  56. },
  57. },
  58. })
  59. }))
  60. defer server.Close()
  61. oldURL := chatGPTAccountsCheckURL
  62. chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
  63. t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL })
  64. got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) {
  65. return req.C().SetTimeout(5 * time.Second), nil
  66. }, "access-token", "", "org-expired-workspace")
  67. require.NotNil(t, got)
  68. require.Equal(t, "free", got.PlanType)
  69. require.Empty(t, got.SubscriptionExpiresAt)
  70. }
  71. func TestFetchChatGPTAccountInfo_SkipsDeactivatedWorkspaceCandidate(t *testing.T) {
  72. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  73. require.Equal(t, "/backend-api/accounts/check/v4-2023-04-27", r.URL.Path)
  74. w.Header().Set("Content-Type", "application/json")
  75. _ = json.NewEncoder(w).Encode(map[string]any{
  76. "accounts": map[string]any{
  77. "org-deactivated-workspace": map[string]any{
  78. "account": map[string]any{
  79. "plan_type": "self_serve_business_usage_based",
  80. "is_default": true,
  81. "is_deactivated": true,
  82. },
  83. },
  84. "personal-account": map[string]any{
  85. "account": map[string]any{
  86. "plan_type": "pro",
  87. },
  88. },
  89. },
  90. })
  91. }))
  92. defer server.Close()
  93. oldURL := chatGPTAccountsCheckURL
  94. chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
  95. t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL })
  96. got := fetchChatGPTAccountInfo(context.Background(), func(proxyURL string) (*req.Client, error) {
  97. return req.C().SetTimeout(5 * time.Second), nil
  98. }, "access-token", "", "org-deactivated-workspace")
  99. require.NotNil(t, got)
  100. require.Equal(t, "pro", got.PlanType)
  101. }
  102. func TestShouldApplyChatGPTAccountInfoPlanType(t *testing.T) {
  103. require.False(t, shouldApplyChatGPTAccountInfoPlanType("pro", "self_serve_business_usage_based"))
  104. require.False(t, shouldApplyChatGPTAccountInfoPlanType("free", "team"))
  105. require.False(t, shouldApplyChatGPTAccountInfoPlanType("", ""))
  106. require.True(t, shouldApplyChatGPTAccountInfoPlanType("", "pro"))
  107. }
  108. func TestChatGPTAccountInfoBelongsToTokenAccount(t *testing.T) {
  109. require.False(t, chatGPTAccountInfoBelongsToTokenAccount(
  110. &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "workspace-b"}))
  111. require.True(t, chatGPTAccountInfoBelongsToTokenAccount(
  112. &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{AccountID: "PERSONAL-A"}))
  113. // 任一侧缺 ID 时无法区分,保持既有行为(采用 accounts/check 的值)。
  114. require.True(t, chatGPTAccountInfoBelongsToTokenAccount(
  115. &OpenAITokenInfo{}, &ChatGPTAccountInfo{AccountID: "workspace-b"}))
  116. require.True(t, chatGPTAccountInfoBelongsToTokenAccount(
  117. &OpenAITokenInfo{ChatGPTAccountID: "personal-a"}, &ChatGPTAccountInfo{}))
  118. }
  119. // accounts 的 map key 可能是 "default" 这类别名,account.account_id 才是账号标识。
  120. func TestFetchChatGPTAccountInfo_ReportsAccountID(t *testing.T) {
  121. futureAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339)
  122. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  123. w.Header().Set("Content-Type", "application/json")
  124. _ = json.NewEncoder(w).Encode(map[string]any{
  125. "accounts": map[string]any{
  126. "default": map[string]any{
  127. "account": map[string]any{
  128. "account_id": "personal-account-a",
  129. "plan_type": "plus",
  130. "is_default": true,
  131. },
  132. "entitlement": map[string]any{"expires_at": futureAt},
  133. },
  134. },
  135. })
  136. }))
  137. defer server.Close()
  138. oldURL := chatGPTAccountsCheckURL
  139. chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
  140. t.Cleanup(func() { chatGPTAccountsCheckURL = oldURL })
  141. got := fetchChatGPTAccountInfo(context.Background(), newTestPrivacyClientFactory(), "access-token", "", "")
  142. require.NotNil(t, got)
  143. require.Equal(t, "plus", got.PlanType)
  144. require.Equal(t, "personal-account-a", got.AccountID, "应优先取 account.account_id 而不是 map key")
  145. }
  146. // issue #5459:poid 指向的默认 Personal workspace 与 chatgpt_account_id 是两个不同的
  147. // 标识时,accounts/check 返回的是 workspace 的 entitlement.expires_at。plan_type 那侧
  148. // 已被 shouldApplyChatGPTAccountInfoPlanType 挡住(保留 JWT 里的个人套餐),到期时间
  149. // 这侧原先无条件覆盖,于是显示成「个人 Pro + workspace 到期时间」。
  150. func TestEnrichTokenInfo_WorkspaceEntitlementDoesNotOverridePersonalSubscription(t *testing.T) {
  151. const (
  152. personalAccountID = "personal-account-a"
  153. workspaceAccountID = "personal-workspace-b"
  154. personalActiveUntil = "2027-03-01T00:00:00Z"
  155. )
  156. workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339)
  157. subscriptionCalls := 0
  158. server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{
  159. accountsCheck: map[string]any{
  160. "accounts": map[string]any{
  161. workspaceAccountID: map[string]any{
  162. "account": map[string]any{
  163. "account_id": workspaceAccountID,
  164. "plan_type": "pro",
  165. "is_default": true,
  166. },
  167. "entitlement": map[string]any{"expires_at": workspaceExpiresAt},
  168. },
  169. },
  170. },
  171. onSubscription: func(accountID string) map[string]any {
  172. subscriptionCalls++
  173. require.Equal(t, personalAccountID, accountID,
  174. "必须用个人 chatgpt_account_id 查订阅,而不是 poid workspace")
  175. return map[string]any{"plan_type": "pro", "active_until": personalActiveUntil, "will_renew": true}
  176. },
  177. })
  178. defer server.Close()
  179. tokenInfo := &OpenAITokenInfo{
  180. AccessToken: "access-token",
  181. ChatGPTAccountID: personalAccountID,
  182. OrganizationID: workspaceAccountID,
  183. PlanType: "pro", // 来自 id_token 的个人套餐
  184. }
  185. svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()}
  186. svc.enrichTokenInfo(context.Background(), tokenInfo, "")
  187. require.Equal(t, "pro", tokenInfo.PlanType)
  188. require.Equal(t, personalActiveUntil, tokenInfo.SubscriptionExpiresAt,
  189. "到期时间必须来自个人订阅 active_until,不能是 workspace 的 entitlement.expires_at")
  190. require.NotEqual(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt)
  191. require.Equal(t, 1, subscriptionCalls)
  192. }
  193. // 单个人账号(poid == chatgpt_account_id)是绝大多数情况,行为必须保持不变:
  194. // 直接用 accounts/check 的 entitlement,不额外打订阅端点。
  195. func TestEnrichTokenInfo_KeepsEntitlementWhenAccountMatches(t *testing.T) {
  196. const personalAccountID = "personal-account-a"
  197. entitlementExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339)
  198. subscriptionCalls := 0
  199. server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{
  200. accountsCheck: map[string]any{
  201. "accounts": map[string]any{
  202. personalAccountID: map[string]any{
  203. "account": map[string]any{
  204. "account_id": personalAccountID,
  205. "plan_type": "plus",
  206. "is_default": true,
  207. },
  208. "entitlement": map[string]any{"expires_at": entitlementExpiresAt},
  209. },
  210. },
  211. },
  212. onSubscription: func(string) map[string]any {
  213. subscriptionCalls++
  214. return map[string]any{}
  215. },
  216. })
  217. defer server.Close()
  218. tokenInfo := &OpenAITokenInfo{
  219. AccessToken: "access-token",
  220. ChatGPTAccountID: personalAccountID,
  221. OrganizationID: personalAccountID,
  222. PlanType: "plus",
  223. }
  224. svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()}
  225. svc.enrichTokenInfo(context.Background(), tokenInfo, "")
  226. require.Equal(t, entitlementExpiresAt, tokenInfo.SubscriptionExpiresAt)
  227. require.Zero(t, subscriptionCalls, "账号一致时不应额外请求订阅端点")
  228. }
  229. // 反向不变式:套餐本身就取自 accounts/check(JWT 没有 plan_type)时,到期时间必须
  230. // 跟着取同一条记录,否则会变成「workspace 套餐 + 个人到期时间」的另一种错配。
  231. func TestEnrichTokenInfo_WorkspacePlanTypeKeepsItsOwnExpiry(t *testing.T) {
  232. const workspaceAccountID = "workspace-b"
  233. workspaceExpiresAt := time.Now().Add(720 * time.Hour).UTC().Format(time.RFC3339)
  234. subscriptionCalls := 0
  235. server := newChatGPTBackendTestServer(t, chatGPTBackendTestServerConfig{
  236. accountsCheck: map[string]any{
  237. "accounts": map[string]any{
  238. workspaceAccountID: map[string]any{
  239. "account": map[string]any{
  240. "account_id": workspaceAccountID,
  241. "plan_type": "self_serve_business_usage_based",
  242. "is_default": true,
  243. },
  244. "entitlement": map[string]any{"expires_at": workspaceExpiresAt},
  245. },
  246. },
  247. },
  248. onSubscription: func(string) map[string]any {
  249. subscriptionCalls++
  250. return map[string]any{}
  251. },
  252. })
  253. defer server.Close()
  254. tokenInfo := &OpenAITokenInfo{
  255. AccessToken: "access-token",
  256. ChatGPTAccountID: "personal-account-a",
  257. OrganizationID: workspaceAccountID,
  258. // id_token 没带 chatgpt_plan_type
  259. }
  260. svc := &OpenAIOAuthService{privacyClientFactory: newTestPrivacyClientFactory()}
  261. svc.enrichTokenInfo(context.Background(), tokenInfo, "")
  262. require.Equal(t, "self_serve_business_usage_based", tokenInfo.PlanType)
  263. require.Equal(t, workspaceExpiresAt, tokenInfo.SubscriptionExpiresAt,
  264. "套餐与到期时间必须来自同一条记录")
  265. require.Zero(t, subscriptionCalls)
  266. }
  267. type chatGPTBackendTestServerConfig struct {
  268. accountsCheck map[string]any
  269. onSubscription func(accountID string) map[string]any
  270. }
  271. // newChatGPTBackendTestServer 同时接管 accounts/check 与 subscriptions 两个端点,
  272. // 并在 t.Cleanup 里还原包级 URL 变量。
  273. func newChatGPTBackendTestServer(t *testing.T, cfg chatGPTBackendTestServerConfig) *httptest.Server {
  274. t.Helper()
  275. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  276. w.Header().Set("Content-Type", "application/json")
  277. switch r.URL.Path {
  278. case "/backend-api/accounts/check/v4-2023-04-27":
  279. _ = json.NewEncoder(w).Encode(cfg.accountsCheck)
  280. case "/backend-api/subscriptions":
  281. body := map[string]any{}
  282. if cfg.onSubscription != nil {
  283. body = cfg.onSubscription(r.URL.Query().Get("account_id"))
  284. }
  285. _ = json.NewEncoder(w).Encode(body)
  286. default:
  287. w.WriteHeader(http.StatusNotFound)
  288. }
  289. }))
  290. oldAccounts, oldSubscriptions := chatGPTAccountsCheckURL, chatGPTSubscriptionsURL
  291. chatGPTAccountsCheckURL = server.URL + "/backend-api/accounts/check/v4-2023-04-27"
  292. chatGPTSubscriptionsURL = server.URL + "/backend-api/subscriptions"
  293. t.Cleanup(func() {
  294. chatGPTAccountsCheckURL, chatGPTSubscriptionsURL = oldAccounts, oldSubscriptions
  295. })
  296. return server
  297. }
  298. // enrichTokenInfo 收尾还会调用 disableOpenAITraining,它的 URL 是常量、指向真实
  299. // chatgpt.com,测试无法接管。给客户端一个短超时让它快速失败——该调用只写
  300. // PrivacyMode,不影响本组用例的断言。
  301. func newTestPrivacyClientFactory() PrivacyClientFactory {
  302. return func(string) (*req.Client, error) {
  303. return req.C().SetTimeout(time.Second), nil
  304. }
  305. }