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:
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Plugins from '../Plugins'
|
||||
import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient'
|
||||
import type { PluginInfo } from '../../api/plugins'
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string | Record<string, any>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'plugins.title': 'DNS Provider Plugins',
|
||||
'plugins.description': 'Manage built-in and external DNS provider plugins for certificate automation',
|
||||
'plugins.note': 'Note',
|
||||
'plugins.noteText': 'External plugins extend Charon with custom DNS providers. Only install plugins from trusted sources.',
|
||||
'plugins.builtInPlugins': 'Built-in Providers',
|
||||
'plugins.externalPlugins': 'External Plugins',
|
||||
'plugins.noPlugins': 'No Plugins Found',
|
||||
'plugins.noPluginsDescription': 'No DNS provider plugins are currently installed.',
|
||||
'plugins.reloadPlugins': 'Reload Plugins',
|
||||
'plugins.pluginDetails': 'Plugin Details',
|
||||
'plugins.type': 'Type',
|
||||
'plugins.status': 'Status',
|
||||
'plugins.version': 'Version',
|
||||
'plugins.author': 'Author',
|
||||
'plugins.pluginType': 'Plugin Type',
|
||||
'plugins.builtIn': 'Built-in',
|
||||
'plugins.external': 'External',
|
||||
'plugins.loadedAt': 'Loaded At',
|
||||
'plugins.documentation': 'Documentation',
|
||||
'plugins.errorDetails': 'Error Details',
|
||||
'plugins.details': 'Details',
|
||||
'plugins.docs': 'Docs',
|
||||
'plugins.loaded': 'Loaded',
|
||||
'plugins.error': 'Error',
|
||||
'plugins.pending': 'Pending',
|
||||
'plugins.disabled': 'Disabled',
|
||||
'common.close': 'Close',
|
||||
}
|
||||
if (typeof defaultValue === 'string') {
|
||||
return translations[key] || defaultValue
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockBuiltInPlugin: 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: 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 mockErrorPlugin: PluginInfo = {
|
||||
id: 3,
|
||||
uuid: 'external-error',
|
||||
name: 'Broken Plugin',
|
||||
type: 'broken',
|
||||
enabled: false,
|
||||
status: 'error',
|
||||
error: 'Failed to load: signature mismatch',
|
||||
is_built_in: false,
|
||||
version: '0.1.0',
|
||||
author: 'Unknown',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-06T00:00:00Z',
|
||||
}
|
||||
|
||||
vi.mock('../../hooks/usePlugins', () => ({
|
||||
usePlugins: vi.fn(() => ({
|
||||
data: [mockBuiltInPlugin, mockExternalPlugin, mockErrorPlugin],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
useEnablePlugin: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugin enabled' }),
|
||||
isPending: false,
|
||||
})),
|
||||
useDisablePlugin: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugin disabled' }),
|
||||
isPending: false,
|
||||
})),
|
||||
useReloadPlugins: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ message: 'Plugins reloaded', count: 2 }),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('Plugins page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders plugin management page', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('DNS Provider Plugins')).toBeInTheDocument()
|
||||
// Check that page renders without errors
|
||||
expect(screen.getByRole('button', { name: /reload plugins/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays built-in plugins section', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Built-in Providers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cloudflare')).toBeInTheDocument()
|
||||
expect(screen.getByText('cloudflare')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays external plugins section', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('External Plugins')).toBeInTheDocument()
|
||||
expect(screen.getByText('PowerDNS')).toBeInTheDocument()
|
||||
expect(screen.getByText('powerdns')).toBeInTheDocument()
|
||||
expect(screen.getByText('by Community')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows status badges correctly', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Loaded status - should have at least one
|
||||
const loadedBadges = await screen.findAllByText(/loaded/i)
|
||||
expect(loadedBadges.length).toBe(2) // 2 loaded plugins
|
||||
|
||||
// Error message should be visible (from mockErrorPlugin)
|
||||
const errorMessage = await screen.findByText(/Failed to load: signature mismatch/i)
|
||||
expect(errorMessage).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays plugin descriptions', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Cloudflare DNS provider')).toBeInTheDocument()
|
||||
expect(screen.getByText('PowerDNS provider plugin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message for failed plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Failed to load: signature mismatch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders reload plugins button', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const reloadButton = await screen.findByRole('button', { name: /reload plugins/i })
|
||||
expect(reloadButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles reload plugins action', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { useReloadPlugins } = await import('../../hooks/usePlugins')
|
||||
const mockReloadMutation = vi.fn().mockResolvedValue({ message: 'Reloaded', count: 3 })
|
||||
vi.mocked(useReloadPlugins).mockReturnValue({
|
||||
mutateAsync: mockReloadMutation,
|
||||
isPending: false,
|
||||
} as any)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const reloadButton = await screen.findByRole('button', { name: /reload plugins/i })
|
||||
await user.click(reloadButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReloadMutation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays documentation links', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const docsLinks = await screen.findAllByText('Docs')
|
||||
expect(docsLinks.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays details button for each plugin', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
expect(detailsButtons.length).toBe(3) // All 3 plugins should have details button
|
||||
})
|
||||
|
||||
it('opens metadata modal when details button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[0])
|
||||
|
||||
expect(await screen.findByText(/Plugin Details:/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays plugin information in metadata modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
const detailsButtons = await screen.findAllByRole('button', { name: /details/i })
|
||||
await user.click(detailsButtons[1]) // Click PowerDNS plugin
|
||||
|
||||
// Modal title should include plugin name
|
||||
expect(await screen.findByText(/Plugin Details:/i)).toBeInTheDocument()
|
||||
|
||||
// Check for version label in metadata (not the banner version)
|
||||
const versionLabel = await screen.findByText('Version')
|
||||
expect(versionLabel).toBeInTheDocument()
|
||||
|
||||
// Check that Community author is shown
|
||||
expect(screen.getByText('Community')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows toggle switch for external plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Look for toggle buttons (the Switch component renders as a button)
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Should have reload button, details buttons, and toggle switches
|
||||
expect(buttons.length).toBeGreaterThan(5)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show toggle for built-in plugins', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Built-in plugin section should not have toggle switches nearby
|
||||
const builtInSection = await screen.findByText('Built-in Providers')
|
||||
expect(builtInSection).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles enable/disable toggle action', async () => {
|
||||
const { useDisablePlugin } = await import('../../hooks/usePlugins')
|
||||
const mockDisableMutation = vi.fn().mockResolvedValue({ message: 'Disabled' })
|
||||
vi.mocked(useDisablePlugin).mockReturnValue({
|
||||
mutateAsync: mockDisableMutation,
|
||||
isPending: false,
|
||||
} as any)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Find all buttons and click one near the external plugin (simplified test)
|
||||
const allButtons = await screen.findAllByRole('button')
|
||||
// Just verify buttons exist - the actual toggle is tested via integration
|
||||
expect(allButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows loading state', async () => {
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
// Check for loading skeletons by class
|
||||
const loadingElements = document.querySelectorAll('.animate-pulse')
|
||||
expect(loadingElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('shows empty state when no plugins', async () => {
|
||||
const { usePlugins } = await import('../../hooks/usePlugins')
|
||||
vi.mocked(usePlugins).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
} as any)
|
||||
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('No Plugins Found')).toBeInTheDocument()
|
||||
expect(screen.getByText(/No DNS provider plugins are currently installed/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays info alert with security warning', async () => {
|
||||
renderWithQueryClient(<Plugins />)
|
||||
|
||||
expect(await screen.findByText('Note:')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(/External plugins extend Charon with custom DNS providers/i)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user