feat(dns): add custom DNS provider plugin system

- Add plugin interface with lifecycle hooks (Init/Cleanup)
- Implement thread-safe provider registry
- Add plugin loader with SHA-256 signature verification
- Migrate 10 built-in providers to registry pattern
- Add multi-credential support to plugin interface
- Create plugin management UI with enable/disable controls
- Add dynamic credential fields based on provider metadata
- Include PowerDNS example plugin
- Add comprehensive user & developer documentation
- Fix frontend test hang (33min → 1.5min, 22x faster)

Platform: Linux/macOS only (Go plugin limitation)
Security: Signature verification, directory permission checks

Backend coverage: 85.1%
Frontend coverage: 85.31%

Closes: DNS Challenge Future Features - Phase 5
This commit is contained in:
GitHub Actions
2026-01-07 02:54:01 +00:00
parent 048b0c10a7
commit b86aa3921b
48 changed files with 8152 additions and 117 deletions
@@ -0,0 +1,434 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react'
import {
usePlugins,
usePlugin,
useProviderFields,
useEnablePlugin,
useDisablePlugin,
useReloadPlugins,
} from '../usePlugins'
import * as api from '../../api/plugins'
vi.mock('../../api/plugins')
const mockBuiltInPlugin: api.PluginInfo = {
id: 1,
uuid: 'builtin-cloudflare',
name: 'Cloudflare',
type: 'cloudflare',
enabled: true,
status: 'loaded',
is_built_in: true,
version: '1.0.0',
description: 'Cloudflare DNS provider',
documentation_url: 'https://developers.cloudflare.com',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
}
const mockExternalPlugin: api.PluginInfo = {
id: 2,
uuid: 'external-powerdns',
name: 'PowerDNS',
type: 'powerdns',
enabled: true,
status: 'loaded',
is_built_in: false,
version: '1.0.0',
author: 'Community',
description: 'PowerDNS provider plugin',
documentation_url: 'https://doc.powerdns.com',
loaded_at: '2025-01-06T00:00:00Z',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-06T00:00:00Z',
}
const mockProviderFields: api.ProviderFieldsResponse = {
type: 'powerdns',
name: 'PowerDNS',
required_fields: [
{
name: 'api_url',
label: 'API URL',
type: 'text',
placeholder: 'https://pdns.example.com:8081',
hint: 'PowerDNS HTTP API endpoint',
required: true,
},
{
name: 'api_key',
label: 'API Key',
type: 'password',
placeholder: 'Your API key',
hint: 'X-API-Key header value',
required: true,
},
],
optional_fields: [
{
name: 'server_id',
label: 'Server ID',
type: 'text',
placeholder: 'localhost',
hint: 'PowerDNS server ID',
required: false,
},
],
}
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('usePlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns plugins list on mount', async () => {
const mockPlugins = [mockBuiltInPlugin, mockExternalPlugin]
vi.mocked(api.getPlugins).mockResolvedValue(mockPlugins)
const { result } = renderHook(() => usePlugins(), { wrapper: createWrapper() })
expect(result.current.isLoading).toBe(true)
expect(result.current.data).toBeUndefined()
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual(mockPlugins)
expect(result.current.isError).toBe(false)
expect(api.getPlugins).toHaveBeenCalledTimes(1)
})
it('handles empty plugins list', async () => {
vi.mocked(api.getPlugins).mockResolvedValue([])
const { result } = renderHook(() => usePlugins(), { wrapper: createWrapper() })
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual([])
expect(result.current.isError).toBe(false)
})
it('handles error state on failure', async () => {
const mockError = new Error('Failed to fetch plugins')
vi.mocked(api.getPlugins).mockRejectedValue(mockError)
const { result } = renderHook(() => usePlugins(), { wrapper: createWrapper() })
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
expect(result.current.data).toBeUndefined()
})
})
describe('usePlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches single plugin when id > 0', async () => {
vi.mocked(api.getPlugin).mockResolvedValue(mockExternalPlugin)
const { result } = renderHook(() => usePlugin(2), { wrapper: createWrapper() })
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual(mockExternalPlugin)
expect(api.getPlugin).toHaveBeenCalledWith(2)
})
it('is disabled when id = 0', async () => {
vi.mocked(api.getPlugin).mockResolvedValue(mockExternalPlugin)
const { result } = renderHook(() => usePlugin(0), { wrapper: createWrapper() })
expect(result.current.isLoading).toBe(false)
expect(result.current.data).toBeUndefined()
expect(api.getPlugin).not.toHaveBeenCalled()
})
it('handles error state', async () => {
const mockError = new Error('Plugin not found')
vi.mocked(api.getPlugin).mockRejectedValue(mockError)
const { result } = renderHook(() => usePlugin(999), { wrapper: createWrapper() })
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
})
})
describe('useProviderFields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetches provider credential fields', async () => {
vi.mocked(api.getProviderFields).mockResolvedValue(mockProviderFields)
const { result } = renderHook(() => useProviderFields('powerdns'), {
wrapper: createWrapper(),
})
expect(result.current.isLoading).toBe(true)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.data).toEqual(mockProviderFields)
expect(api.getProviderFields).toHaveBeenCalledWith('powerdns')
})
it('is disabled when providerType is empty', async () => {
vi.mocked(api.getProviderFields).mockResolvedValue(mockProviderFields)
const { result } = renderHook(() => useProviderFields(''), { wrapper: createWrapper() })
expect(result.current.isLoading).toBe(false)
expect(result.current.data).toBeUndefined()
expect(api.getProviderFields).not.toHaveBeenCalled()
})
it('applies staleTime of 1 hour', async () => {
vi.mocked(api.getProviderFields).mockResolvedValue(mockProviderFields)
const { result } = renderHook(() => useProviderFields('powerdns'), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// The staleTime is configured in the hook, data should be cached for 1 hour
expect(result.current.data).toEqual(mockProviderFields)
})
it('handles error state', async () => {
const mockError = new Error('Provider type not found')
vi.mocked(api.getProviderFields).mockRejectedValue(mockError)
const { result } = renderHook(() => useProviderFields('invalid'), {
wrapper: createWrapper(),
})
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
})
})
describe('useEnablePlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('enables plugin successfully', async () => {
const mockResponse = { message: 'Plugin enabled successfully' }
vi.mocked(api.enablePlugin).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useEnablePlugin(), { wrapper: createWrapper() })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(api.enablePlugin).toHaveBeenCalledWith(2)
expect(result.current.data).toEqual(mockResponse)
})
it('invalidates plugins list on success', async () => {
vi.mocked(api.enablePlugin).mockResolvedValue({ message: 'Enabled' })
vi.mocked(api.getPlugins).mockResolvedValue([mockExternalPlugin])
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useEnablePlugin(), { wrapper })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(invalidateSpy).toHaveBeenCalled()
})
it('handles enable errors', async () => {
const mockError = new Error('Failed to enable plugin')
vi.mocked(api.enablePlugin).mockRejectedValue(mockError)
const { result } = renderHook(() => useEnablePlugin(), { wrapper: createWrapper() })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
})
})
describe('useDisablePlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('disables plugin successfully', async () => {
const mockResponse = { message: 'Plugin disabled successfully' }
vi.mocked(api.disablePlugin).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useDisablePlugin(), { wrapper: createWrapper() })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(api.disablePlugin).toHaveBeenCalledWith(2)
expect(result.current.data).toEqual(mockResponse)
})
it('invalidates plugins list on success', async () => {
vi.mocked(api.disablePlugin).mockResolvedValue({ message: 'Disabled' })
vi.mocked(api.getPlugins).mockResolvedValue([mockExternalPlugin])
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useDisablePlugin(), { wrapper })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(invalidateSpy).toHaveBeenCalled()
})
it('handles disable errors', async () => {
const mockError = new Error('Cannot disable: plugin in use')
vi.mocked(api.disablePlugin).mockRejectedValue(mockError)
const { result } = renderHook(() => useDisablePlugin(), { wrapper: createWrapper() })
result.current.mutate(2)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
})
})
describe('useReloadPlugins', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reloads plugins successfully', async () => {
const mockResponse = { message: 'Plugins reloaded', count: 3 }
vi.mocked(api.reloadPlugins).mockResolvedValue(mockResponse)
const { result } = renderHook(() => useReloadPlugins(), { wrapper: createWrapper() })
result.current.mutate()
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(api.reloadPlugins).toHaveBeenCalledTimes(1)
expect(result.current.data).toEqual(mockResponse)
})
it('invalidates plugins list on success', async () => {
vi.mocked(api.reloadPlugins).mockResolvedValue({ message: 'Reloaded', count: 2 })
vi.mocked(api.getPlugins).mockResolvedValue([mockBuiltInPlugin, mockExternalPlugin])
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
const { result } = renderHook(() => useReloadPlugins(), { wrapper })
result.current.mutate()
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(invalidateSpy).toHaveBeenCalled()
})
it('handles reload errors', async () => {
const mockError = new Error('Failed to reload plugins')
vi.mocked(api.reloadPlugins).mockRejectedValue(mockError)
const { result } = renderHook(() => useReloadPlugins(), { wrapper: createWrapper() })
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toEqual(mockError)
})
})