fix: enhance DNSProviders page to improve manual challenge handling and visibility of provider cards

This commit is contained in:
GitHub Actions
2026-02-15 08:29:00 +00:00
parent 96ee1d717b
commit 3d614dd8e2
3 changed files with 493 additions and 163 deletions
+39 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Plus, Cloud } from 'lucide-react'
import { Button, Alert, EmptyState, Skeleton } from '../components/ui'
@@ -19,37 +19,43 @@ export default function DNSProviders() {
const [testingProviderId, setTestingProviderId] = useState<number | null>(null)
const [manualChallenge, setManualChallenge] = useState<ManualChallenge | null>(null)
const [activeManualProviderId, setActiveManualProviderId] = useState<number | null>(null)
const [isManualChallengeOpen, setIsManualChallengeOpen] = useState(false)
const manualProviderId = providers.find((provider) => provider.provider_type === 'manual')?.id ?? 1
const manualProviderId = providers.find((provider) => provider.provider_type === 'manual')?.id ?? null
const loadManualChallenge = useCallback(async (providerId: number) => {
const loadManualChallenge = useCallback(async (providerId: number): Promise<boolean> => {
try {
const challenge = await getChallenge(providerId, 'active')
setManualChallenge(challenge)
setActiveManualProviderId(providerId)
return true
} catch {
const now = new Date()
const fallbackChallenge: ManualChallenge = {
id: 'active',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'mock-challenge-token-value-abc123',
ttl: 300,
created_at: now.toISOString(),
expires_at: new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
dns_propagated: false,
}
setManualChallenge(fallbackChallenge)
setManualChallenge(null)
setActiveManualProviderId(providerId)
return false
}
}, [])
useEffect(() => {
if (isLoading) return
void loadManualChallenge(manualProviderId)
}, [isLoading, loadManualChallenge, manualProviderId])
const manualChallengeProviderId = activeManualProviderId ?? manualProviderId
const showManualChallenge =
isManualChallengeOpen && Boolean(manualChallenge) && manualChallengeProviderId !== null
const showManualChallenge = Boolean(manualChallenge)
const handleManualChallengeClick = async () => {
if (manualProviderId === null) {
toast.error(t('dnsProviders.noProviders'))
return
}
const hasChallenge = await loadManualChallenge(manualProviderId)
if (!hasChallenge) {
toast.error(t('dnsProvider.manual.challengeNotFound'))
setIsManualChallengeOpen(false)
return
}
setIsManualChallengeOpen(true)
}
const handleAddProvider = () => {
setEditingProvider(null)
@@ -124,20 +130,29 @@ export default function DNSProviders() {
</Alert>
<div className="flex justify-end">
<Button variant="secondary" onClick={() => void loadManualChallenge(manualProviderId)}>
<Button variant="secondary" onClick={() => void handleManualChallengeClick()}>
{t('dnsProvider.manual.title')}
</Button>
</div>
{showManualChallenge && manualChallenge && (
{showManualChallenge && manualChallenge && manualChallengeProviderId !== null && (
<ManualDNSChallenge
providerId={activeManualProviderId ?? manualProviderId}
providerId={manualChallengeProviderId}
challenge={manualChallenge}
onComplete={() => {
void loadManualChallenge(activeManualProviderId ?? manualProviderId)
const providerId = activeManualProviderId ?? manualProviderId
if (providerId === null) {
setIsManualChallengeOpen(false)
return
}
void loadManualChallenge(providerId).then((hasChallenge) => {
setIsManualChallengeOpen(hasChallenge)
})
}}
onCancel={() => {
setManualChallenge(null)
setIsManualChallengeOpen(false)
}}
/>
)}
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import DNSProviders from '../DNSProviders'
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../../hooks/useDNSProviders'
import { getChallenge } from '../../api/manualChallenge'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../../hooks/useDNSProviders', () => ({
useDNSProviders: vi.fn(),
useDNSProviderMutations: vi.fn(),
}))
vi.mock('../../api/manualChallenge', () => ({
getChallenge: vi.fn(),
}))
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
vi.mock('../../components/ui', () => ({
Button: ({ children, onClick, variant }: { children: ReactNode; onClick?: () => void; variant?: string }) => (
<button type="button" data-variant={variant} onClick={onClick}>
{children}
</button>
),
Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
EmptyState: ({ title }: { title: string }) => <div>{title}</div>,
Skeleton: () => <div data-testid="skeleton" />,
}))
vi.mock('../../components/DNSProviderCard', () => ({
default: ({ provider }: { provider: DNSProvider }) => (
<article data-testid="provider-card">
<h3>{provider.name}</h3>
</article>
),
}))
vi.mock('../../components/DNSProviderForm', () => ({
default: () => null,
}))
vi.mock('../../components/dns-providers', () => ({
ManualDNSChallenge: ({ challenge }: { challenge: { fqdn: string } }) => (
<section data-testid="manual-dns-challenge">{challenge.fqdn}</section>
),
}))
const buildProvider = (overrides: Partial<DNSProvider> = {}): DNSProvider => ({
id: 7,
uuid: 'provider-uuid',
name: 'Seeded Provider',
provider_type: 'manual',
enabled: true,
is_default: false,
has_credentials: true,
propagation_timeout: 60,
polling_interval: 5,
success_count: 0,
failure_count: 0,
created_at: '2026-02-15T00:00:00Z',
updated_at: '2026-02-15T00:00:00Z',
...overrides,
})
describe('DNSProviders page state behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useDNSProviderMutations).mockReturnValue({
deleteMutation: { mutateAsync: vi.fn() },
testMutation: { mutateAsync: vi.fn() },
createMutation: { mutateAsync: vi.fn() },
updateMutation: { mutateAsync: vi.fn() },
testCredentialsMutation: { mutateAsync: vi.fn() },
} as unknown as ReturnType<typeof useDNSProviderMutations>)
vi.mocked(useDNSProviders).mockReturnValue({
data: [buildProvider()],
isLoading: false,
refetch: vi.fn(),
} as unknown as ReturnType<typeof useDNSProviders>)
})
it('keeps provider cards visible by default without challenge fetch side effects', async () => {
renderWithQueryClient(<DNSProviders />)
expect(await screen.findByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
expect(getChallenge).not.toHaveBeenCalled()
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
})
it('does not hide provider cards when manual challenge fetch fails', async () => {
vi.mocked(getChallenge).mockRejectedValue(new Error('not found'))
const user = userEvent.setup()
renderWithQueryClient(<DNSProviders />)
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
await waitFor(() => {
expect(getChallenge).toHaveBeenCalledWith(7, 'active')
})
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Seeded Provider' })).toBeInTheDocument()
})
it('opens manual challenge panel only after explicit action when fetch succeeds', async () => {
vi.mocked(getChallenge).mockResolvedValue({
id: 'active',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'token',
ttl: 300,
created_at: '2026-02-15T00:00:00Z',
expires_at: '2026-02-15T00:10:00Z',
dns_propagated: false,
})
const user = userEvent.setup()
renderWithQueryClient(<DNSProviders />)
expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' }))
expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument()
})
})