feat: add ManualDNSChallenge component and related hooks for manual DNS challenge management

- Implemented `useManualChallenge`, `useChallengePoll`, and `useManualChallengeMutations` hooks for managing manual DNS challenges.
- Created tests for the `useManualChallenge` hooks to ensure correct fetching and mutation behavior.
- Added `ManualDNSChallenge` component for displaying challenge details and actions.
- Developed end-to-end tests for the Manual DNS Provider feature, covering provider selection, challenge UI, and accessibility compliance.
- Included error handling tests for verification failures and network errors.
This commit is contained in:
GitHub Actions
2026-01-12 04:01:40 +00:00
parent a199dfd079
commit d7939bed70
132 changed files with 8680 additions and 878 deletions
@@ -0,0 +1,230 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getChallenge,
createChallenge,
verifyChallenge,
pollChallenge,
deleteChallenge,
} from '../manualChallenge'
import client from '../client'
vi.mock('../client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
describe('manualChallenge API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('getChallenge', () => {
it('fetches challenge by provider and challenge ID', async () => {
const mockChallenge = {
id: 'challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'test-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockChallenge })
const result = await getChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
expect(result).toEqual(mockChallenge)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.get).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(getChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
})
describe('createChallenge', () => {
it('creates a new challenge for the provider', async () => {
const mockChallenge = {
id: 'new-challenge-uuid',
status: 'created',
fqdn: '_acme-challenge.example.com',
value: 'generated-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockChallenge })
const result = await createChallenge(1, { domain: 'example.com' })
expect(client.post).toHaveBeenCalledWith('/dns-providers/1/manual-challenge', {
domain: 'example.com',
})
expect(result).toEqual(mockChallenge)
})
it('throws error when provider not found', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Provider not found' } },
})
await expect(createChallenge(999, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when challenge already in progress', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 409, data: { code: 'CHALLENGE_IN_PROGRESS' } },
})
await expect(createChallenge(1, { domain: 'example.com' })).rejects.toMatchObject({
response: { status: 409 },
})
})
})
describe('verifyChallenge', () => {
it('triggers verification for a challenge', async () => {
const mockResult = {
success: true,
dns_found: true,
message: 'TXT record verified successfully',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(client.post).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/verify'
)
expect(result).toEqual(mockResult)
})
it('returns dns_found false when record not propagated', async () => {
const mockResult = {
success: false,
dns_found: false,
message: 'DNS record not found',
}
vi.mocked(client.post).mockResolvedValueOnce({ data: mockResult })
const result = await verifyChallenge(1, 'challenge-uuid')
expect(result.success).toBe(false)
expect(result.dns_found).toBe(false)
})
it('throws error when challenge expired', async () => {
vi.mocked(client.post).mockRejectedValueOnce({
response: { status: 410, data: { code: 'CHALLENGE_EXPIRED' } },
})
await expect(verifyChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 410 },
})
})
})
describe('pollChallenge', () => {
it('returns current challenge status', async () => {
const mockPoll = {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: '2026-01-11T00:02:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(client.get).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid/poll'
)
expect(result).toEqual(mockPoll)
})
it('returns verified status when DNS propagated', async () => {
const mockPoll = {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('verified')
expect(result.dns_propagated).toBe(true)
})
it('includes error message when challenge failed', async () => {
const mockPoll = {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: '2026-01-11T00:05:00Z',
error_message: 'ACME validation failed',
}
vi.mocked(client.get).mockResolvedValueOnce({ data: mockPoll })
const result = await pollChallenge(1, 'challenge-uuid')
expect(result.status).toBe('failed')
expect(result.error_message).toBe('ACME validation failed')
})
})
describe('deleteChallenge', () => {
it('deletes/cancels a challenge', async () => {
vi.mocked(client.delete).mockResolvedValueOnce({ data: undefined })
await deleteChallenge(1, 'challenge-uuid')
expect(client.delete).toHaveBeenCalledWith(
'/dns-providers/1/manual-challenge/challenge-uuid'
)
})
it('throws error when challenge not found', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 404, data: { error: 'Challenge not found' } },
})
await expect(deleteChallenge(1, 'invalid-uuid')).rejects.toMatchObject({
response: { status: 404 },
})
})
it('throws error when unauthorized', async () => {
vi.mocked(client.delete).mockRejectedValueOnce({
response: { status: 403, data: { error: 'Unauthorized' } },
})
await expect(deleteChallenge(1, 'challenge-uuid')).rejects.toMatchObject({
response: { status: 403 },
})
})
})
})
+5
View File
@@ -12,6 +12,11 @@ export type DNSProviderType =
| 'hetzner'
| 'vultr'
| 'dnsimple'
// Custom plugin types
| 'manual'
| 'webhook'
| 'rfc2136'
| 'script'
/** Represents a configured DNS provider */
export interface DNSProvider {
+115
View File
@@ -0,0 +1,115 @@
import client from './client'
/** Status of a manual DNS challenge */
export type ChallengeStatus = 'created' | 'pending' | 'verifying' | 'verified' | 'expired' | 'failed'
/** Manual DNS challenge response from API */
export interface ManualChallenge {
id: string
status: ChallengeStatus
fqdn: string
value: string
ttl: number
created_at: string
expires_at: string
last_check_at?: string
dns_propagated: boolean
error_message?: string
}
/** Polling response for challenge status */
export interface ChallengePollResponse {
status: ChallengeStatus
dns_propagated: boolean
time_remaining_seconds: number
last_check_at: string
error_message?: string
}
/** Challenge verification result */
export interface ChallengeVerifyResponse {
success: boolean
dns_found: boolean
message: string
}
/** Request to create a new manual challenge */
export interface CreateChallengeRequest {
domain: string
}
/**
* Fetches a manual challenge by ID.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to the challenge details
* @throws {AxiosError} If not found or request fails
*/
export async function getChallenge(providerId: number, challengeId: string): Promise<ManualChallenge> {
const response = await client.get<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}`
)
return response.data
}
/**
* Creates a new manual DNS challenge.
* @param providerId - The DNS provider ID
* @param data - Challenge creation data
* @returns Promise resolving to the created challenge
* @throws {AxiosError} If validation fails or request fails
*/
export async function createChallenge(
providerId: number,
data: CreateChallengeRequest
): Promise<ManualChallenge> {
const response = await client.post<ManualChallenge>(
`/dns-providers/${providerId}/manual-challenge`,
data
)
return response.data
}
/**
* Triggers verification of a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to verification result
* @throws {AxiosError} If not found or request fails
*/
export async function verifyChallenge(
providerId: number,
challengeId: string
): Promise<ChallengeVerifyResponse> {
const response = await client.post<ChallengeVerifyResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/verify`
)
return response.data
}
/**
* Polls for challenge status updates.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @returns Promise resolving to poll response
* @throws {AxiosError} If not found or request fails
*/
export async function pollChallenge(
providerId: number,
challengeId: string
): Promise<ChallengePollResponse> {
const response = await client.get<ChallengePollResponse>(
`/dns-providers/${providerId}/manual-challenge/${challengeId}/poll`
)
return response.data
}
/**
* Deletes/cancels a manual challenge.
* @param providerId - The DNS provider ID
* @param challengeId - The challenge UUID
* @throws {AxiosError} If not found or request fails
*/
export async function deleteChallenge(providerId: number, challengeId: string): Promise<void> {
await client.delete(`/dns-providers/${providerId}/manual-challenge/${challengeId}`)
}
@@ -0,0 +1,712 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import ManualDNSChallenge from '../dns-providers/ManualDNSChallenge'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
// Mock dependencies
vi.mock('../../hooks/useManualChallenge')
vi.mock('../../utils/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}))
// Mock clipboard API using vi.stubGlobal
const mockWriteText = vi.fn()
vi.stubGlobal('navigator', {
...navigator,
clipboard: {
writeText: mockWriteText,
},
})
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const translations: Record<string, string> = {
'dnsProvider.manual.title': 'Manual DNS Challenge',
'dnsProvider.manual.instructions': `To obtain a certificate for ${options?.domain || 'example.com'}, create the following TXT record at your DNS provider:`,
'dnsProvider.manual.createRecord': 'Create this TXT record at your DNS provider',
'dnsProvider.manual.recordName': 'Record Name',
'dnsProvider.manual.recordValue': 'Record Value',
'dnsProvider.manual.ttl': 'TTL',
'dnsProvider.manual.seconds': 'seconds',
'dnsProvider.manual.minutes': 'minutes',
'dnsProvider.manual.timeRemaining': 'Time remaining',
'dnsProvider.manual.progressPercent': `${options?.percent || 0}% time remaining`,
'dnsProvider.manual.challengeProgress': 'Challenge timeout progress',
'dnsProvider.manual.copy': 'Copy',
'dnsProvider.manual.copied': 'Copied!',
'dnsProvider.manual.copyFailed': 'Failed to copy to clipboard',
'dnsProvider.manual.copyRecordName': 'Copy record name to clipboard',
'dnsProvider.manual.copyRecordValue': 'Copy record value to clipboard',
'dnsProvider.manual.checkDnsNow': 'Check DNS Now',
'dnsProvider.manual.checkDnsDescription': 'Immediately check if the DNS record has propagated',
'dnsProvider.manual.verifyButton': "I've Created the Record - Verify",
'dnsProvider.manual.verifyDescription': 'Verify that the DNS record exists',
'dnsProvider.manual.cancelChallenge': 'Cancel Challenge',
'dnsProvider.manual.lastCheck': 'Last checked',
'dnsProvider.manual.lastCheckSecondsAgo': `${options?.seconds || 0} seconds ago`,
'dnsProvider.manual.lastCheckMinutesAgo': `${options?.minutes || 0} minutes ago`,
'dnsProvider.manual.notPropagated': 'DNS record not yet propagated',
'dnsProvider.manual.dnsNotFound': 'DNS record not found',
'dnsProvider.manual.verifySuccess': 'DNS challenge verified successfully!',
'dnsProvider.manual.verifyFailed': 'DNS verification failed',
'dnsProvider.manual.challengeExpired': 'Challenge expired',
'dnsProvider.manual.challengeCancelled': 'Challenge cancelled',
'dnsProvider.manual.cancelFailed': 'Failed to cancel challenge',
'dnsProvider.manual.statusChanged': `Challenge status changed to ${options?.status || ''}`,
'dnsProvider.manual.status.created': 'Created',
'dnsProvider.manual.status.pending': 'Pending',
'dnsProvider.manual.status.verifying': 'Verifying...',
'dnsProvider.manual.status.verified': 'Verified',
'dnsProvider.manual.status.expired': 'Expired',
'dnsProvider.manual.status.failed': 'Failed',
'dnsProvider.manual.statusMessage.pending': 'Waiting for DNS propagation...',
'dnsProvider.manual.statusMessage.verified': 'DNS challenge verified successfully!',
'dnsProvider.manual.statusMessage.expired': 'Challenge has expired.',
'dnsProvider.manual.statusMessage.failed': 'DNS verification failed.',
}
return translations[key] || key
},
}),
}))
const mockChallenge: ManualChallenge = {
id: 'test-challenge-uuid',
status: 'pending',
fqdn: '_acme-challenge.example.com',
value: 'gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7',
ttl: 300,
created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(), // 2 minutes ago
expires_at: new Date(Date.now() + 8 * 60 * 1000).toISOString(), // 8 minutes from now
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(), // 10 seconds ago
dns_propagated: false,
}
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderComponent = (
challenge: ManualChallenge = mockChallenge,
onComplete = vi.fn(),
onCancel = vi.fn()
) => {
const queryClient = createQueryClient()
return {
...render(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={challenge}
onComplete={onComplete}
onCancel={onCancel}
/>
</QueryClientProvider>
),
onComplete,
onCancel,
}
}
describe('ManualDNSChallenge', () => {
let mockVerifyMutation: ReturnType<typeof vi.fn>
let mockDeleteMutation: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
mockWriteText.mockResolvedValue(undefined)
mockVerifyMutation = vi.fn()
mockDeleteMutation = vi.fn()
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'pending',
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: new Date(Date.now() - 10 * 1000).toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('renders the challenge title', () => {
renderComponent()
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
})
it('displays the FQDN record name', () => {
renderComponent()
expect(screen.getByText('_acme-challenge.example.com')).toBeInTheDocument()
})
it('displays the challenge value', () => {
renderComponent()
expect(
screen.getByText('gZrH7wL9t3kM2nP4qX5yR8sT0uV1wZ2aB3cD4eF5gH6iJ7')
).toBeInTheDocument()
})
it('displays TTL information', () => {
renderComponent()
expect(screen.getByText(/300/)).toBeInTheDocument()
expect(screen.getByText(/5 minutes/)).toBeInTheDocument()
})
it('renders copy buttons with aria labels', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value/i })
).toBeInTheDocument()
})
it('renders verify and check DNS buttons', () => {
renderComponent()
expect(screen.getByText('Check DNS Now')).toBeInTheDocument()
expect(screen.getByText("I've Created the Record - Verify")).toBeInTheDocument()
})
it('renders cancel button when not in terminal state', () => {
renderComponent()
expect(screen.getByText('Cancel Challenge')).toBeInTheDocument()
})
})
describe('Progress and Countdown', () => {
it('displays time remaining', () => {
renderComponent()
expect(screen.getByText(/Time remaining/i)).toBeInTheDocument()
})
it('displays progress bar', () => {
renderComponent()
expect(
screen.getByRole('progressbar', { name: /challenge.*progress/i })
).toBeInTheDocument()
})
it('updates countdown every second', async () => {
renderComponent()
// Get initial time display
const timeElement = screen.getByText(/Time remaining/i)
expect(timeElement).toBeInTheDocument()
// Advance timer by 1 second
await act(async () => {
vi.advanceTimersByTime(1000)
})
// Time should have updated (countdown decreased)
expect(timeElement).toBeInTheDocument()
})
})
describe('Copy Functionality', () => {
// Note: These tests verify the clipboard copy functionality.
// Due to jsdom limitations with navigator.clipboard mocking, we test
// the UI state changes instead of verifying the actual clipboard API calls.
// The component correctly shows "Copied!" state after clicking, which
// indicates the async copy handler completed successfully.
beforeEach(() => {
vi.useRealTimers()
})
afterEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
it('shows copied state after clicking copy record name button', async () => {
const user = userEvent.setup()
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
await user.click(copyNameButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('shows copied state after clicking copy record value button', async () => {
const user = userEvent.setup()
renderComponent()
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
await user.click(copyValueButton)
// The button should show the "Copied!" state after successful copy
await waitFor(() => {
expect(screen.getByText('Copied!')).toBeInTheDocument()
})
})
it('copy buttons are accessible and clickable', () => {
renderComponent()
const copyNameButton = screen.getByRole('button', { name: /copy record name/i })
const copyValueButton = screen.getByRole('button', { name: /copy record value/i })
expect(copyNameButton).toBeEnabled()
expect(copyValueButton).toBeEnabled()
expect(copyNameButton).toHaveAttribute('aria-label')
expect(copyValueButton).toHaveAttribute('aria-label')
})
})
describe('Verification', () => {
it('calls verify mutation when verify button is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(mockVerifyMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
})
it('calls verify mutation when Check DNS Now is clicked', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const checkButton = screen.getByText('Check DNS Now')
await user.click(checkButton)
expect(mockVerifyMutation).toHaveBeenCalled()
})
it('shows success toast on successful verification', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: true, dns_found: true, message: 'OK' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.success).toHaveBeenCalledWith('DNS challenge verified successfully!')
})
it('shows warning toast when DNS not found', async () => {
mockVerifyMutation.mockResolvedValueOnce({ success: false, dns_found: false, message: '' })
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.warning).toHaveBeenCalledWith('DNS record not found')
})
it('shows error toast on verification failure', async () => {
mockVerifyMutation.mockRejectedValueOnce({
response: { data: { message: 'Server error' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify")
await user.click(verifyButton)
expect(toast.error).toHaveBeenCalledWith('Server error')
})
})
describe('Cancellation', () => {
it('calls delete mutation and onCancel when cancelled', async () => {
mockDeleteMutation.mockResolvedValueOnce(undefined)
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const { onCancel } = renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(mockDeleteMutation).toHaveBeenCalledWith({
providerId: 1,
challengeId: 'test-challenge-uuid',
})
expect(onCancel).toHaveBeenCalled()
expect(toast.info).toHaveBeenCalledWith('Challenge cancelled')
})
it('shows error toast when cancellation fails', async () => {
mockDeleteMutation.mockRejectedValueOnce({
response: { data: { message: 'Cannot cancel' } },
})
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge')
await user.click(cancelButton)
expect(toast.error).toHaveBeenCalledWith('Cannot cancel')
})
})
describe('Terminal States', () => {
it('hides cancel button when challenge is verified', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
renderComponent(verifiedChallenge)
expect(screen.queryByText('Cancel Challenge')).not.toBeInTheDocument()
})
it('hides progress bar when challenge is expired', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
renderComponent(expiredChallenge)
expect(
screen.queryByRole('progressbar', { name: /challenge.*progress/i })
).not.toBeInTheDocument()
})
it('disables verify buttons when challenge is failed', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME validation failed',
},
isLoading: false,
isError: false,
error: null,
} as any)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
}
renderComponent(failedChallenge)
expect(screen.getByText('Check DNS Now').closest('button')).toBeDisabled()
expect(
screen.getByText("I've Created the Record - Verify").closest('button')
).toBeDisabled()
})
it('calls onComplete with true when status changes to verified', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to verified
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
// Re-render to trigger effect
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
dns_propagated: true,
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={verifiedChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(true)
})
})
it('calls onComplete with false when status changes to expired', async () => {
const { onComplete, rerender } = renderComponent()
// Update poll data to expired
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'expired',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const expiredChallenge: ManualChallenge = {
...mockChallenge,
status: 'expired',
}
const queryClient = createQueryClient()
rerender(
<QueryClientProvider client={queryClient}>
<ManualDNSChallenge
providerId={1}
challenge={expiredChallenge}
onComplete={onComplete}
onCancel={vi.fn()}
/>
</QueryClientProvider>
)
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(false)
})
})
})
describe('Accessibility', () => {
it('has proper ARIA labels for copy buttons', () => {
renderComponent()
expect(
screen.getByRole('button', { name: /copy record name to clipboard/i })
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /copy record value to clipboard/i })
).toBeInTheDocument()
})
it('has screen reader announcer for status changes', () => {
renderComponent()
const announcer = document.querySelector('[role="status"][aria-live="polite"]')
expect(announcer).toBeInTheDocument()
})
it('has proper labels for form fields', () => {
renderComponent()
expect(screen.getByText('Record Name')).toBeInTheDocument()
expect(screen.getByText('Record Value')).toBeInTheDocument()
})
it('progress bar has accessible label', () => {
renderComponent()
const progressBar = screen.getByRole('progressbar')
expect(progressBar).toHaveAttribute('aria-label')
})
it('buttons have aria-describedby for additional context', () => {
renderComponent()
const checkDnsButton = screen.getByText('Check DNS Now').closest('button')
expect(checkDnsButton).toHaveAttribute('aria-describedby')
})
it('uses semantic heading structure', () => {
renderComponent()
// Card title should exist
expect(screen.getByText('Manual DNS Challenge')).toBeInTheDocument()
// Section heading for DNS record
expect(screen.getByText('Create this TXT record at your DNS provider')).toBeInTheDocument()
})
})
describe('Polling Behavior', () => {
it('enables polling when challenge is pending', () => {
renderComponent()
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', true, 10000)
})
it('disables polling when challenge is in terminal state', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'verified',
dns_propagated: true,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
},
isLoading: false,
isError: false,
error: null,
} as any)
const verifiedChallenge: ManualChallenge = {
...mockChallenge,
status: 'verified',
}
renderComponent(verifiedChallenge)
// The component should pass enabled=false for terminal states
expect(useChallengePoll).toHaveBeenCalledWith(1, 'test-challenge-uuid', false, 10000)
})
})
describe('Loading States', () => {
it('shows loading state on verify button while verifying', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: true,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: false,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
renderComponent()
const verifyButton = screen.getByText("I've Created the Record - Verify").closest('button')
expect(verifyButton).toBeDisabled()
})
it('shows loading state on cancel button while cancelling', () => {
vi.mocked(useManualChallengeMutations).mockReturnValue({
verifyMutation: {
mutateAsync: mockVerifyMutation,
isPending: false,
},
deleteMutation: {
mutateAsync: mockDeleteMutation,
isPending: true,
},
createMutation: {
mutateAsync: vi.fn(),
isPending: false,
},
} as any)
renderComponent()
const cancelButton = screen.getByText('Cancel Challenge').closest('button')
expect(cancelButton).toBeDisabled()
})
})
describe('Error Messages', () => {
it('displays error message from poll response', () => {
vi.mocked(useChallengePoll).mockReturnValue({
data: {
status: 'failed',
dns_propagated: false,
time_remaining_seconds: 0,
last_check_at: new Date().toISOString(),
error_message: 'ACME server rejected the challenge',
},
isLoading: false,
isError: false,
error: null,
} as any)
const failedChallenge: ManualChallenge = {
...mockChallenge,
status: 'failed',
error_message: 'ACME server rejected the challenge',
}
renderComponent(failedChallenge)
expect(screen.getByText('ACME server rejected the challenge')).toBeInTheDocument()
})
})
describe('Last Check Display', () => {
it('shows last check time when available', () => {
renderComponent()
expect(screen.getByText(/Last checked/i)).toBeInTheDocument()
})
it('shows propagation status when not propagated', () => {
renderComponent()
expect(screen.getByText(/not yet propagated/i)).toBeInTheDocument()
})
})
})
@@ -0,0 +1,479 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Copy,
Check,
Clock,
RefreshCw,
CheckCircle2,
XCircle,
AlertCircle,
Loader2,
Info,
} from 'lucide-react'
import { Button, Card, CardHeader, CardTitle, CardContent, Progress, Alert } from '../ui'
import { useChallengePoll, useManualChallengeMutations } from '../../hooks/useManualChallenge'
import type { ManualChallenge, ChallengeStatus } from '../../api/manualChallenge'
import { toast } from '../../utils/toast'
interface ManualDNSChallengeProps {
/** The DNS provider ID */
providerId: number
/** Initial challenge data */
challenge: ManualChallenge
/** Callback when challenge is completed or cancelled */
onComplete: (success: boolean) => void
/** Callback when challenge is cancelled */
onCancel: () => void
}
/** Maps challenge status to visual properties */
const STATUS_CONFIG: Record<
ChallengeStatus,
{
icon: typeof CheckCircle2
colorClass: string
labelKey: string
}
> = {
created: {
icon: Clock,
colorClass: 'text-content-muted',
labelKey: 'dnsProvider.manual.status.created',
},
pending: {
icon: Clock,
colorClass: 'text-yellow-500',
labelKey: 'dnsProvider.manual.status.pending',
},
verifying: {
icon: Loader2,
colorClass: 'text-brand-500',
labelKey: 'dnsProvider.manual.status.verifying',
},
verified: {
icon: CheckCircle2,
colorClass: 'text-success',
labelKey: 'dnsProvider.manual.status.verified',
},
expired: {
icon: XCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.expired',
},
failed: {
icon: AlertCircle,
colorClass: 'text-error',
labelKey: 'dnsProvider.manual.status.failed',
},
}
/** Terminal states where polling should stop */
const TERMINAL_STATES: ChallengeStatus[] = ['verified', 'expired', 'failed']
/**
* Formats seconds into MM:SS display format
*/
function formatTimeRemaining(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
/**
* Calculates progress percentage based on time elapsed
*/
function calculateProgress(expiresAt: string, createdAt: string): number {
const now = Date.now()
const created = new Date(createdAt).getTime()
const expires = new Date(expiresAt).getTime()
const total = expires - created
const elapsed = now - created
const remaining = Math.max(0, 100 - (elapsed / total) * 100)
return Math.round(remaining)
}
/**
* Calculate time remaining in seconds
*/
function getTimeRemainingSeconds(expiresAt: string): number {
const now = Date.now()
const expires = new Date(expiresAt).getTime()
return Math.max(0, Math.floor((expires - now) / 1000))
}
export default function ManualDNSChallenge({
providerId,
challenge,
onComplete,
onCancel,
}: ManualDNSChallengeProps) {
const { t } = useTranslation()
const [copiedField, setCopiedField] = useState<'name' | 'value' | null>(null)
const [timeRemaining, setTimeRemaining] = useState(() =>
getTimeRemainingSeconds(challenge.expires_at)
)
const [progress, setProgress] = useState(() =>
calculateProgress(challenge.expires_at, challenge.created_at)
)
const statusAnnouncerRef = useRef<HTMLDivElement>(null)
const previousStatusRef = useRef<ChallengeStatus>(challenge.status)
// Determine if challenge is in a terminal state
const isTerminal = TERMINAL_STATES.includes(challenge.status)
// Poll for status updates (every 10 seconds when not terminal)
const { data: pollData } = useChallengePoll(
providerId,
challenge.id,
!isTerminal,
10000
)
const { verifyMutation, deleteMutation } = useManualChallengeMutations()
// Current status from poll data or initial challenge
const currentStatus: ChallengeStatus = pollData?.status || challenge.status
const dnsPropagated = pollData?.dns_propagated ?? challenge.dns_propagated
const lastCheckAt = pollData?.last_check_at ?? challenge.last_check_at
// Update countdown timer
useEffect(() => {
if (isTerminal) return
const interval = setInterval(() => {
const remaining = getTimeRemainingSeconds(challenge.expires_at)
setTimeRemaining(remaining)
setProgress(calculateProgress(challenge.expires_at, challenge.created_at))
// Auto-expire if time runs out
if (remaining <= 0) {
clearInterval(interval)
}
}, 1000)
return () => clearInterval(interval)
}, [challenge.expires_at, challenge.created_at, isTerminal])
// Announce status changes to screen readers
useEffect(() => {
if (currentStatus !== previousStatusRef.current) {
previousStatusRef.current = currentStatus
const statusLabel = t(STATUS_CONFIG[currentStatus].labelKey)
// Announce the status change for screen readers
if (statusAnnouncerRef.current) {
statusAnnouncerRef.current.textContent = t('dnsProvider.manual.statusChanged', {
status: statusLabel,
})
}
// Show toast for terminal states
if (currentStatus === 'verified') {
toast.success(t('dnsProvider.manual.verifySuccess'))
onComplete(true)
} else if (currentStatus === 'expired') {
toast.error(t('dnsProvider.manual.challengeExpired'))
onComplete(false)
} else if (currentStatus === 'failed') {
toast.error(pollData?.error_message || t('dnsProvider.manual.verifyFailed'))
onComplete(false)
}
}
}, [currentStatus, pollData?.error_message, onComplete, t])
// Copy to clipboard handler
const handleCopy = useCallback(
async (field: 'name' | 'value', text: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(t('dnsProvider.manual.copied'))
// Reset copied state after 2 seconds
setTimeout(() => setCopiedField(null), 2000)
} catch {
toast.error(t('dnsProvider.manual.copyFailed'))
}
},
[t]
)
// Verify challenge handler
const handleVerify = useCallback(async () => {
try {
const result = await verifyMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
if (result.success) {
toast.success(t('dnsProvider.manual.verifySuccess'))
} else if (!result.dns_found) {
toast.warning(t('dnsProvider.manual.dnsNotFound'))
}
} catch (error: any) {
toast.error(error.response?.data?.message || t('dnsProvider.manual.verifyFailed'))
}
}, [verifyMutation, providerId, challenge.id, t])
// Cancel challenge handler
const handleCancel = useCallback(async () => {
try {
await deleteMutation.mutateAsync({
providerId,
challengeId: challenge.id,
})
toast.info(t('dnsProvider.manual.challengeCancelled'))
onCancel()
} catch (error: any) {
toast.error(error.response?.data?.message || t('dnsProvider.manual.cancelFailed'))
}
}, [deleteMutation, providerId, challenge.id, onCancel, t])
// Get status display properties
const statusConfig = STATUS_CONFIG[currentStatus]
const StatusIcon = statusConfig.icon
// Format last check time
const getLastCheckText = (): string => {
if (!lastCheckAt) return ''
const seconds = Math.floor((Date.now() - new Date(lastCheckAt).getTime()) / 1000)
if (seconds < 60) return t('dnsProvider.manual.lastCheckSecondsAgo', { seconds })
const minutes = Math.floor(seconds / 60)
return t('dnsProvider.manual.lastCheckMinutesAgo', { minutes })
}
return (
<Card className="w-full max-w-2xl">
{/* Screen reader announcer for status changes */}
<div
ref={statusAnnouncerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span aria-hidden="true">🔐</span>
{t('dnsProvider.manual.title')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Instructions */}
<p className="text-content-secondary">
{t('dnsProvider.manual.instructions', { domain: challenge.fqdn.replace('_acme-challenge.', '') })}
</p>
{/* DNS Record Details */}
<div
className="border border-border rounded-lg overflow-hidden"
role="region"
aria-labelledby="dns-record-heading"
>
<div className="bg-surface-subtle px-4 py-2 border-b border-border">
<h3 id="dns-record-heading" className="text-sm font-medium flex items-center gap-2">
<span aria-hidden="true">📋</span>
{t('dnsProvider.manual.createRecord')}
</h3>
</div>
{/* Record Name */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-name"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordName')}
</label>
<code
id="record-name"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.fqdn}
>
{challenge.fqdn}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('name', challenge.fqdn)}
aria-label={t('dnsProvider.manual.copyRecordName')}
className="flex-shrink-0"
>
{copiedField === 'name' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'name' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* Record Value */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<label
htmlFor="record-value"
className="block text-xs font-medium text-content-muted mb-1"
>
{t('dnsProvider.manual.recordValue')}
</label>
<code
id="record-value"
className="block text-sm font-mono text-content-primary truncate"
title={challenge.value}
>
{challenge.value}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy('value', challenge.value)}
aria-label={t('dnsProvider.manual.copyRecordValue')}
className="flex-shrink-0"
>
{copiedField === 'value' ? (
<Check className="h-4 w-4 text-success" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only">
{copiedField === 'value' ? t('dnsProvider.manual.copied') : t('dnsProvider.manual.copy')}
</span>
</Button>
</div>
</div>
{/* TTL */}
<div className="px-4 py-3 bg-surface-subtle/50">
<span className="text-sm text-content-muted">
{t('dnsProvider.manual.ttl')}: {challenge.ttl} {t('dnsProvider.manual.seconds')} ({Math.floor(challenge.ttl / 60)} {t('dnsProvider.manual.minutes')})
</span>
</div>
</div>
{/* Progress Section */}
{!isTerminal && (
<div className="space-y-2" role="region" aria-labelledby="time-remaining-heading">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-content-muted" aria-hidden="true" />
<span id="time-remaining-heading" className="text-sm font-medium">
{t('dnsProvider.manual.timeRemaining')}: {formatTimeRemaining(timeRemaining)}
</span>
</div>
<span
className="text-sm text-content-muted tabular-nums"
aria-label={t('dnsProvider.manual.progressPercent', { percent: progress })}
>
{progress}%
</span>
</div>
<Progress
value={progress}
variant={progress < 25 ? 'error' : progress < 50 ? 'warning' : 'default'}
aria-label={t('dnsProvider.manual.challengeProgress')}
/>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={RefreshCw}
aria-describedby="check-dns-description"
>
{t('dnsProvider.manual.checkDnsNow')}
</Button>
<span id="check-dns-description" className="sr-only">
{t('dnsProvider.manual.checkDnsDescription')}
</span>
<Button
variant="primary"
onClick={handleVerify}
disabled={isTerminal || verifyMutation.isPending}
isLoading={verifyMutation.isPending}
leftIcon={CheckCircle2}
aria-describedby="verify-description"
>
{t('dnsProvider.manual.verifyButton')}
</Button>
<span id="verify-description" className="sr-only">
{t('dnsProvider.manual.verifyDescription')}
</span>
</div>
{/* Status Indicator */}
<Alert
variant={
currentStatus === 'verified'
? 'success'
: currentStatus === 'failed' || currentStatus === 'expired'
? 'error'
: 'info'
}
>
<div className="flex items-start gap-3">
<StatusIcon
className={`h-5 w-5 flex-shrink-0 ${statusConfig.colorClass} ${
currentStatus === 'verifying' ? 'animate-spin' : ''
}`}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<p className="font-medium">
{t(`dnsProvider.manual.statusMessage.${currentStatus}`, {
defaultValue: t(statusConfig.labelKey),
})}
</p>
{lastCheckAt && !isTerminal && (
<p className="text-sm text-content-muted mt-1">
{t('dnsProvider.manual.lastCheck')}: {getLastCheckText()}
</p>
)}
{!dnsPropagated && !isTerminal && (
<p className="text-sm text-content-muted mt-1 flex items-center gap-1">
<Info className="h-3 w-3" aria-hidden="true" />
{t('dnsProvider.manual.notPropagated')}
</p>
)}
{pollData?.error_message && (
<p className="text-sm text-error mt-1">{pollData.error_message}</p>
)}
</div>
</div>
</Alert>
{/* Cancel Button */}
{!isTerminal && (
<div className="flex justify-end pt-2 border-t border-border">
<Button
variant="ghost"
onClick={handleCancel}
disabled={deleteMutation.isPending}
isLoading={deleteMutation.isPending}
>
{t('dnsProvider.manual.cancelChallenge')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1 @@
export { default as ManualDNSChallenge } from './ManualDNSChallenge'
+58
View File
@@ -205,4 +205,62 @@ export const defaultProviderSchemas: Record<DNSProviderType, Partial<DNSProvider
],
documentation_url: 'https://developer.dnsimple.com/',
},
manual: {
type: 'manual',
name: 'Manual DNS',
fields: [],
documentation_url: 'https://letsencrypt.org/docs/challenge-types/',
},
script: {
type: 'script',
name: 'Custom Script',
fields: [
{
name: 'script_path',
label: 'Script Path',
type: 'text',
required: true,
hint: 'Path to custom DNS update script',
},
],
documentation_url: '',
},
webhook: {
type: 'webhook',
name: 'Webhook',
fields: [
{
name: 'url',
label: 'Webhook URL',
type: 'text',
required: true,
},
],
documentation_url: '',
},
rfc2136: {
type: 'rfc2136',
name: 'RFC2136 (Dynamic DNS)',
fields: [
{
name: 'server',
label: 'DNS Server',
type: 'text',
required: true,
},
{
name: 'key_name',
label: 'TSIG Key Name',
type: 'text',
required: true,
},
{
name: 'key_secret',
label: 'TSIG Key Secret',
type: 'password',
required: true,
},
],
documentation_url: 'https://tools.ietf.org/html/rfc2136',
},
}
@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useManualChallenge, useChallengePoll, useManualChallengeMutations } from '../useManualChallenge'
import * as api from '../../api/manualChallenge'
vi.mock('../../api/manualChallenge')
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useManualChallenge hooks', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('useManualChallenge', () => {
it('fetches challenge by ID when enabled', async () => {
const mockChallenge = {
id: 'test-uuid',
status: 'pending' as const,
fqdn: '_acme-challenge.example.com',
value: 'test-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(api.getChallenge).mockResolvedValueOnce(mockChallenge)
const { result } = renderHook(
() => useManualChallenge(1, 'test-uuid'),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(api.getChallenge).toHaveBeenCalledWith(1, 'test-uuid')
expect(result.current.data).toEqual(mockChallenge)
})
it('does not fetch when providerId is 0', async () => {
const { result } = renderHook(
() => useManualChallenge(0, 'test-uuid'),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(api.getChallenge).not.toHaveBeenCalled()
})
it('does not fetch when challengeId is empty', async () => {
const { result } = renderHook(
() => useManualChallenge(1, ''),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(api.getChallenge).not.toHaveBeenCalled()
})
})
describe('useChallengePoll', () => {
it('fetches poll data when enabled', async () => {
const mockPoll = {
status: 'pending' as const,
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: '2026-01-11T00:02:00Z',
}
vi.mocked(api.pollChallenge).mockResolvedValue(mockPoll)
const { result } = renderHook(
() => useChallengePoll(1, 'test-uuid', true),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(api.pollChallenge).toHaveBeenCalledWith(1, 'test-uuid')
expect(result.current.data).toEqual(mockPoll)
})
it('does not fetch when disabled', async () => {
const { result } = renderHook(
() => useChallengePoll(1, 'test-uuid', false),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(api.pollChallenge).not.toHaveBeenCalled()
})
it('uses custom refetch interval', async () => {
const mockPoll = {
status: 'pending' as const,
dns_propagated: false,
time_remaining_seconds: 480,
last_check_at: '2026-01-11T00:02:00Z',
}
vi.mocked(api.pollChallenge).mockResolvedValue(mockPoll)
const { result } = renderHook(
() => useChallengePoll(1, 'test-uuid', true, 5000),
{ wrapper: createWrapper() }
)
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// Hook configured with 5 second interval
expect(result.current.data).toEqual(mockPoll)
})
})
describe('useManualChallengeMutations', () => {
describe('createMutation', () => {
it('creates a new challenge', async () => {
const mockChallenge = {
id: 'new-uuid',
status: 'created' as const,
fqdn: '_acme-challenge.example.com',
value: 'generated-value',
ttl: 300,
created_at: '2026-01-11T00:00:00Z',
expires_at: '2026-01-11T00:10:00Z',
dns_propagated: false,
}
vi.mocked(api.createChallenge).mockResolvedValueOnce(mockChallenge)
const { result } = renderHook(
() => useManualChallengeMutations(),
{ wrapper: createWrapper() }
)
await act(async () => {
const response = await result.current.createMutation.mutateAsync({
providerId: 1,
data: { domain: 'example.com' },
})
expect(response).toEqual(mockChallenge)
})
expect(api.createChallenge).toHaveBeenCalledWith(1, { domain: 'example.com' })
})
})
describe('verifyMutation', () => {
it('verifies a challenge', async () => {
const mockResult = {
success: true,
dns_found: true,
message: 'TXT record verified',
}
vi.mocked(api.verifyChallenge).mockResolvedValueOnce(mockResult)
const { result } = renderHook(
() => useManualChallengeMutations(),
{ wrapper: createWrapper() }
)
await act(async () => {
const response = await result.current.verifyMutation.mutateAsync({
providerId: 1,
challengeId: 'test-uuid',
})
expect(response).toEqual(mockResult)
})
expect(api.verifyChallenge).toHaveBeenCalledWith(1, 'test-uuid')
})
it('handles verification failure', async () => {
vi.mocked(api.verifyChallenge).mockRejectedValueOnce(new Error('Network error'))
const { result } = renderHook(
() => useManualChallengeMutations(),
{ wrapper: createWrapper() }
)
await expect(
act(() =>
result.current.verifyMutation.mutateAsync({
providerId: 1,
challengeId: 'test-uuid',
})
)
).rejects.toThrow('Network error')
})
})
describe('deleteMutation', () => {
it('deletes a challenge', async () => {
vi.mocked(api.deleteChallenge).mockResolvedValueOnce(undefined)
const { result } = renderHook(
() => useManualChallengeMutations(),
{ wrapper: createWrapper() }
)
await act(async () => {
await result.current.deleteMutation.mutateAsync({
providerId: 1,
challengeId: 'test-uuid',
})
})
expect(api.deleteChallenge).toHaveBeenCalledWith(1, 'test-uuid')
})
it('handles deletion failure', async () => {
vi.mocked(api.deleteChallenge).mockRejectedValueOnce(
new Error('Challenge not found')
)
const { result } = renderHook(
() => useManualChallengeMutations(),
{ wrapper: createWrapper() }
)
await expect(
act(() =>
result.current.deleteMutation.mutateAsync({
providerId: 1,
challengeId: 'invalid-uuid',
})
)
).rejects.toThrow('Challenge not found')
})
})
})
})
+111
View File
@@ -0,0 +1,111 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
getChallenge,
createChallenge,
verifyChallenge,
pollChallenge,
deleteChallenge,
type ManualChallenge,
type CreateChallengeRequest,
type ChallengePollResponse,
type ChallengeVerifyResponse,
} from '../api/manualChallenge'
/** Query key factory for manual challenges */
const queryKeys = {
all: ['manual-challenges'] as const,
detail: (providerId: number, challengeId: string) =>
[...queryKeys.all, 'detail', providerId, challengeId] as const,
poll: (providerId: number, challengeId: string) =>
[...queryKeys.all, 'poll', providerId, challengeId] as const,
}
/**
* Hook for fetching a manual challenge by ID.
* @param providerId - DNS provider ID
* @param challengeId - Challenge UUID
* @returns Query result with challenge data
*/
export function useManualChallenge(providerId: number, challengeId: string) {
return useQuery({
queryKey: queryKeys.detail(providerId, challengeId),
queryFn: () => getChallenge(providerId, challengeId),
enabled: providerId > 0 && !!challengeId,
staleTime: 1000 * 5, // 5 seconds
})
}
/**
* Hook for polling challenge status with automatic refresh.
* @param providerId - DNS provider ID
* @param challengeId - Challenge UUID
* @param enabled - Whether polling is active
* @param refetchInterval - Polling interval in ms (default 10s)
* @returns Query result with poll data
*/
export function useChallengePoll(
providerId: number,
challengeId: string,
enabled: boolean = true,
refetchInterval: number = 10000
) {
return useQuery({
queryKey: queryKeys.poll(providerId, challengeId),
queryFn: () => pollChallenge(providerId, challengeId),
enabled: enabled && providerId > 0 && !!challengeId,
refetchInterval: enabled ? refetchInterval : false,
refetchIntervalInBackground: false,
})
}
/**
* Hook providing manual challenge mutation operations.
* @returns Object with mutation functions for create, verify, and delete
*/
export function useManualChallengeMutations() {
const queryClient = useQueryClient()
const createMutation = useMutation({
mutationFn: ({ providerId, data }: { providerId: number; data: CreateChallengeRequest }) =>
createChallenge(providerId, data),
})
const verifyMutation = useMutation({
mutationFn: ({ providerId, challengeId }: { providerId: number; challengeId: string }) =>
verifyChallenge(providerId, challengeId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.poll(variables.providerId, variables.challengeId),
})
queryClient.invalidateQueries({
queryKey: queryKeys.detail(variables.providerId, variables.challengeId),
})
},
})
const deleteMutation = useMutation({
mutationFn: ({ providerId, challengeId }: { providerId: number; challengeId: string }) =>
deleteChallenge(providerId, challengeId),
onSuccess: (_, variables) => {
queryClient.removeQueries({
queryKey: queryKeys.poll(variables.providerId, variables.challengeId),
})
queryClient.removeQueries({
queryKey: queryKeys.detail(variables.providerId, variables.challengeId),
})
},
})
return {
createMutation,
verifyMutation,
deleteMutation,
}
}
export type {
ManualChallenge,
CreateChallengeRequest,
ChallengePollResponse,
ChallengeVerifyResponse,
}
+62 -1
View File
@@ -1084,7 +1084,68 @@
"azure": "Azure DNS",
"hetzner": "Hetzner",
"vultr": "Vultr",
"dnsimple": "DNSimple"
"dnsimple": "DNSimple",
"manual": "Manual (No Automation)",
"webhook": "Webhook (HTTP)",
"rfc2136": "RFC 2136 (Dynamic DNS)",
"script": "Script (Shell)"
},
"categories": {
"builtIn": "Built-in Providers",
"custom": "Custom Integrations"
}
},
"dnsProvider": {
"manual": {
"title": "Manual DNS Challenge",
"instructions": "To obtain a certificate for {{domain}}, create the following TXT record at your DNS provider:",
"createRecord": "Create this TXT record at your DNS provider",
"recordName": "Record Name",
"recordValue": "Record Value",
"recordType": "Record Type",
"ttl": "TTL",
"seconds": "seconds",
"minutes": "minutes",
"timeRemaining": "Time remaining",
"progressPercent": "{{percent}}% time remaining",
"challengeProgress": "Challenge timeout progress",
"copy": "Copy",
"copied": "Copied!",
"copyFailed": "Failed to copy to clipboard",
"copyRecordName": "Copy record name to clipboard",
"copyRecordValue": "Copy record value to clipboard",
"checkDnsNow": "Check DNS Now",
"checkDnsDescription": "Immediately check if the DNS record has propagated",
"verifyButton": "I've Created the Record - Verify",
"verifyDescription": "Verify that the DNS record exists and complete the challenge",
"cancelChallenge": "Cancel Challenge",
"lastCheck": "Last checked",
"lastCheckSecondsAgo": "{{seconds}} seconds ago",
"lastCheckMinutesAgo": "{{minutes}} minutes ago",
"notPropagated": "DNS record not yet propagated",
"dnsNotFound": "DNS record not found. Please ensure the TXT record is created correctly.",
"verifySuccess": "DNS challenge verified successfully!",
"verifyFailed": "DNS verification failed",
"challengeExpired": "Challenge expired. Please try again.",
"challengeCancelled": "Challenge cancelled",
"cancelFailed": "Failed to cancel challenge",
"statusChanged": "Challenge status changed to {{status}}",
"status": {
"created": "Created",
"pending": "Pending",
"verifying": "Verifying...",
"verified": "Verified",
"expired": "Expired",
"failed": "Failed"
},
"statusMessage": {
"created": "Challenge created. Create the DNS record to continue.",
"pending": "Waiting for DNS propagation...",
"verifying": "Verifying DNS record...",
"verified": "DNS challenge verified successfully!",
"expired": "Challenge has expired. Please start a new challenge.",
"failed": "DNS verification failed. Please check the record and try again."
}
}
},
"dns_detection": {