Files
Charon/frontend/src/pages/__tests__/Login.overlay.audit.test.tsx
T
2026-03-04 18:34:49 +00:00

241 lines
8.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import Login from '../Login'
import * as authHook from '../../hooks/useAuth'
import client from '../../api/client'
import * as setupApi from '../../api/setup'
// Mock modules
vi.mock('../../api/client')
vi.mock('../../hooks/useAuth')
vi.mock('../../api/setup')
const mockLogin = vi.fn()
vi.mocked(authHook.useAuth).mockReturnValue({
user: null,
login: mockLogin,
logout: vi.fn(),
loading: false,
} as unknown as ReturnType<typeof authHook.useAuth>)
const renderWithProviders = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
)
}
describe('Login - Coin Overlay Security Audit', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock setup status to resolve immediately with no setup required
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false })
})
it('shows coin-themed overlay during login', async () => {
vi.mocked(client.post).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'admin@example.com')
await userEvent.type(passwordInput, 'password123')
await userEvent.click(submitButton)
// Coin-themed overlay should appear
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
// Verify coin theme (gold/amber) - use querySelector to find actual overlay container
const overlay = document.querySelector('.bg-amber-950\\/90')
expect(overlay).toBeInTheDocument()
// Wait for completion
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 200 })
})
it('ATTACK: rapid fire login attempts are blocked by overlay', async () => {
let resolveCount = 0
vi.mocked(client.post).mockImplementation(
() => new Promise(resolve => {
setTimeout(() => {
resolveCount++
resolve({ data: {} })
}, 200)
})
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'admin@example.com')
await userEvent.type(passwordInput, 'password123')
// Click multiple times rapidly
await userEvent.click(submitButton)
await userEvent.click(submitButton)
await userEvent.click(submitButton)
// Overlay should block subsequent clicks (form is disabled)
expect(emailInput).toBeDisabled()
expect(passwordInput).toBeDisabled()
expect(submitButton).toBeDisabled()
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 300 })
// Should only execute once
expect(resolveCount).toBe(1)
})
it('clears overlay on login error', async () => {
// Use delayed rejection so overlay has time to appear
vi.mocked(client.post).mockImplementation(
() => new Promise((_, reject) => {
setTimeout(() => reject({ response: { data: { error: 'Invalid credentials' } } }), 100)
})
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'wrong@example.com')
await userEvent.type(passwordInput, 'wrong')
await userEvent.click(submitButton)
// Overlay appears
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
// Overlay clears after error
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 300 })
// Form should be re-enabled
expect(emailInput).not.toBeDisabled()
expect(passwordInput).not.toBeDisabled()
})
it('ATTACK: XSS in login credentials does not break overlay', async () => {
// Use delayed promise so we can catch the overlay
vi.mocked(client.post).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
// Use valid email format with XSS-like characters in password
await userEvent.type(emailInput, 'test@example.com')
await userEvent.type(passwordInput, '<img src=x onerror=alert(1)>')
await userEvent.click(submitButton)
// Overlay should still work
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 300 })
})
it('ATTACK: network timeout does not leave overlay stuck', async () => {
vi.mocked(client.post).mockImplementation(
() => new Promise((_, reject) => {
setTimeout(() => reject(new Error('Network timeout')), 100)
})
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'admin@example.com')
await userEvent.type(passwordInput, 'password123')
await userEvent.click(submitButton)
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
// Overlay should clear after error
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 200 })
})
it('overlay has correct z-index hierarchy', async () => {
vi.mocked(client.post).mockImplementation(
() => new Promise(() => {}) // Never resolves
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'admin@example.com')
await userEvent.type(passwordInput, 'password123')
await userEvent.click(submitButton)
// Overlay should be z-50
const overlay = document.querySelector('.z-50')
expect(overlay).toBeInTheDocument()
})
it('overlay renders CharonCoinLoader component', async () => {
vi.mocked(client.post).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 100))
)
renderWithProviders(<Login />)
// Wait for setup check to complete and form to render
const emailInput = await screen.findByPlaceholderText('admin@example.com')
const passwordInput = screen.getByPlaceholderText('••••••••')
const submitButton = screen.getByRole('button', { name: /sign in/i })
await userEvent.type(emailInput, 'admin@example.com')
await userEvent.type(passwordInput, 'password123')
await userEvent.click(submitButton)
// CharonCoinLoader has aria-label="Authenticating"
expect(screen.getByLabelText('Authenticating')).toBeInTheDocument()
})
})