matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
315 lines • 21.2 kB
JavaScript
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