oauth_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. //go:build unit
  2. package xai
  3. import (
  4. "net/url"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
  7. "github.com/stretchr/testify/require"
  8. )
  9. func TestParseAuthorizationInput(t *testing.T) {
  10. t.Parallel()
  11. tests := []struct {
  12. name string
  13. raw string
  14. wantCode string
  15. wantState string
  16. wantRequiresState bool
  17. }{
  18. {
  19. name: "full callback url",
  20. raw: "http://127.0.0.1:56121/callback?code=abc123&state=state456",
  21. wantCode: "abc123",
  22. wantState: "state456",
  23. wantRequiresState: true,
  24. },
  25. {
  26. name: "query string",
  27. raw: "?code=abc123&state=state456",
  28. wantCode: "abc123",
  29. wantState: "state456",
  30. wantRequiresState: true,
  31. },
  32. {
  33. name: "full callback url missing state",
  34. raw: "http://127.0.0.1:56121/callback?code=abc123",
  35. wantCode: "abc123",
  36. wantRequiresState: true,
  37. },
  38. {
  39. name: "query string missing state",
  40. raw: "code=abc123",
  41. wantCode: "abc123",
  42. wantRequiresState: true,
  43. },
  44. {
  45. name: "bare code",
  46. raw: "abc123",
  47. wantCode: "abc123",
  48. },
  49. }
  50. for _, tt := range tests {
  51. t.Run(tt.name, func(t *testing.T) {
  52. t.Parallel()
  53. got := ParseAuthorizationInput(tt.raw)
  54. require.Equal(t, tt.wantCode, got.Code)
  55. require.Equal(t, tt.wantState, got.State)
  56. require.Equal(t, tt.wantRequiresState, got.RequiresState)
  57. })
  58. }
  59. }
  60. func TestBuildAuthorizationURLIncludesHermesCompatibleParameters(t *testing.T) {
  61. t.Setenv(EnvAuthorizeURL, "https://auth.example.test/oauth2/authorize")
  62. t.Setenv(EnvClientID, "client-id")
  63. t.Setenv(EnvScope, "openid profile offline_access api:access")
  64. t.Setenv(EnvAllowUnsafeURLOverrides, "true")
  65. authURL, err := BuildAuthorizationURL("state", "challenge", "http://127.0.0.1:56121/callback", "nonce")
  66. require.NoError(t, err)
  67. parsed, err := url.Parse(authURL)
  68. require.NoError(t, err)
  69. values := parsed.Query()
  70. require.Equal(t, "https", parsed.Scheme)
  71. require.Equal(t, "auth.example.test", parsed.Host)
  72. require.Equal(t, "/oauth2/authorize", parsed.Path)
  73. require.Equal(t, "code", values.Get("response_type"))
  74. require.Equal(t, "client-id", values.Get("client_id"))
  75. require.Equal(t, "http://127.0.0.1:56121/callback", values.Get("redirect_uri"))
  76. require.Equal(t, "openid profile offline_access api:access", values.Get("scope"))
  77. require.Equal(t, "state", values.Get("state"))
  78. require.Equal(t, "nonce", values.Get("nonce"))
  79. require.Equal(t, "challenge", values.Get("code_challenge"))
  80. require.Equal(t, "S256", values.Get("code_challenge_method"))
  81. require.Equal(t, "generic", values.Get("plan"))
  82. require.Equal(t, "sub2api", values.Get("referrer"))
  83. }
  84. func TestValidateXAIURLsAllowOfficialOAuthAndGatewayHosts(t *testing.T) {
  85. authorizeURL, err := ValidateOAuthEndpointURL(DefaultAuthorizeURL)
  86. require.NoError(t, err)
  87. require.Equal(t, DefaultAuthorizeURL, authorizeURL)
  88. tokenURL, err := ValidateOAuthEndpointURL(DefaultTokenURL)
  89. require.NoError(t, err)
  90. require.Equal(t, DefaultTokenURL, tokenURL)
  91. baseURL, err := ValidateBaseURL(DefaultBaseURL)
  92. require.NoError(t, err)
  93. require.Equal(t, DefaultBaseURL, baseURL)
  94. cliBaseURL, err := ValidateBaseURL(DefaultCLIBaseURL)
  95. require.NoError(t, err)
  96. require.Equal(t, DefaultCLIBaseURL, cliBaseURL)
  97. baseURLNoPath, err := ValidateBaseURL("https://api.x.ai")
  98. require.NoError(t, err)
  99. require.Equal(t, DefaultBaseURL, baseURLNoPath)
  100. chatURL, err := BuildChatCompletionsURL(DefaultCLIBaseURL + "/")
  101. require.NoError(t, err)
  102. require.Equal(t, DefaultCLIBaseURL+"/chat/completions", chatURL)
  103. }
  104. func TestBuildGrokMediaURLs(t *testing.T) {
  105. imagesURL, err := BuildImagesGenerationsURL(DefaultBaseURL + "/")
  106. require.NoError(t, err)
  107. require.Equal(t, DefaultBaseURL+"/images/generations", imagesURL)
  108. editsURL, err := BuildImagesEditsURL(DefaultBaseURL)
  109. require.NoError(t, err)
  110. require.Equal(t, DefaultBaseURL+"/images/edits", editsURL)
  111. videosURL, err := BuildVideosGenerationsURL(DefaultBaseURL)
  112. require.NoError(t, err)
  113. require.Equal(t, DefaultBaseURL+"/videos/generations", videosURL)
  114. videoEditsURL, err := BuildVideosEditsURL(DefaultBaseURL)
  115. require.NoError(t, err)
  116. require.Equal(t, DefaultBaseURL+"/videos/edits", videoEditsURL)
  117. videoExtensionsURL, err := BuildVideosExtensionsURL(DefaultBaseURL)
  118. require.NoError(t, err)
  119. require.Equal(t, DefaultBaseURL+"/videos/extensions", videoExtensionsURL)
  120. videoURL, err := BuildVideoURL(DefaultBaseURL, "req 123")
  121. require.NoError(t, err)
  122. require.Equal(t, DefaultBaseURL+"/videos/req%20123", videoURL)
  123. _, err = BuildVideoURL(DefaultBaseURL, " ")
  124. require.Error(t, err)
  125. }
  126. func TestValidateXAIURLsRejectUntrustedOAuthAndUnsafeBaseURLsByDefault(t *testing.T) {
  127. _, err := ValidateOAuthEndpointURL("https://auth.example.test/oauth2/token")
  128. require.Error(t, err)
  129. _, err = ValidateBaseURL("http://127.0.0.1:8080/v1")
  130. require.Error(t, err)
  131. _, err = ValidateBaseURL("https://api.x.ai/custom")
  132. require.Error(t, err)
  133. }
  134. func TestValidateBaseURLAllowsPublicThirdPartyGrokAPI(t *testing.T) {
  135. baseURL, err := ValidateBaseURL("https://grok.example.test/v1/")
  136. require.NoError(t, err)
  137. require.Equal(t, "https://grok.example.test/v1", baseURL)
  138. _, err = ValidateTrustedBaseURL("https://grok.example.test/v1")
  139. require.Error(t, err)
  140. }
  141. func TestValidateBaseURLPathPrefixPolicy(t *testing.T) {
  142. // 非官方主机保留管理员配置的任意 path 前缀。
  143. prefixed, err := ValidateBaseURL("https://relay.example.test/xai/v1/")
  144. require.NoError(t, err)
  145. require.Equal(t, "https://relay.example.test/xai/v1", prefixed)
  146. deepPrefixed, err := ValidateBaseURL("https://relay.example.test/tenant-a/proxy")
  147. require.NoError(t, err)
  148. require.Equal(t, "https://relay.example.test/tenant-a/proxy", deepPrefixed)
  149. // 空 path 仍按惯例补 /v1,保持既有配置兼容。
  150. rootOnly, err := ValidateBaseURL("https://relay.example.test")
  151. require.NoError(t, err)
  152. require.Equal(t, "https://relay.example.test/v1", rootOnly)
  153. // 官方主机固定 /v1 前缀。
  154. _, err = ValidateBaseURL("https://api.x.ai/xai/v1")
  155. require.Error(t, err)
  156. _, err = ValidateBaseURL("https://cli-chat-proxy.grok.com/other")
  157. require.Error(t, err)
  158. }
  159. func TestIsOfficialBaseURL(t *testing.T) {
  160. official := []string{
  161. "",
  162. " ",
  163. DefaultBaseURL,
  164. DefaultCLIBaseURL,
  165. "https://api.x.ai",
  166. "HTTPS://API.X.AI:443/",
  167. "https://api.x.ai:0443/v1",
  168. "https://api.x.ai/%76%31",
  169. "https://api.x.ai:8443/v1",
  170. "HTTPS://CLI-CHAT-PROXY.GROK.COM:443/%76%31/",
  171. "::invalid::url", // 无法解析的值按官方处理,回落默认端点
  172. }
  173. for _, raw := range official {
  174. require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw)
  175. }
  176. custom := []string{
  177. "https://relay.example.test/v1",
  178. "https://relay.example.test/xai/v1",
  179. "http://relay.example.test/v1",
  180. "https://grok.com.evil.example.test/v1",
  181. "https://api.x.ai.evil.example.test/v1", // 后缀伪装不属于 *.api.x.ai
  182. }
  183. for _, raw := range custom {
  184. require.False(t, IsOfficialBaseURL(raw), "expected custom: %q", raw)
  185. }
  186. }
  187. func TestRegionalAPIEndpointsAreOfficialAndTrusted(t *testing.T) {
  188. regional := []string{
  189. "https://us-east-1.api.x.ai/v1",
  190. "https://us-west-2.api.x.ai/v1",
  191. "https://eu-west-1.api.x.ai/v1",
  192. }
  193. for _, raw := range regional {
  194. require.True(t, IsOfficialBaseURL(raw), "expected official: %q", raw)
  195. validated, err := ValidateTrustedBaseURL(raw)
  196. require.NoError(t, err, "trusted validation should accept regional endpoint %q", raw)
  197. require.Equal(t, raw, validated)
  198. }
  199. // 区域端点作为官方主机同样强制 /v1 path
  200. _, err := ValidateTrustedBaseURL("https://us-east-1.api.x.ai/other")
  201. require.Error(t, err)
  202. }
  203. func TestValidateBaseURLsRejectEmptyQueryDelimiter(t *testing.T) {
  204. _, err := ValidateBaseURL("https://grok.example.test/v1?")
  205. require.Error(t, err)
  206. _, err = ValidateTrustedBaseURL("https://api.x.ai/v1?")
  207. require.Error(t, err)
  208. }
  209. func TestBuildResponsesURLWithValidatorUsesCallerPolicy(t *testing.T) {
  210. validator := func(raw string) (string, error) {
  211. return urlvalidator.ValidateURLFormat(raw, true)
  212. }
  213. target, err := BuildResponsesURLWithValidator("http://grok.example.test/v1/", validator)
  214. require.NoError(t, err)
  215. require.Equal(t, "http://grok.example.test/v1/responses", target)
  216. }
  217. func TestValidateTrustedBaseURLAcceptsOfficialRegionalHosts(t *testing.T) {
  218. for _, raw := range []string{DefaultUSEast1BaseURL, DefaultUSWest2BaseURL, DefaultEUWest1BaseURL} {
  219. got, err := ValidateTrustedBaseURL(raw)
  220. require.NoError(t, err, raw)
  221. require.Equal(t, raw, got)
  222. }
  223. }
  224. func TestBuildResponsesURLPreservesUnsafeOverrideCustomPath(t *testing.T) {
  225. t.Setenv(EnvAllowUnsafeURLOverrides, "true")
  226. target, err := BuildResponsesURL("http://localhost:8080/custom")
  227. require.NoError(t, err)
  228. require.Equal(t, "http://localhost:8080/custom/responses", target)
  229. }
  230. func TestBuildResponsesURLWithValidatorRejectsBaseURLComponents(t *testing.T) {
  231. permissive := func(raw string) (string, error) { return raw, nil }
  232. tests := []struct {
  233. name string
  234. raw string
  235. }{
  236. {name: "userinfo", raw: "https://user:secret@grok.example.test/v1"},
  237. {name: "query", raw: "https://grok.example.test/v1?token=secret"},
  238. {name: "empty query delimiter", raw: "https://grok.example.test/v1?"},
  239. {name: "fragment", raw: "https://grok.example.test/v1#secret"},
  240. }
  241. for _, tt := range tests {
  242. t.Run(tt.name, func(t *testing.T) {
  243. _, err := BuildResponsesURLWithValidator(tt.raw, permissive)
  244. require.Error(t, err)
  245. require.NotContains(t, err.Error(), "secret")
  246. })
  247. }
  248. }
  249. func TestValidateXAIURLsAllowUnsafeDevOverride(t *testing.T) {
  250. t.Setenv(EnvAllowUnsafeURLOverrides, "true")
  251. tokenURL, err := ValidateOAuthEndpointURL("http://127.0.0.1:8080/oauth2/token")
  252. require.NoError(t, err)
  253. require.Equal(t, "http://127.0.0.1:8080/oauth2/token", tokenURL)
  254. baseURL, err := ValidateBaseURL("http://127.0.0.1:8080/v1/")
  255. require.NoError(t, err)
  256. require.Equal(t, "http://127.0.0.1:8080/v1", baseURL)
  257. }
  258. func TestRuntimeSanityReportsSafeDefaults(t *testing.T) {
  259. t.Setenv(EnvBaseURL, "")
  260. t.Setenv(EnvAuthorizeURL, "")
  261. t.Setenv(EnvTokenURL, "")
  262. t.Setenv(EnvRedirectURI, "")
  263. t.Setenv(EnvAllowUnsafeURLOverrides, "")
  264. t.Setenv(EnvUnsafeAllowHighConcurrency, "")
  265. report := RuntimeSanity()
  266. require.True(t, report.BaseURL.Valid)
  267. require.Equal(t, DefaultBaseURL, report.BaseURL.Value)
  268. require.True(t, report.BaseURL.IsDefault)
  269. require.True(t, report.OAuthAuthorizeURL.Valid)
  270. require.True(t, report.OAuthTokenURL.Valid)
  271. require.True(t, report.OAuthRedirectURI.Valid)
  272. require.False(t, report.UnsafeURLOverrides)
  273. require.False(t, report.UnsafeHighConcurrency)
  274. require.Equal(t, "responses_only", report.PublicGatewayScope)
  275. require.Contains(t, report.ProxyPolicy, "account_proxy_optional")
  276. require.Contains(t, report.ProxyPolicy, "API-key base URLs require public HTTPS")
  277. }
  278. func TestRuntimeSanityReportsInvalidOverridesWithoutSecrets(t *testing.T) {
  279. t.Setenv(EnvBaseURL, "http://127.0.0.1:8080/v1?access_token=secret")
  280. t.Setenv(EnvAuthorizeURL, "https://auth.example.test/oauth2/authorize")
  281. t.Setenv(EnvTokenURL, "https://auth.example.test/oauth2/token")
  282. t.Setenv(EnvRedirectURI, "not a url")
  283. t.Setenv(EnvClientID, "client-secret-like-value")
  284. t.Setenv(EnvAllowUnsafeURLOverrides, "")
  285. report := RuntimeSanity()
  286. require.False(t, report.BaseURL.Valid)
  287. require.False(t, report.BaseURL.IsDefault)
  288. require.Contains(t, report.BaseURL.Error, "invalid url")
  289. require.NotContains(t, report.BaseURL.Value, "secret")
  290. require.False(t, report.OAuthAuthorizeURL.Valid)
  291. require.False(t, report.OAuthTokenURL.Valid)
  292. require.False(t, report.OAuthRedirectURI.Valid)
  293. require.NotContains(t, report.ProxyPolicy, "client-secret-like-value")
  294. }
  295. func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
  296. original := RuntimeModelMappingOptions()
  297. t.Cleanup(func() { SetRuntimeModelMappingOptions(original) })
  298. SetRuntimeModelMappingOptions(ModelMappingOptions{})
  299. mapping := DefaultModelMapping()
  300. require.Equal(t, "grok-4.6", mapping["grok"])
  301. require.Equal(t, "grok-4.6", mapping["grok-latest"])
  302. require.Equal(t, "grok-4.6", mapping["grok-4.6"])
  303. require.Equal(t, "grok-4.6", mapping["grok-4.6-latest"])
  304. require.Equal(t, "grok-4.5", mapping["grok-4.5"])
  305. require.Equal(t, "grok-4.5", mapping["grok-4.5-latest"])
  306. require.Equal(t, "grok-build-0.1", mapping["grok-build"])
  307. require.Equal(t, "grok-build-0.1", mapping["grok-build-latest"])
  308. require.Equal(t, "grok-composer-2.5-fast", mapping["grok-composer"])
  309. require.Equal(t, "grok-composer-2.5-fast", mapping["composer-2.5"])
  310. require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"])
  311. require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"])
  312. require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"])
  313. require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine"])
  314. require.Equal(t, DefaultImagineImageFastModel, mapping["grok-imagine-image"])
  315. require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"])
  316. require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"])
  317. require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"])
  318. require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5"])
  319. require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
  320. _, hasGPT := mapping["gpt-*"]
  321. require.False(t, hasGPT, "cross-client wildcards must be opt-in")
  322. }