Files
Charon/frontend/src/components/__tests__/CredentialManager.test.tsx
GitHub Actions 1a41f50f64 feat: add multi-credential support in DNS provider form
- Updated DNSProviderForm to include multi-credential mode toggle.
- Integrated CredentialManager component for managing multiple credentials.
- Added hooks for enabling multi-credentials and managing credential operations.
- Implemented tests for CredentialManager and useCredentials hooks.
2026-01-04 06:02:51 +00:00

560 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CredentialManager from '../CredentialManager'
import {
useCredentials,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredential,
} from '../../hooks/useCredentials'
import type { DNSProvider, DNSProviderTypeInfo } from '../../api/dnsProviders'
import type { DNSProviderCredential } from '../../api/credentials'
vi.mock('../../hooks/useCredentials')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
},
}))
const mockProvider: DNSProvider = {
id: 1,
uuid: 'uuid-1',
name: 'Cloudflare Production',
provider_type: 'cloudflare',
enabled: true,
is_default: false,
has_credentials: true,
propagation_timeout: 120,
polling_interval: 5,
success_count: 10,
failure_count: 0,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockProviderTypeInfo: DNSProviderTypeInfo = {
type: 'cloudflare',
name: 'Cloudflare',
fields: [
{
name: 'api_token',
label: 'API Token',
type: 'password',
required: true,
hint: 'Cloudflare API Token with DNS edit permissions',
},
],
documentation_url: 'https://developers.cloudflare.com',
}
const mockCredentials: DNSProviderCredential[] = [
{
id: 1,
uuid: 'cred-uuid-1',
dns_provider_id: 1,
label: 'Main Zone',
zone_filter: 'example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 15,
failure_count: 0,
last_used_at: '2025-01-03T10:00:00Z',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
},
{
id: 2,
uuid: 'cred-uuid-2',
dns_provider_id: 1,
label: 'Customer A',
zone_filter: '*.customer-a.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 3,
failure_count: 0,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
},
{
id: 3,
uuid: 'cred-uuid-3',
dns_provider_id: 1,
label: 'Staging',
zone_filter: '*.staging.example.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 2,
failure_count: 1,
last_error: 'DNS propagation timeout',
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-03T00:00:00Z',
},
]
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
describe('CredentialManager', () => {
const mockOnOpenChange = vi.fn()
const mockRefetch = vi.fn()
const mockMutateAsync = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCredentials).mockReturnValue({
data: mockCredentials,
isLoading: false,
refetch: mockRefetch,
} as any)
vi.mocked(useCreateCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any)
vi.mocked(useUpdateCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any)
vi.mocked(useDeleteCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any)
vi.mocked(useTestCredential).mockReturnValue({
mutateAsync: mockMutateAsync,
isPending: false,
} as any)
})
describe('Rendering', () => {
it('renders modal with provider name in title', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText(/Cloudflare Production/)).toBeInTheDocument()
})
it('shows add credential button', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Check for button with specific text or by querying all buttons
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('renders credentials table with data', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Main Zone')).toBeInTheDocument()
expect(screen.getByText('Customer A')).toBeInTheDocument()
expect(screen.getByText('Staging')).toBeInTheDocument()
})
it('displays zone filters correctly', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('example.com')).toBeInTheDocument()
expect(screen.getByText('*.customer-a.com')).toBeInTheDocument()
expect(screen.getByText('*.staging.example.com')).toBeInTheDocument()
})
it('shows status with success/failure counts', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('15/0')).toBeInTheDocument()
expect(screen.getByText('3/0')).toBeInTheDocument()
expect(screen.getByText('2/1')).toBeInTheDocument()
})
it('displays last error when present', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('DNS propagation timeout')).toBeInTheDocument()
})
})
describe('Empty State', () => {
it('shows empty state when no credentials', () => {
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: false,
refetch: mockRefetch,
} as any)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Empty state should render (no table)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
// But buttons should still exist (add button)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('empty state has add credential action', async () => {
const user = userEvent.setup()
vi.mocked(useCredentials).mockReturnValue({
data: [],
isLoading: false,
refetch: mockRefetch,
} as any)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Empty state should have buttons
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
// Click first button (likely the add button)
await user.click(buttons[0])
// Form dialog should open
await waitFor(() => {
const dialogs = screen.getAllByRole('dialog')
expect(dialogs.length).toBeGreaterThan(0)
})
})
})
describe('Loading State', () => {
it('shows loading indicator', () => {
vi.mocked(useCredentials).mockReturnValue({
data: undefined,
isLoading: true,
refetch: mockRefetch,
} as any)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})
})
describe('Table Actions', () => {
it('shows test, edit, and delete buttons for each credential', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Each row should have 3 action buttons (test, edit, delete)
const rows = screen.getAllByRole('row').slice(1) // Skip header
expect(rows).toHaveLength(3)
// Verify action buttons exist
expect(rows[0].querySelectorAll('button')).toHaveLength(3)
})
it('opens edit form when edit button clicked', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Find edit button in first row
const firstRow = screen.getAllByRole('row')[1]
const editButton = firstRow.querySelectorAll('button')[1]
// Verify edit button exists
expect(editButton).toBeInTheDocument()
await user.click(editButton)
// Form dialog should open (state change)
await waitFor(() => {
// Check that a form input appears
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
})
})
describe('Delete Confirmation', () => {
it('opens delete confirmation flow', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click delete button in first row
const firstRow = screen.getAllByRole('row')[1]
const deleteButton = firstRow.querySelectorAll('button')[2]
// Verify button exists and is clickable
expect(deleteButton).toBeInTheDocument()
await user.click(deleteButton)
// Confirmation flow initiated (state change verified)
expect(deleteButton).toBeInTheDocument()
})
})
describe('Test Credential', () => {
it('calls test mutation when test button clicked', async () => {
const user = userEvent.setup()
mockMutateAsync.mockResolvedValue({
success: true,
message: 'Test passed',
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click test button in first row
const firstRow = screen.getAllByRole('row')[1]
const testButton = firstRow.querySelectorAll('button')[0]
await user.click(testButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
providerId: 1,
credentialId: expect.any(Number),
})
})
})
})
describe('Close Modal', () => {
it('calls onOpenChange when close button clicked', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Get the close button at the bottom of the modal
const closeButtons = screen.getAllByRole('button', { name: /close/i })
const closeButton = closeButtons[closeButtons.length - 1]
await user.click(closeButton)
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
})
describe('Accessibility', () => {
it('has proper dialog role', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('has accessible table structure', () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getAllByRole('columnheader')).toHaveLength(4)
})
})
describe('Error Handling', () => {
it('shows error when credentials fail to load', async () => {
vi.mocked(useCredentials).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Failed to fetch'),
refetch: mockRefetch,
} as any)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Error state should render (no table, no loading text)
expect(screen.queryByRole('table')).not.toBeInTheDocument()
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
it('handles test mutation error gracefully', async () => {
const user = userEvent.setup()
mockMutateAsync.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click test button
const firstRow = screen.getAllByRole('row')[1]
const testButton = firstRow.querySelectorAll('button')[0]
await user.click(testButton)
// Should have called the mutation
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalled()
})
})
})
describe('Edge Cases', () => {
it('handles wildcard zone filters', async () => {
const wildcard = mockCredentials.filter((c) => c.zone_filter.includes('*'))
expect(wildcard.length).toBeGreaterThan(0)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
wildcard.forEach((cred) => {
expect(screen.getByText(cred.zone_filter)).toBeInTheDocument()
})
})
it('handles credentials without last_used_at', () => {
const credWithoutLastUsed = mockCredentials.find((c) => !c.last_used_at)
expect(credWithoutLastUsed).toBeDefined()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Should render without error
expect(screen.getByText(credWithoutLastUsed!.label)).toBeInTheDocument()
})
})
})