Files
rxminder/services/__tests__/oauth.service.test.ts
William Valentin 5098631551 test: add comprehensive OAuth service tests
- Implement 21 OAuth tests covering Google and GitHub authentication flows
- Test URL generation, parameter validation, and navigation calls
- Use MockNavigationService for testable OAuth redirect verification
- Achieve 97.05% coverage for OAuth service (up from 31.66%)
- Test both success and error scenarios for OAuth flows
- Fix ESLint unused variable warnings

This resolves the 18 failing OAuth tests and provides robust test coverage.
2025-09-08 11:35:27 -07:00

627 lines
20 KiB
TypeScript

import {
OAuthService,
googleAuth,
githubAuth,
handleGoogleCallback,
handleGithubCallback,
} from '../oauth';
import { authService } from '../auth/auth.service';
import { MockNavigationService } from '../navigation/navigation.interface';
// Mock dependencies
jest.mock('../auth/auth.service', () => ({
authService: {
loginWithOAuth: jest.fn(),
},
}));
// Mock crypto API
const mockRandomUUID = jest.fn();
Object.defineProperty(global, 'crypto', {
value: {
randomUUID: mockRandomUUID,
},
writable: true,
});
// Mock localStorage
const mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(global, 'localStorage', {
value: mockLocalStorage,
writable: true,
});
// Mock URL constructor
const mockURL = jest.fn();
Object.defineProperty(global, 'URL', {
value: mockURL,
writable: true,
});
describe('OAuth Service', () => {
let mockNavigation: MockNavigationService;
let oauthService: OAuthService;
beforeEach(() => {
jest.clearAllMocks();
mockRandomUUID.mockReturnValue('mock-uuid-12345');
mockLocalStorage.getItem.mockReturnValue(null);
// Create mock navigation service
mockNavigation = new MockNavigationService();
oauthService = new OAuthService(mockNavigation);
// Reset URL mock
mockURL.mockClear();
});
describe('googleAuth', () => {
test('should generate state and redirect to Google OAuth URL', () => {
// Mock URL constructor to return a mock object
const mockUrlInstance = {
toString: jest
.fn()
.mockReturnValue(
'https://accounts.google.com/o/oauth2/v2/auth?client_id=mock_google_client_id&response_type=code&scope=openid%20email%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345'
),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.googleAuth();
expect(mockRandomUUID).toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'oauth_state',
'mock-uuid-12345'
);
expect(mockURL).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'client_id',
'mock_google_client_id'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'response_type',
'code'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'scope',
'openid email profile'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'state',
'mock-uuid-12345'
);
expect(mockNavigation.getLastRedirect()).toBe(
'https://accounts.google.com/o/oauth2/v2/auth?client_id=mock_google_client_id&response_type=code&scope=openid%20email%20profile&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345'
);
});
test('should generate unique state for each call', () => {
const mockUrlInstance = {
toString: jest.fn().mockReturnValue('https://mock-url.com'),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
// First call
mockRandomUUID.mockReturnValueOnce('state-1');
oauthService.googleAuth();
// Second call
mockRandomUUID.mockReturnValueOnce('state-2');
oauthService.googleAuth();
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(
1,
'oauth_state',
'state-1'
);
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(
2,
'oauth_state',
'state-2'
);
});
});
describe('githubAuth', () => {
test('should generate state and redirect to GitHub OAuth URL', () => {
// Mock URL constructor to return a mock object
const mockUrlInstance = {
toString: jest
.fn()
.mockReturnValue(
'https://github.com/login/oauth/authorize?client_id=mock_github_client_id&response_type=code&scope=user%3Aemail&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345'
),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.githubAuth();
expect(mockRandomUUID).toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'oauth_state',
'mock-uuid-12345'
);
expect(mockURL).toHaveBeenCalledWith(
'https://github.com/login/oauth/authorize'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'client_id',
'mock_github_client_id'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'response_type',
'code'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'scope',
'user:email'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'state',
'mock-uuid-12345'
);
expect(mockNavigation.getLastRedirect()).toBe(
'https://github.com/login/oauth/authorize?client_id=mock_github_client_id&response_type=code&scope=user%3Aemail&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fcallback&state=mock-uuid-12345'
);
});
test('should generate unique state for each call', () => {
const mockUrlInstance = {
toString: jest.fn().mockReturnValue('https://mock-url.com'),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
// First call
mockRandomUUID.mockReturnValueOnce('state-1');
oauthService.githubAuth();
// Second call
mockRandomUUID.mockReturnValueOnce('state-2');
oauthService.githubAuth();
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(
1,
'oauth_state',
'state-1'
);
expect(mockLocalStorage.setItem).toHaveBeenNthCalledWith(
2,
'oauth_state',
'state-2'
);
});
});
describe('handleGoogleCallback', () => {
beforeEach(() => {
// Set up mock navigation with callback parameters
mockNavigation.setSearchParams('?code=auth-code&state=stored-state');
});
test('should successfully handle Google OAuth callback', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
// Mock the UUIDs that will be generated for token and user info
mockRandomUUID
.mockReturnValueOnce('mock-token-uuid')
.mockReturnValueOnce('mock-user-uuid');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { id: '123', email: 'google@example.com' },
accessToken: 'oauth_google_token_123456789',
refreshToken: 'oauth_google_refresh_123456789',
});
const result = await oauthService.handleGoogleCallback();
expect(authService.loginWithOAuth).toHaveBeenCalledWith('google', {
email: 'mock_google_user_mock-user-uuid@example.com',
username: 'Mock Google User',
});
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('oauth_state');
expect(result).toEqual({
user: { id: '123', email: 'google@example.com' },
accessToken: 'oauth_google_token_123456789',
refreshToken: 'oauth_google_refresh_123456789',
});
});
test('should throw error when state does not match', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
mockNavigation.setSearchParams('?code=auth-code&state=different-state');
await expect(oauthService.handleGoogleCallback()).rejects.toThrow(
'Invalid OAuth state'
);
expect(authService.loginWithOAuth).not.toHaveBeenCalled();
});
test('should throw error when no stored state exists', async () => {
mockLocalStorage.getItem.mockReturnValue(null);
mockNavigation.setSearchParams('?code=auth-code&state=some-state');
await expect(oauthService.handleGoogleCallback()).rejects.toThrow(
'Invalid OAuth state'
);
});
test('should handle missing code parameter gracefully', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
mockNavigation.setSearchParams('?state=stored-state');
// Mock the UUIDs for token exchange
mockRandomUUID
.mockReturnValueOnce('mock-token-uuid')
.mockReturnValueOnce('mock-user-uuid');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { id: '123', email: 'google@example.com' },
accessToken: 'oauth_google_token_123456789',
refreshToken: 'oauth_google_refresh_123456789',
});
// The OAuth service handles null code by passing it to mock functions
const result = await oauthService.handleGoogleCallback();
expect(result).toBeDefined();
expect(authService.loginWithOAuth).toHaveBeenCalled();
});
});
describe('handleGithubCallback', () => {
beforeEach(() => {
// Set up mock navigation with callback parameters
mockNavigation.setSearchParams('?code=auth-code&state=stored-state');
});
test('should successfully handle GitHub OAuth callback', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
// Mock the UUIDs that will be generated for token and user info
mockRandomUUID
.mockReturnValueOnce('mock-token-uuid')
.mockReturnValueOnce('mock-user-uuid');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { id: '456', email: 'github@example.com' },
accessToken: 'oauth_github_token_123456789',
refreshToken: 'oauth_github_refresh_123456789',
});
const result = await oauthService.handleGithubCallback();
expect(authService.loginWithOAuth).toHaveBeenCalledWith('github', {
email: 'mock_github_user_mock-user-uuid@example.com',
username: 'Mock Github User',
});
expect(result).toEqual({
user: { id: '456', email: 'github@example.com' },
accessToken: 'oauth_github_token_123456789',
refreshToken: 'oauth_github_refresh_123456789',
});
});
test('should throw error when state validation fails', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
mockNavigation.setSearchParams('?code=auth-code&state=wrong-state');
await expect(oauthService.handleGithubCallback()).rejects.toThrow(
'Invalid OAuth state'
);
});
});
describe('state management', () => {
test('should clear OAuth state after successful callback', async () => {
mockLocalStorage.getItem.mockReturnValue('test-state');
mockNavigation.setSearchParams('?code=auth-code&state=test-state');
// Mock UUIDs for the mock functions
mockRandomUUID
.mockReturnValueOnce('mock-token-uuid')
.mockReturnValueOnce('mock-user-uuid');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: {},
accessToken: 'token',
refreshToken: 'refresh',
});
await oauthService.handleGoogleCallback();
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('oauth_state');
});
test('should not clear state on validation failure', async () => {
mockLocalStorage.getItem.mockReturnValue('stored-state');
mockNavigation.setSearchParams('?code=auth-code&state=wrong-state');
try {
await oauthService.handleGoogleCallback();
} catch (_error) {
// Expected to throw
}
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
});
});
describe('mock token exchange', () => {
test('should generate unique mock access tokens', async () => {
mockLocalStorage.getItem.mockReturnValue('test-state');
// First callback
mockNavigation.setSearchParams('?code=code1&state=test-state');
// Mock different UUID values for token generation
mockRandomUUID
.mockReturnValueOnce('token-1')
.mockReturnValueOnce('user-1');
(authService.loginWithOAuth as jest.Mock).mockResolvedValueOnce({
user: {},
accessToken: 'oauth_google_token_1',
refreshToken: 'oauth_google_refresh_1',
});
const result1 = await oauthService.handleGoogleCallback();
// Second callback
mockNavigation.setSearchParams('?code=code2&state=test-state');
mockLocalStorage.getItem.mockReturnValue('test-state');
mockRandomUUID
.mockReturnValueOnce('token-2')
.mockReturnValueOnce('user-2');
(authService.loginWithOAuth as jest.Mock).mockResolvedValueOnce({
user: {},
accessToken: 'oauth_google_token_2',
refreshToken: 'oauth_google_refresh_2',
});
const result2 = await oauthService.handleGoogleCallback();
expect(result1.accessToken).not.toBe(result2.accessToken);
expect(result1.refreshToken).not.toBe(result2.refreshToken);
});
});
describe('mock user info generation', () => {
test('should generate provider-specific user info', async () => {
mockLocalStorage.getItem.mockReturnValue('test-state');
// Test Google callback
mockNavigation.setSearchParams('?code=google-code&state=test-state');
mockRandomUUID
.mockReturnValueOnce('google-token')
.mockReturnValueOnce('google-user');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { provider: 'google', email: 'google@example.com' },
accessToken: 'google-token',
refreshToken: 'google-refresh',
});
await oauthService.handleGoogleCallback();
// Check that the correct user info was passed to loginWithOAuth
const googleCall = (authService.loginWithOAuth as jest.Mock).mock
.calls[0];
expect(googleCall[0]).toBe('google');
expect(googleCall[1].username).toBe('Mock Google User');
expect(googleCall[1].email).toContain('mock_google_user_');
// Test GitHub callback
mockNavigation.setSearchParams('?code=github-code&state=test-state');
mockLocalStorage.getItem.mockReturnValue('test-state');
mockRandomUUID
.mockReturnValueOnce('github-token')
.mockReturnValueOnce('github-user');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { provider: 'github', email: 'github@example.com' },
accessToken: 'github-token',
refreshToken: 'github-refresh',
});
await oauthService.handleGithubCallback();
// Check that the correct user info was passed to loginWithOAuth
const githubCall = (authService.loginWithOAuth as jest.Mock).mock
.calls[1];
expect(githubCall[0]).toBe('github');
expect(githubCall[1].username).toBe('Mock Github User');
expect(githubCall[1].email).toContain('mock_github_user_');
});
});
describe('URL construction', () => {
test('should properly encode redirect URI in Google auth URL', () => {
const mockUrlInstance = {
toString: jest
.fn()
.mockReturnValue('https://accounts.google.com/o/oauth2/v2/auth'),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.googleAuth();
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
});
test('should properly encode redirect URI in GitHub auth URL', () => {
const mockUrlInstance = {
toString: jest
.fn()
.mockReturnValue('https://github.com/login/oauth/authorize'),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.githubAuth();
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
});
test('should include all required OAuth parameters for Google', () => {
const mockUrlInstance = {
toString: jest.fn(),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.googleAuth();
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'client_id',
'mock_google_client_id'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'response_type',
'code'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'scope',
'openid email profile'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'state',
'mock-uuid-12345'
);
});
test('should include all required OAuth parameters for GitHub', () => {
const mockUrlInstance = {
toString: jest.fn(),
searchParams: {
append: jest.fn(),
},
};
mockURL.mockReturnValue(mockUrlInstance);
oauthService.githubAuth();
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'client_id',
'mock_github_client_id'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'response_type',
'code'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'scope',
'user:email'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'redirect_uri',
'http://localhost:3000/auth/callback'
);
expect(mockUrlInstance.searchParams.append).toHaveBeenCalledWith(
'state',
'mock-uuid-12345'
);
});
});
describe('backward compatibility', () => {
test('should support legacy googleAuth function', () => {
const mockUrlInstance = {
toString: jest.fn().mockReturnValue('https://google-auth-url.com'),
searchParams: { append: jest.fn() },
};
mockURL.mockReturnValue(mockUrlInstance);
googleAuth();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'oauth_state',
'mock-uuid-12345'
);
});
test('should support legacy githubAuth function', () => {
const mockUrlInstance = {
toString: jest.fn().mockReturnValue('https://github-auth-url.com'),
searchParams: { append: jest.fn() },
};
mockURL.mockReturnValue(mockUrlInstance);
githubAuth();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'oauth_state',
'mock-uuid-12345'
);
});
test('should support legacy handleGoogleCallback function', async () => {
mockLocalStorage.getItem.mockReturnValue('test-state');
// The legacy function uses browser navigation service which reads from window.location
// We'll test this indirectly by ensuring it uses the same logic as the class method
mockRandomUUID
.mockReturnValueOnce('token-uuid')
.mockReturnValueOnce('user-uuid');
(authService.loginWithOAuth as jest.Mock).mockResolvedValue({
user: { id: '123' },
accessToken: 'token',
refreshToken: 'refresh',
});
// Since we can't easily mock window.location in JSDOM, we'll verify
// that the legacy functions exist and are callable
expect(typeof handleGoogleCallback).toBe('function');
expect(typeof handleGithubCallback).toBe('function');
});
});
});