snowflake-cortex.test.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import { describe, expect, test } from "bun:test"
  2. import { OAUTH_DUMMY_KEY } from "../../src/auth"
  3. import { oauthScope, SnowflakeCortexAuthPlugin } from "../../src/plugin/snowflake-cortex"
  4. function makeInput() {
  5. let auth: any = {
  6. type: "oauth",
  7. access: "access-old",
  8. refresh: "refresh-old",
  9. expires: Date.now() + 3600_000,
  10. accountId: "myorg-myaccount",
  11. }
  12. const setCalls: Array<Record<string, unknown>> = []
  13. return {
  14. getAuth: async () => auth,
  15. setAuth: (next: any) => {
  16. auth = next
  17. },
  18. input: {
  19. client: {
  20. auth: {
  21. set: async (request: any) => {
  22. setCalls.push(request)
  23. auth = request.body
  24. },
  25. },
  26. },
  27. } as any,
  28. setCalls,
  29. }
  30. }
  31. describe("plugin.snowflake-cortex", () => {
  32. test("oauthScope uses Snowflake-compatible scope values", () => {
  33. expect(oauthScope(undefined)).toBe("refresh_token")
  34. expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
  35. expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
  36. })
  37. test("loader returns empty options when auth is not oauth", async () => {
  38. const hooks = await SnowflakeCortexAuthPlugin({} as any)
  39. const options = await hooks.auth!.loader!(async () => ({ type: "api", key: "token" }) as any, {} as any)
  40. expect(options).toEqual({})
  41. })
  42. test("loader injects bearer header and preserves custom headers", async () => {
  43. const { input, getAuth, setAuth } = makeInput()
  44. setAuth({
  45. type: "oauth",
  46. access: "access-live",
  47. refresh: "refresh-live",
  48. expires: Date.now() + 60 * 60 * 1000,
  49. accountId: "myorg-myaccount",
  50. })
  51. const hooks = await SnowflakeCortexAuthPlugin(input)
  52. const options = await hooks.auth!.loader!(getAuth as any, {} as any)
  53. expect(options.apiKey).toBe(OAUTH_DUMMY_KEY)
  54. const originalFetch = globalThis.fetch
  55. const captured: Headers[] = []
  56. globalThis.fetch = (async (_request, init) => {
  57. captured.push(new Headers(init?.headers))
  58. return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
  59. }) as typeof fetch
  60. try {
  61. await options.fetch("https://example.test/v1/chat", {
  62. headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
  63. })
  64. } finally {
  65. globalThis.fetch = originalFetch
  66. }
  67. expect(captured).toHaveLength(1)
  68. expect(captured[0].get("authorization")).toBe("Bearer access-live")
  69. expect(captured[0].get("x-keep")).toBe("yes")
  70. expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
  71. })
  72. test("loader refreshes expired token with single-flight and persists refreshed oauth", async () => {
  73. const { input, getAuth, setCalls } = makeInput()
  74. let refreshCalls = 0
  75. const apiAuthHeaders: string[] = []
  76. // Must mock fetch before calling loader because startup refresh triggers for expires: 0
  77. const originalFetch = globalThis.fetch
  78. globalThis.fetch = (async (request, init) => {
  79. const url =
  80. typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
  81. if (url.includes("/oauth/token-request")) {
  82. refreshCalls += 1
  83. const body = new URLSearchParams(String(init?.body ?? ""))
  84. expect(body.get("grant_type")).toBe("refresh_token")
  85. expect(body.get("refresh_token")).toBe("refresh-old")
  86. expect(new Headers(init?.headers).get("authorization")).toMatch(/^Basic /)
  87. await new Promise((resolve) => setTimeout(resolve, 20))
  88. return Response.json({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 })
  89. }
  90. apiAuthHeaders.push(new Headers(init?.headers).get("authorization") || "")
  91. return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
  92. }) as typeof fetch
  93. try {
  94. const hooks = await SnowflakeCortexAuthPlugin(input)
  95. const options = await hooks.auth!.loader!(
  96. async () =>
  97. ({
  98. type: "oauth",
  99. access: "access-expired",
  100. refresh: "refresh-old",
  101. expires: 0,
  102. accountId: "myorg-myaccount",
  103. }) as any,
  104. {} as any,
  105. )
  106. await Promise.all([
  107. options.fetch("https://example.test/v1/chat", { headers: {} }),
  108. options.fetch("https://example.test/v1/chat", { headers: {} }),
  109. ])
  110. } finally {
  111. globalThis.fetch = originalFetch
  112. }
  113. expect(refreshCalls).toBe(1)
  114. expect(apiAuthHeaders).toEqual(["Bearer access-new", "Bearer access-new"])
  115. expect(setCalls).toHaveLength(1)
  116. expect((setCalls[0] as any).body).toMatchObject({
  117. type: "oauth",
  118. access: "access-new",
  119. refresh: "refresh-new",
  120. accountId: "myorg-myaccount",
  121. })
  122. })
  123. test("loader retries once after 401 by refreshing token", async () => {
  124. const { input, getAuth, setCalls } = makeInput()
  125. const hooks = await SnowflakeCortexAuthPlugin(input)
  126. const options = await hooks.auth!.loader!(
  127. async () =>
  128. ({
  129. type: "oauth",
  130. access: "access-stale",
  131. refresh: "refresh-old",
  132. expires: Date.now() + 60 * 60 * 1000,
  133. accountId: "myorg-myaccount",
  134. }) as any,
  135. {} as any,
  136. )
  137. let apiCalls = 0
  138. const seenAuth: string[] = []
  139. const originalFetch = globalThis.fetch
  140. globalThis.fetch = (async (request, init) => {
  141. const url =
  142. typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
  143. if (url.includes("/oauth/token-request")) {
  144. return Response.json({ access_token: "access-fresh", refresh_token: "refresh-fresh", expires_in: 3600 })
  145. }
  146. apiCalls += 1
  147. seenAuth.push(new Headers(init?.headers).get("authorization") || "")
  148. if (apiCalls === 1) return new Response("unauthorized", { status: 401 })
  149. return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
  150. }) as typeof fetch
  151. try {
  152. const response = await options.fetch("https://example.test/v1/chat", { headers: {} })
  153. expect(response.status).toBe(200)
  154. } finally {
  155. globalThis.fetch = originalFetch
  156. }
  157. expect(apiCalls).toBe(2)
  158. expect(seenAuth).toEqual(["Bearer access-stale", "Bearer access-fresh"])
  159. expect(setCalls).toHaveLength(1)
  160. expect((setCalls[0] as any).body).toMatchObject({
  161. type: "oauth",
  162. access: "access-fresh",
  163. refresh: "refresh-fresh",
  164. accountId: "myorg-myaccount",
  165. })
  166. })
  167. test("loader converts max_tokens to max_completion_tokens in request body", async () => {
  168. const { input, getAuth } = makeInput()
  169. const hooks = await SnowflakeCortexAuthPlugin(input)
  170. const options = await hooks.auth!.loader!(getAuth as any, {} as any)
  171. let sentBody: string | undefined
  172. const originalFetch = globalThis.fetch
  173. globalThis.fetch = (async (request, init) => {
  174. sentBody = typeof init?.body === "string" ? init.body : undefined
  175. return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
  176. }) as typeof fetch
  177. try {
  178. await options.fetch("https://example.test/v1/chat", {
  179. method: "POST",
  180. body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 4096, messages: [] }),
  181. })
  182. } finally {
  183. globalThis.fetch = originalFetch
  184. }
  185. expect(sentBody).toBeDefined()
  186. const parsed = JSON.parse(sentBody!)
  187. expect(parsed.max_completion_tokens).toBe(4096)
  188. expect(parsed.max_tokens).toBeUndefined()
  189. expect(parsed.model).toBe("claude-sonnet-4-5")
  190. })
  191. test("loader maps 400 'conversation complete' to 200 stop", async () => {
  192. const { input, getAuth } = makeInput()
  193. const hooks = await SnowflakeCortexAuthPlugin(input)
  194. const options = await hooks.auth!.loader!(getAuth as any, {} as any)
  195. const originalFetch = globalThis.fetch
  196. globalThis.fetch = (async () => {
  197. return new Response(JSON.stringify({ message: "Conversation complete" }), {
  198. status: 400,
  199. headers: { "content-type": "application/json" },
  200. })
  201. }) as unknown as typeof fetch
  202. try {
  203. const response = await options.fetch("https://example.test/v1/chat", {
  204. method: "POST",
  205. body: JSON.stringify({ model: "test", messages: [] }),
  206. })
  207. expect(response.status).toBe(200)
  208. const body = await response.json()
  209. expect(body.choices[0].finish_reason).toBe("stop")
  210. } finally {
  211. globalThis.fetch = originalFetch
  212. }
  213. })
  214. test("loader fixes empty role in SSE stream", async () => {
  215. const { input, getAuth } = makeInput()
  216. const hooks = await SnowflakeCortexAuthPlugin(input)
  217. const options = await hooks.auth!.loader!(getAuth as any, {} as any)
  218. const originalFetch = globalThis.fetch
  219. const sseChunk = `data: {"choices":[{"delta":{"role":"","content":"hello"}}]}\n\n`
  220. globalThis.fetch = (async () => {
  221. const stream = new ReadableStream({
  222. start(ctrl) {
  223. ctrl.enqueue(new TextEncoder().encode(sseChunk))
  224. ctrl.close()
  225. },
  226. })
  227. return new Response(stream, {
  228. status: 200,
  229. headers: { "content-type": "text/event-stream" },
  230. })
  231. }) as unknown as typeof fetch
  232. try {
  233. const response = await options.fetch("https://example.test/v1/chat", {
  234. method: "POST",
  235. body: JSON.stringify({ model: "test", messages: [], stream: true }),
  236. })
  237. expect(response.status).toBe(200)
  238. const reader = response.body!.getReader()
  239. const { value } = await reader.read()
  240. const text = new TextDecoder().decode(value)
  241. expect(text).not.toContain('"role":""')
  242. expect(text).toContain('"role":"assistant"')
  243. } finally {
  244. globalThis.fetch = originalFetch
  245. }
  246. })
  247. })