| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243 |
- // npx vitest run src/__tests__/auth/WebAuthService.spec.ts
- import crypto from "crypto"
- import type { Mock } from "vitest"
- import type { ExtensionContext } from "vscode"
- import { WebAuthService } from "../WebAuthService.js"
- import { RefreshTimer } from "../RefreshTimer.js"
- import { getClerkBaseUrl, getRooCodeApiUrl } from "../config.js"
- import { getUserAgent } from "../utils.js"
- vi.mock("crypto")
- vi.mock("../RefreshTimer")
- vi.mock("../config")
- vi.mock("../utils")
- const mockFetch = vi.fn()
- global.fetch = mockFetch
- vi.mock("vscode", () => ({
- window: {
- showInformationMessage: vi.fn(),
- showErrorMessage: vi.fn(),
- },
- env: {
- openExternal: vi.fn(),
- uriScheme: "vscode",
- },
- Uri: {
- parse: vi.fn((uri: string) => ({ toString: () => uri })),
- },
- }))
- describe("WebAuthService", () => {
- let authService: WebAuthService
- let mockTimer: {
- start: Mock
- stop: Mock
- reset: Mock
- }
- let mockLog: Mock
- let mockContext: {
- subscriptions: { push: Mock }
- secrets: {
- get: Mock
- store: Mock
- delete: Mock
- onDidChange: Mock
- }
- globalState: {
- get: Mock
- update: Mock
- }
- extension: {
- packageJSON: {
- version: string
- publisher: string
- name: string
- }
- }
- }
- beforeEach(() => {
- // Reset all mocks
- vi.clearAllMocks()
- // Setup mock context with proper subscriptions array
- mockContext = {
- subscriptions: {
- push: vi.fn(),
- },
- secrets: {
- get: vi.fn().mockResolvedValue(undefined),
- store: vi.fn().mockResolvedValue(undefined),
- delete: vi.fn().mockResolvedValue(undefined),
- onDidChange: vi.fn().mockReturnValue({ dispose: vi.fn() }),
- },
- globalState: {
- get: vi.fn().mockReturnValue(undefined),
- update: vi.fn().mockResolvedValue(undefined),
- },
- extension: {
- packageJSON: {
- version: "1.0.0",
- publisher: "RooVeterinaryInc",
- name: "roo-cline",
- },
- },
- }
- // Setup timer mock
- mockTimer = {
- start: vi.fn(),
- stop: vi.fn(),
- reset: vi.fn(),
- }
- const MockedRefreshTimer = vi.mocked(RefreshTimer)
- MockedRefreshTimer.mockImplementation(() => mockTimer as unknown as RefreshTimer)
- // Setup config mocks - use production URL by default to maintain existing test behavior
- vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
- vi.mocked(getRooCodeApiUrl).mockReturnValue("https://api.test.com")
- // Setup utils mock
- vi.mocked(getUserAgent).mockReturnValue("Roo-Code 1.0.0")
- // Setup crypto mock
- vi.mocked(crypto.randomBytes).mockReturnValue(Buffer.from("test-random-bytes") as never)
- // Setup log mock
- mockLog = vi.fn()
- authService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- })
- afterEach(() => {
- vi.clearAllMocks()
- })
- describe("constructor", () => {
- it("should initialize with correct default values", () => {
- expect(authService.getState()).toBe("initializing")
- expect(authService.isAuthenticated()).toBe(false)
- expect(authService.hasActiveSession()).toBe(false)
- expect(authService.getSessionToken()).toBeUndefined()
- expect(authService.getUserInfo()).toBeNull()
- })
- it("should create RefreshTimer with correct configuration", () => {
- expect(RefreshTimer).toHaveBeenCalledWith({
- callback: expect.any(Function),
- successInterval: 50_000,
- initialBackoffMs: 1_000,
- maxBackoffMs: 300_000,
- })
- })
- it("should use console.log as default logger", () => {
- const serviceWithoutLog = new WebAuthService(mockContext as unknown as ExtensionContext)
- // Can't directly test console.log usage, but constructor should not throw
- expect(serviceWithoutLog).toBeInstanceOf(WebAuthService)
- })
- })
- describe("initialize", () => {
- it("should handle credentials change and setup event listener", async () => {
- await authService.initialize()
- expect(mockContext.subscriptions.push).toHaveBeenCalled()
- expect(mockContext.secrets.onDidChange).toHaveBeenCalled()
- })
- it("should not initialize twice", async () => {
- await authService.initialize()
- const firstCallCount = vi.mocked(mockContext.secrets.onDidChange).mock.calls.length
- await authService.initialize()
- expect(mockContext.secrets.onDidChange).toHaveBeenCalledTimes(firstCallCount)
- expect(mockLog).toHaveBeenCalledWith("[auth] initialize() called after already initialized")
- })
- it("should transition to logged-out when no credentials exist", async () => {
- mockContext.secrets.get.mockResolvedValue(undefined)
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await authService.initialize()
- expect(authService.getState()).toBe("logged-out")
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "logged-out",
- previousState: "initializing",
- })
- })
- it("should transition to attempting-session when valid credentials exist", async () => {
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await authService.initialize()
- expect(authService.getState()).toBe("attempting-session")
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "attempting-session",
- previousState: "initializing",
- })
- expect(mockTimer.start).toHaveBeenCalled()
- })
- it("should handle invalid credentials gracefully", async () => {
- mockContext.secrets.get.mockResolvedValue("invalid-json")
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await authService.initialize()
- expect(authService.getState()).toBe("logged-out")
- expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
- })
- it("should handle credentials change events", async () => {
- let onDidChangeCallback: (e: { key: string }) => void
- mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
- onDidChangeCallback = callback
- return { dispose: vi.fn() }
- })
- await authService.initialize()
- // Simulate credentials change event
- const newCredentials = {
- clientToken: "new-token",
- sessionId: "new-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials))
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- onDidChangeCallback!({ key: "clerk-auth-credentials" })
- await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
- expect(authStateChangedSpy).toHaveBeenCalled()
- })
- })
- describe("login", () => {
- beforeEach(async () => {
- await authService.initialize()
- })
- it("should generate state and open external URL", async () => {
- const mockOpenExternal = vi.fn()
- const vscode = await import("vscode")
- vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
- await authService.login()
- expect(crypto.randomBytes).toHaveBeenCalledWith(16)
- expect(mockContext.globalState.update).toHaveBeenCalledWith(
- "clerk-auth-state",
- "746573742d72616e646f6d2d6279746573",
- )
- expect(mockOpenExternal).toHaveBeenCalledWith(
- expect.objectContaining({
- toString: expect.any(Function),
- }),
- )
- })
- it("should use package.json values for redirect URI with default sign-in endpoint", async () => {
- const mockOpenExternal = vi.fn()
- const vscode = await import("vscode")
- vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
- await authService.login()
- const expectedUrl =
- "https://api.test.com/extension/sign-in?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
- expect(mockOpenExternal).toHaveBeenCalledWith(
- expect.objectContaining({
- toString: expect.any(Function),
- }),
- )
- // Verify the actual URL
- const calledUri = mockOpenExternal.mock.calls[0]?.[0]
- expect(calledUri.toString()).toBe(expectedUrl)
- })
- it("should use provider signup URL when useProviderSignup is true", async () => {
- const mockOpenExternal = vi.fn()
- const vscode = await import("vscode")
- vi.mocked(vscode.env.openExternal).mockImplementation(mockOpenExternal)
- await authService.login(undefined, true)
- const expectedUrl =
- "https://api.test.com/extension/provider-sign-up?state=746573742d72616e646f6d2d6279746573&auth_redirect=vscode%3A%2F%2FRooVeterinaryInc.roo-cline"
- expect(mockOpenExternal).toHaveBeenCalledWith(
- expect.objectContaining({
- toString: expect.any(Function),
- }),
- )
- // Verify the actual URL
- const calledUri = mockOpenExternal.mock.calls[0]?.[0]
- expect(calledUri.toString()).toBe(expectedUrl)
- })
- it("should handle errors during login", async () => {
- vi.mocked(crypto.randomBytes).mockImplementation(() => {
- throw new Error("Crypto error")
- })
- await expect(authService.login()).rejects.toThrow("Failed to initiate Roo Code Cloud authentication")
- expect(mockLog).toHaveBeenCalledWith("[auth] Error initiating Roo Code Cloud auth: Error: Crypto error")
- })
- })
- describe("handleCallback", () => {
- beforeEach(async () => {
- await authService.initialize()
- })
- it("should handle invalid parameters", async () => {
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.handleCallback(null, "state")
- expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
- await authService.handleCallback("code", null)
- expect(mockShowInfo).toHaveBeenCalledWith("Invalid Roo Code Cloud sign in url")
- })
- it("should validate state parameter", async () => {
- mockContext.globalState.get.mockReturnValue("stored-state")
- await expect(authService.handleCallback("code", "different-state")).rejects.toThrow(
- "Failed to handle Roo Code Cloud callback",
- )
- expect(mockLog).toHaveBeenCalledWith("[auth] State mismatch in callback")
- })
- it("should successfully handle valid callback", async () => {
- const storedState = "valid-state"
- mockContext.globalState.get.mockReturnValue(storedState)
- // Mock successful Clerk sign-in response
- const mockResponse = {
- ok: true,
- json: () =>
- Promise.resolve({
- response: { created_session_id: "session-123" },
- }),
- headers: {
- get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
- },
- }
- mockFetch.mockResolvedValue(mockResponse)
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.handleCallback("auth-code", storedState)
- expect(mockContext.secrets.store).toHaveBeenCalledWith(
- "clerk-auth-credentials",
- JSON.stringify({
- clientToken: "Bearer token-123",
- sessionId: "session-123",
- organizationId: null,
- }),
- )
- expect(mockShowInfo).toHaveBeenCalledWith("Successfully authenticated with Roo Code Cloud")
- })
- it("should store provider model when provided in callback", async () => {
- const storedState = "valid-state"
- mockContext.globalState.get.mockReturnValue(storedState)
- // Mock successful Clerk sign-in response
- const mockResponse = {
- ok: true,
- json: () =>
- Promise.resolve({
- response: { created_session_id: "session-123" },
- }),
- headers: {
- get: (header: string) => (header === "authorization" ? "Bearer token-123" : null),
- },
- }
- mockFetch.mockResolvedValue(mockResponse)
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.handleCallback("auth-code", storedState, null, "xai/grok-code-fast-1")
- expect(mockContext.globalState.update).toHaveBeenCalledWith("roo-provider-model", "xai/grok-code-fast-1")
- expect(mockLog).toHaveBeenCalledWith("[auth] Stored provider model: xai/grok-code-fast-1")
- })
- it("should handle Clerk API errors", async () => {
- const storedState = "valid-state"
- mockContext.globalState.get.mockReturnValue(storedState)
- mockFetch.mockResolvedValue({
- ok: false,
- status: 400,
- statusText: "Bad Request",
- })
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
- "Failed to handle Roo Code Cloud callback",
- )
- expect(authStateChangedSpy).toHaveBeenCalled()
- })
- })
- describe("logout", () => {
- beforeEach(async () => {
- await authService.initialize()
- })
- it("should clear credentials and call Clerk logout", async () => {
- // Set up credentials first by simulating a login state
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- // Manually set the credentials in the service
- authService["credentials"] = credentials
- // Mock successful logout response
- mockFetch.mockResolvedValue({ ok: true })
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.logout()
- expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
- expect(mockContext.globalState.update).toHaveBeenCalledWith("clerk-auth-state", undefined)
- expect(mockFetch).toHaveBeenCalledWith(
- "https://clerk.roocode.com/v1/client/sessions/test-session/remove",
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({
- Authorization: "Bearer test-token",
- }),
- }),
- )
- expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
- })
- it("should handle logout without credentials", async () => {
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.logout()
- expect(mockContext.secrets.delete).toHaveBeenCalled()
- expect(mockFetch).not.toHaveBeenCalled()
- expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
- })
- it("should handle Clerk logout errors gracefully", async () => {
- // Set up credentials first by simulating a login state
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- // Manually set the credentials in the service
- authService["credentials"] = credentials
- // Mock failed logout response
- mockFetch.mockRejectedValue(new Error("Network error"))
- const vscode = await import("vscode")
- const mockShowInfo = vi.fn()
- vi.mocked(vscode.window.showInformationMessage).mockImplementation(mockShowInfo)
- await authService.logout()
- expect(mockLog).toHaveBeenCalledWith("[auth] Error calling clerkLogout:", expect.any(Error))
- expect(mockShowInfo).toHaveBeenCalledWith("Logged out from Roo Code Cloud")
- })
- })
- describe("state management", () => {
- it("should return correct state", () => {
- expect(authService.getState()).toBe("initializing")
- })
- it("should return correct authentication status", async () => {
- await authService.initialize()
- expect(authService.isAuthenticated()).toBe(false)
- // Create a new service instance with credentials
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- const authenticatedService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await authenticatedService.initialize()
- expect(authenticatedService.isAuthenticated()).toBe(true)
- expect(authenticatedService.hasActiveSession()).toBe(false)
- })
- it("should return session token only for active sessions", () => {
- expect(authService.getSessionToken()).toBeUndefined()
- // Manually set state to active-session for testing
- // This would normally happen through refreshSession
- authService["state"] = "active-session"
- authService["sessionToken"] = "test-jwt"
- expect(authService.getSessionToken()).toBe("test-jwt")
- })
- it("should return correct values for new methods", async () => {
- await authService.initialize()
- expect(authService.hasOrIsAcquiringActiveSession()).toBe(false)
- // Create a new service instance with credentials (attempting-session)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- const attemptingService = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await attemptingService.initialize()
- expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true)
- expect(attemptingService.hasActiveSession()).toBe(false)
- // Manually set state to active-session for testing
- attemptingService["state"] = "active-session"
- expect(attemptingService.hasOrIsAcquiringActiveSession()).toBe(true)
- expect(attemptingService.hasActiveSession()).toBe(true)
- })
- })
- describe("session refresh", () => {
- beforeEach(async () => {
- // Set up with credentials
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- })
- it("should refresh session successfully", async () => {
- // Mock successful token creation and user info fetch
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "new-jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "John",
- last_name: "Doe",
- image_url: "https://example.com/avatar.jpg",
- primary_email_address_id: "email-1",
- email_addresses: [{ id: "email-1", email_address: "[email protected]" }],
- },
- }),
- })
- const authStateChangedSpy = vi.fn()
- const userInfoSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- authService.on("user-info", userInfoSpy)
- // Trigger refresh by calling the timer callback
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- expect(authService.getState()).toBe("active-session")
- expect(authService.hasActiveSession()).toBe(true)
- expect(authService.getSessionToken()).toBe("new-jwt-token")
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "active-session",
- previousState: "attempting-session",
- })
- expect(userInfoSpy).toHaveBeenCalledWith({
- userInfo: {
- id: undefined,
- name: "John Doe",
- email: "[email protected]",
- picture: "https://example.com/avatar.jpg",
- extensionBridgeEnabled: true,
- },
- })
- })
- it("should handle invalid client token error", async () => {
- // Mock 401 response (invalid token)
- mockFetch.mockResolvedValue({
- ok: false,
- status: 401,
- statusText: "Unauthorized",
- })
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await expect(timerCallback?.()).rejects.toThrow()
- expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
- expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials")
- })
- it("should handle network errors during refresh", async () => {
- mockFetch.mockRejectedValue(new Error("Network error"))
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await expect(timerCallback?.()).rejects.toThrow("Network error")
- expect(mockLog).toHaveBeenCalledWith("[auth] Failed to refresh session", expect.any(Error))
- })
- it("should transition to inactive-session on first attempt failure", async () => {
- // Mock failed token creation response
- mockFetch.mockResolvedValue({
- ok: false,
- status: 500,
- statusText: "Internal Server Error",
- })
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- // Verify we start in attempting-session state
- expect(authService.getState()).toBe("attempting-session")
- expect(authService["isFirstRefreshAttempt"]).toBe(true)
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await expect(timerCallback?.()).rejects.toThrow()
- // Should transition to inactive-session after first failure
- expect(authService.getState()).toBe("inactive-session")
- expect(authService["isFirstRefreshAttempt"]).toBe(false)
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "inactive-session",
- previousState: "attempting-session",
- })
- })
- it("should not transition to inactive-session on subsequent failures", async () => {
- // First, transition to inactive-session by failing the first attempt
- mockFetch.mockResolvedValue({
- ok: false,
- status: 500,
- statusText: "Internal Server Error",
- })
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await expect(timerCallback?.()).rejects.toThrow()
- // Verify we're now in inactive-session
- expect(authService.getState()).toBe("inactive-session")
- expect(authService["isFirstRefreshAttempt"]).toBe(false)
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- // Subsequent failure should not trigger another transition
- await expect(timerCallback?.()).rejects.toThrow()
- expect(authService.getState()).toBe("inactive-session")
- expect(authStateChangedSpy).not.toHaveBeenCalled()
- })
- it("should clear credentials on 401 during first refresh attempt (bug fix)", async () => {
- // Mock 401 response during first refresh attempt
- mockFetch.mockResolvedValue({
- ok: false,
- status: 401,
- statusText: "Unauthorized",
- })
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await expect(timerCallback?.()).rejects.toThrow()
- // Should clear credentials (not just transition to inactive-session)
- expect(mockContext.secrets.delete).toHaveBeenCalledWith("clerk-auth-credentials")
- expect(mockLog).toHaveBeenCalledWith("[auth] Invalid/Expired client token: clearing credentials")
- // Simulate credentials cleared event
- mockContext.secrets.get.mockResolvedValue(undefined)
- await authService["handleCredentialsChange"]()
- expect(authService.getState()).toBe("logged-out")
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "logged-out",
- previousState: "attempting-session",
- })
- })
- })
- describe("user info", () => {
- it("should return null initially", () => {
- expect(authService.getUserInfo()).toBeNull()
- })
- it("should parse user info correctly for personal accounts", async () => {
- // Set up with credentials for personal account (no organizationId)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- organizationId: null,
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- // Clear previous mock calls
- mockFetch.mockClear()
- // Mock successful responses
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "Jane",
- last_name: "Smith",
- image_url: "https://example.com/jane.jpg",
- primary_email_address_id: "email-2",
- email_addresses: [
- { id: "email-1", email_address: "[email protected]" },
- { id: "email-2", email_address: "[email protected]" },
- ],
- },
- }),
- })
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- const userInfo = authService.getUserInfo()
- expect(userInfo).toEqual({
- id: undefined,
- name: "Jane Smith",
- email: "[email protected]",
- picture: "https://example.com/jane.jpg",
- extensionBridgeEnabled: true,
- })
- })
- it("should parse user info correctly for organization accounts", async () => {
- // Set up with credentials for organization account
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- organizationId: "org_1",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- // Clear previous mock calls
- mockFetch.mockClear()
- // Mock successful responses
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "Jane",
- last_name: "Smith",
- image_url: "https://example.com/jane.jpg",
- primary_email_address_id: "email-2",
- email_addresses: [
- { id: "email-1", email_address: "[email protected]" },
- { id: "email-2", email_address: "[email protected]" },
- ],
- },
- }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: [
- {
- id: "org_member_id_1",
- role: "member",
- organization: {
- id: "org_1",
- name: "Org 1",
- },
- },
- ],
- }),
- })
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- const userInfo = authService.getUserInfo()
- expect(userInfo).toEqual({
- id: undefined,
- name: "Jane Smith",
- email: "[email protected]",
- picture: "https://example.com/jane.jpg",
- extensionBridgeEnabled: false,
- organizationId: "org_1",
- organizationName: "Org 1",
- organizationRole: "member",
- organizationImageUrl: undefined,
- })
- })
- it("should handle missing user info fields", async () => {
- // Set up with credentials for personal account (no organizationId)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- organizationId: null,
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- // Clear previous mock calls
- mockFetch.mockClear()
- // Mock responses with minimal data
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "John",
- last_name: "Doe",
- },
- }),
- })
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- const userInfo = authService.getUserInfo()
- expect(userInfo).toEqual({
- id: undefined,
- name: "John Doe",
- email: undefined,
- picture: undefined,
- extensionBridgeEnabled: true,
- })
- })
- })
- describe("event emissions", () => {
- it("should emit auth-state-changed event for logged-out", async () => {
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await authService.initialize()
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "logged-out",
- previousState: "initializing",
- })
- })
- it("should emit auth-state-changed event for attempting-session", async () => {
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- await authService.initialize()
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "attempting-session",
- previousState: "initializing",
- })
- })
- it("should emit auth-state-changed event for active-session", async () => {
- // Set up with credentials
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- // Clear previous mock calls
- mockFetch.mockClear()
- // Mock both the token creation and user info fetch
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "Test",
- last_name: "User",
- },
- }),
- })
- const authStateChangedSpy = vi.fn()
- authService.on("auth-state-changed", authStateChangedSpy)
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- expect(authStateChangedSpy).toHaveBeenCalledWith({
- state: "active-session",
- previousState: "attempting-session",
- })
- })
- it("should emit user-info event", async () => {
- // Set up with credentials
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- // Clear previous mock calls
- mockFetch.mockClear()
- mockFetch
- .mockResolvedValueOnce({
- ok: true,
- json: () => Promise.resolve({ jwt: "jwt-token" }),
- })
- .mockResolvedValueOnce({
- ok: true,
- json: () =>
- Promise.resolve({
- response: {
- first_name: "Test",
- last_name: "User",
- },
- }),
- })
- const userInfoSpy = vi.fn()
- authService.on("user-info", userInfoSpy)
- const timerCallback = vi.mocked(RefreshTimer).mock.calls[0]?.[0]?.callback
- await timerCallback?.()
- // Wait for async operations to complete
- await new Promise((resolve) => setTimeout(resolve, 0))
- expect(userInfoSpy).toHaveBeenCalledWith({
- userInfo: {
- id: undefined,
- name: "Test User",
- email: undefined,
- picture: undefined,
- extensionBridgeEnabled: true,
- },
- })
- })
- })
- describe("error handling", () => {
- it("should handle credentials change errors", async () => {
- mockContext.secrets.get.mockRejectedValue(new Error("Storage error"))
- await authService.initialize()
- expect(mockLog).toHaveBeenCalledWith("[auth] Error handling credentials change:", expect.any(Error))
- })
- it("should handle malformed JSON in credentials", async () => {
- mockContext.secrets.get.mockResolvedValue("invalid-json{")
- await authService.initialize()
- expect(authService.getState()).toBe("logged-out")
- expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse stored credentials:", expect.any(Error))
- })
- it("should handle invalid credentials schema", async () => {
- mockContext.secrets.get.mockResolvedValue(JSON.stringify({ invalid: "data" }))
- await authService.initialize()
- expect(authService.getState()).toBe("logged-out")
- expect(mockLog).toHaveBeenCalledWith("[auth] Invalid credentials format:", expect.any(Array))
- })
- it("should handle missing authorization header in sign-in response", async () => {
- const storedState = "valid-state"
- mockContext.globalState.get.mockReturnValue(storedState)
- mockFetch.mockResolvedValue({
- ok: true,
- json: () =>
- Promise.resolve({
- response: { created_session_id: "session-123" },
- }),
- headers: {
- get: () => null, // No authorization header
- },
- })
- await expect(authService.handleCallback("auth-code", storedState)).rejects.toThrow(
- "Failed to handle Roo Code Cloud callback",
- )
- })
- })
- describe("timer integration", () => {
- it("should stop timer on logged-out transition", async () => {
- await authService.initialize()
- expect(mockTimer.stop).toHaveBeenCalled()
- })
- it("should start timer on attempting-session transition", async () => {
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await authService.initialize()
- expect(mockTimer.start).toHaveBeenCalled()
- })
- })
- describe("auth credentials key scoping", () => {
- it("should use default key when getClerkBaseUrl returns production URL", async () => {
- // Mock getClerkBaseUrl to return production URL
- vi.mocked(getClerkBaseUrl).mockReturnValue("https://clerk.roocode.com")
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- await service.initialize()
- await service["storeCredentials"](credentials)
- expect(mockContext.secrets.store).toHaveBeenCalledWith(
- "clerk-auth-credentials",
- JSON.stringify(credentials),
- )
- })
- it("should use scoped key when getClerkBaseUrl returns custom URL", async () => {
- const customUrl = "https://custom.clerk.com"
- // Mock getClerkBaseUrl to return custom URL
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- await service.initialize()
- await service["storeCredentials"](credentials)
- expect(mockContext.secrets.store).toHaveBeenCalledWith(
- `clerk-auth-credentials-${customUrl}`,
- JSON.stringify(credentials),
- )
- })
- it("should load credentials using scoped key", async () => {
- const customUrl = "https://custom.clerk.com"
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- const credentials = {
- clientToken: "test-token",
- sessionId: "test-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(credentials))
- await service.initialize()
- const loadedCredentials = await service["loadCredentials"]()
- expect(mockContext.secrets.get).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`)
- expect(loadedCredentials).toEqual(credentials)
- })
- it("should clear credentials using scoped key", async () => {
- const customUrl = "https://custom.clerk.com"
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await service.initialize()
- await service["clearCredentials"]()
- expect(mockContext.secrets.delete).toHaveBeenCalledWith(`clerk-auth-credentials-${customUrl}`)
- })
- it("should listen for changes on scoped key", async () => {
- const customUrl = "https://custom.clerk.com"
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- let onDidChangeCallback: (e: { key: string }) => void
- mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
- onDidChangeCallback = callback
- return { dispose: vi.fn() }
- })
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await service.initialize()
- // Simulate credentials change event with scoped key
- const newCredentials = {
- clientToken: "new-token",
- sessionId: "new-session",
- }
- mockContext.secrets.get.mockResolvedValue(JSON.stringify(newCredentials))
- const authStateChangedSpy = vi.fn()
- service.on("auth-state-changed", authStateChangedSpy)
- onDidChangeCallback!({ key: `clerk-auth-credentials-${customUrl}` })
- await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
- expect(authStateChangedSpy).toHaveBeenCalled()
- })
- it("should not respond to changes on different scoped keys", async () => {
- const customUrl = "https://custom.clerk.com"
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- let onDidChangeCallback: (e: { key: string }) => void
- mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
- onDidChangeCallback = callback
- return { dispose: vi.fn() }
- })
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await service.initialize()
- const authStateChangedSpy = vi.fn()
- service.on("auth-state-changed", authStateChangedSpy)
- // Simulate credentials change event with different scoped key
- onDidChangeCallback!({
- key: "clerk-auth-credentials-https://other.clerk.com",
- })
- await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
- expect(authStateChangedSpy).not.toHaveBeenCalled()
- })
- it("should not respond to changes on default key when using scoped key", async () => {
- const customUrl = "https://custom.clerk.com"
- vi.mocked(getClerkBaseUrl).mockReturnValue(customUrl)
- let onDidChangeCallback: (e: { key: string }) => void
- mockContext.secrets.onDidChange.mockImplementation((callback: (e: { key: string }) => void) => {
- onDidChangeCallback = callback
- return { dispose: vi.fn() }
- })
- const service = new WebAuthService(mockContext as unknown as ExtensionContext, mockLog)
- await service.initialize()
- const authStateChangedSpy = vi.fn()
- service.on("auth-state-changed", authStateChangedSpy)
- // Simulate credentials change event with default key
- onDidChangeCallback!({ key: "clerk-auth-credentials" })
- await new Promise((resolve) => setTimeout(resolve, 0)) // Wait for async handling
- expect(authStateChangedSpy).not.toHaveBeenCalled()
- })
- })
- })
|