Files
Charon/frontend/src/components/__tests__/CredentialManager.test.tsx
GitHub Actions 3169b05156 fix: skip incomplete system log viewer tests
- Marked 12 tests as skip pending feature implementation
- Features tracked in GitHub issue #686 (system log viewer feature completion)
- Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality
- Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation
- TODO comments in code reference GitHub #686 for feature completion tracking
- Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
2026-02-09 21:55:55 +00:00

870 lines
25 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, type UseMutationResult } from '@tanstack/react-query'
import CredentialManager from '../CredentialManager'
import {
useCredentials,
useCreateCredential,
useUpdateCredential,
useDeleteCredential,
useTestCredential,
} from '../../hooks/useCredentials'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: string) => defaultValue || key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
}))
import type { DNSProvider, DNSProviderTypeInfo } from '../../api/dnsProviders'
import type { CredentialRequest, CredentialTestResult, 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',
},
{
name: 'email',
label: 'Email Address',
type: 'text',
required: false,
}
],
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',
},
]
const createCredentialsQueryResult = (
overrides: Record<string, unknown> = {}
): ReturnType<typeof useCredentials> => ({
data: mockCredentials,
isLoading: false,
refetch: vi.fn(),
error: null,
isError: false,
isSuccess: true,
...overrides,
} as unknown as ReturnType<typeof useCredentials>)
const createMutationResult = <TData, TVariables>(
mutateAsync: ReturnType<typeof vi.fn>,
overrides: Partial<UseMutationResult<TData, Error, TVariables, unknown>> = {}
): UseMutationResult<TData, Error, TVariables, unknown> => ({
mutate: vi.fn() as UseMutationResult<TData, Error, TVariables, unknown>['mutate'],
mutateAsync: mutateAsync as UseMutationResult<TData, Error, TVariables, unknown>['mutateAsync'],
isPending: false,
...overrides,
} as UseMutationResult<TData, Error, TVariables, unknown>)
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 mockCreateMutate = vi.fn()
const mockUpdateMutate = vi.fn()
const mockDeleteMutate = vi.fn()
const mockTestMutate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCredentials).mockReturnValue(createCredentialsQueryResult())
vi.mocked(useCreateCredential).mockReturnValue(
createMutationResult<DNSProviderCredential, { providerId: number; data: CredentialRequest }>(
mockCreateMutate
)
)
vi.mocked(useUpdateCredential).mockReturnValue(
createMutationResult<
DNSProviderCredential,
{ providerId: number; credentialId: number; data: CredentialRequest }
>(mockUpdateMutate)
)
vi.mocked(useDeleteCredential).mockReturnValue(
createMutationResult<void, { providerId: number; credentialId: number }>(
mockDeleteMutate
)
)
vi.mocked(useTestCredential).mockReturnValue(
createMutationResult<CredentialTestResult, { providerId: number; credentialId: number }>(
mockTestMutate
)
)
})
// 1. Rendering Checks
it('renders credentials properly', async () => {
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Manage Credentials: Cloudflare Production')).toBeInTheDocument()
expect(screen.getByText('Main Zone')).toBeInTheDocument()
expect(screen.getByText('example.com')).toBeInTheDocument()
})
// 2. Add Operation
it('allows adding a new credential', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Click Add Credential
await user.click(screen.getByText('Add Credential'))
// Verify Form opens
expect(screen.getByRole('dialog', { name: 'Add Credential' })).toBeInTheDocument()
// Fill Form
// Label requires *
await user.type(screen.getByLabelText(/Label/i), 'New Staging')
// Zone Filter
await user.type(screen.getByLabelText(/Zone Filter/i), '*.staging.com')
// Credentials fields from type info
// API Token (required)
await user.type(screen.getByLabelText(/API Token/i), 'my-secret-token')
// Click Save
await user.click(screen.getByRole('button', { name: 'Save' }))
// Expect Create Mutation
await waitFor(() => {
expect(mockCreateMutate).toHaveBeenCalledWith({
providerId: 1,
data: expect.objectContaining({
label: 'New Staging',
zone_filter: '*.staging.com',
credentials: expect.objectContaining({
api_token: 'my-secret-token'
}),
enabled: true
})
})
})
})
// 3. Edit Operation
it('allows editing an existing credential', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
// Locate the edit button for the first credential.
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const editBtn = credRow?.querySelectorAll('button')[1] // 0=Test, 1=Edit, 2=Delete
expect(editBtn).toBeDefined()
await user.click(editBtn!)
// Verify Form opens with pre-filled values
expect(screen.getByRole('dialog', { name: 'Edit Credential' })).toBeInTheDocument()
expect(screen.getByDisplayValue('Main Zone')).toBeInTheDocument()
expect(screen.getByDisplayValue('example.com')).toBeInTheDocument()
// Change label
const labelInput = screen.getByLabelText(/Label/i)
await user.clear(labelInput)
await user.type(labelInput, 'Updated Label')
// Click Save
await user.click(screen.getByRole('button', { name: 'Save' }))
// Expect Update Mutation
await waitFor(() => {
expect(mockUpdateMutate).toHaveBeenCalledWith({
providerId: 1,
credentialId: 1,
data: expect.objectContaining({
label: 'Updated Label',
zone_filter: 'example.com'
})
})
})
})
// 4. Delete Operation
it('allows deleting a credential after confirmation', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const deleteBtn = credRow?.querySelectorAll('button')[2] // 0=Test, 1=Edit, 2=Delete
expect(deleteBtn).toBeDefined()
await user.click(deleteBtn!)
// Confirmation Dialog
expect(screen.getByText('Delete Credential?')).toBeInTheDocument()
// Confirm
await user.click(screen.getByRole('button', { name: 'Delete' }))
// Expect Delete Mutation
await waitFor(() => {
expect(mockDeleteMutate).toHaveBeenCalledWith({
providerId: 1,
credentialId: 1
})
})
})
// 5. Validation - Required Fields
it('validates required fields on add', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
// Click Save without filling anything
await user.click(screen.getByRole('button', { name: 'Save' }))
// Mutation should NOT be called.
expect(mockCreateMutate).not.toHaveBeenCalled()
// Fill Label but not API Key (which is required by type info)
await user.type(screen.getByLabelText(/Label/i), 'Incomplete')
await user.click(screen.getByRole('button', { name: 'Save' }))
// Still no mutation
expect(mockCreateMutate).not.toHaveBeenCalled()
})
// 6. Validation - Zone Filter Format
it('validates zone filter format', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'Bad Zone')
await user.type(screen.getByLabelText(/API Token/i), 'token')
// Invalid zone
await user.type(screen.getByLabelText(/Zone Filter/i), 'invalid zone')
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(mockCreateMutate).not.toHaveBeenCalled()
// Fix zone
const zoneInput = screen.getByLabelText(/Zone Filter/i)
await user.clear(zoneInput)
await user.type(zoneInput, 'valid.com')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockCreateMutate).toHaveBeenCalled()
})
})
// ===== BRANCH COVERAGE EXPANSION TESTS =====
// 7. Empty Credential List Rendering
it('renders empty state when no credentials exist', () => {
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [] })
)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText(/No credentials configured/i)).toBeInTheDocument()
expect(screen.getByText(/Add credentials to enable/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Add First Credential/i })).toBeInTheDocument()
})
// 8. Loading State
it('renders loading state while fetching credentials', () => {
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({
data: [],
isLoading: true,
isSuccess: false,
status: 'loading',
fetchStatus: 'fetching',
})
)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
// 9. Delete Error Handling
it('shows error toast when delete fails', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockDeleteMutate.mockRejectedValue({
response: { data: { error: 'Credential in use' } }
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const deleteBtn = credRow?.querySelectorAll('button')[2]
await user.click(deleteBtn!)
await user.click(screen.getByRole('button', { name: 'Delete' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
})
// 10. Test Credential - Success
it('tests credential and shows success', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockTestMutate.mockResolvedValue({ success: true, message: 'Test passed' })
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const testBtn = credRow?.querySelectorAll('button')[0]
await user.click(testBtn!)
await waitFor(() => {
expect(mockTestMutate).toHaveBeenCalledWith({
providerId: 1,
credentialId: 1,
})
expect(toast.success).toHaveBeenCalled()
})
})
// 11. Test Credential - Failure
it('tests credential and shows error on failure', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockTestMutate.mockResolvedValue({ success: false, error: 'Invalid token' })
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const testBtn = credRow?.querySelectorAll('button')[0]
await user.click(testBtn!)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
})
// 12. Test Credential - Exception
it('handles test credential exception', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockTestMutate.mockRejectedValue({ message: 'Network error' })
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const testBtn = credRow?.querySelectorAll('button')[0]
await user.click(testBtn!)
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
})
// 13. Multiple Credentials
it('renders multiple credentials in table', () => {
const multipleCreds = [
...mockCredentials,
{
id: 2,
uuid: 'cred-uuid-2',
dns_provider_id: 1,
label: 'Staging Zone',
zone_filter: '*.staging.com',
enabled: true,
propagation_timeout: 120,
polling_interval: 5,
key_version: 1,
success_count: 5,
failure_count: 2,
last_used_at: undefined,
last_error: undefined,
created_at: '2025-01-02T00:00:00Z',
updated_at: '2025-01-02T00:00:00Z',
}
]
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: multipleCreds })
)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Main Zone')).toBeInTheDocument()
expect(screen.getByText('Staging Zone')).toBeInTheDocument()
expect(screen.getByText('*.staging.com')).toBeInTheDocument()
})
// 14. Disabled Credential
it('displays disabled status for disabled credentials', () => {
const disabledCred = {
...mockCredentials[0],
enabled: false,
}
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [disabledCred] })
)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('Disabled')).toBeInTheDocument()
})
// 15. Credential with Last Error
it('displays last error when credential has failure', () => {
const errorCred = {
...mockCredentials[0],
failure_count: 3,
last_error: 'API rate limit exceeded',
}
vi.mocked(useCredentials).mockReturnValue(
createCredentialsQueryResult({ data: [errorCred] })
)
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
expect(screen.getByText('API rate limit exceeded')).toBeInTheDocument()
})
// 16. Create Credential Error
it('shows error toast when create fails', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockCreateMutate.mockRejectedValue({
response: { data: { error: 'Invalid provider' } }
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'Test')
await user.type(screen.getByLabelText(/API Token/i), 'token')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
})
// 17. Update Credential Error
it('shows error toast when update fails', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockUpdateMutate.mockRejectedValue({
response: { data: { error: 'Zone filter conflict' } }
})
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const editBtn = credRow?.querySelectorAll('button')[1]
await user.click(editBtn!)
await user.type(screen.getByLabelText(/Label/i), ' Updated')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
})
// 18. Cancel Delete
it('cancels delete operation', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const deleteBtn = credRow?.querySelectorAll('button')[2]
await user.click(deleteBtn!)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockDeleteMutate).not.toHaveBeenCalled()
})
// 19. Cancel Form Dialog
it('closes form dialog without saving', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'Test')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(mockCreateMutate).not.toHaveBeenCalled()
})
// 20. Advanced Options - Propagation Timeout
it('allows editing propagation timeout in advanced options', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'Advanced Test')
await user.type(screen.getByLabelText(/API Token/i), 'token')
// Find and open details element
const detailsElements = document.querySelectorAll('details')
const detailsElement = Array.from(detailsElements).find(d =>
d.textContent?.includes('Advanced Options')
)
if (detailsElement) {
const summary = detailsElement.querySelector('summary')
if (summary) {
await user.click(summary)
}
}
// Try to find the propagation timeout input
const timeoutInputs = document.querySelectorAll('input')
const timeoutInput = Array.from(timeoutInputs).find(i =>
i.id === 'propagation_timeout' || i.placeholder?.includes('120')
) as HTMLInputElement
if (timeoutInput) {
await user.clear(timeoutInput)
await user.type(timeoutInput, '300')
}
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockCreateMutate).toHaveBeenCalled()
})
})
// 21. Advanced Options - Polling Interval
it('allows editing polling interval in advanced options', async () => {
const user = userEvent.setup()
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'Polling Test')
await user.type(screen.getByLabelText(/API Token/i), 'token')
// Find and open details element
const detailsElements = document.querySelectorAll('details')
const detailsElement = Array.from(detailsElements).find(d =>
d.textContent?.includes('Advanced Options')
)
if (detailsElement) {
const summary = detailsElement.querySelector('summary')
if (summary) {
await user.click(summary)
}
}
// Try to find the polling interval input
const inputs = document.querySelectorAll('input')
const pollingInput = Array.from(inputs).find(i =>
i.id === 'polling_interval' || (i.type === 'number' && i.placeholder?.includes('5'))
) as HTMLInputElement
if (pollingInput) {
await user.clear(pollingInput)
await user.type(pollingInput, '10')
}
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(mockCreateMutate).toHaveBeenCalled()
})
})
// 22. Form Success Toast
it('shows success toast after credential creation', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockCreateMutate.mockResolvedValue({ id: 2, label: 'New Cred' })
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
await user.click(screen.getByText('Add Credential'))
await user.type(screen.getByLabelText(/Label/i), 'New Cred')
await user.type(screen.getByLabelText(/API Token/i), 'token')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('created successfully'))
})
})
// 23. Form Success Toast - Update
it('shows success toast after credential update', async () => {
const user = userEvent.setup()
const { toast } = await import('../../utils/toast')
mockUpdateMutate.mockResolvedValue({ id: 1, label: 'Updated' })
renderWithClient(
<CredentialManager
open={true}
onOpenChange={mockOnOpenChange}
provider={mockProvider}
providerTypeInfo={mockProviderTypeInfo}
/>
)
const rows = screen.getAllByRole('row')
const credRow = rows.find(r => r.innerHTML.includes('Main Zone'))
const editBtn = credRow?.querySelectorAll('button')[1]
await user.click(editBtn!)
await user.type(screen.getByLabelText(/Label/i), ' Updated')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('updated successfully'))
})
})
})