feat: Implement SSL Provider selection feature with tests and documentation
- Added functionality to select SSL Provider (Auto, Let's Encrypt, ZeroSSL) in the Caddy Manager. - Updated the ApplyConfig method to handle different SSL provider settings and staging flags. - Created unit tests for various SSL provider scenarios, ensuring correct behavior and backward compatibility. - Enhanced frontend System Settings page to include SSL Provider dropdown with appropriate options and descriptions. - Updated documentation to reflect new SSL Provider feature and its usage. - Added QA report detailing testing outcomes and security verification for the SSL Provider implementation.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import SystemSettings from '../SystemSettings'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
import client from '../../api/client'
|
||||
|
||||
// Mock API modules
|
||||
vi.mock('../../api/settings', () => ({
|
||||
getSettings: vi.fn(),
|
||||
updateSetting: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn(),
|
||||
updateFeatureFlags: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const queryClient = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to get SSL Provider select element
|
||||
const getSSLProviderSelect = (): HTMLSelectElement => {
|
||||
const selects = document.querySelectorAll('select')
|
||||
const sslSelect = Array.from(selects).find(s =>
|
||||
s.querySelector('option[value="auto"]')
|
||||
) as HTMLSelectElement
|
||||
return sslSelect
|
||||
}
|
||||
|
||||
describe('SystemSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Default mock responses
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
'ui.domain_link_behavior': 'new_tab',
|
||||
'security.cerberus.enabled': 'false',
|
||||
})
|
||||
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
|
||||
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '0.1.0',
|
||||
git_commit: 'abc123',
|
||||
build_time: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSL Provider Selection', () => {
|
||||
it('defaults to "auto" when no setting is present', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const sslSelect = getSSLProviderSelect()
|
||||
expect(sslSelect).toBeTruthy()
|
||||
expect(sslSelect.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('renders all SSL provider options correctly', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const select = getSSLProviderSelect()
|
||||
const options = Array.from(select.options).map(opt => ({
|
||||
value: opt.value,
|
||||
text: opt.textContent,
|
||||
}))
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: 'auto', text: 'Auto (Recommended)' },
|
||||
{ value: 'letsencrypt-prod', text: "Let's Encrypt (Prod)" },
|
||||
{ value: 'letsencrypt-staging', text: "Let's Encrypt (Staging)" },
|
||||
{ value: 'zerossl', text: 'ZeroSSL' },
|
||||
])
|
||||
})
|
||||
|
||||
it('displays the correct help text for SSL provider', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Choose the Certificate Authority. 'Auto' uses Let's Encrypt with ZeroSSL fallback. Staging is for testing.")).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('loads "auto" value from API correctly', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'auto',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('loads "letsencrypt-staging" value from API correctly', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'letsencrypt-staging',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('letsencrypt-staging')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads "letsencrypt-prod" value from API correctly', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'letsencrypt-prod',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('letsencrypt-prod')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads "zerossl" value from API correctly', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'zerossl',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('zerossl')
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to "auto" when API returns invalid value', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'invalid-provider',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('defaults to "auto" when API returns empty string', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': '',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const select = getSSLProviderSelect()
|
||||
expect(select.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('allows changing SSL provider selection', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const select = getSSLProviderSelect()
|
||||
|
||||
// Change to Let's Encrypt Staging
|
||||
await user.selectOptions(select, 'letsencrypt-staging')
|
||||
expect(select.value).toBe('letsencrypt-staging')
|
||||
|
||||
// Change to ZeroSSL
|
||||
await user.selectOptions(select, 'zerossl')
|
||||
expect(select.value).toBe('zerossl')
|
||||
|
||||
// Change to Let's Encrypt Prod
|
||||
await user.selectOptions(select, 'letsencrypt-prod')
|
||||
expect(select.value).toBe('letsencrypt-prod')
|
||||
|
||||
// Change back to Auto
|
||||
await user.selectOptions(select, 'auto')
|
||||
expect(select.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('saves SSL provider setting when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const select = getSSLProviderSelect()
|
||||
|
||||
// Change to Let's Encrypt Staging
|
||||
await user.selectOptions(select, 'letsencrypt-staging')
|
||||
|
||||
// Click save
|
||||
const saveButton = screen.getByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
'letsencrypt-staging',
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles backward compatibility with legacy "letsencrypt" value', async () => {
|
||||
// Old deployments might have "letsencrypt" instead of "letsencrypt-prod"
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://localhost:2019',
|
||||
'caddy.ssl_provider': 'letsencrypt',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SSL Provider')).toBeTruthy()
|
||||
})
|
||||
|
||||
const select = getSSLProviderSelect()
|
||||
// Should default to 'auto' for invalid values
|
||||
expect(select.value).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Settings', () => {
|
||||
it('renders the page title', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Settings')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and displays Caddy Admin API setting', async () => {
|
||||
vi.mocked(settingsApi.getSettings).mockResolvedValue({
|
||||
'caddy.admin_api': 'http://custom:2019',
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByPlaceholderText('http://localhost:2019') as HTMLInputElement
|
||||
expect(input.value).toBe('http://custom:2019')
|
||||
})
|
||||
})
|
||||
|
||||
it('saves all settings when save button is clicked', async () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Save Settings')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const saveButton = screen.getByRole('button', { name: /Save Settings/i })
|
||||
await user.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledTimes(4)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.admin_api',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'caddy.ssl_provider',
|
||||
expect.any(String),
|
||||
'caddy',
|
||||
'string'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('System Status', () => {
|
||||
it('displays system health information', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
status: 'healthy',
|
||||
service: 'charon',
|
||||
version: '1.0.0',
|
||||
git_commit: 'abc123def',
|
||||
build_time: '2025-12-06T00:00:00Z',
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('charon')).toBeTruthy()
|
||||
expect(screen.getByText('1.0.0')).toBeTruthy()
|
||||
expect(screen.getByText('abc123def')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state for system status', async () => {
|
||||
vi.mocked(client.get).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('System Status')).toBeTruthy()
|
||||
})
|
||||
|
||||
// Check for loading spinner
|
||||
const spinners = document.querySelectorAll('.animate-spin')
|
||||
expect(spinners.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user