Roo-Code/packages/cloud/src/__tests__/WebAuthService.spec.ts

1196 lines
36 KiB
TypeScript

// 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", 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 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 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: "john@example.com" }],
},
}),
})
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: "john@example.com",
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: "jane.old@example.com" },
{ id: "email-2", email_address: "jane@example.com" },
],
},
}),
})
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: "jane@example.com",
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: "jane.old@example.com" },
{ id: "email-2", email_address: "jane@example.com" },
],
},
}),
})
.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: "jane@example.com",
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()
})
})
})