oauth_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  1. package geminicli
  2. import (
  3. "encoding/hex"
  4. "strings"
  5. "sync"
  6. "testing"
  7. "time"
  8. )
  9. // ---------------------------------------------------------------------------
  10. // SessionStore 测试
  11. // ---------------------------------------------------------------------------
  12. func TestSessionStore_SetAndGet(t *testing.T) {
  13. store := NewSessionStore()
  14. defer store.Stop()
  15. session := &OAuthSession{
  16. State: "test-state",
  17. OAuthType: "code_assist",
  18. CreatedAt: time.Now(),
  19. }
  20. store.Set("sid-1", session)
  21. got, ok := store.Get("sid-1")
  22. if !ok {
  23. t.Fatal("期望 Get 返回 ok=true,实际返回 false")
  24. }
  25. if got.State != "test-state" {
  26. t.Errorf("期望 State=%q,实际=%q", "test-state", got.State)
  27. }
  28. }
  29. func TestSessionStore_GetNotFound(t *testing.T) {
  30. store := NewSessionStore()
  31. defer store.Stop()
  32. _, ok := store.Get("不存在的ID")
  33. if ok {
  34. t.Error("期望不存在的 sessionID 返回 ok=false")
  35. }
  36. }
  37. func TestSessionStore_GetExpired(t *testing.T) {
  38. store := NewSessionStore()
  39. defer store.Stop()
  40. // 创建一个已过期的 session(CreatedAt 设置为 SessionTTL+1 分钟之前)
  41. session := &OAuthSession{
  42. State: "expired-state",
  43. OAuthType: "code_assist",
  44. CreatedAt: time.Now().Add(-(SessionTTL + 1*time.Minute)),
  45. }
  46. store.Set("expired-sid", session)
  47. _, ok := store.Get("expired-sid")
  48. if ok {
  49. t.Error("期望过期的 session 返回 ok=false")
  50. }
  51. }
  52. func TestSessionStore_Delete(t *testing.T) {
  53. store := NewSessionStore()
  54. defer store.Stop()
  55. session := &OAuthSession{
  56. State: "to-delete",
  57. OAuthType: "code_assist",
  58. CreatedAt: time.Now(),
  59. }
  60. store.Set("del-sid", session)
  61. // 先确认存在
  62. if _, ok := store.Get("del-sid"); !ok {
  63. t.Fatal("删除前 session 应该存在")
  64. }
  65. store.Delete("del-sid")
  66. if _, ok := store.Get("del-sid"); ok {
  67. t.Error("删除后 session 不应该存在")
  68. }
  69. }
  70. func TestSessionStore_Stop_Idempotent(t *testing.T) {
  71. store := NewSessionStore()
  72. // 多次调用 Stop 不应 panic
  73. store.Stop()
  74. store.Stop()
  75. store.Stop()
  76. }
  77. func TestSessionStore_ConcurrentAccess(t *testing.T) {
  78. store := NewSessionStore()
  79. defer store.Stop()
  80. const goroutines = 50
  81. var wg sync.WaitGroup
  82. wg.Add(goroutines * 3)
  83. // 并发写入
  84. for i := 0; i < goroutines; i++ {
  85. go func(idx int) {
  86. defer wg.Done()
  87. sid := "concurrent-" + string(rune('A'+idx%26))
  88. store.Set(sid, &OAuthSession{
  89. State: sid,
  90. OAuthType: "code_assist",
  91. CreatedAt: time.Now(),
  92. })
  93. }(i)
  94. }
  95. // 并发读取
  96. for i := 0; i < goroutines; i++ {
  97. go func(idx int) {
  98. defer wg.Done()
  99. sid := "concurrent-" + string(rune('A'+idx%26))
  100. store.Get(sid) // 可能找到也可能没找到,关键是不 panic
  101. }(i)
  102. }
  103. // 并发删除
  104. for i := 0; i < goroutines; i++ {
  105. go func(idx int) {
  106. defer wg.Done()
  107. sid := "concurrent-" + string(rune('A'+idx%26))
  108. store.Delete(sid)
  109. }(i)
  110. }
  111. wg.Wait()
  112. }
  113. // ---------------------------------------------------------------------------
  114. // GenerateRandomBytes 测试
  115. // ---------------------------------------------------------------------------
  116. func TestGenerateRandomBytes(t *testing.T) {
  117. tests := []int{0, 1, 16, 32, 64}
  118. for _, n := range tests {
  119. b, err := GenerateRandomBytes(n)
  120. if err != nil {
  121. t.Errorf("GenerateRandomBytes(%d) 出错: %v", n, err)
  122. continue
  123. }
  124. if len(b) != n {
  125. t.Errorf("GenerateRandomBytes(%d) 返回长度=%d,期望=%d", n, len(b), n)
  126. }
  127. }
  128. }
  129. func TestGenerateRandomBytes_Uniqueness(t *testing.T) {
  130. // 两次调用应该返回不同的结果(极小概率相同,32字节足够)
  131. a, _ := GenerateRandomBytes(32)
  132. b, _ := GenerateRandomBytes(32)
  133. if string(a) == string(b) {
  134. t.Error("两次 GenerateRandomBytes(32) 返回了相同结果,随机性可能有问题")
  135. }
  136. }
  137. // ---------------------------------------------------------------------------
  138. // GenerateState 测试
  139. // ---------------------------------------------------------------------------
  140. func TestGenerateState(t *testing.T) {
  141. state, err := GenerateState()
  142. if err != nil {
  143. t.Fatalf("GenerateState() 出错: %v", err)
  144. }
  145. if state == "" {
  146. t.Error("GenerateState() 返回空字符串")
  147. }
  148. // base64url 编码不应包含 padding '='
  149. if strings.Contains(state, "=") {
  150. t.Errorf("GenerateState() 结果包含 '=' padding: %s", state)
  151. }
  152. // base64url 不应包含 '+' 或 '/'
  153. if strings.ContainsAny(state, "+/") {
  154. t.Errorf("GenerateState() 结果包含非 base64url 字符: %s", state)
  155. }
  156. }
  157. // ---------------------------------------------------------------------------
  158. // GenerateSessionID 测试
  159. // ---------------------------------------------------------------------------
  160. func TestGenerateSessionID(t *testing.T) {
  161. sid, err := GenerateSessionID()
  162. if err != nil {
  163. t.Fatalf("GenerateSessionID() 出错: %v", err)
  164. }
  165. // 16 字节 -> 32 个 hex 字符
  166. if len(sid) != 32 {
  167. t.Errorf("GenerateSessionID() 长度=%d,期望=32", len(sid))
  168. }
  169. // 必须是合法的 hex 字符串
  170. if _, err := hex.DecodeString(sid); err != nil {
  171. t.Errorf("GenerateSessionID() 不是合法的 hex 字符串: %s, err=%v", sid, err)
  172. }
  173. }
  174. func TestGenerateSessionID_Uniqueness(t *testing.T) {
  175. a, _ := GenerateSessionID()
  176. b, _ := GenerateSessionID()
  177. if a == b {
  178. t.Error("两次 GenerateSessionID() 返回了相同结果")
  179. }
  180. }
  181. // ---------------------------------------------------------------------------
  182. // GenerateCodeVerifier 测试
  183. // ---------------------------------------------------------------------------
  184. func TestGenerateCodeVerifier(t *testing.T) {
  185. verifier, err := GenerateCodeVerifier()
  186. if err != nil {
  187. t.Fatalf("GenerateCodeVerifier() 出错: %v", err)
  188. }
  189. if verifier == "" {
  190. t.Error("GenerateCodeVerifier() 返回空字符串")
  191. }
  192. // RFC 7636 要求 code_verifier 至少 43 个字符
  193. if len(verifier) < 43 {
  194. t.Errorf("GenerateCodeVerifier() 长度=%d,RFC 7636 要求至少 43 字符", len(verifier))
  195. }
  196. // base64url 编码不应包含 padding 和非 URL 安全字符
  197. if strings.Contains(verifier, "=") {
  198. t.Errorf("GenerateCodeVerifier() 包含 '=' padding: %s", verifier)
  199. }
  200. if strings.ContainsAny(verifier, "+/") {
  201. t.Errorf("GenerateCodeVerifier() 包含非 base64url 字符: %s", verifier)
  202. }
  203. }
  204. // ---------------------------------------------------------------------------
  205. // GenerateCodeChallenge 测试
  206. // ---------------------------------------------------------------------------
  207. func TestGenerateCodeChallenge(t *testing.T) {
  208. // 使用已知输入验证输出
  209. // RFC 7636 附录 B 示例: verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  210. // 预期 challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
  211. verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
  212. expected := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
  213. challenge := GenerateCodeChallenge(verifier)
  214. if challenge != expected {
  215. t.Errorf("GenerateCodeChallenge(%q) = %q,期望 %q", verifier, challenge, expected)
  216. }
  217. }
  218. func TestGenerateCodeChallenge_NoPadding(t *testing.T) {
  219. challenge := GenerateCodeChallenge("test-verifier-string")
  220. if strings.Contains(challenge, "=") {
  221. t.Errorf("GenerateCodeChallenge() 结果包含 '=' padding: %s", challenge)
  222. }
  223. }
  224. // ---------------------------------------------------------------------------
  225. // base64URLEncode 测试
  226. // ---------------------------------------------------------------------------
  227. func TestBase64URLEncode(t *testing.T) {
  228. tests := []struct {
  229. name string
  230. input []byte
  231. }{
  232. {"空字节", []byte{}},
  233. {"单字节", []byte{0xff}},
  234. {"多字节", []byte{0x01, 0x02, 0x03, 0x04, 0x05}},
  235. {"全零", []byte{0x00, 0x00, 0x00}},
  236. }
  237. for _, tt := range tests {
  238. t.Run(tt.name, func(t *testing.T) {
  239. result := base64URLEncode(tt.input)
  240. // 不应包含 '=' padding
  241. if strings.Contains(result, "=") {
  242. t.Errorf("base64URLEncode(%v) 包含 '=' padding: %s", tt.input, result)
  243. }
  244. // 不应包含标准 base64 的 '+' 或 '/'
  245. if strings.ContainsAny(result, "+/") {
  246. t.Errorf("base64URLEncode(%v) 包含非 URL 安全字符: %s", tt.input, result)
  247. }
  248. })
  249. }
  250. }
  251. // ---------------------------------------------------------------------------
  252. // hasRestrictedScope 测试
  253. // ---------------------------------------------------------------------------
  254. func TestHasRestrictedScope(t *testing.T) {
  255. tests := []struct {
  256. scope string
  257. expected bool
  258. }{
  259. // 受限 scope
  260. {"https://www.googleapis.com/auth/generative-language", true},
  261. {"https://www.googleapis.com/auth/generative-language.retriever", true},
  262. {"https://www.googleapis.com/auth/generative-language.tuning", true},
  263. {"https://www.googleapis.com/auth/drive", true},
  264. {"https://www.googleapis.com/auth/drive.readonly", true},
  265. {"https://www.googleapis.com/auth/drive.file", true},
  266. // 非受限 scope
  267. {"https://www.googleapis.com/auth/cloud-platform", false},
  268. {"https://www.googleapis.com/auth/userinfo.email", false},
  269. {"https://www.googleapis.com/auth/userinfo.profile", false},
  270. // 边界情况
  271. {"", false},
  272. {"random-scope", false},
  273. }
  274. for _, tt := range tests {
  275. t.Run(tt.scope, func(t *testing.T) {
  276. got := hasRestrictedScope(tt.scope)
  277. if got != tt.expected {
  278. t.Errorf("hasRestrictedScope(%q) = %v,期望 %v", tt.scope, got, tt.expected)
  279. }
  280. })
  281. }
  282. }
  283. // ---------------------------------------------------------------------------
  284. // BuildAuthorizationURL 测试
  285. // ---------------------------------------------------------------------------
  286. func TestBuildAuthorizationURL(t *testing.T) {
  287. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
  288. authURL, err := BuildAuthorizationURL(
  289. OAuthConfig{},
  290. "test-state",
  291. "test-challenge",
  292. "https://example.com/callback",
  293. "",
  294. "code_assist",
  295. )
  296. if err != nil {
  297. t.Fatalf("BuildAuthorizationURL() 出错: %v", err)
  298. }
  299. // 检查返回的 URL 包含期望的参数
  300. checks := []string{
  301. "response_type=code",
  302. "client_id=" + GeminiCLIOAuthClientID,
  303. "redirect_uri=",
  304. "state=test-state",
  305. "code_challenge=test-challenge",
  306. "code_challenge_method=S256",
  307. "access_type=offline",
  308. "prompt=consent",
  309. "include_granted_scopes=true",
  310. }
  311. for _, check := range checks {
  312. if !strings.Contains(authURL, check) {
  313. t.Errorf("BuildAuthorizationURL() URL 缺少参数 %q\nURL: %s", check, authURL)
  314. }
  315. }
  316. // 不应包含 project_id(因为传的是空字符串)
  317. if strings.Contains(authURL, "project_id=") {
  318. t.Errorf("BuildAuthorizationURL() 空 projectID 时不应包含 project_id 参数")
  319. }
  320. // URL 应该以正确的授权端点开头
  321. if !strings.HasPrefix(authURL, AuthorizeURL+"?") {
  322. t.Errorf("BuildAuthorizationURL() URL 应以 %s? 开头,实际: %s", AuthorizeURL, authURL)
  323. }
  324. }
  325. func TestBuildAuthorizationURL_EmptyRedirectURI(t *testing.T) {
  326. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
  327. _, err := BuildAuthorizationURL(
  328. OAuthConfig{},
  329. "test-state",
  330. "test-challenge",
  331. "", // 空 redirectURI
  332. "",
  333. "code_assist",
  334. )
  335. if err == nil {
  336. t.Error("BuildAuthorizationURL() 空 redirectURI 应该报错")
  337. }
  338. if !strings.Contains(err.Error(), "redirect_uri") {
  339. t.Errorf("错误消息应包含 'redirect_uri',实际: %v", err)
  340. }
  341. }
  342. func TestBuildAuthorizationURL_WithProjectID(t *testing.T) {
  343. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-secret")
  344. authURL, err := BuildAuthorizationURL(
  345. OAuthConfig{},
  346. "test-state",
  347. "test-challenge",
  348. "https://example.com/callback",
  349. "my-project-123",
  350. "code_assist",
  351. )
  352. if err != nil {
  353. t.Fatalf("BuildAuthorizationURL() 出错: %v", err)
  354. }
  355. if !strings.Contains(authURL, "project_id=my-project-123") {
  356. t.Errorf("BuildAuthorizationURL() 带 projectID 时应包含 project_id 参数\nURL: %s", authURL)
  357. }
  358. }
  359. func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
  360. t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
  361. authURL, err := BuildAuthorizationURL(
  362. OAuthConfig{},
  363. "test-state",
  364. "test-challenge",
  365. "https://example.com/callback",
  366. "",
  367. "code_assist",
  368. )
  369. if err != nil {
  370. t.Fatalf("BuildAuthorizationURL() 不应报错: %v", err)
  371. }
  372. if !strings.Contains(authURL, "client_id="+GeminiCLIOAuthClientID) {
  373. t.Errorf("应使用内置 Gemini CLI client_id,实际 URL: %s", authURL)
  374. }
  375. }
  376. // ---------------------------------------------------------------------------
  377. // EffectiveOAuthConfig 测试 - 原有测试
  378. // ---------------------------------------------------------------------------
  379. func TestEffectiveOAuthConfig_GoogleOne(t *testing.T) {
  380. // 内置的 Gemini CLI client secret 不嵌入在此仓库中。
  381. // 测试通过环境变量设置一个假的 secret 来模拟运维配置。
  382. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  383. tests := []struct {
  384. name string
  385. input OAuthConfig
  386. oauthType string
  387. wantClientID string
  388. wantScopes string
  389. wantErr bool
  390. }{
  391. {
  392. name: "Google One 使用内置客户端(空配置)",
  393. input: OAuthConfig{},
  394. oauthType: "google_one",
  395. wantClientID: GeminiCLIOAuthClientID,
  396. wantScopes: DefaultCodeAssistScopes,
  397. wantErr: false,
  398. },
  399. {
  400. name: "Google One 使用自定义客户端(传入自定义凭据时使用自定义)",
  401. input: OAuthConfig{
  402. ClientID: "custom-client-id",
  403. ClientSecret: "custom-client-secret",
  404. },
  405. oauthType: "google_one",
  406. wantClientID: "custom-client-id",
  407. wantScopes: DefaultCodeAssistScopes,
  408. wantErr: false,
  409. },
  410. {
  411. name: "Google One 内置客户端 + 自定义 scopes(应过滤受限 scopes)",
  412. input: OAuthConfig{
  413. Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
  414. },
  415. oauthType: "google_one",
  416. wantClientID: GeminiCLIOAuthClientID,
  417. wantScopes: "https://www.googleapis.com/auth/cloud-platform",
  418. wantErr: false,
  419. },
  420. {
  421. name: "Google One 内置客户端 + 仅受限 scopes(应回退到默认)",
  422. input: OAuthConfig{
  423. Scopes: "https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
  424. },
  425. oauthType: "google_one",
  426. wantClientID: GeminiCLIOAuthClientID,
  427. wantScopes: DefaultCodeAssistScopes,
  428. wantErr: false,
  429. },
  430. {
  431. name: "Code Assist 使用内置客户端",
  432. input: OAuthConfig{},
  433. oauthType: "code_assist",
  434. wantClientID: GeminiCLIOAuthClientID,
  435. wantScopes: DefaultCodeAssistScopes,
  436. wantErr: false,
  437. },
  438. }
  439. for _, tt := range tests {
  440. t.Run(tt.name, func(t *testing.T) {
  441. got, err := EffectiveOAuthConfig(tt.input, tt.oauthType)
  442. if (err != nil) != tt.wantErr {
  443. t.Errorf("EffectiveOAuthConfig() error = %v, wantErr %v", err, tt.wantErr)
  444. return
  445. }
  446. if err != nil {
  447. return
  448. }
  449. if got.ClientID != tt.wantClientID {
  450. t.Errorf("EffectiveOAuthConfig() ClientID = %v, want %v", got.ClientID, tt.wantClientID)
  451. }
  452. if got.Scopes != tt.wantScopes {
  453. t.Errorf("EffectiveOAuthConfig() Scopes = %v, want %v", got.Scopes, tt.wantScopes)
  454. }
  455. })
  456. }
  457. }
  458. func TestEffectiveOAuthConfig_ScopeFiltering(t *testing.T) {
  459. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  460. // 测试 Google One + 内置客户端过滤受限 scopes
  461. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  462. Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/userinfo.profile",
  463. }, "google_one")
  464. if err != nil {
  465. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  466. }
  467. // 应仅包含 cloud-platform、userinfo.email 和 userinfo.profile
  468. // 不应包含 generative-language 或 drive scopes
  469. if strings.Contains(cfg.Scopes, "generative-language") {
  470. t.Errorf("使用内置客户端时 Scopes 不应包含 generative-language,实际: %v", cfg.Scopes)
  471. }
  472. if strings.Contains(cfg.Scopes, "drive") {
  473. t.Errorf("使用内置客户端时 Scopes 不应包含 drive,实际: %v", cfg.Scopes)
  474. }
  475. if !strings.Contains(cfg.Scopes, "cloud-platform") {
  476. t.Errorf("Scopes 应包含 cloud-platform,实际: %v", cfg.Scopes)
  477. }
  478. if !strings.Contains(cfg.Scopes, "userinfo.email") {
  479. t.Errorf("Scopes 应包含 userinfo.email,实际: %v", cfg.Scopes)
  480. }
  481. if !strings.Contains(cfg.Scopes, "userinfo.profile") {
  482. t.Errorf("Scopes 应包含 userinfo.profile,实际: %v", cfg.Scopes)
  483. }
  484. }
  485. // ---------------------------------------------------------------------------
  486. // EffectiveOAuthConfig 测试 - 新增分支覆盖
  487. // ---------------------------------------------------------------------------
  488. func TestEffectiveOAuthConfig_OnlyClientID_NoSecret(t *testing.T) {
  489. // 只提供 clientID 不提供 secret 应报错
  490. _, err := EffectiveOAuthConfig(OAuthConfig{
  491. ClientID: "some-client-id",
  492. }, "code_assist")
  493. if err == nil {
  494. t.Error("只提供 ClientID 不提供 ClientSecret 应该报错")
  495. }
  496. if !strings.Contains(err.Error(), "client_id") || !strings.Contains(err.Error(), "client_secret") {
  497. t.Errorf("错误消息应提及 client_id 和 client_secret,实际: %v", err)
  498. }
  499. }
  500. func TestEffectiveOAuthConfig_OnlyClientSecret_NoID(t *testing.T) {
  501. // 只提供 secret 不提供 clientID 应报错
  502. _, err := EffectiveOAuthConfig(OAuthConfig{
  503. ClientSecret: "some-client-secret",
  504. }, "code_assist")
  505. if err == nil {
  506. t.Error("只提供 ClientSecret 不提供 ClientID 应该报错")
  507. }
  508. if !strings.Contains(err.Error(), "client_id") || !strings.Contains(err.Error(), "client_secret") {
  509. t.Errorf("错误消息应提及 client_id 和 client_secret,实际: %v", err)
  510. }
  511. }
  512. func TestEffectiveOAuthConfig_AIStudio_DefaultScopes_BuiltinClient(t *testing.T) {
  513. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  514. // ai_studio 类型,使用内置客户端,scopes 为空 -> 应使用 DefaultCodeAssistScopes(因为内置客户端不能请求 generative-language scope)
  515. cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "ai_studio")
  516. if err != nil {
  517. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  518. }
  519. if cfg.Scopes != DefaultCodeAssistScopes {
  520. t.Errorf("ai_studio + 内置客户端应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
  521. }
  522. }
  523. func TestEffectiveOAuthConfig_AIStudio_DefaultScopes_CustomClient(t *testing.T) {
  524. // ai_studio 类型,使用自定义客户端,scopes 为空 -> 应使用 DefaultAIStudioScopes
  525. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  526. ClientID: "custom-id",
  527. ClientSecret: "custom-secret",
  528. }, "ai_studio")
  529. if err != nil {
  530. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  531. }
  532. if cfg.Scopes != DefaultAIStudioScopes {
  533. t.Errorf("ai_studio + 自定义客户端应使用 DefaultAIStudioScopes,实际: %q", cfg.Scopes)
  534. }
  535. }
  536. func TestEffectiveOAuthConfig_AIStudio_ScopeNormalization(t *testing.T) {
  537. // ai_studio 类型,旧的 generative-language scope 应被归一化为 generative-language.retriever
  538. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  539. ClientID: "custom-id",
  540. ClientSecret: "custom-secret",
  541. Scopes: "https://www.googleapis.com/auth/generative-language https://www.googleapis.com/auth/cloud-platform",
  542. }, "ai_studio")
  543. if err != nil {
  544. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  545. }
  546. if strings.Contains(cfg.Scopes, "auth/generative-language ") || strings.HasSuffix(cfg.Scopes, "auth/generative-language") {
  547. // 确保不包含未归一化的旧 scope(仅 generative-language 而非 generative-language.retriever)
  548. parts := strings.Fields(cfg.Scopes)
  549. for _, p := range parts {
  550. if p == "https://www.googleapis.com/auth/generative-language" {
  551. t.Errorf("ai_studio 应将 generative-language 归一化为 generative-language.retriever,实际 scopes: %q", cfg.Scopes)
  552. }
  553. }
  554. }
  555. if !strings.Contains(cfg.Scopes, "generative-language.retriever") {
  556. t.Errorf("ai_studio 归一化后应包含 generative-language.retriever,实际: %q", cfg.Scopes)
  557. }
  558. }
  559. func TestEffectiveOAuthConfig_CommaSeparatedScopes(t *testing.T) {
  560. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  561. // 逗号分隔的 scopes 应被归一化为空格分隔
  562. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  563. ClientID: "custom-id",
  564. ClientSecret: "custom-secret",
  565. Scopes: "https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email",
  566. }, "code_assist")
  567. if err != nil {
  568. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  569. }
  570. // 应该用空格分隔,而非逗号
  571. if strings.Contains(cfg.Scopes, ",") {
  572. t.Errorf("逗号分隔的 scopes 应被归一化为空格分隔,实际: %q", cfg.Scopes)
  573. }
  574. if !strings.Contains(cfg.Scopes, "cloud-platform") {
  575. t.Errorf("归一化后应包含 cloud-platform,实际: %q", cfg.Scopes)
  576. }
  577. if !strings.Contains(cfg.Scopes, "userinfo.email") {
  578. t.Errorf("归一化后应包含 userinfo.email,实际: %q", cfg.Scopes)
  579. }
  580. }
  581. func TestEffectiveOAuthConfig_MixedCommaAndSpaceScopes(t *testing.T) {
  582. // 混合逗号和空格分隔的 scopes
  583. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  584. ClientID: "custom-id",
  585. ClientSecret: "custom-secret",
  586. Scopes: "https://www.googleapis.com/auth/cloud-platform, https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile",
  587. }, "code_assist")
  588. if err != nil {
  589. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  590. }
  591. parts := strings.Fields(cfg.Scopes)
  592. if len(parts) != 3 {
  593. t.Errorf("归一化后应有 3 个 scope,实际: %d,scopes: %q", len(parts), cfg.Scopes)
  594. }
  595. }
  596. func TestEffectiveOAuthConfig_WhitespaceTriming(t *testing.T) {
  597. // 输入中的前后空白应被清理
  598. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  599. ClientID: " custom-id ",
  600. ClientSecret: " custom-secret ",
  601. Scopes: " https://www.googleapis.com/auth/cloud-platform ",
  602. }, "code_assist")
  603. if err != nil {
  604. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  605. }
  606. if cfg.ClientID != "custom-id" {
  607. t.Errorf("ClientID 应去除前后空白,实际: %q", cfg.ClientID)
  608. }
  609. if cfg.ClientSecret != "custom-secret" {
  610. t.Errorf("ClientSecret 应去除前后空白,实际: %q", cfg.ClientSecret)
  611. }
  612. if cfg.Scopes != "https://www.googleapis.com/auth/cloud-platform" {
  613. t.Errorf("Scopes 应去除前后空白,实际: %q", cfg.Scopes)
  614. }
  615. }
  616. func TestEffectiveOAuthConfig_NoEnvSecret(t *testing.T) {
  617. t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
  618. cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
  619. if err != nil {
  620. t.Fatalf("不设置环境变量时应回退到内置 secret,实际报错: %v", err)
  621. }
  622. if strings.TrimSpace(cfg.ClientSecret) == "" {
  623. t.Error("ClientSecret 不应为空")
  624. }
  625. if cfg.ClientID != GeminiCLIOAuthClientID {
  626. t.Errorf("ClientID 应回退为内置客户端 ID,实际: %q", cfg.ClientID)
  627. }
  628. }
  629. func TestEffectiveOAuthConfig_AIStudio_BuiltinClient_CustomScopes(t *testing.T) {
  630. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  631. // ai_studio + 内置客户端 + 自定义 scopes -> 应过滤受限 scopes
  632. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  633. Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever",
  634. }, "ai_studio")
  635. if err != nil {
  636. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  637. }
  638. // 内置客户端应过滤 generative-language.retriever
  639. if strings.Contains(cfg.Scopes, "generative-language") {
  640. t.Errorf("ai_studio + 内置客户端应过滤受限 scopes,实际: %q", cfg.Scopes)
  641. }
  642. if !strings.Contains(cfg.Scopes, "cloud-platform") {
  643. t.Errorf("应保留 cloud-platform scope,实际: %q", cfg.Scopes)
  644. }
  645. }
  646. func TestEffectiveOAuthConfig_UnknownOAuthType_DefaultScopes(t *testing.T) {
  647. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  648. // 未知的 oauthType 应回退到默认的 code_assist scopes
  649. cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "unknown_type")
  650. if err != nil {
  651. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  652. }
  653. if cfg.Scopes != DefaultCodeAssistScopes {
  654. t.Errorf("未知 oauthType 应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
  655. }
  656. }
  657. func TestEffectiveOAuthConfig_EmptyOAuthType_DefaultScopes(t *testing.T) {
  658. t.Setenv(GeminiCLIOAuthClientSecretEnv, "test-built-in-secret")
  659. // 空的 oauthType 应走 default 分支,使用 DefaultCodeAssistScopes
  660. cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "")
  661. if err != nil {
  662. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  663. }
  664. if cfg.Scopes != DefaultCodeAssistScopes {
  665. t.Errorf("空 oauthType 应使用 DefaultCodeAssistScopes,实际: %q", cfg.Scopes)
  666. }
  667. }
  668. func TestEffectiveOAuthConfig_CustomClient_NoScopeFiltering(t *testing.T) {
  669. // 自定义客户端 + google_one + 包含受限 scopes -> 不应被过滤(因为不是内置客户端)
  670. cfg, err := EffectiveOAuthConfig(OAuthConfig{
  671. ClientID: "custom-id",
  672. ClientSecret: "custom-secret",
  673. Scopes: "https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/drive.readonly",
  674. }, "google_one")
  675. if err != nil {
  676. t.Fatalf("EffectiveOAuthConfig() error = %v", err)
  677. }
  678. // 自定义客户端不应过滤任何 scope
  679. if !strings.Contains(cfg.Scopes, "generative-language.retriever") {
  680. t.Errorf("自定义客户端不应过滤 generative-language.retriever,实际: %q", cfg.Scopes)
  681. }
  682. if !strings.Contains(cfg.Scopes, "drive.readonly") {
  683. t.Errorf("自定义客户端不应过滤 drive.readonly,实际: %q", cfg.Scopes)
  684. }
  685. }