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:
230
frontend/src/api/__tests__/manualChallenge.test.ts
Normal file
230
frontend/src/api/__tests__/manualChallenge.test.ts
Normal file
@@ -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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
frontend/src/api/manualChallenge.ts
Normal file
115
frontend/src/api/manualChallenge.ts
Normal 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}`)
|
||||
}
|
||||
Reference in New Issue
Block a user