UNPKG

matterbridge-roborock-vacuum-plugin

Version:
315 lines 21.2 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TwoFactorAuthStrategy } from '../../../services/authentication/TwoFactorAuthStrategy.js'; import { asPartial, createMockLogger } from '../../testUtils.js'; describe('TwoFactorAuthStrategy', () => { let strategy; let mockAuthService; let mockUserDataRepository; let mockVerificationCodeService; let mockConfigManager; let mockLogger; const mockUserData = { username: 'test@example.com', uid: 'test-uid', token: 'test-token', rruid: 'test-rruid', region: 'us', countrycode: 'US', }; beforeEach(() => { mockLogger = createMockLogger(); mockAuthService = asPartial({ loginWithVerificationCode: vi.fn(), loginWithCachedToken: vi.fn(), }); mockUserDataRepository = asPartial({ loadUserData: vi.fn(), saveUserData: vi.fn(), clearUserData: vi.fn(), }); mockVerificationCodeService = asPartial({ isRateLimited: vi.fn(), getRemainingWaitTime: vi.fn(), requestVerificationCode: vi.fn(), recordCodeRequest: vi.fn(), clearCodeRequestState: vi.fn(), }); const mockRawConfig = asPartial({ authentication: asPartial({ forceAuthentication: false, }), }); mockConfigManager = asPartial({ get rawConfig() { return mockRawConfig; }, get alwaysExecuteAuthentication() { return mockRawConfig.authentication.forceAuthentication ?? false; }, }); strategy = new TwoFactorAuthStrategy(mockAuthService, mockUserDataRepository, mockVerificationCodeService, mockConfigManager, vi.fn(), mockLogger); vi.clearAllMocks(); }); describe('authenticate', () => { const createContext = (username, verificationCode) => ({ username, password: '', verificationCode, }); describe('cached token authentication', () => { it('should return cached user data when token is valid', async () => { const savedUserData = { ...mockUserData }; vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(savedUserData); vi.mocked(mockAuthService.loginWithCachedToken).mockResolvedValue(mockUserData); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockUserDataRepository.loadUserData).toHaveBeenCalledWith('test@example.com'); expect(mockAuthService.loginWithCachedToken).toHaveBeenCalledWith('test@example.com', savedUserData); expect(mockVerificationCodeService.requestVerificationCode).not.toHaveBeenCalled(); }); it('should proceed with verification code flow when cached token is invalid', async () => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(mockUserData); vi.mocked(mockAuthService.loginWithCachedToken).mockRejectedValue(new Error('Token expired')); vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockUserDataRepository.clearUserData).toHaveBeenCalled(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalledWith('test@example.com'); }); it('should skip cached token when alwaysExecuteAuthentication is true', async () => { mockConfigManager.rawConfig.authentication.forceAuthentication = true; vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockUserDataRepository.clearUserData).toHaveBeenCalled(); expect(mockUserDataRepository.loadUserData).not.toHaveBeenCalled(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalled(); }); it('should proceed with verification code flow when no cached data exists', async () => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalledWith('test@example.com'); }); }); describe('verification code validation', () => { beforeEach(() => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); }); it('should return undefined when verification code is missing', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com', undefined); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalled(); }); it('should return undefined when verification code is empty string', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com', ''); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalled(); }); it('should return undefined when verification code is only whitespace', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com', ' '); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalled(); }); it('should authenticate with valid verification code', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockAuthService.loginWithVerificationCode).toHaveBeenCalledWith('test@example.com', '123456'); }); it('should trim verification code before authentication', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', ' 123456 '); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockAuthService.loginWithVerificationCode).toHaveBeenCalledWith('test@example.com', '123456'); }); }); describe('rate limiting', () => { beforeEach(() => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); }); it('should return undefined when rate limited', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(true); vi.mocked(mockVerificationCodeService.getRemainingWaitTime).mockResolvedValue(45); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockLogger.warn).toHaveBeenCalledWith('Please wait 45 seconds before requesting another code.'); expect(mockVerificationCodeService.requestVerificationCode).not.toHaveBeenCalled(); }); it('should log verification code banner when rate limited', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(true); vi.mocked(mockVerificationCodeService.getRemainingWaitTime).mockResolvedValue(30); const context = createContext('test@example.com'); await strategy.authenticate(context); expect(mockLogger.notice).toHaveBeenCalledWith(expect.stringContaining('A verification code was previously sent to: test@example.com')); }); it('should proceed with code request when not rate limited', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalledWith('test@example.com'); expect(mockVerificationCodeService.recordCodeRequest).toHaveBeenCalledWith('test@example.com'); }); }); describe('verification code request', () => { beforeEach(() => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); }); it('should successfully request verification code', async () => { vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); const result = await strategy.authenticate(context); expect(result).toBeUndefined(); expect(mockLogger.notice).toHaveBeenCalledWith('Requesting verification code for: test@example.com'); expect(mockVerificationCodeService.requestVerificationCode).toHaveBeenCalledWith('test@example.com'); expect(mockVerificationCodeService.recordCodeRequest).toHaveBeenCalledWith('test@example.com'); }); it('should log verification code banner after requesting code', async () => { vi.mocked(mockVerificationCodeService.requestVerificationCode).mockResolvedValue(); vi.mocked(mockVerificationCodeService.recordCodeRequest).mockResolvedValue(); const context = createContext('test@example.com'); await strategy.authenticate(context); expect(mockLogger.notice).toHaveBeenCalledWith(expect.stringContaining('A verification code has been sent to: test@example.com')); }); it('should throw error when code request fails', async () => { const error = new Error('Network error'); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockRejectedValue(error); const context = createContext('test@example.com'); await expect(strategy.authenticate(context)).rejects.toThrow('Network error'); expect(mockLogger.error).toHaveBeenCalledWith('Failed to request verification code: Network error'); }); it('should handle non-Error objects in request failures', async () => { vi.mocked(mockVerificationCodeService.requestVerificationCode).mockRejectedValue('String error'); const context = createContext('test@example.com'); await expect(strategy.authenticate(context)).rejects.toBe('String error'); expect(mockLogger.error).toHaveBeenCalledWith('Failed to request verification code: String error'); }); }); describe('authentication with code', () => { beforeEach(() => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); }); it('should successfully authenticate with verification code', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result).toEqual({ ...mockUserData, username: 'test@example.com' }); expect(mockLogger.notice).toHaveBeenCalledWith('Attempting login with verification code...'); expect(mockLogger.notice).toHaveBeenCalledWith('Authentication successful!'); }); it('should set username on userData after authentication', async () => { const userDataWithoutUsername = asPartial({ ...mockUserData, username: undefined }); vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(userDataWithoutUsername); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result?.username).toBe('test@example.com'); expect(mockUserDataRepository.saveUserData).toHaveBeenCalledWith(expect.objectContaining({ username: 'test@example.com' })); }); it('should save user data after successful authentication', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); await strategy.authenticate(context); expect(mockUserDataRepository.saveUserData).toHaveBeenCalledWith(expect.objectContaining({ username: 'test@example.com', token: 'test-token', })); }); it('should clear code request state after successful authentication', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); await strategy.authenticate(context); expect(mockVerificationCodeService.clearCodeRequestState).toHaveBeenCalled(); }); it('should continue authentication when save user data fails', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockRejectedValue(new Error('Save failed')); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockResolvedValue(); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockLogger.warn).toHaveBeenCalledWith('Failed to save user data, but login succeeded:', expect.any(Error)); expect(mockLogger.notice).toHaveBeenCalledWith('Authentication successful!'); }); it('should continue authentication when clear state fails', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockResolvedValue(); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockRejectedValue(new Error('Clear failed')); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockLogger.warn).toHaveBeenCalledWith('Failed to clear auth state, but login succeeded:', expect.any(Error)); expect(mockLogger.notice).toHaveBeenCalledWith('Authentication successful!'); }); it('should handle both save and clear failures gracefully', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockResolvedValue(mockUserData); vi.mocked(mockUserDataRepository.saveUserData).mockRejectedValue(new Error('Save failed')); vi.mocked(mockVerificationCodeService.clearCodeRequestState).mockRejectedValue(new Error('Clear failed')); const context = createContext('test@example.com', '123456'); const result = await strategy.authenticate(context); expect(result).toBe(mockUserData); expect(mockLogger.warn).toHaveBeenCalledTimes(2); expect(mockLogger.notice).toHaveBeenCalledWith('Authentication successful!'); }); }); describe('error handling', () => { beforeEach(() => { vi.mocked(mockUserDataRepository.loadUserData).mockResolvedValue(undefined); }); it('should propagate authentication errors', async () => { vi.mocked(mockAuthService.loginWithVerificationCode).mockRejectedValue(new Error('Invalid code')); const context = createContext('test@example.com', '123456'); await expect(strategy.authenticate(context)).rejects.toThrow('Invalid code'); }); it('should propagate code request errors', async () => { vi.mocked(mockVerificationCodeService.isRateLimited).mockResolvedValue(false); vi.mocked(mockVerificationCodeService.requestVerificationCode).mockRejectedValue(new Error('API error')); const context = createContext('test@example.com'); await expect(strategy.authenticate(context)).rejects.toThrow('API error'); }); }); }); }); //# sourceMappingURL=TwoFactorAuthStrategy.test.js.map