WebAuthService.spec.ts 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243
  1. // npx vitest run src/__tests__/auth/WebAuthService.spec.ts
  2. import crypto from "crypto"
  3. import type { Mock } from "vitest"
  4. import type { ExtensionContext } from "vscode"
  5. import { WebAuthService } from "../WebAuthService.js"
  6. import { RefreshTimer } from "../RefreshTimer.js"
  7. import { getClerkBaseUrl, getRooCodeApiUrl } from "../config.js"
  8. import { getUserAgent } from "../utils.js"
  9. vi.mock("crypto")
  10. vi.mock("../RefreshTimer")
  11. vi.mock("../config")
  12. vi.mock("../utils")
  13. const mockFetch = vi.fn()
  14. global.fetch = mockFetch
  15. vi.mock("vscode", () => ({
  16. window: {
  17. showInformationMessage: vi.fn(),
  18. showErrorMessage: vi.fn(),
  19. },
  20. env: {
  21. openExternal: vi.fn(),
  22. uriScheme: "vscode",
  23. },
  24. Uri: {
  25. parse: vi.fn((uri: string) => ({ toString: () => uri })),
  26. },
  27. }))
  28. describe("WebAuthService", () => {
  29. let authService: WebAuthService
  30. let mockTimer: {
  31. start: Mock
  32. stop: Mock
  33. reset: Mock
  34. }
  35. let mockLog: Mock
  36. let mockContext: {
  37. subscriptions: { push: Mock }
  38. secrets: {
  39. get: Mock
  40. store: Mock
  41. delete: Mock
  42. onDidChange: Mock
  43. }
  44. globalState: {
  45. get: Mock
  46. update: Mock
  47. }
  48. extension: {
  49. packageJSON: {
  50. version: string
  51. publisher: string
  52. name: string
  53. }
  54. }
  55. }
  56. beforeEach(() => {
  57. // Reset all mocks
  58. vi.clearAllMocks()
  59. // Setup mock context with proper subscriptions array
  60. mockContext = {
  61. subscriptions: {
  62. push: vi.fn(),
  63. },
  64. secrets: {
  65. get: vi.fn().mockResolvedValue(undefined),
  66. store: vi.fn().mockResolvedValue(undefined),
  67. delete: vi.fn().mockResolvedValue(undefined),
  68. onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
  69. },
  70. globalState: {
  71. get: vi.fn().mockReturnValue(undefined),
  72. update: vi.fn().mockResolvedValue(undefined),
  73. },
  74. extension: {
  75. packageJSON: {
  76. version: "1.0.0",
  77. publisher: "RooVeterinaryInc",
  78. name: "roo-cline",
  79. },
  80. },
  81. }
  82. // Setup timer mock
  83. mockTimer = {
  84. start: vi.fn(),
  85. stop: vi.fn(),
  86. reset: vi.fn(),
  87. }
  88. const MockedRefreshTimer = vi.mocked(RefreshTimer)
  89. MockedRefreshTimer.mockImplementation(() => mockTimer as unknown as RefreshTimer)
  90. // Setup config mocks - use production URL by default to maintain existing test behavior
  91. vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
  92. vi.mocked(getRooCodeApiUrl).mockReturnValue("https://api.test.com")
  93. // Setup utils mock
  94. vi.mocked(getUserAgent).mockReturnValue("Roo-Code 1.0.0")
  95. // Setup crypto mock
  96. vi.mocked(crypto.randomBytes).mockReturnValue(Buffer.from("test-random-bytes") as never)
  97. // Setup log mock
  98. mockLog = vi.fn()
  99. authService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  100. })
  101. afterEach(() => {
  102. vi.clearAllMocks()
  103. })
  104. describe("constructor", () => {
  105. it("should initialize with correct default values", () => {
  106. expect(authService.getState()).toBe("initializing")
  107. expect(authService.isAuthenticated()).toBe(false)
  108. expect(authService.hasActiveSession()).toBe(false)
  109. expect(authService.getSessionToken()).toBeUndefined()
  110. expect(authService.getUserInfo()).toBeNull()
  111. })
  112. it("should create RefreshTimer with correct configuration", () => {
  113. expect(RefreshTimer).toHaveBeenCalledWith({
  114. callback: expect.any(Function),
  115. successInterval: 50_000,
  116. initialBackoffMs: 1_000,
  117. maxBackoffMs: 300_000,
  118. })
  119. })
  120. it("should use console.log as default logger", () => {
  121. const serviceWithoutLog = new WebAuthService(mockContext as unknown as ExtensionContext)
  122. // Can't directly test console.log usage, but constructor should not throw
  123. expect(serviceWithoutLog).toBeInstanceOf(WebAuthService)
  124. })
  125. })
  126. describe("initialize", () => {
  127. it("should handle credentials change and setup event listener", async () => {
  128. await authService.initialize()
  129. expect(mockContext.subscriptions.push).toHaveBeenCalled()
  130. expect(mockContext.secrets.onDidChange).toHaveBeenCalled()
  131. })
  132. it("should not initialize twice", async () => {
  133. await authService.initialize()
  134. const firstCallCount = vi.mocked(mockContext.secrets.onDidChange).mock.calls.length
  135. await authService.initialize()
  136. expect(mockContext.secrets.onDidChange).toHaveBeenCalledTimes(firstCallCount)
  137. expect(mockLog).toHaveBeenCalledWith("[auth] initialize() called after already initialized")
  138. })
  139. it("should transition to logged-out when no credentials exist", async () => {
  140. mockContext.secrets.get.mockResolvedValue(undefined)
  141. const authStateChangedSpy = vi.fn()
  142. authService.on("auth-state-changed", authStateChangedSpy)
  143. await authService.initialize()
  144. expect(authService.getState()).toBe("logged-out")
  145. expect(authStateChangedSpy).toHaveBeenCalledWith({
  146. state: "logged-out",
  147. previousState: "initializing",
  148. })
  149. })
  150. it("should transition to attempting-session when valid credentials exist", async () => {
  151. const credentials = {
  152. clientToken: "test-token",
  153. sessionId: "test-session",
  154. }
  155. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  156. const authStateChangedSpy = vi.fn()
  157. authService.on("auth-state-changed", authStateChangedSpy)
  158. await authService.initialize()
  159. expect(authService.getState()).toBe("attempting-session")
  160. expect(authStateChangedSpy).toHaveBeenCalledWith({
  161. state: "attempting-session",
  162. previousState: "initializing",
  163. })
  164. expect(mockTimer.start).toHaveBeenCalled()
  165. })
  166. it("should handle invalid credentials gracefully", async () => {
  167. mockContext.secrets.get.mockResolvedValue("invalid-json")
  168. const authStateChangedSpy = vi.fn()
  169. authService.on("auth-state-changed", authStateChangedSpy)
  170. await authService.initialize()
  171. expect(authService.getState()).toBe("logged-out")
  172. expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
  173. })
  174. it("should handle credentials change events", async () => {
  175. let onDidChangeCallback: (e: { key: string }) => void
  176. mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
  177. onDidChangeCallback = callback
  178. return { dispose: vi.fn() }
  179. })
  180. await authService.initialize()
  181. // Simulate credentials change event
  182. const newCredentials = {
  183. clientToken: "new-token",
  184. sessionId: "new-session",
  185. }
  186. mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials))
  187. const authStateChangedSpy = vi.fn()
  188. authService.on("auth-state-changed", authStateChangedSpy)
  189. onDidChangeCallback!({ key: "clerk-auth-credentials" })
  190. await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
  191. expect(authStateChangedSpy).toHaveBeenCalled()
  192. })
  193. })
  194. describe("login", () => {
  195. beforeEach(async () => {
  196. await authService.initialize()
  197. })
  198. it("should generate state and open external URL", async () => {
  199. const mockOpenExternal = vi.fn()
  200. const vscode = await import("vscode")
  201. vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
  202. await authService.login()
  203. expect(crypto.randomBytes).toHaveBeenCalledWith(16)
  204. expect(mockContext.globalState.update).toHaveBeenCalledWith(
  205. "clerk-auth-state",
  206. "746573742d72616e646f6d2d6279746573",
  207. )
  208. expect(mockOpenExternal).toHaveBeenCalledWith(
  209. expect.objectContaining({
  210. toString: expect.any(Function),
  211. }),
  212. )
  213. })
  214. it("should use package.json values for redirect URI with default sign-in endpoint", async () => {
  215. const mockOpenExternal = vi.fn()
  216. const vscode = await import("vscode")
  217. vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
  218. await authService.login()
  219. const expectedUrl =
  220. "https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
  221. expect(mockOpenExternal).toHaveBeenCalledWith(
  222. expect.objectContaining({
  223. toString: expect.any(Function),
  224. }),
  225. )
  226. // Verify the actual URL
  227. const calledUri = mockOpenExternal.mock.calls[0]?.[0]
  228. expect(calledUri.toString()).toBe(expectedUrl)
  229. })
  230. it("should use provider signup URL when useProviderSignup is true", async () => {
  231. const mockOpenExternal = vi.fn()
  232. const vscode = await import("vscode")
  233. vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
  234. await authService.login(undefined, true)
  235. const expectedUrl =
  236. "https://api.test.com/extension/provider-sign-up?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
  237. expect(mockOpenExternal).toHaveBeenCalledWith(
  238. expect.objectContaining({
  239. toString: expect.any(Function),
  240. }),
  241. )
  242. // Verify the actual URL
  243. const calledUri = mockOpenExternal.mock.calls[0]?.[0]
  244. expect(calledUri.toString()).toBe(expectedUrl)
  245. })
  246. it("should handle errors during login", async () => {
  247. vi.mocked(crypto.randomBytes).mockImplementation(() => {
  248. throw new Error("Crypto error")
  249. })
  250. await expect(authService.login()).rejects.toThrow("Failed to initiate Roo Code Cloud authentication")
  251. expect(mockLog).toHaveBeenCalledWith("[auth] Error initiating Roo Code Cloud auth: Error: Crypto error")
  252. })
  253. })
  254. describe("handleCallback", () => {
  255. beforeEach(async () => {
  256. await authService.initialize()
  257. })
  258. it("should handle invalid parameters", async () => {
  259. const vscode = await import("vscode")
  260. const mockShowInfo = vi.fn()
  261. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  262. await authService.handleCallback(null, "state")
  263. expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
  264. await authService.handleCallback("code", null)
  265. expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
  266. })
  267. it("should validate state parameter", async () => {
  268. mockContext.globalState.get.mockReturnValue("stored-state")
  269. await expect(authService.handleCallback("code", "different-state")).rejects.toThrow(
  270. "Failed to handle Roo Code Cloud callback",
  271. )
  272. expect(mockLog).toHaveBeenCalledWith("[auth] State mismatch in callback")
  273. })
  274. it("should successfully handle valid callback", async () => {
  275. const storedState = "valid-state"
  276. mockContext.globalState.get.mockReturnValue(storedState)
  277. // Mock successful Clerk sign-in response
  278. const mockResponse = {
  279. ok: true,
  280. json: () =>
  281. Promise.resolve({
  282. response: { created_session_id: "session-123" },
  283. }),
  284. headers: {
  285. get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
  286. },
  287. }
  288. mockFetch.mockResolvedValue(mockResponse)
  289. const vscode = await import("vscode")
  290. const mockShowInfo = vi.fn()
  291. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  292. await authService.handleCallback("auth-code", storedState)
  293. expect(mockContext.secrets.store).toHaveBeenCalledWith(
  294. "clerk-auth-credentials",
  295. JSON.stringify({
  296. clientToken: "Bearer token-123",
  297. sessionId: "session-123",
  298. organizationId: null,
  299. }),
  300. )
  301. expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud")
  302. })
  303. it("should store provider model when provided in callback", async () => {
  304. const storedState = "valid-state"
  305. mockContext.globalState.get.mockReturnValue(storedState)
  306. // Mock successful Clerk sign-in response
  307. const mockResponse = {
  308. ok: true,
  309. json: () =>
  310. Promise.resolve({
  311. response: { created_session_id: "session-123" },
  312. }),
  313. headers: {
  314. get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
  315. },
  316. }
  317. mockFetch.mockResolvedValue(mockResponse)
  318. const vscode = await import("vscode")
  319. const mockShowInfo = vi.fn()
  320. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  321. await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1")
  322. expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1")
  323. expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1")
  324. })
  325. it("should handle Clerk API errors", async () => {
  326. const storedState = "valid-state"
  327. mockContext.globalState.get.mockReturnValue(storedState)
  328. mockFetch.mockResolvedValue({
  329. ok: false,
  330. status: 400,
  331. statusText: "Bad Request",
  332. })
  333. const authStateChangedSpy = vi.fn()
  334. authService.on("auth-state-changed", authStateChangedSpy)
  335. await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
  336. "Failed to handle Roo Code Cloud callback",
  337. )
  338. expect(authStateChangedSpy).toHaveBeenCalled()
  339. })
  340. })
  341. describe("logout", () => {
  342. beforeEach(async () => {
  343. await authService.initialize()
  344. })
  345. it("should clear credentials and call Clerk logout", async () => {
  346. // Set up credentials first by simulating a login state
  347. const credentials = {
  348. clientToken: "test-token",
  349. sessionId: "test-session",
  350. }
  351. // Manually set the credentials in the service
  352. authService["credentials"] = credentials
  353. // Mock successful logout response
  354. mockFetch.mockResolvedValue({ ok: true })
  355. const vscode = await import("vscode")
  356. const mockShowInfo = vi.fn()
  357. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  358. await authService.logout()
  359. expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
  360. expect(mockContext.globalState.update).toHaveBeenCalledWith("clerk-auth-state", undefined)
  361. expect(mockFetch).toHaveBeenCalledWith(
  362. "https://clerk.roocode.com/v1/client/sessions/test-session/remove",
  363. expect.objectContaining({
  364. method: "POST",
  365. headers: expect.objectContaining({
  366. Authorization: "Bearer test-token",
  367. }),
  368. }),
  369. )
  370. expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
  371. })
  372. it("should handle logout without credentials", async () => {
  373. const vscode = await import("vscode")
  374. const mockShowInfo = vi.fn()
  375. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  376. await authService.logout()
  377. expect(mockContext.secrets.delete).toHaveBeenCalled()
  378. expect(mockFetch).not.toHaveBeenCalled()
  379. expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
  380. })
  381. it("should handle Clerk logout errors gracefully", async () => {
  382. // Set up credentials first by simulating a login state
  383. const credentials = {
  384. clientToken: "test-token",
  385. sessionId: "test-session",
  386. }
  387. // Manually set the credentials in the service
  388. authService["credentials"] = credentials
  389. // Mock failed logout response
  390. mockFetch.mockRejectedValue(new Error("Network error"))
  391. const vscode = await import("vscode")
  392. const mockShowInfo = vi.fn()
  393. vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
  394. await authService.logout()
  395. expect(mockLog).toHaveBeenCalledWith("[auth] Error calling clerkLogout:", expect.any(Error))
  396. expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
  397. })
  398. })
  399. describe("state management", () => {
  400. it("should return correct state", () => {
  401. expect(authService.getState()).toBe("initializing")
  402. })
  403. it("should return correct authentication status", async () => {
  404. await authService.initialize()
  405. expect(authService.isAuthenticated()).toBe(false)
  406. // Create a new service instance with credentials
  407. const credentials = {
  408. clientToken: "test-token",
  409. sessionId: "test-session",
  410. }
  411. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  412. const authenticatedService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  413. await authenticatedService.initialize()
  414. expect(authenticatedService.isAuthenticated()).toBe(true)
  415. expect(authenticatedService.hasActiveSession()).toBe(false)
  416. })
  417. it("should return session token only for active sessions", () => {
  418. expect(authService.getSessionToken()).toBeUndefined()
  419. // Manually set state to active-session for testing
  420. // This would normally happen through refreshSession
  421. authService["state"] = "active-session"
  422. authService["sessionToken"] = "test-jwt"
  423. expect(authService.getSessionToken()).toBe("test-jwt")
  424. })
  425. it("should return correct values for new methods", async () => {
  426. await authService.initialize()
  427. expect(authService.hasOrIsAcquiringActiveSession()).toBe(false)
  428. // Create a new service instance with credentials (attempting-session)
  429. const credentials = {
  430. clientToken: "test-token",
  431. sessionId: "test-session",
  432. }
  433. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  434. const attemptingService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  435. await attemptingService.initialize()
  436. expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true)
  437. expect(attemptingService.hasActiveSession()).toBe(false)
  438. // Manually set state to active-session for testing
  439. attemptingService["state"] = "active-session"
  440. expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true)
  441. expect(attemptingService.hasActiveSession()).toBe(true)
  442. })
  443. })
  444. describe("session refresh", () => {
  445. beforeEach(async () => {
  446. // Set up with credentials
  447. const credentials = {
  448. clientToken: "test-token",
  449. sessionId: "test-session",
  450. }
  451. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  452. await authService.initialize()
  453. })
  454. it("should refresh session successfully", async () => {
  455. // Mock successful token creation and user info fetch
  456. mockFetch
  457. .mockResolvedValueOnce({
  458. ok: true,
  459. json: () => Promise.resolve({ jwt: "new-jwt-token" }),
  460. })
  461. .mockResolvedValueOnce({
  462. ok: true,
  463. json: () =>
  464. Promise.resolve({
  465. response: {
  466. first_name: "John",
  467. last_name: "Doe",
  468. image_url: "https://example.com/avatar.jpg",
  469. primary_email_address_id: "email-1",
  470. email_addresses: [{ id: "email-1", email_address: "[email protected]" }],
  471. },
  472. }),
  473. })
  474. const authStateChangedSpy = vi.fn()
  475. const userInfoSpy = vi.fn()
  476. authService.on("auth-state-changed", authStateChangedSpy)
  477. authService.on("user-info", userInfoSpy)
  478. // Trigger refresh by calling the timer callback
  479. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  480. await timerCallback?.()
  481. // Wait for async operations to complete
  482. await new Promise((resolve) => setTimeout(resolve, 0))
  483. expect(authService.getState()).toBe("active-session")
  484. expect(authService.hasActiveSession()).toBe(true)
  485. expect(authService.getSessionToken()).toBe("new-jwt-token")
  486. expect(authStateChangedSpy).toHaveBeenCalledWith({
  487. state: "active-session",
  488. previousState: "attempting-session",
  489. })
  490. expect(userInfoSpy).toHaveBeenCalledWith({
  491. userInfo: {
  492. id: undefined,
  493. name: "John Doe",
  494. email: "[email protected]",
  495. picture: "https://example.com/avatar.jpg",
  496. extensionBridgeEnabled: true,
  497. },
  498. })
  499. })
  500. it("should handle invalid client token error", async () => {
  501. // Mock 401 response (invalid token)
  502. mockFetch.mockResolvedValue({
  503. ok: false,
  504. status: 401,
  505. statusText: "Unauthorized",
  506. })
  507. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  508. await expect(timerCallback?.()).rejects.toThrow()
  509. expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
  510. expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials")
  511. })
  512. it("should handle network errors during refresh", async () => {
  513. mockFetch.mockRejectedValue(new Error("Network error"))
  514. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  515. await expect(timerCallback?.()).rejects.toThrow("Network error")
  516. expect(mockLog).toHaveBeenCalledWith("[auth] Failed to refresh session", expect.any(Error))
  517. })
  518. it("should transition to inactive-session on first attempt failure", async () => {
  519. // Mock failed token creation response
  520. mockFetch.mockResolvedValue({
  521. ok: false,
  522. status: 500,
  523. statusText: "Internal Server Error",
  524. })
  525. const authStateChangedSpy = vi.fn()
  526. authService.on("auth-state-changed", authStateChangedSpy)
  527. // Verify we start in attempting-session state
  528. expect(authService.getState()).toBe("attempting-session")
  529. expect(authService["isFirstRefreshAttempt"]).toBe(true)
  530. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  531. await expect(timerCallback?.()).rejects.toThrow()
  532. // Should transition to inactive-session after first failure
  533. expect(authService.getState()).toBe("inactive-session")
  534. expect(authService["isFirstRefreshAttempt"]).toBe(false)
  535. expect(authStateChangedSpy).toHaveBeenCalledWith({
  536. state: "inactive-session",
  537. previousState: "attempting-session",
  538. })
  539. })
  540. it("should not transition to inactive-session on subsequent failures", async () => {
  541. // First, transition to inactive-session by failing the first attempt
  542. mockFetch.mockResolvedValue({
  543. ok: false,
  544. status: 500,
  545. statusText: "Internal Server Error",
  546. })
  547. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  548. await expect(timerCallback?.()).rejects.toThrow()
  549. // Verify we're now in inactive-session
  550. expect(authService.getState()).toBe("inactive-session")
  551. expect(authService["isFirstRefreshAttempt"]).toBe(false)
  552. const authStateChangedSpy = vi.fn()
  553. authService.on("auth-state-changed", authStateChangedSpy)
  554. // Subsequent failure should not trigger another transition
  555. await expect(timerCallback?.()).rejects.toThrow()
  556. expect(authService.getState()).toBe("inactive-session")
  557. expect(authStateChangedSpy).not.toHaveBeenCalled()
  558. })
  559. it("should clear credentials on 401 during first refresh attempt (bug fix)", async () => {
  560. // Mock 401 response during first refresh attempt
  561. mockFetch.mockResolvedValue({
  562. ok: false,
  563. status: 401,
  564. statusText: "Unauthorized",
  565. })
  566. const authStateChangedSpy = vi.fn()
  567. authService.on("auth-state-changed", authStateChangedSpy)
  568. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  569. await expect(timerCallback?.()).rejects.toThrow()
  570. // Should clear credentials (not just transition to inactive-session)
  571. expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
  572. expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials")
  573. // Simulate credentials cleared event
  574. mockContext.secrets.get.mockResolvedValue(undefined)
  575. await authService["handleCredentialsChange"]()
  576. expect(authService.getState()).toBe("logged-out")
  577. expect(authStateChangedSpy).toHaveBeenCalledWith({
  578. state: "logged-out",
  579. previousState: "attempting-session",
  580. })
  581. })
  582. })
  583. describe("user info", () => {
  584. it("should return null initially", () => {
  585. expect(authService.getUserInfo()).toBeNull()
  586. })
  587. it("should parse user info correctly for personal accounts", async () => {
  588. // Set up with credentials for personal account (no organizationId)
  589. const credentials = {
  590. clientToken: "test-token",
  591. sessionId: "test-session",
  592. organizationId: null,
  593. }
  594. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  595. await authService.initialize()
  596. // Clear previous mock calls
  597. mockFetch.mockClear()
  598. // Mock successful responses
  599. mockFetch
  600. .mockResolvedValueOnce({
  601. ok: true,
  602. json: () => Promise.resolve({ jwt: "jwt-token" }),
  603. })
  604. .mockResolvedValueOnce({
  605. ok: true,
  606. json: () =>
  607. Promise.resolve({
  608. response: {
  609. first_name: "Jane",
  610. last_name: "Smith",
  611. image_url: "https://example.com/jane.jpg",
  612. primary_email_address_id: "email-2",
  613. email_addresses: [
  614. { id: "email-1", email_address: "[email protected]" },
  615. { id: "email-2", email_address: "[email protected]" },
  616. ],
  617. },
  618. }),
  619. })
  620. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  621. await timerCallback?.()
  622. // Wait for async operations to complete
  623. await new Promise((resolve) => setTimeout(resolve, 0))
  624. const userInfo = authService.getUserInfo()
  625. expect(userInfo).toEqual({
  626. id: undefined,
  627. name: "Jane Smith",
  628. email: "[email protected]",
  629. picture: "https://example.com/jane.jpg",
  630. extensionBridgeEnabled: true,
  631. })
  632. })
  633. it("should parse user info correctly for organization accounts", async () => {
  634. // Set up with credentials for organization account
  635. const credentials = {
  636. clientToken: "test-token",
  637. sessionId: "test-session",
  638. organizationId: "org_1",
  639. }
  640. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  641. await authService.initialize()
  642. // Clear previous mock calls
  643. mockFetch.mockClear()
  644. // Mock successful responses
  645. mockFetch
  646. .mockResolvedValueOnce({
  647. ok: true,
  648. json: () => Promise.resolve({ jwt: "jwt-token" }),
  649. })
  650. .mockResolvedValueOnce({
  651. ok: true,
  652. json: () =>
  653. Promise.resolve({
  654. response: {
  655. first_name: "Jane",
  656. last_name: "Smith",
  657. image_url: "https://example.com/jane.jpg",
  658. primary_email_address_id: "email-2",
  659. email_addresses: [
  660. { id: "email-1", email_address: "[email protected]" },
  661. { id: "email-2", email_address: "[email protected]" },
  662. ],
  663. },
  664. }),
  665. })
  666. .mockResolvedValueOnce({
  667. ok: true,
  668. json: () =>
  669. Promise.resolve({
  670. response: [
  671. {
  672. id: "org_member_id_1",
  673. role: "member",
  674. organization: {
  675. id: "org_1",
  676. name: "Org 1",
  677. },
  678. },
  679. ],
  680. }),
  681. })
  682. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  683. await timerCallback?.()
  684. // Wait for async operations to complete
  685. await new Promise((resolve) => setTimeout(resolve, 0))
  686. const userInfo = authService.getUserInfo()
  687. expect(userInfo).toEqual({
  688. id: undefined,
  689. name: "Jane Smith",
  690. email: "[email protected]",
  691. picture: "https://example.com/jane.jpg",
  692. extensionBridgeEnabled: false,
  693. organizationId: "org_1",
  694. organizationName: "Org 1",
  695. organizationRole: "member",
  696. organizationImageUrl: undefined,
  697. })
  698. })
  699. it("should handle missing user info fields", async () => {
  700. // Set up with credentials for personal account (no organizationId)
  701. const credentials = {
  702. clientToken: "test-token",
  703. sessionId: "test-session",
  704. organizationId: null,
  705. }
  706. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  707. await authService.initialize()
  708. // Clear previous mock calls
  709. mockFetch.mockClear()
  710. // Mock responses with minimal data
  711. mockFetch
  712. .mockResolvedValueOnce({
  713. ok: true,
  714. json: () => Promise.resolve({ jwt: "jwt-token" }),
  715. })
  716. .mockResolvedValueOnce({
  717. ok: true,
  718. json: () =>
  719. Promise.resolve({
  720. response: {
  721. first_name: "John",
  722. last_name: "Doe",
  723. },
  724. }),
  725. })
  726. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  727. await timerCallback?.()
  728. // Wait for async operations to complete
  729. await new Promise((resolve) => setTimeout(resolve, 0))
  730. const userInfo = authService.getUserInfo()
  731. expect(userInfo).toEqual({
  732. id: undefined,
  733. name: "John Doe",
  734. email: undefined,
  735. picture: undefined,
  736. extensionBridgeEnabled: true,
  737. })
  738. })
  739. })
  740. describe("event emissions", () => {
  741. it("should emit auth-state-changed event for logged-out", async () => {
  742. const authStateChangedSpy = vi.fn()
  743. authService.on("auth-state-changed", authStateChangedSpy)
  744. await authService.initialize()
  745. expect(authStateChangedSpy).toHaveBeenCalledWith({
  746. state: "logged-out",
  747. previousState: "initializing",
  748. })
  749. })
  750. it("should emit auth-state-changed event for attempting-session", async () => {
  751. const credentials = {
  752. clientToken: "test-token",
  753. sessionId: "test-session",
  754. }
  755. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  756. const authStateChangedSpy = vi.fn()
  757. authService.on("auth-state-changed", authStateChangedSpy)
  758. await authService.initialize()
  759. expect(authStateChangedSpy).toHaveBeenCalledWith({
  760. state: "attempting-session",
  761. previousState: "initializing",
  762. })
  763. })
  764. it("should emit auth-state-changed event for active-session", async () => {
  765. // Set up with credentials
  766. const credentials = {
  767. clientToken: "test-token",
  768. sessionId: "test-session",
  769. }
  770. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  771. await authService.initialize()
  772. // Clear previous mock calls
  773. mockFetch.mockClear()
  774. // Mock both the token creation and user info fetch
  775. mockFetch
  776. .mockResolvedValueOnce({
  777. ok: true,
  778. json: () => Promise.resolve({ jwt: "jwt-token" }),
  779. })
  780. .mockResolvedValueOnce({
  781. ok: true,
  782. json: () =>
  783. Promise.resolve({
  784. response: {
  785. first_name: "Test",
  786. last_name: "User",
  787. },
  788. }),
  789. })
  790. const authStateChangedSpy = vi.fn()
  791. authService.on("auth-state-changed", authStateChangedSpy)
  792. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  793. await timerCallback?.()
  794. // Wait for async operations to complete
  795. await new Promise((resolve) => setTimeout(resolve, 0))
  796. expect(authStateChangedSpy).toHaveBeenCalledWith({
  797. state: "active-session",
  798. previousState: "attempting-session",
  799. })
  800. })
  801. it("should emit user-info event", async () => {
  802. // Set up with credentials
  803. const credentials = {
  804. clientToken: "test-token",
  805. sessionId: "test-session",
  806. }
  807. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  808. await authService.initialize()
  809. // Clear previous mock calls
  810. mockFetch.mockClear()
  811. mockFetch
  812. .mockResolvedValueOnce({
  813. ok: true,
  814. json: () => Promise.resolve({ jwt: "jwt-token" }),
  815. })
  816. .mockResolvedValueOnce({
  817. ok: true,
  818. json: () =>
  819. Promise.resolve({
  820. response: {
  821. first_name: "Test",
  822. last_name: "User",
  823. },
  824. }),
  825. })
  826. const userInfoSpy = vi.fn()
  827. authService.on("user-info", userInfoSpy)
  828. const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
  829. await timerCallback?.()
  830. // Wait for async operations to complete
  831. await new Promise((resolve) => setTimeout(resolve, 0))
  832. expect(userInfoSpy).toHaveBeenCalledWith({
  833. userInfo: {
  834. id: undefined,
  835. name: "Test User",
  836. email: undefined,
  837. picture: undefined,
  838. extensionBridgeEnabled: true,
  839. },
  840. })
  841. })
  842. })
  843. describe("error handling", () => {
  844. it("should handle credentials change errors", async () => {
  845. mockContext.secrets.get.mockRejectedValue(new Error("Storage error"))
  846. await authService.initialize()
  847. expect(mockLog).toHaveBeenCalledWith("[auth] Error handling credentials change:", expect.any(Error))
  848. })
  849. it("should handle malformed JSON in credentials", async () => {
  850. mockContext.secrets.get.mockResolvedValue("invalid-json{")
  851. await authService.initialize()
  852. expect(authService.getState()).toBe("logged-out")
  853. expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
  854. })
  855. it("should handle invalid credentials schema", async () => {
  856. mockContext.secrets.get.mockResolvedValue(JSON.stringify({ invalid: "data" }))
  857. await authService.initialize()
  858. expect(authService.getState()).toBe("logged-out")
  859. expect(mockLog).toHaveBeenCalledWith("[auth] Invalid credentials format:", expect.any(Array))
  860. })
  861. it("should handle missing authorization header in sign-in response", async () => {
  862. const storedState = "valid-state"
  863. mockContext.globalState.get.mockReturnValue(storedState)
  864. mockFetch.mockResolvedValue({
  865. ok: true,
  866. json: () =>
  867. Promise.resolve({
  868. response: { created_session_id: "session-123" },
  869. }),
  870. headers: {
  871. get: () => null, // No authorization header
  872. },
  873. })
  874. await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
  875. "Failed to handle Roo Code Cloud callback",
  876. )
  877. })
  878. })
  879. describe("timer integration", () => {
  880. it("should stop timer on logged-out transition", async () => {
  881. await authService.initialize()
  882. expect(mockTimer.stop).toHaveBeenCalled()
  883. })
  884. it("should start timer on attempting-session transition", async () => {
  885. const credentials = {
  886. clientToken: "test-token",
  887. sessionId: "test-session",
  888. }
  889. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  890. await authService.initialize()
  891. expect(mockTimer.start).toHaveBeenCalled()
  892. })
  893. })
  894. describe("auth credentials key scoping", () => {
  895. it("should use default key when getClerkBaseUrl returns production URL", async () => {
  896. // Mock getClerkBaseUrl to return production URL
  897. vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
  898. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  899. const credentials = {
  900. clientToken: "test-token",
  901. sessionId: "test-session",
  902. }
  903. await service.initialize()
  904. await service["storeCredentials"](credentials)
  905. expect(mockContext.secrets.store).toHaveBeenCalledWith(
  906. "clerk-auth-credentials",
  907. JSON.stringify(credentials),
  908. )
  909. })
  910. it("should use scoped key when getClerkBaseUrl returns custom URL", async () => {
  911. const customUrl = "https://custom.clerk.com"
  912. // Mock getClerkBaseUrl to return custom URL
  913. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  914. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  915. const credentials = {
  916. clientToken: "test-token",
  917. sessionId: "test-session",
  918. }
  919. await service.initialize()
  920. await service["storeCredentials"](credentials)
  921. expect(mockContext.secrets.store).toHaveBeenCalledWith(
  922. `clerk-auth-credentials-${customUrl}`,
  923. JSON.stringify(credentials),
  924. )
  925. })
  926. it("should load credentials using scoped key", async () => {
  927. const customUrl = "https://custom.clerk.com"
  928. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  929. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  930. const credentials = {
  931. clientToken: "test-token",
  932. sessionId: "test-session",
  933. }
  934. mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
  935. await service.initialize()
  936. const loadedCredentials = await service["loadCredentials"]()
  937. expect(mockContext.secrets.get).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`)
  938. expect(loadedCredentials).toEqual(credentials)
  939. })
  940. it("should clear credentials using scoped key", async () => {
  941. const customUrl = "https://custom.clerk.com"
  942. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  943. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  944. await service.initialize()
  945. await service["clearCredentials"]()
  946. expect(mockContext.secrets.delete).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`)
  947. })
  948. it("should listen for changes on scoped key", async () => {
  949. const customUrl = "https://custom.clerk.com"
  950. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  951. let onDidChangeCallback: (e: { key: string }) => void
  952. mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
  953. onDidChangeCallback = callback
  954. return { dispose: vi.fn() }
  955. })
  956. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  957. await service.initialize()
  958. // Simulate credentials change event with scoped key
  959. const newCredentials = {
  960. clientToken: "new-token",
  961. sessionId: "new-session",
  962. }
  963. mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials))
  964. const authStateChangedSpy = vi.fn()
  965. service.on("auth-state-changed", authStateChangedSpy)
  966. onDidChangeCallback!({ key: `clerk-auth-credentials-${customUrl}` })
  967. await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
  968. expect(authStateChangedSpy).toHaveBeenCalled()
  969. })
  970. it("should not respond to changes on different scoped keys", async () => {
  971. const customUrl = "https://custom.clerk.com"
  972. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  973. let onDidChangeCallback: (e: { key: string }) => void
  974. mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
  975. onDidChangeCallback = callback
  976. return { dispose: vi.fn() }
  977. })
  978. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  979. await service.initialize()
  980. const authStateChangedSpy = vi.fn()
  981. service.on("auth-state-changed", authStateChangedSpy)
  982. // Simulate credentials change event with different scoped key
  983. onDidChangeCallback!({
  984. key: "clerk-auth-credentials-https://other.clerk.com",
  985. })
  986. await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
  987. expect(authStateChangedSpy).not.toHaveBeenCalled()
  988. })
  989. it("should not respond to changes on default key when using scoped key", async () => {
  990. const customUrl = "https://custom.clerk.com"
  991. vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
  992. let onDidChangeCallback: (e: { key: string }) => void
  993. mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
  994. onDidChangeCallback = callback
  995. return { dispose: vi.fn() }
  996. })
  997. const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
  998. await service.initialize()
  999. const authStateChangedSpy = vi.fn()
  1000. service.on("auth-state-changed", authStateChangedSpy)
  1001. // Simulate credentials change event with default key
  1002. onDidChangeCallback!({ key: "clerk-auth-credentials" })
  1003. await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
  1004. expect(authStateChangedSpy).not.toHaveBeenCalled()
  1005. })
  1006. })
  1007. })