Add QA test outputs, build scripts, and Dockerfile validation
- Created `qa-test-output-after-fix.txt` and `qa-test-output.txt` to log results of certificate page authentication tests. - Added `build.sh` for deterministic backend builds in CI, utilizing `go list` for efficiency. - Introduced `codeql_scan.sh` for CodeQL database creation and analysis for Go and JavaScript/TypeScript. - Implemented `dockerfile_check.sh` to validate Dockerfiles for base image and package manager mismatches. - Added `sourcery_precommit_wrapper.sh` to facilitate Sourcery CLI usage in pre-commit hooks.
This commit is contained in:
124
frontend/src/components/__tests__/AccessListSelector.test.tsx
Normal file
124
frontend/src/components/__tests__/AccessListSelector.test.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import AccessListSelector from '../AccessListSelector';
|
||||
import * as useAccessListsHook from '../../hooks/useAccessLists';
|
||||
import type { AccessList } from '../../api/accessLists';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useAccessLists');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AccessListSelector', () => {
|
||||
it('should render with no access lists', () => {
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: [],
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with access lists and show only enabled ones', () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Test ACL 1',
|
||||
description: 'Description 1',
|
||||
type: 'whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'uuid-2',
|
||||
name: 'Test ACL 2',
|
||||
description: 'Description 2',
|
||||
type: 'blacklist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test ACL 1 (whitelist)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Test ACL 2 (blacklist)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected ACL details', () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Selected ACL',
|
||||
description: 'This is selected',
|
||||
type: 'geo_whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: 'US,CA',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={1} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Selected ACL')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is selected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
113
frontend/src/components/__tests__/CertificateList.test.tsx
Normal file
113
frontend/src/components/__tests__/CertificateList.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import CertificateList from '../CertificateList'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'expired', provider: 'custom' },
|
||||
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: new Date().toISOString(), status: 'untrusted', provider: 'letsencrypt-staging' },
|
||||
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'valid', provider: 'custom' },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
deleteCertificate: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(() => ({
|
||||
hosts: [
|
||||
{ uuid: 'h1', name: 'Host1', certificate_id: 3 },
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: vi.fn(),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
isBulkUpdating: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
function renderWithClient(ui: React.ReactNode) {
|
||||
const qc = createTestQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('CertificateList', () => {
|
||||
it('deletes custom certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { createBackup } = await import('../../api/backups')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
|
||||
expect(customRow).toBeTruthy()
|
||||
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
|
||||
expect(customBtn).toBeTruthy()
|
||||
await user.click(customBtn)
|
||||
|
||||
await waitFor(() => expect(createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Certificate deleted'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes staging certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
|
||||
expect(stagingButtons.length).toBeGreaterThan(0)
|
||||
await user.click(stagingButtons[0])
|
||||
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('blocks deletion when certificate is in use by a proxy host', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// Find button corresponding to ActiveCert (id 3)
|
||||
const activeButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(activeButton).toBeTruthy()
|
||||
if (activeButton) await user.click(activeButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('in use')))
|
||||
})
|
||||
|
||||
it('blocks deletion when certificate status is active (valid/expiring)', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// ActiveCert (valid) should block even if not linked – ensure hosts mock links it so previous test covers linkage.
|
||||
// Here, simulate clicking a valid cert button if present
|
||||
const validButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(validButton).toBeTruthy()
|
||||
if (validButton) await user.click(validButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
262
frontend/src/components/__tests__/ImportReviewTable.test.tsx
Normal file
262
frontend/src/components/__tests__/ImportReviewTable.test.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ImportReviewTable from '../ImportReviewTable'
|
||||
import { mockImportPreview } from '../../test/mockData'
|
||||
|
||||
describe('ImportReviewTable', () => {
|
||||
const mockOnCommit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('displays hosts to import', () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Review Imported Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays conflicts with resolution dropdowns', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays errors', () => {
|
||||
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={errors}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Issues found during parsing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing required field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCommit with resolutions and names', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement
|
||||
await userEvent.selectOptions(dropdown, 'overwrite')
|
||||
|
||||
const commitButton = screen.getByText('Commit Import')
|
||||
await userEvent.click(commitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommit).toHaveBeenCalledWith(
|
||||
{ 'test.example.com': 'overwrite' },
|
||||
{ 'test.example.com': 'test.example.com' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Back'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows conflict indicator on conflicting hosts', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses conflict details', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
|
||||
// Find and click expand button (it's the ▶ button)
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Now should show details
|
||||
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.2:9090')).toBeInTheDocument()
|
||||
|
||||
// Click collapse button
|
||||
const collapseButton = screen.getByText('▼')
|
||||
await userEvent.click(collapseButton)
|
||||
|
||||
// Details should be hidden again
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows recommendation based on configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Expand to see recommendation
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Should show recommendation about config changes (SSL differs)
|
||||
expect(screen.getByText(/different SSL or WebSocket settings/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'https',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Check for differences being displayed
|
||||
expect(screen.getByText('https://192.168.1.2:9090')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
320
frontend/src/components/__tests__/Layout.test.tsx
Normal file
320
frontend/src/components/__tests__/Layout.test.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from '../Layout'
|
||||
import { ThemeProvider } from '../../context/ThemeContext'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
// Mock AuthContext
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
logout: mockLogout,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({
|
||||
version: '0.1.0',
|
||||
git_commit: 'abcdef1',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn().mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const renderWithProviders = (children: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('sidebarCollapsed', 'false')
|
||||
// Default: all features enabled
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the application logo', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const logos = screen.getAllByAltText('Charon')
|
||||
expect(logos.length).toBeGreaterThan(0)
|
||||
expect(logos[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all navigation items', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
// Expand Tasks and Import to see nested items
|
||||
await userEvent.click(screen.getByText('Tasks'))
|
||||
expect(screen.getByText('Import')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByText('Import'))
|
||||
expect(screen.getByText('Caddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children content', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays version information', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Version 0.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Logout'))
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles sidebar on mobile', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// The mobile sidebar toggle is found by test-id
|
||||
const toggleButton = screen.getByTestId('mobile-menu-toggle')
|
||||
|
||||
// Click to open the sidebar
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// The overlay should be present when mobile sidebar is open
|
||||
// The overlay has class 'fixed inset-0 bg-gray-900/50 z-20 lg:hidden'
|
||||
// Click the toggle again to close
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// Toggle button should still be in the document
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists collapse state to localStorage', async () => {
|
||||
localStorage.clear()
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const collapseBtn = await screen.findByTitle('Collapse sidebar')
|
||||
await userEvent.click(collapseBtn)
|
||||
expect(JSON.parse(localStorage.getItem('sidebarCollapsed') || 'false')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores collapsed state from localStorage on load', async () => {
|
||||
localStorage.setItem('sidebarCollapsed', 'true')
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByTitle('Expand sidebar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Feature Flags - Conditional Sidebar Items', () => {
|
||||
it('displays Cerberus nav item when Cerberus is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Cerberus nav item when Cerberus is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays Uptime nav item when Uptime is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Uptime nav item when Uptime is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Cerberus and Uptime when both features are enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides both Cerberus and Uptime when both features are disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({} as any)
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// When flags are undefined, items should be visible by default (conservative approach)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows other nav items regardless of feature flags', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
315
frontend/src/components/__tests__/LiveLogViewer.test.tsx
Normal file
315
frontend/src/components/__tests__/LiveLogViewer.test.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LiveLogViewer } from '../LiveLogViewer';
|
||||
import * as logsApi from '../../api/logs';
|
||||
|
||||
// Mock the connectLiveLogs function
|
||||
vi.mock('../../api/logs', async () => {
|
||||
const actual = await vi.importActual('../../api/logs');
|
||||
return {
|
||||
...actual,
|
||||
connectLiveLogs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LiveLogViewer', () => {
|
||||
let mockCloseConnection: ReturnType<typeof vi.fn>;
|
||||
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
|
||||
let mockOnClose: (() => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCloseConnection = vi.fn();
|
||||
mockOnMessage = null;
|
||||
mockOnClose = null;
|
||||
|
||||
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
|
||||
mockOnMessage = onMessage;
|
||||
mockOnClose = onClose ?? null;
|
||||
// Simulate connection success
|
||||
if (onOpen) {
|
||||
setTimeout(() => onOpen(), 0);
|
||||
}
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component with initial state', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
expect(screen.getByText('Live Security Logs')).toBeTruthy();
|
||||
// Initially disconnected until WebSocket opens
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Wait for onOpen callback to be called
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('displays incoming log messages', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Simulate receiving a log
|
||||
const logEntry: logsApi.LiveLogEntry = {
|
||||
level: 'info',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Test log message',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logEntry);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test log message')).toBeTruthy();
|
||||
expect(screen.getByText('INFO')).toBeTruthy();
|
||||
expect(screen.getByText('[test]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'First message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Second message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.getByText('Second message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply text filter
|
||||
const filterInput = screen.getByPlaceholderText('Filter by text...');
|
||||
await user.type(filterInput, 'First');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.queryByText('Second message')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by level', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Info message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Error message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info message')).toBeTruthy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply level filter
|
||||
const levelSelect = screen.getByRole('combobox');
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Info message')).toBeFalsy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('pauses and resumes log streaming', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add initial log
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Before pause' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Before pause')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click pause button
|
||||
const pauseButton = screen.getByTitle('Pause');
|
||||
await user.click(pauseButton);
|
||||
|
||||
// Verify paused state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('⏸ Paused')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Try to add log while paused (should not appear)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'During pause' });
|
||||
}
|
||||
|
||||
// Log should not appear
|
||||
expect(screen.queryByText('During pause')).toBeFalsy();
|
||||
|
||||
// Resume
|
||||
const resumeButton = screen.getByTitle('Resume');
|
||||
await user.click(resumeButton);
|
||||
|
||||
// Add log after resume
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'After resume' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('After resume')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Log 1')).toBeTruthy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByTitle('Clear logs');
|
||||
await user.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.queryByText('Log 2')).toBeFalsy();
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('limits the number of stored logs', async () => {
|
||||
render(<LiveLogViewer maxLogs={2} />);
|
||||
|
||||
// Add 3 logs (exceeding maxLogs)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'Log 3' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// First log should be removed, only last 2 should remain
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
expect(screen.getByText('Log 3')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays log data when available', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
const logWithData: logsApi.LiveLogEntry = {
|
||||
level: 'error',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Error occurred',
|
||||
data: { error_code: 500, details: 'Internal server error' },
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logWithData);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error occurred')).toBeTruthy();
|
||||
// Check that data is rendered as JSON
|
||||
expect(screen.getByText(/"error_code"/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes WebSocket connection on unmount', () => {
|
||||
const { unmount } = render(<LiveLogViewer />);
|
||||
|
||||
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockCloseConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<LiveLogViewer className="custom-class" />);
|
||||
|
||||
const element = container.querySelector('.custom-class');
|
||||
expect(element).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows correct connection status', async () => {
|
||||
let mockOnOpen: (() => void) | undefined;
|
||||
let mockOnError: ((error: Event) => void) | undefined;
|
||||
|
||||
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, _onMessage, onOpen, onError) => {
|
||||
mockOnOpen = onOpen;
|
||||
mockOnError = onError;
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Initially disconnected until onOpen is called
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Simulate connection opened
|
||||
if (mockOnOpen) {
|
||||
mockOnOpen();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate connection error
|
||||
if (mockOnError) {
|
||||
mockOnError(new Event('error'));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-match message when filters exclude all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Visible' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Hidden' });
|
||||
}
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Visible')).toBeTruthy());
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Filter by text...'), 'nomatch');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No logs match the current filters.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks connection as disconnected when WebSocket closes', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
mockOnClose?.();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CharonLoader, CharonCoinLoader, CerberusLoader, ConfigReloadOverlay } from '../LoadingStates'
|
||||
|
||||
describe('CharonLoader', () => {
|
||||
it('renders boat animation with accessibility label', () => {
|
||||
render(<CharonLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders coin animation with accessibility label', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders guardian animation with accessibility label', () => {
|
||||
render(<CerberusLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay', () => {
|
||||
it('renders with Charon theme (default)', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Coin theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Paying the ferryman..."
|
||||
submessage="Your obol grants passage"
|
||||
type="coin"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Cerberus theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Cerberus awakens..."
|
||||
submessage="Guardian of the gates stands watch"
|
||||
type="cerberus"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cerberus awakens...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Guardian of the gates stands watch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom messages', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Custom message"
|
||||
submessage="Custom submessage"
|
||||
type="charon"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom submessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct theme colors', () => {
|
||||
const { container, rerender } = render(<ConfigReloadOverlay type="charon" />)
|
||||
let overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="coin" />)
|
||||
overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="cerberus" />)
|
||||
overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as full-screen overlay with high z-index', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.fixed.inset-0.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CharonLoader,
|
||||
CharonCoinLoader,
|
||||
CerberusLoader,
|
||||
ConfigReloadOverlay,
|
||||
} from '../LoadingStates'
|
||||
|
||||
describe('LoadingStates - Security Audit', () => {
|
||||
describe('CharonLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles all size variants', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label', () => {
|
||||
render(<CharonLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for authentication', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders gradient definition', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const gradient = container.querySelector('#goldGradient')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonCoinLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for security', () => {
|
||||
render(<CerberusLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders three heads (three circles for heads)', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const circles = container.querySelectorAll('circle')
|
||||
// At least 3 head circles should exist (plus paws and eyes)
|
||||
expect(circles.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CerberusLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay - XSS Protection', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in message prop', () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>'
|
||||
render(<ConfigReloadOverlay message={xssPayload} />)
|
||||
|
||||
// React should escape this automatically
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in submessage prop', () => {
|
||||
const xssPayload = '<img src=x onerror="alert(1)">'
|
||||
render(<ConfigReloadOverlay submessage={xssPayload} />)
|
||||
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('img[onerror]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles extremely long messages', () => {
|
||||
const longMessage = 'A'.repeat(10000)
|
||||
const { container } = render(<ConfigReloadOverlay message={longMessage} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles special characters', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message={specialChars}
|
||||
submessage={specialChars}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText(specialChars)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ATTACK: handles unicode and emoji', () => {
|
||||
const unicode = '🔥💀🐕🦺 λ µ π Σ 中文 العربية עברית'
|
||||
render(<ConfigReloadOverlay message={unicode} />)
|
||||
|
||||
expect(screen.getByText(unicode)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - charon (blue)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="charon" />)
|
||||
const overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - coin (gold)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="coin" />)
|
||||
const overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - cerberus (red)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="cerberus" />)
|
||||
const overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct z-index (z-50)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies backdrop blur', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const backdrop = container.querySelector('.backdrop-blur-sm')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: type prop injection attempt', () => {
|
||||
// @ts-expect-error - Testing invalid type
|
||||
const { container } = render(<ConfigReloadOverlay type="<script>alert(1)</script>" />)
|
||||
|
||||
// Should default to charon theme
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Integration Tests', () => {
|
||||
it('CharonLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="charon" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('CharonCoinLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="coin" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('CerberusLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="cerberus" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Animation Requirements', () => {
|
||||
it('CharonLoader uses animate-bob-boat class', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
const animated = container.querySelector('.animate-bob-boat')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CharonCoinLoader uses animate-spin-y class', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const animated = container.querySelector('.animate-spin-y')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CerberusLoader uses animate-rotate-head class', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const animated = container.querySelector('.animate-rotate-head')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined size prop gracefully', () => {
|
||||
const { container } = render(<CharonLoader size={undefined} />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20') // defaults to md
|
||||
})
|
||||
|
||||
it('handles null message', () => {
|
||||
// @ts-expect-error - Testing null
|
||||
render(<ConfigReloadOverlay message={null} />)
|
||||
// Null message renders as empty paragraph - component gracefully handles null
|
||||
const textContainer = screen.getByText(/Charon is crossing the Styx/i).closest('div')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty string message', () => {
|
||||
render(<ConfigReloadOverlay message="" submessage="" />)
|
||||
// Should render but be empty
|
||||
expect(screen.queryByText('Ferrying configuration...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles undefined type prop', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type={undefined} />)
|
||||
// Should default to charon
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility Requirements', () => {
|
||||
it('overlay is keyboard accessible', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.firstChild
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('all loaders have status role', () => {
|
||||
render(
|
||||
<>
|
||||
<CharonLoader />
|
||||
<CharonCoinLoader />
|
||||
<CerberusLoader />
|
||||
</>
|
||||
)
|
||||
const statuses = screen.getAllByRole('status')
|
||||
expect(statuses).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('all loaders have aria-label', () => {
|
||||
const { container: c1 } = render(<CharonLoader />)
|
||||
const { container: c2 } = render(<CharonCoinLoader />)
|
||||
const { container: c3 } = render(<CerberusLoader />)
|
||||
|
||||
expect(c1.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c2.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c3.firstChild).toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
it('renders CharonLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100) // Should render in <100ms
|
||||
})
|
||||
|
||||
it('renders CharonCoinLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonCoinLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders CerberusLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CerberusLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders ConfigReloadOverlay quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<ConfigReloadOverlay />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
172
frontend/src/components/__tests__/NotificationCenter.test.tsx
Normal file
172
frontend/src/components/__tests__/NotificationCenter.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } 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 NotificationCenter from '../NotificationCenter'
|
||||
import * as api from '../../api/system'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/system', () => ({
|
||||
getNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockNotifications: api.Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
title: 'Info Notification',
|
||||
message: 'This is an info message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'success',
|
||||
title: 'Success Notification',
|
||||
message: 'This is a success message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Warning Notification',
|
||||
message: 'This is a warning message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'error',
|
||||
title: 'Error Notification',
|
||||
message: 'This is an error message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('NotificationCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '0.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders bell icon and unread count', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens notification panel on click', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await userEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Success Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warning Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error Notification')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays empty state when no notifications', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue([])
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await userEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No new notifications')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks single notification as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markNotificationRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark all read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('Mark all read'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markAllNotificationsRead).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes panel when clicking outside', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('notification-backdrop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PasswordStrengthMeter } from '../PasswordStrengthMeter'
|
||||
|
||||
describe('PasswordStrengthMeter', () => {
|
||||
it('renders nothing when password is empty', () => {
|
||||
const { container } = render(<PasswordStrengthMeter password="" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders strength label when password is provided', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// Depending on the implementation, it might show "Weak", "Fair", etc.
|
||||
// "password123" is likely weak or fair.
|
||||
// Let's just check if any text is rendered.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders progress bars', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// It usually renders 4 bars
|
||||
// In the implementation I read, it renders one bar with width.
|
||||
// <div className="h-1.5 w-full ..."><div className="h-full ..." style={{ width: ... }} /></div>
|
||||
// So we can check for the progress bar container or the inner bar.
|
||||
// Let's check for the label text which we already did.
|
||||
// Let's check if the feedback is shown if present.
|
||||
// For "password123", it might have feedback.
|
||||
// But let's just stick to checking the label for now as "renders progress bars" was a bit vague in my previous attempt.
|
||||
// I'll replace this test with something more specific or just remove it if covered by others.
|
||||
// Actually, let's check that the bar exists.
|
||||
// It doesn't have a role, so we can't use getByRole('progressbar').
|
||||
// We can check if the container has the class 'bg-gray-200' or 'dark:bg-gray-700'.
|
||||
// But testing implementation details (classes) is brittle.
|
||||
// Let's just check that the component renders without crashing and shows the label.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates label based on password strength', () => {
|
||||
const { rerender } = render(<PasswordStrengthMeter password="123" />)
|
||||
expect(screen.getByText('Weak')).toBeInTheDocument()
|
||||
|
||||
rerender(<PasswordStrengthMeter password="CorrectHorseBatteryStaple1!" />)
|
||||
expect(screen.getByText('Strong')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
syncMonitors: vi.fn(() => Promise.resolve({})),
|
||||
}))
|
||||
|
||||
// Minimal hook mocks used by the component
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
// stub global fetch for health endpoint
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
|
||||
|
||||
describe('ProxyHostForm Add Uptime flow', () => {
|
||||
it('submits host and requests uptime sync when Add Uptime is checked', async () => {
|
||||
const onSubmit = vi.fn(() => Promise.resolve())
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Check Add Uptime
|
||||
const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
await userEvent.click(addUptimeCheckbox)
|
||||
|
||||
// Adjust uptime options — locate the container for the uptime inputs
|
||||
const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement
|
||||
if (!uptimeContainer) throw new Error('Uptime container not found')
|
||||
|
||||
const { within } = await import('@testing-library/react')
|
||||
const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton')
|
||||
// first spinbutton is interval, second is max retries
|
||||
fireEvent.change(spinbuttons[0], { target: { value: '30' } })
|
||||
fireEvent.change(spinbuttons[1], { target: { value: '2' } })
|
||||
|
||||
// Submit
|
||||
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement
|
||||
if (!submitBtn) throw new Error('Submit button not found')
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
// wait for onSubmit to have been called
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
|
||||
// Ensure uptime API was called with provided options
|
||||
const uptime = await import('../../api/uptime')
|
||||
await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 }))
|
||||
|
||||
// Ensure onSubmit payload does not include temporary uptime keys
|
||||
const onSubmitMock = onSubmit as unknown as import('vitest').Mock
|
||||
const submittedPayload = onSubmitMock.mock.calls[0][0]
|
||||
expect(submittedPayload).not.toHaveProperty('addUptime')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
|
||||
})
|
||||
})
|
||||
660
frontend/src/components/__tests__/ProxyHostForm.test.tsx
Normal file
660
frontend/src/components/__tests__/ProxyHostForm.test.tsx
Normal file
@@ -0,0 +1,660 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: mockRemoteServers,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({
|
||||
containers: [
|
||||
{
|
||||
id: 'container-123',
|
||||
names: ['my-app'],
|
||||
image: 'nginx:latest',
|
||||
state: 'running',
|
||||
status: 'Up 2 hours',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.2',
|
||||
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({
|
||||
domains: [
|
||||
{ uuid: 'domain-1', name: 'existing.com' }
|
||||
],
|
||||
createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'existing.com' }),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'Cert 1', domain: 'example.com', provider: 'custom', issuer: 'Custom', expires_at: '2026-01-01' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useSecurity', () => ({
|
||||
useAuthPolicies: vi.fn(() => ({
|
||||
policies: [
|
||||
{ id: 1, name: 'Admin Only', description: 'Requires admin role' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock global fetch for health API
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithClientAct = async (ui: React.ReactElement) => {
|
||||
await act(async () => {
|
||||
renderWithClient(ui)
|
||||
})
|
||||
}
|
||||
|
||||
import { testProxyHostConnection } from '../../api/proxyHosts'
|
||||
|
||||
describe('ProxyHostForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
// Default fetch mock for health endpoint
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handles scheme selection', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find scheme select - it defaults to HTTP
|
||||
// We can find it by label "Scheme"
|
||||
const schemeSelect = screen.getByLabelText('Scheme') as HTMLSelectElement
|
||||
await userEvent.selectOptions(schemeSelect, 'https')
|
||||
|
||||
expect(schemeSelect).toHaveValue('https')
|
||||
})
|
||||
|
||||
it('prompts to save new base domain', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Enter a subdomain of a new base domain
|
||||
await userEvent.type(domainInput, 'sub.newdomain.com')
|
||||
await userEvent.tab()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('newdomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click "Yes, save it"
|
||||
await userEvent.click(screen.getByText('Yes, save it'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('respects "Dont ask me again" for new domains', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Trigger prompt
|
||||
await userEvent.type(domainInput, 'sub.another.com')
|
||||
await userEvent.tab()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check "Don't ask me again"
|
||||
await userEvent.click(screen.getByLabelText("Don't ask me again"))
|
||||
|
||||
// Click "No, thanks"
|
||||
await userEvent.click(screen.getByText('No, thanks'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Try another new domain - should not prompt
|
||||
await userEvent.type(domainInput, 'sub.yetanother.com')
|
||||
await userEvent.tab()
|
||||
|
||||
// Should not see prompt
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests connection successfully', async () => {
|
||||
vi.mocked(testProxyHostConnection).mockResolvedValue(undefined)
|
||||
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields for test connection
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
await userEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalledWith('10.0.0.5', 80)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles connection test failure', async () => {
|
||||
vi.mocked(testProxyHostConnection).mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
await userEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should show error state (red button) - we can check class or icon
|
||||
// The button changes class to bg-red-600
|
||||
await waitFor(() => {
|
||||
expect(testBtn).toHaveClass('bg-red-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles base domain selection', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
|
||||
|
||||
// Should not update domain names yet as no container selected
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('')
|
||||
|
||||
// Select container then base domain
|
||||
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
|
||||
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'container-123')
|
||||
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
|
||||
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
|
||||
})
|
||||
|
||||
// Application Preset Tests
|
||||
describe('Application Presets', () => {
|
||||
it('renders application preset dropdown with all options', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toBeInTheDocument()
|
||||
|
||||
// Check that all presets are available
|
||||
expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
|
||||
expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to none preset', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toHaveValue('none')
|
||||
})
|
||||
|
||||
it('enables websockets when selecting plex preset', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// First uncheck websockets
|
||||
const websocketCheckbox = screen.getByLabelText(/Websockets Support/i)
|
||||
if (websocketCheckbox.getAttribute('checked') !== null) {
|
||||
await userEvent.click(websocketCheckbox)
|
||||
}
|
||||
|
||||
// Select Plex preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
})
|
||||
|
||||
// Websockets should be enabled
|
||||
expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows plex config helper with external URL when preset is selected', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Should show the helper with external URL
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows jellyfin config helper with internal IP', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'jellyfin.mydomain.com')
|
||||
|
||||
// Select Jellyfin preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'jellyfin')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.50')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows home assistant config helper with yaml snippet', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ha.mydomain.com')
|
||||
|
||||
// Select Home Assistant preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'homeassistant')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows nextcloud config helper with php snippet', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'nextcloud.mydomain.com')
|
||||
|
||||
// Select Nextcloud preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'nextcloud')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows vaultwarden helper text', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'vault.mydomain.com')
|
||||
|
||||
// Select Vaultwarden preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'vaultwarden')
|
||||
|
||||
// Wait for helper text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument()
|
||||
expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-detects plex preset from container image', async () => {
|
||||
// Mock useDocker to return a Plex container
|
||||
const { useDocker } = await import('../../hooks/useDocker')
|
||||
vi.mocked(useDocker).mockReturnValue({
|
||||
containers: [
|
||||
{
|
||||
id: 'plex-container',
|
||||
names: ['plex'],
|
||||
image: 'linuxserver/plex:latest',
|
||||
state: 'running',
|
||||
status: 'Up 1 hour',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.3',
|
||||
ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select local source
|
||||
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
|
||||
|
||||
// Select the plex container
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'plex-container')
|
||||
|
||||
// The preset should be auto-detected as plex
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
})
|
||||
|
||||
it('auto-populates advanced_config when selecting plex preset and field empty', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Ensure advanced config is empty
|
||||
const textarea = screen.getByLabelText(/Advanced Caddy Config/i)
|
||||
expect(textarea).toHaveValue('')
|
||||
|
||||
// Select Plex preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
})
|
||||
|
||||
// Value should now be populated with a snippet containing X-Real-IP which is used by the preset
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts to confirm overwrite when selecting preset and advanced_config is non-empty', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'ConfTest',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 8080,
|
||||
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
|
||||
advanced_config_backup: '',
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select Plex preset (should prompt since advanced_config is non-empty)
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click Overwrite
|
||||
await userEvent.click(screen.getByText('Overwrite'))
|
||||
|
||||
// After overwrite, the textarea should contain the preset 'X-Real-IP' snippet
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
|
||||
})
|
||||
})
|
||||
|
||||
it('restores previous advanced_config from backup when clicking restore', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'RestoreTest',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 8080,
|
||||
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
|
||||
advanced_config_backup: '{"handler":"headers","request":{"set":{"X-Prev":"backup"}}}',
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The restore button should be visible
|
||||
const restoreBtn = await screen.findByText('Restore previous config')
|
||||
expect(restoreBtn).toBeInTheDocument()
|
||||
|
||||
// Click restore and expect the textarea to have backup value
|
||||
await userEvent.click(restoreBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Prev')
|
||||
})
|
||||
})
|
||||
|
||||
it('includes application field in form submission', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Plex Server')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.test.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '32400')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Submit form
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
application: 'plex',
|
||||
websocket_support: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('loads existing host application preset', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing Plex',
|
||||
domain_names: 'plex.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 32400,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'plex' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The preset should be pre-selected
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
|
||||
// The config helper should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show config helper when preset is none', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'test.mydomain.com')
|
||||
})
|
||||
|
||||
// Preset defaults to none, so no helper should be shown
|
||||
expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies external URL to clipboard for plex', async () => {
|
||||
// Mock clipboard API
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: mockWriteText },
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Wait for helper to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the copy button
|
||||
const copyButtons = screen.getAllByText('Copy')
|
||||
await userEvent.click(copyButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443')
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
200
frontend/src/components/__tests__/RemoteServerForm.test.tsx
Normal file
200
frontend/src/components/__tests__/RemoteServerForm.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import RemoteServerForm from '../RemoteServerForm'
|
||||
import * as remoteServersApi from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
testRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080' })),
|
||||
testCustomRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080', reachable: true })),
|
||||
}))
|
||||
|
||||
describe('RemoteServerForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders create form', () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('renders edit form with pre-filled data', () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
username: 'admin',
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows test connection button in create and edit mode', () => {
|
||||
const { rerender } = render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: false,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
rerender(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('submits form with correct data', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('My Production Server')
|
||||
const hostInput = screen.getByPlaceholderText('192.168.1.100')
|
||||
const portInput = screen.getByDisplayValue('22')
|
||||
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'New Server')
|
||||
await userEvent.clear(hostInput)
|
||||
await userEvent.type(hostInput, '10.0.0.5')
|
||||
await userEvent.clear(portInput)
|
||||
await userEvent.type(portInput, '9090')
|
||||
|
||||
await userEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'New Server',
|
||||
host: '10.0.0.5',
|
||||
port: 9090,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles provider selection', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const providerSelect = screen.getByDisplayValue('Generic')
|
||||
await userEvent.selectOptions(providerSelect, 'docker')
|
||||
|
||||
expect(providerSelect).toHaveValue('docker')
|
||||
})
|
||||
|
||||
it('handles submission error', async () => {
|
||||
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.clear(screen.getByPlaceholderText('My Production Server'))
|
||||
await userEvent.type(screen.getByPlaceholderText('My Production Server'), 'Test Server')
|
||||
await userEvent.clear(screen.getByPlaceholderText('192.168.1.100'))
|
||||
await userEvent.type(screen.getByPlaceholderText('192.168.1.100'), '10.0.0.1')
|
||||
|
||||
await userEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submission failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection success', async () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const testButton = screen.getByText('Test Connection')
|
||||
await userEvent.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for success state (green background)
|
||||
expect(testButton).toHaveClass('bg-green-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection failure', async () => {
|
||||
// Override mock for this test
|
||||
vi.mocked(remoteServersApi.testCustomRemoteServerConnection).mockRejectedValueOnce(new Error('Connection failed'))
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
enabled: true,
|
||||
reachable: true,
|
||||
created_at: '2025-11-18T10:00:00Z',
|
||||
updated_at: '2025-11-18T10:00:00Z',
|
||||
}
|
||||
|
||||
render(
|
||||
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Test Connection'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SecurityNotificationSettingsModal } from '../SecurityNotificationSettingsModal';
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient';
|
||||
import * as notificationsApi from '../../api/notifications';
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/notifications', async () => {
|
||||
const actual = await vi.importActual('../../api/notifications');
|
||||
return {
|
||||
...actual,
|
||||
getSecurityNotificationSettings: vi.fn(),
|
||||
updateSecurityNotificationSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock toast
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SecurityNotificationSettingsModal', () => {
|
||||
const mockSettings: notificationsApi.SecurityNotificationSettings = {
|
||||
enabled: true,
|
||||
min_log_level: 'warn',
|
||||
notify_waf_blocks: true,
|
||||
notify_acl_denials: true,
|
||||
notify_rate_limit_hits: false,
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
email_recipients: 'admin@example.com',
|
||||
};
|
||||
|
||||
let queryClient: ReturnType<typeof createTestQueryClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
});
|
||||
|
||||
const renderModal = (isOpen = true, onClose = vi.fn()) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SecurityNotificationSettingsModal isOpen={isOpen} onClose={onClose} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
renderModal(false);
|
||||
expect(screen.queryByText('Security Notification Settings')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and displays existing settings', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Check that settings are loaded
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
expect(levelSelect.value).toBe('warn');
|
||||
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
expect(webhookInput.value).toBe('https://example.com/webhook');
|
||||
});
|
||||
|
||||
it('closes modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal when clicking outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
const { container } = renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click on the backdrop
|
||||
const backdrop = container.querySelector('.fixed.inset-0');
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits updated settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change minimum log level
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i);
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
// Change webhook URL
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i);
|
||||
await user.clear(webhookInput);
|
||||
await user.type(webhookInput, 'https://new-webhook.com');
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
min_log_level: 'error',
|
||||
webhook_url: 'https://new-webhook.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Modal should close on success
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles notification enable/disable', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
|
||||
// Disable notifications
|
||||
await user.click(enableSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables controls when notifications are disabled', async () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue({
|
||||
...mockSettings,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
// Wait for settings to be loaded and form to render
|
||||
await waitFor(() => {
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
expect(levelSelect.disabled).toBe(true);
|
||||
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
expect(webhookInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles event type filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WAF Blocks')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and toggle WAF blocks switch
|
||||
const wafSwitch = screen.getByLabelText('WAF Blocks') as HTMLInputElement;
|
||||
expect(wafSwitch.checked).toBe(true);
|
||||
|
||||
await user.click(wafSwitch);
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notify_waf_blocks: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should NOT close on error
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockReturnValue(
|
||||
new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText('Loading settings...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles email recipients input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/admin@example.com/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/admin@example.com/i);
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, 'user1@test.com, user2@test.com');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email_recipients: 'user1@test.com, user2@test.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents modal content clicks from closing modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click inside the modal content
|
||||
const modalContent = screen.getByText('Security Notification Settings');
|
||||
await user.click(modalContent);
|
||||
|
||||
// Modal should not close
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
42
frontend/src/components/__tests__/SystemStatus.test.tsx
Normal file
42
frontend/src/components/__tests__/SystemStatus.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import SystemStatus from '../SystemStatus'
|
||||
import * as systemApi from '../../api/system'
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemStatus', () => {
|
||||
it('calls checkUpdates on mount', async () => {
|
||||
vi.mocked(systemApi.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '1.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
|
||||
renderWithClient(<SystemStatus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(systemApi.checkUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user