272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
/**
|
|
* Tests for TwoFactorVerify Component
|
|
* FE-TEST-005: Test two-factor verification component
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { TwoFactorVerify } from './TwoFactorVerify';
|
|
import { twoFactorService } from '@/services/2fa-service';
|
|
import { useToast } from '@/hooks/useToast';
|
|
|
|
// Mock dependencies
|
|
vi.mock('@/services/2fa-service', () => ({
|
|
twoFactorService: {
|
|
verify: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/hooks/useToast', () => ({
|
|
useToast: vi.fn(),
|
|
}));
|
|
|
|
describe('TwoFactorVerify', () => {
|
|
const mockOnSuccess = vi.fn();
|
|
const mockOnCancel = vi.fn();
|
|
const mockToast = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(useToast).mockReturnValue({
|
|
toast: mockToast,
|
|
} as any);
|
|
});
|
|
|
|
it('should render two-factor verification form', () => {
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
expect(screen.getByText('Two-Factor Authentication')).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText('Enter the code from your authenticator app'),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Verification Code')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow entering verification code', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
await user.type(codeInput, '123456');
|
|
|
|
expect(codeInput).toHaveValue('123456');
|
|
});
|
|
|
|
it('should only allow numeric input', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
await user.type(codeInput, 'abc123def456');
|
|
|
|
expect(codeInput).toHaveValue('123456');
|
|
});
|
|
|
|
it('should limit code to 6 digits', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
await user.type(codeInput, '1234567890');
|
|
|
|
expect(codeInput).toHaveValue('123456');
|
|
});
|
|
|
|
it('should verify code on submit', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(twoFactorService.verify).mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
|
|
await user.type(codeInput, '123456');
|
|
await user.click(verifyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(twoFactorService.verify).toHaveBeenCalledWith('', '123456');
|
|
expect(mockOnSuccess).toHaveBeenCalledWith('123456');
|
|
});
|
|
});
|
|
|
|
it('should show error for invalid code', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(twoFactorService.verify).mockRejectedValue(
|
|
new Error('Invalid verification code'),
|
|
);
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
|
|
await user.type(codeInput, '123456');
|
|
await user.click(verifyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/invalid verification code/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show error on verification failure', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(twoFactorService.verify).mockRejectedValue(
|
|
new Error('Verification failed'),
|
|
);
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
|
|
await user.type(codeInput, '123456');
|
|
await user.click(verifyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Verification failed')).toBeInTheDocument();
|
|
expect(mockToast).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should show loading state during verification', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(twoFactorService.verify).mockImplementation(
|
|
() => new Promise((resolve) => setTimeout(() => resolve(undefined), 100)),
|
|
);
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
|
|
await user.type(codeInput, '123456');
|
|
await user.click(verifyButton);
|
|
|
|
expect(screen.getByText('Verifying...')).toBeInTheDocument();
|
|
expect(verifyButton).toBeDisabled();
|
|
});
|
|
|
|
it('should switch to backup code mode', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const useBackupLink = screen.getByText(/use a backup code/i);
|
|
await user.click(useBackupLink);
|
|
|
|
expect(screen.getByLabelText('Backup Code')).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByLabelText('Verification Code'),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should switch back to regular code mode', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
// Switch to backup code
|
|
const useBackupLink = screen.getByText(/use a backup code/i);
|
|
await user.click(useBackupLink);
|
|
|
|
// Switch back
|
|
const useAuthLink = screen.getByText(/use authenticator code instead/i);
|
|
await user.click(useAuthLink);
|
|
|
|
expect(screen.getByLabelText('Verification Code')).toBeInTheDocument();
|
|
expect(screen.queryByLabelText('Backup Code')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should verify backup code', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(twoFactorService.verify).mockResolvedValue(undefined);
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
// Switch to backup code
|
|
const useBackupLink = screen.getByText(/use a backup code/i);
|
|
await user.click(useBackupLink);
|
|
|
|
const backupInput = screen.getByLabelText('Backup Code');
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
|
|
await user.type(backupInput, 'backup-code-123');
|
|
await user.click(verifyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(twoFactorService.verify).toHaveBeenCalledWith(
|
|
'',
|
|
'backup-code-123',
|
|
);
|
|
expect(mockOnSuccess).toHaveBeenCalledWith('backup-code-123');
|
|
});
|
|
});
|
|
|
|
it('should call onCancel when cancel button is clicked', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
|
await user.click(cancelButton);
|
|
|
|
expect(mockOnCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should disable verify button when no code entered', () => {
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
expect(verifyButton).toBeDisabled();
|
|
});
|
|
|
|
it('should show error when trying to verify without code', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<TwoFactorVerify onSuccess={mockOnSuccess} onCancel={mockOnCancel} />,
|
|
);
|
|
|
|
const verifyButton = screen.getByRole('button', { name: /verify/i });
|
|
// Button should be disabled, but let's test the error handling
|
|
// by manually triggering the handler
|
|
const codeInput = screen.getByLabelText('Verification Code');
|
|
fireEvent.change(codeInput, { target: { value: '' } });
|
|
|
|
// The button should remain disabled
|
|
expect(verifyButton).toBeDisabled();
|
|
});
|
|
});
|