diff --git a/packages/core/src/auth/__mocks__/core-client.mocks.ts b/packages/core/src/auth/__mocks__/core-client.mocks.ts new file mode 100644 index 00000000..fc2971a8 --- /dev/null +++ b/packages/core/src/auth/__mocks__/core-client.mocks.ts @@ -0,0 +1,250 @@ +import { vi } from 'vitest'; + +import { createMockI18nService } from '../../i18n/__mocks__/i18n-service.mocks'; +import type { + AuthDetails, + BasicAuth0ContextInterface, + CoreClientInterface, + User, + Auth0ContextInterface, + GetTokenSilentlyVerboseResponse, + GetTokenSilentlyOptions, +} from '../auth-types'; + +/** + * Creates a mock user object + */ +export const createMockUser = (overrides?: Partial): User => ({ + sub: 'auth0|test-user-123', + name: 'Test User', + given_name: 'Test', + family_name: 'User', + email: 'user@example.com', + email_verified: true, + picture: 'https://example.com/avatar.jpg', + updated_at: '2024-01-01T00:00:00.000Z', + ...overrides, +}); + +/** + * Creates a mock GetTokenSilentlyVerboseResponse + */ +export const createMockVerboseTokenResponse = ( + overrides?: Partial, +): GetTokenSilentlyVerboseResponse => ({ + id_token: 'mock-id-token', + access_token: 'mock-access-token', + expires_in: 3600, + ...overrides, +}); + +/** + * Creates a mock BasicAuth0ContextInterface + */ +export const createMockBasicAuth0Context = ( + overrides?: Partial, +): BasicAuth0ContextInterface => ({ + isAuthenticated: true, + user: createMockUser(), + getAccessTokenSilently: vi.fn().mockImplementation(async (options?: GetTokenSilentlyOptions) => { + if (options?.detailedResponse) { + return createMockVerboseTokenResponse(); + } + return 'mock-access-token'; + }), + getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-access-token'), + loginWithRedirect: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +/** + * Creates a mock Auth0ContextInterface with full properties + */ +export const createMockAuth0Context = ( + overrides?: Partial, +): Auth0ContextInterface => ({ + isAuthenticated: true, + isLoading: false, + user: createMockUser(), + getAccessTokenSilently: vi.fn().mockImplementation(async (options?: GetTokenSilentlyOptions) => { + if (options?.detailedResponse) { + return createMockVerboseTokenResponse(); + } + return 'mock-access-token'; + }), + getAccessTokenWithPopup: vi.fn().mockResolvedValue('mock-access-token'), + loginWithRedirect: vi.fn().mockResolvedValue(undefined), + loginWithPopup: vi.fn().mockResolvedValue(undefined), + logout: vi.fn().mockResolvedValue(undefined), + getIdTokenClaims: vi.fn().mockResolvedValue({ + sub: 'auth0|test-user-123', + aud: 'test-client-id', + iss: 'https://test-domain.auth0.com/', + }), + handleRedirectCallback: vi.fn().mockResolvedValue({ + appState: {}, + }), + ...overrides, +}); + +/** + * Creates a mock AuthDetails object + */ +export const createMockAuthDetails = (overrides?: Partial): AuthDetails => ({ + authProxyUrl: 'https://mock-auth-proxy.com', + domain: 'mock-domain.auth0.com', + contextInterface: createMockBasicAuth0Context(), + ...overrides, +}); + +/** + * Creates a mock MyAccountClient service + */ +export const createMockMyAccountApiClient = (): CoreClientInterface['myAccountApiClient'] => { + return { + factors: { + list: vi.fn().mockResolvedValue({ factors: [] }), + }, + authenticationMethods: { + list: vi.fn().mockResolvedValue({ authentication_methods: [] }), + create: vi.fn().mockResolvedValue({ id: 'new_method_123', type: 'totp' }), + delete: vi.fn().mockResolvedValue(undefined), + verify: vi.fn().mockResolvedValue({ confirmed: true }), + }, + } as unknown as CoreClientInterface['myAccountApiClient']; +}; + +/** + * Creates a mock MyOrganizationClient service + */ +export const createMockMyOrgApiClient = (): CoreClientInterface['myOrgApiClient'] => { + return { + organizationDetails: { + get: vi.fn().mockResolvedValue({ + id: 'org_123', + name: 'Test Organization', + display_name: 'Test Organization', + }), + update: vi.fn().mockResolvedValue({ + id: 'org_123', + name: 'Test Organization', + display_name: 'Test Organization', + }), + }, + organization: { + identityProviders: { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue({ + id: 'idp_123', + name: 'Test Provider', + strategy: 'oidc', + }), + create: vi.fn().mockResolvedValue({ id: 'idp_123' }), + update: vi.fn().mockResolvedValue({ id: 'idp_123' }), + delete: vi.fn().mockResolvedValue(undefined), + detach: vi.fn().mockResolvedValue(undefined), + domains: { + create: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + provisioning: { + get: vi.fn().mockRejectedValue({ status: 404 }), + create: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue(undefined), + }, + }, + domains: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ + id: 'domain_123', + domain: 'example.com', + status: 'pending', + }), + update: vi.fn().mockResolvedValue({ id: 'domain_123' }), + delete: vi.fn().mockResolvedValue(undefined), + verify: { + create: vi.fn().mockResolvedValue({ status: 'verified' }), + }, + identityProviders: { + get: vi.fn().mockResolvedValue({ identity_providers: [] }), + }, + }, + configuration: { + get: vi.fn().mockResolvedValue({ + allowed_strategies: [ + 'samlp', + 'oidc', + 'adfs', + 'waad', + 'google-apps', + 'pingfederate', + 'okta', + ], + connection_deletion_behavior: 'allow', + }), + identityProviders: { + get: vi.fn().mockResolvedValue({ + strategies: { + samlp: { + enabled_features: ['provisioning'], + provisioning_methods: ['scim'], + }, + oidc: { + enabled_features: [], + provisioning_methods: [], + }, + }, + }), + }, + }, + }, + } as unknown as CoreClientInterface['myOrgApiClient']; +}; + +/** + * Creates a mock CoreClientInterface + */ +export const createMockCoreClient = (authDetails?: Partial): CoreClientInterface => { + const mockAuth = createMockAuthDetails(authDetails); + const mockI18nService = createMockI18nService(); + const mockMyAccountApiClient = createMockMyAccountApiClient(); + const mockMyOrgApiClient = createMockMyOrgApiClient(); + + return { + auth: mockAuth, + i18nService: mockI18nService, + myAccountApiClient: mockMyAccountApiClient, + myOrgApiClient: mockMyOrgApiClient, + getMyAccountApiClient: vi.fn( + () => mockMyAccountApiClient, + ) as CoreClientInterface['getMyAccountApiClient'], + getMyOrgApiClient: vi.fn(() => mockMyOrgApiClient) as CoreClientInterface['getMyOrgApiClient'], + getToken: vi.fn().mockResolvedValue('mock-access-token'), + isProxyMode: vi.fn().mockReturnValue(false), + ensureScopes: vi.fn().mockResolvedValue(undefined), + }; +}; + +/** + * Creates a mock CoreClientInterface in proxy mode + */ +export const createMockProxyCoreClient = ( + authDetails?: Partial, +): CoreClientInterface => { + const mockCoreClient = createMockCoreClient(authDetails); + mockCoreClient.auth.authProxyUrl = 'https://mock-auth-proxy.com'; + mockCoreClient.isProxyMode = vi.fn().mockReturnValue(true); + return mockCoreClient; +}; + +/** + * Creates a mock unauthenticated CoreClientInterface + */ +export const createMockUnauthenticatedCoreClient = (): CoreClientInterface => { + return createMockCoreClient({ + contextInterface: createMockBasicAuth0Context({ + isAuthenticated: false, + user: undefined, + }), + }); +}; diff --git a/packages/core/src/auth/__mocks__/index.ts b/packages/core/src/auth/__mocks__/index.ts new file mode 100644 index 00000000..dc3db7cf --- /dev/null +++ b/packages/core/src/auth/__mocks__/index.ts @@ -0,0 +1,2 @@ +export * from './core-client.mocks'; +export * from './token-manager.mocks'; diff --git a/packages/core/src/auth/__mocks__/token-manager.mocks.ts b/packages/core/src/auth/__mocks__/token-manager.mocks.ts new file mode 100644 index 00000000..bf6bd48e --- /dev/null +++ b/packages/core/src/auth/__mocks__/token-manager.mocks.ts @@ -0,0 +1,38 @@ +import { vi } from 'vitest'; + +import type { createTokenManager } from '../token-manager'; + +/** + * Creates a mock token manager service + */ +export const createMockTokenManager = ( + tokenValue: string | undefined = 'mock-access-token', +): ReturnType => ({ + getToken: vi.fn(async () => tokenValue), +}); + +export const createMockTokenManagerWithScopes = ( + tokenValue: string | undefined = 'mock-access-token', +): ReturnType & { + lastScope?: string; + lastAudiencePath?: string; +} => { + const mockManager = { + lastScope: undefined as string | undefined, + lastAudiencePath: undefined as string | undefined, + getToken: vi.fn(async (scope: string, audiencePath: string) => { + mockManager.lastScope = scope; + mockManager.lastAudiencePath = audiencePath; + return tokenValue; + }), + }; + return mockManager; +}; + +export const createMockTokenManagerWithError = ( + error: Error = new Error('Token retrieval failed'), +): ReturnType => ({ + getToken: async () => { + throw error; + }, +}); diff --git a/packages/core/src/auth/__tests__/auth-utils.test.ts b/packages/core/src/auth/__tests__/auth-utils.test.ts new file mode 100644 index 00000000..7d32426c --- /dev/null +++ b/packages/core/src/auth/__tests__/auth-utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { AuthUtils } from '../auth-utils'; + +describe('auth-utils', () => { + describe.each([ + { + domain: 'https://example.auth0.com', + expected: 'https://example.auth0.com/', + }, + { + domain: 'example.auth0.com', + expected: 'https://example.auth0.com/', + }, + { + domain: 'http://localhost:3000', + expected: 'http://localhost:3000/', + }, + ])('toURL with domain', ({ domain, expected }) => { + it('should convert to the expected URL format', () => { + const result = AuthUtils.toURL(domain); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/core/src/auth/__tests__/core-client.test.ts b/packages/core/src/auth/__tests__/core-client.test.ts new file mode 100644 index 00000000..6d8f3645 --- /dev/null +++ b/packages/core/src/auth/__tests__/core-client.test.ts @@ -0,0 +1,345 @@ +import type { MyAccountClient } from '@auth0/myaccount-js'; +import type { MyOrganizationClient } from '@auth0/myorganization-js'; +import { initializeMyAccountClient } from '@core/services/my-account/my-account-api-service'; +import { initializeMyOrgClient } from '@core/services/my-org/my-org-api-service'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createI18nService } from '../../i18n'; +import { createMockI18nService } from '../../i18n/__mocks__/i18n-service.mocks'; +import { createMockMyAccountClient } from '../../services/my-account/__tests__/__mocks__/my-account-api-service.mocks'; +import { createMockMyOrgClient } from '../../services/my-org/__tests__/__mocks__/my-org-api-service.mocks'; +import { createMockTokenManager } from '../__mocks__/token-manager.mocks'; +import type { AuthDetails } from '../auth-types'; +import { createCoreClient } from '../core-client'; +import { createTokenManager } from '../token-manager'; + +// Mock the modules +vi.mock('../../i18n'); +vi.mock('../token-manager'); +vi.mock('@core/services/my-org/my-org-api-service'); +vi.mock('@core/services/my-account/my-account-api-service'); + +describe('createCoreClient', () => { + // Create mock instances using mock utilities + const mockI18nService = createMockI18nService(); + const mockTokenManager = createMockTokenManager(); + const mockMyOrgClient = createMockMyOrgClient(); + const mockMyAccountClient = createMockMyAccountClient(); + + // Get the mocked functions + const createI18nServiceMock = vi.mocked(createI18nService); + const createTokenManagerMock = vi.mocked(createTokenManager); + const initializeMyOrgClientMock = vi.mocked(initializeMyOrgClient); + const initializeMyAccountClientMock = vi.mocked(initializeMyAccountClient); + + const createAuthDetails = (overrides: Partial = {}): AuthDetails => { + return { + domain: 'example.auth0.com', + authProxyUrl: undefined, + contextInterface: {} as AuthDetails['contextInterface'], + ...overrides, + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup default mock implementations + createI18nServiceMock.mockResolvedValue(mockI18nService); + createTokenManagerMock.mockReturnValue(mockTokenManager); + initializeMyOrgClientMock.mockReturnValue(mockMyOrgClient); + initializeMyAccountClientMock.mockReturnValue(mockMyAccountClient); + + // Reset token manager mock to return successful token + vi.mocked(mockTokenManager.getToken).mockResolvedValue('mock-token'); + }); + + describe('i18n initialization', () => { + it('initializes i18n with default options when none are provided', async () => { + const authDetails = createAuthDetails(); + await createCoreClient(authDetails); + + expect(createI18nServiceMock).toHaveBeenCalledWith({ + currentLanguage: 'en-US', + fallbackLanguage: 'en-US', + }); + }); + + it('initializes i18n with provided language options', async () => { + const i18nOptions = { currentLanguage: 'es', fallbackLanguage: 'en' }; + const authDetails = createAuthDetails(); + await createCoreClient(authDetails, i18nOptions); + + expect(createI18nServiceMock).toHaveBeenCalledWith(i18nOptions); + }); + + it('exposes i18nService on the client', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.i18nService).toBe(mockI18nService); + }); + }); + + describe('isProxyMode', () => { + it('returns false when authProxyUrl is undefined', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.isProxyMode()).toBe(false); + }); + + it('returns true when authProxyUrl is set', async () => { + const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); + const client = await createCoreClient(authDetails); + + expect(client.isProxyMode()).toBe(true); + }); + + it('returns false when authProxyUrl is empty string', async () => { + const authDetails = createAuthDetails({ authProxyUrl: '' }); + const client = await createCoreClient(authDetails); + + expect(client.isProxyMode()).toBe(false); + }); + }); + + describe('getToken', () => { + it('delegates to token manager with all parameters', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await client.getToken('read:org', 'my-org', true); + + expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:org', 'my-org', true); + }); + + it('delegates to token manager with default ignoreCache', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await client.getToken('read:me', 'me'); + + expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:me', 'me', false); + }); + + it('returns the token from token manager', async () => { + const authDetails = createAuthDetails(); + vi.mocked(mockTokenManager.getToken).mockResolvedValueOnce('specific-token-value'); + const client = await createCoreClient(authDetails); + + const token = await client.getToken('read:me', 'me'); + + expect(token).toBe('specific-token-value'); + }); + }); + + describe('ensureScopes - proxy mode', () => { + it('sets org scopes without token fetch in proxy mode', async () => { + const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:org', 'my-org'); + + expect(mockMyOrgClient.setLatestScopes).toHaveBeenCalledWith('read:org'); + expect(mockTokenManager.getToken).not.toHaveBeenCalled(); + }); + + it('sets account scopes without token fetch in proxy mode', async () => { + const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:me', 'me'); + + expect(mockMyAccountClient.setLatestScopes).toHaveBeenCalledWith('read:me'); + expect(mockTokenManager.getToken).not.toHaveBeenCalled(); + }); + + it('does not set scopes for unknown audience in proxy mode', async () => { + const authDetails = createAuthDetails({ authProxyUrl: 'https://proxy.auth0.com' }); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:something', 'unknown-audience'); + + expect(mockMyOrgClient.setLatestScopes).not.toHaveBeenCalled(); + expect(mockMyAccountClient.setLatestScopes).not.toHaveBeenCalled(); + expect(mockTokenManager.getToken).not.toHaveBeenCalled(); + }); + }); + + describe('ensureScopes - non-proxy mode', () => { + it('throws when domain is missing in non-proxy mode', async () => { + const authDetails = createAuthDetails({ domain: undefined }); + const client = await createCoreClient(authDetails); + + await expect(client.ensureScopes('read:org', 'my-org')).rejects.toThrow( + 'Authentication domain is missing, cannot initialize SPA service.', + ); + expect(mockMyOrgClient.setLatestScopes).not.toHaveBeenCalled(); + expect(mockTokenManager.getToken).not.toHaveBeenCalled(); + }); + + it('sets org scopes and fetches token in non-proxy mode', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:org', 'my-org'); + + expect(mockMyOrgClient.setLatestScopes).toHaveBeenCalledWith('read:org'); + expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:org', 'my-org', true); + }); + + it('sets account scopes and fetches token in non-proxy mode', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:me', 'me'); + + expect(mockMyAccountClient.setLatestScopes).toHaveBeenCalledWith('read:me'); + expect(mockTokenManager.getToken).toHaveBeenCalledWith('read:me', 'me', true); + }); + + it('throws when token retrieval returns undefined in non-proxy mode', async () => { + vi.mocked(mockTokenManager.getToken).mockResolvedValueOnce(undefined); + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await expect(client.ensureScopes('read:me', 'me')).rejects.toThrow( + 'Failed to retrieve token for audience: me', + ); + }); + + it('does not set scopes for unknown audience in non-proxy mode', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + await client.ensureScopes('read:something', 'unknown-audience'); + + expect(mockMyOrgClient.setLatestScopes).not.toHaveBeenCalled(); + expect(mockMyAccountClient.setLatestScopes).not.toHaveBeenCalled(); + // Token fetch still happens for unknown audiences in non-proxy mode + expect(mockTokenManager.getToken).toHaveBeenCalledWith( + 'read:something', + 'unknown-audience', + true, + ); + }); + }); + + describe('API client initialization', () => { + it('initializes token manager with auth details', async () => { + const authDetails = createAuthDetails(); + await createCoreClient(authDetails); + + expect(createTokenManagerMock).toHaveBeenCalledWith(authDetails); + }); + + it('initializes MyOrg client with auth and token manager', async () => { + const authDetails = createAuthDetails(); + await createCoreClient(authDetails); + + expect(initializeMyOrgClientMock).toHaveBeenCalledWith(authDetails, mockTokenManager); + }); + + it('initializes MyAccount client with auth and token manager', async () => { + const authDetails = createAuthDetails(); + await createCoreClient(authDetails); + + expect(initializeMyAccountClientMock).toHaveBeenCalledWith(authDetails, mockTokenManager); + }); + }); + + describe('API client access', () => { + it('exposes myAccountApiClient directly on the client', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.myAccountApiClient).toBe(mockMyAccountClient.client); + }); + + it('exposes myOrgApiClient directly on the client', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.myOrgApiClient).toBe(mockMyOrgClient.client); + }); + + it('returns myAccountApiClient when available via getter', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.getMyAccountApiClient()).toBe(mockMyAccountClient.client); + }); + + it('returns myOrgApiClient when available via getter', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.getMyOrgApiClient()).toBe(mockMyOrgClient.client); + }); + + it('throws when myAccountApiClient is not available', async () => { + initializeMyAccountClientMock.mockReturnValueOnce({ + client: undefined as unknown as MyAccountClient, + setLatestScopes: vi.fn(), + }); + + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(() => client.getMyAccountApiClient()).toThrow( + 'myAccountApiClient is not enabled. Please use it within Auth0ComponentProvider.', + ); + }); + + it('throws when myOrgApiClient is not available', async () => { + initializeMyOrgClientMock.mockReturnValueOnce({ + client: undefined as unknown as MyOrganizationClient, + setLatestScopes: vi.fn(), + }); + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(() => client.getMyOrgApiClient()).toThrow( + 'myOrgApiClient is not enabled. Please ensure you are in an Auth0 Organization context.', + ); + }); + }); + + describe('client properties', () => { + it('exposes auth details on the client', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.auth).toEqual(authDetails); + }); + + it('preserves authProxyUrl in auth details', async () => { + const authDetails = createAuthDetails({ authProxyUrl: 'https://custom-proxy.com' }); + const client = await createCoreClient(authDetails); + + expect(client.auth.authProxyUrl).toBe('https://custom-proxy.com'); + }); + + it('preserves domain in auth details', async () => { + const authDetails = createAuthDetails(); + const client = await createCoreClient(authDetails); + + expect(client.auth.domain).toBe('example.auth0.com'); + }); + + it('preserves contextInterface in auth details', async () => { + const customContext: AuthDetails['contextInterface'] = { + user: { name: 'Test User' }, + isAuthenticated: true, + getAccessTokenSilently: vi.fn(), + getAccessTokenWithPopup: vi.fn(), + loginWithRedirect: vi.fn(), + }; + const authDetails = createAuthDetails({ contextInterface: customContext }); + const client = await createCoreClient(authDetails); + + expect(client.auth.contextInterface).toBe(customContext); + }); + }); +}); diff --git a/packages/core/src/auth/__tests__/token-manager.test.ts b/packages/core/src/auth/__tests__/token-manager.test.ts new file mode 100644 index 00000000..55128050 --- /dev/null +++ b/packages/core/src/auth/__tests__/token-manager.test.ts @@ -0,0 +1,530 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; + +import type { + AuthDetails, + BasicAuth0ContextInterface, + GetTokenSilentlyVerboseResponse, +} from '../auth-types'; +import { createTokenManager } from '../token-manager'; + +describe('token-manager', () => { + let mockContextInterface: BasicAuth0ContextInterface = { + user: undefined, + isAuthenticated: true, + getAccessTokenSilently: vi.fn(), + getAccessTokenWithPopup: vi.fn(), + loginWithRedirect: vi.fn(), + }; + + const createAuthConfig = (overrides: Partial = {}): AuthDetails => ({ + domain: 'example.auth0.com', + contextInterface: mockContextInterface, + ...overrides, + }); + + const mockToken = 'mock-access-token'; + + beforeEach(() => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockResolvedValue({ + access_token: mockToken, + id_token: 'mock-id-token', + expires_in: 3600, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('createTokenManager', () => { + it('should create a token manager with getToken method', () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + expect(tokenManager).toBeDefined(); + expect(tokenManager.getToken).toBeDefined(); + expect(typeof tokenManager.getToken).toBe('function'); + }); + }); + + describe('getToken', () => { + describe('validation errors', () => { + it('should throw error when auth is not initialized', async () => { + const tokenManager = createTokenManager(null as unknown as AuthDetails); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'TokenUtils: auth in CoreClient is not initialized.', + ); + }); + + it('should throw error when contextInterface is not initialized', async () => { + const authWithoutContext = createAuthConfig({ contextInterface: undefined }); + const tokenManager = createTokenManager(authWithoutContext); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'TokenUtils: contextInterface in CoreClient is not initialized.', + ); + }); + + it('should throw error when domain is not configured', async () => { + const authWithoutDomain = createAuthConfig({ domain: undefined }); + const tokenManager = createTokenManager(authWithoutDomain); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'TokenUtils: Auth0 domain is not configured', + ); + }); + }); + + describe('proxy mode', () => { + it('should return undefined when in proxy mode', async () => { + const proxyAuth = createAuthConfig({ authProxyUrl: 'https://proxy.example.com' }); + const tokenManager = createTokenManager(proxyAuth); + const token = await tokenManager.getToken('read:users', 'management'); + expect(token).toBeUndefined(); + expect(mockContextInterface.getAccessTokenSilently).not.toHaveBeenCalled(); + }); + + it('should not validate contextInterface when in proxy mode', async () => { + const proxyAuth = createAuthConfig({ + authProxyUrl: 'https://proxy.example.com', + contextInterface: undefined, + }); + const tokenManager = createTokenManager(proxyAuth); + const token = await tokenManager.getToken('read:users', 'management'); + expect(token).toBeUndefined(); + }); + }); + + describe('successful token retrieval', () => { + it('should fetch token with correct audience and scope', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + const token = await tokenManager.getToken('read:users', 'management'); + + expect(token).toBe(mockToken); + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + }, + detailedResponse: true, + }); + }); + + it('should build audience URL correctly for MFA', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await tokenManager.getToken('read:me:authentication_methods', 'mfa'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/mfa/', + scope: 'read:me:authentication_methods', + }, + detailedResponse: true, + }); + }); + + it('should handle domain with https protocol', async () => { + const authWithHttps = createAuthConfig({ domain: 'https://example.auth0.com' }); + + const tokenManager = createTokenManager(authWithHttps); + await tokenManager.getToken('read:users', 'management'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + }, + detailedResponse: true, + }); + }); + }); + + describe('cache management', () => { + it('should not use cacheMode option when ignoreCache is false', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await tokenManager.getToken('read:users', 'management', false); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + }, + detailedResponse: true, + }); + }); + + it('should use cacheMode off when ignoreCache is true', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await tokenManager.getToken('read:users', 'management', true); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + }, + detailedResponse: true, + cacheMode: 'off', + }); + }); + + it('should deduplicate concurrent requests for same token', async () => { + const mockToken = 'mock-token'; + let resolvePromise: (value: unknown) => void; + const delayedPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + vi.mocked(mockContextInterface.getAccessTokenSilently).mockReturnValue( + delayedPromise as Promise, + ); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + // Start multiple concurrent requests for the same token + const promise1 = tokenManager.getToken('read:users', 'management'); + const promise2 = tokenManager.getToken('read:users', 'management'); + const promise3 = tokenManager.getToken('read:users', 'management'); + + // Resolve the underlying promise + resolvePromise!({ + access_token: mockToken, + id_token: 'mock-id-token', + expires_in: 3600, + }); + + const [token1, token2, token3] = await Promise.all([promise1, promise2, promise3]); + + expect(token1).toBe(mockToken); + expect(token2).toBe(mockToken); + expect(token3).toBe(mockToken); + // Should only call the API once despite 3 requests + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(1); + }); + + it('should not deduplicate requests with different scopes', async () => { + const mockToken1 = 'mock-token-1'; + const mockToken2 = 'mock-token-2'; + + vi.mocked(mockContextInterface.getAccessTokenSilently) + .mockResolvedValueOnce({ + access_token: mockToken1, + id_token: 'mock-id-token', + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: mockToken2, + id_token: 'mock-id-token', + expires_in: 3600, + }); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + const [token1, token2] = await Promise.all([ + tokenManager.getToken('read:users', 'management'), + tokenManager.getToken('write:users', 'management'), + ]); + + expect(token1).toBe(mockToken1); + expect(token2).toBe(mockToken2); + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(2); + }); + + it('should not deduplicate requests with different audiences', async () => { + const mockToken1 = 'mock-token-1'; + const mockToken2 = 'mock-token-2'; + + vi.mocked(mockContextInterface.getAccessTokenSilently) + .mockResolvedValueOnce({ + access_token: mockToken1, + id_token: 'mock-id-token', + expires_in: 3600, + }) + .mockResolvedValueOnce({ + access_token: mockToken2, + id_token: 'mock-id-token', + expires_in: 3600, + }); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + const [token1, token2] = await Promise.all([ + tokenManager.getToken('read:users', 'management'), + tokenManager.getToken('read:users', 'mfa'), + ]); + + expect(token1).toBe(mockToken1); + expect(token2).toBe(mockToken2); + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(2); + }); + + it('should clear pending request when ignoreCache is true', async () => { + const mockToken1 = 'mock-token-1'; + const mockToken2 = 'mock-token-2'; + + let resolveFirstPromise: (value: unknown) => void; + const firstPromise = new Promise((resolve) => { + resolveFirstPromise = resolve; + }); + + vi.mocked(mockContextInterface.getAccessTokenSilently) + .mockReturnValueOnce(firstPromise as Promise) + .mockResolvedValueOnce({ + access_token: mockToken2, + id_token: 'mock-id-token', + expires_in: 3600, + }); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + // Start first request + const promise1 = tokenManager.getToken('read:users', 'management'); + + // Start second request with ignoreCache, should not reuse first request + const promise2 = tokenManager.getToken('read:users', 'management', true); + + // Resolve first promise + resolveFirstPromise!({ + access_token: mockToken1, + id_token: 'mock-id-token', + expires_in: 3600, + }); + + const [token1, token2] = await Promise.all([promise1, promise2]); + + expect(token1).toBe(mockToken1); + expect(token2).toBe(mockToken2); + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(2); + }); + + it('should clean up pending request after completion', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + // First request + await tokenManager.getToken('read:users', 'management'); + + // Second request should make a new API call since first is completed + await tokenManager.getToken('read:users', 'management'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(2); + }); + + it('should clean up pending request after error', async () => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValueOnce( + new Error('Network error'), + ); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + + // First request fails + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessToken: failed', + ); + + // Reset mock for second call + vi.mocked(mockContextInterface.getAccessTokenSilently).mockResolvedValueOnce({ + access_token: 'mock-token', + id_token: 'mock-id-token', + expires_in: 3600, + }); + + // Second request should succeed with new API call + const token = await tokenManager.getToken('read:users', 'management'); + expect(token).toBe('mock-token'); + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledTimes(2); + }); + }); + + describe('error handling with fallback', () => { + it('should use popup with consent prompt for consent_required error', async () => { + const mockToken = 'popup-token'; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + error: 'consent_required', + }); + vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + const token = await tokenManager.getToken('read:users', 'management'); + + expect(token).toBe(mockToken); + expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + prompt: 'consent', + }, + }); + }); + + it('should use popup with login prompt for login_required error', async () => { + const mockToken = 'popup-token'; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + error: 'login_required', + }); + vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + const token = await tokenManager.getToken('read:users', 'management'); + + expect(token).toBe(mockToken); + expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + prompt: 'login', + }, + }); + }); + + it('should use popup with consent prompt for mfa_required error', async () => { + const mockToken = 'popup-token'; + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + error: 'mfa_required', + }); + vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(mockToken); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + const token = await tokenManager.getToken('read:users', 'management'); + + expect(token).toBe(mockToken); + expect(mockContextInterface.getAccessTokenWithPopup).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: 'read:users', + prompt: 'consent', + }, + }); + }); + + it('should throw error when popup returns undefined token', async () => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + error: 'consent_required', + }); + vi.mocked(mockContextInterface.getAccessTokenWithPopup).mockResolvedValue(undefined); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessTokenWithPopup: Access token is not defined', + ); + }); + + it('should throw error for non-fallback errors', async () => { + const originalError = new Error('Network timeout'); + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(originalError); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessToken: failed', + ); + }); + + it('should include original error as cause for non-fallback errors', async () => { + const originalError = new Error('Network timeout'); + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(originalError); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + try { + await tokenManager.getToken('read:users', 'management'); + expect.fail('Should have thrown an error'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('getAccessToken: failed'); + expect((error as Error).cause).toBe(originalError); + } + }); + + it('should handle error objects with error property correctly', async () => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue({ + error: 'invalid_grant', + error_description: 'Some error description', + }); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessToken: failed', + ); + expect(mockContextInterface.getAccessTokenWithPopup).not.toHaveBeenCalled(); + }); + + it('should handle null error objects', async () => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue(null); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessToken: failed', + ); + }); + + it('should handle string errors', async () => { + vi.mocked(mockContextInterface.getAccessTokenSilently).mockRejectedValue( + 'String error message', + ); + + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await expect(tokenManager.getToken('read:users', 'management')).rejects.toThrow( + 'getAccessToken: failed', + ); + }); + }); + + describe('edge cases', () => { + it('should handle empty scope', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await tokenManager.getToken('', 'management'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope: '', + }, + detailedResponse: true, + }); + }); + + it('should handle empty audiencePath', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + await tokenManager.getToken('read:users', ''); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com//', + scope: 'read:users', + }, + detailedResponse: true, + }); + }); + + it('should handle special characters in scope', async () => { + const auth = createAuthConfig(); + const tokenManager = createTokenManager(auth); + const scope = 'read:users write:users update:users:self'; + await tokenManager.getToken(scope, 'management'); + + expect(mockContextInterface.getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { + audience: 'https://example.auth0.com/management/', + scope, + }, + detailedResponse: true, + }); + }); + }); + }); +}); diff --git a/packages/core/src/i18n/__mocks__/i18n-service.mocks.ts b/packages/core/src/i18n/__mocks__/i18n-service.mocks.ts new file mode 100644 index 00000000..b204aa97 --- /dev/null +++ b/packages/core/src/i18n/__mocks__/i18n-service.mocks.ts @@ -0,0 +1,66 @@ +import type { + I18nServiceInterface, + TranslationElements, + EnhancedTranslationFunction, +} from '../i18n-types'; + +/** + * Creates a mock translator function with trans support + */ +const createMockTranslator = ( + _namespace: string, + customMessages?: Record, +): EnhancedTranslationFunction => { + const translationFn = (key: string, _vars?: Record, fallback?: string) => { + if (customMessages) { + // Simplified nested value getter for mocks + const keys = key.split('.'); + let value: unknown = customMessages; + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + value = undefined; + break; + } + } + if (value !== undefined) { + return String(value); + } + } + return fallback || key; + }; + + const enhancedFn = translationFn as EnhancedTranslationFunction; + + enhancedFn.trans = ( + key: string, + _options?: { + components?: TranslationElements; + vars?: Record; + fallback?: string; + }, + ) => { + return [translationFn(key, _options?.vars, _options?.fallback)]; + }; + + return enhancedFn; +}; + +/** + * Creates a mock I18nServiceInterface + */ +export const createMockI18nService = ( + overrides?: Partial, +): I18nServiceInterface => ({ + currentLanguage: 'en', + fallbackLanguage: 'en', + translator: (namespace: string, customMessages?: Record) => + createMockTranslator(namespace, customMessages), + get commonTranslator() { + return createMockTranslator('common'); + }, + getCurrentTranslations: () => null, + changeLanguage: async () => {}, + ...overrides, +}); diff --git a/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts b/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts index 9360eafd..887bef8a 100644 --- a/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts +++ b/packages/core/src/services/my-account/__tests__/__mocks__/my-account-api-service.mocks.ts @@ -1,3 +1,18 @@ +import type { MyAccountClient } from '@auth0/myaccount-js'; +import { vi } from 'vitest'; + +import type { initializeMyAccountClient } from '../../my-account-api-service'; + +/** + * Creates a mock MyAccount API client + */ +export const createMockMyAccountClient = (): ReturnType => { + return { + client: {} as MyAccountClient, + setLatestScopes: vi.fn(), + }; +}; + // Re-export shared API service mocks export { // Auth Details Mocks diff --git a/packages/core/src/services/my-org/__tests__/__mocks__/my-org-api-service.mocks.ts b/packages/core/src/services/my-org/__tests__/__mocks__/my-org-api-service.mocks.ts index d065d058..a6c65772 100644 --- a/packages/core/src/services/my-org/__tests__/__mocks__/my-org-api-service.mocks.ts +++ b/packages/core/src/services/my-org/__tests__/__mocks__/my-org-api-service.mocks.ts @@ -1,3 +1,8 @@ +import type { MyOrganizationClient } from '@auth0/myorganization-js'; +import { vi } from 'vitest'; + +import type { initializeMyOrgClient } from '../../my-org-api-service'; + // Re-export shared API service mocks export { // Auth Details Mocks @@ -31,7 +36,7 @@ export { // MyOrg-specific Test Data // ============================================================================= -// Expected Proxy URL helper (service-specific path) +// Expected URLs export const getExpectedProxyBaseUrl = (proxyUrl: string): string => { const cleanUrl = proxyUrl.replace(/\/$/, ''); return `${cleanUrl}/my-org`; @@ -90,3 +95,13 @@ export const mockMyOrgClientMethods = { listMembers: 'listMembers', listRoles: 'listRoles', } as const; + +/** + * Creates a mock MyOrg API client + */ +export const createMockMyOrgClient = (): ReturnType => { + return { + client: {} as MyOrganizationClient, + setLatestScopes: vi.fn(), + }; +};