import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import ProxyHostForm from '../ProxyHostForm' import type { ProxyHost } from '../../api/proxyHosts' import { mockRemoteServers } from '../../test/mockData' // Mock the hooks vi.mock('../../hooks/useRemoteServers', () => ({ useRemoteServers: vi.fn(() => ({ servers: mockRemoteServers, isLoading: false, error: null, createRemoteServer: vi.fn(), updateRemoteServer: vi.fn(), deleteRemoteServer: vi.fn(), })), })) vi.mock('../../hooks/useDocker', () => ({ useDocker: vi.fn(() => ({ containers: [ { id: 'container-123', names: ['my-app'], image: 'nginx:latest', state: 'running', status: 'Up 2 hours', network: 'bridge', ip: '172.17.0.2', ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }] } ], isLoading: false, error: null, refetch: vi.fn(), })), })) vi.mock('../../hooks/useDomains', () => ({ useDomains: vi.fn(() => ({ domains: [ { uuid: 'domain-1', name: 'existing.com' } ], createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'existing.com' }), isLoading: false, error: null, })), })) vi.mock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [ { id: 1, name: 'Cert 1', domain: 'example.com', provider: 'custom', issuer: 'Custom', expires_at: '2026-01-01' } ], isLoading: false, error: null, })), })) vi.mock('../../hooks/useSecurity', () => ({ useAuthPolicies: vi.fn(() => ({ policies: [ { id: 1, name: 'Admin Only', description: 'Requires admin role' } ], isLoading: false, error: null, })), })) vi.mock('../../api/proxyHosts', () => ({ testProxyHostConnection: vi.fn(), })) // Mock global fetch for health API const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }) const renderWithClient = (ui: React.ReactElement) => { return render( {ui} ) } const renderWithClientAct = async (ui: React.ReactElement) => { await act(async () => { renderWithClient(ui) }) } import { testProxyHostConnection } from '../../api/proxyHosts' describe('ProxyHostForm', () => { const mockOnSubmit = vi.fn(() => Promise.resolve()) const mockOnCancel = vi.fn() beforeEach(() => { // Default fetch mock for health endpoint mockFetch.mockResolvedValue({ json: () => Promise.resolve({ internal_ip: '192.168.1.50' }), }) }) afterEach(() => { vi.clearAllMocks() }) it('handles scheme selection', async () => { await renderWithClientAct( ) await waitFor(() => { expect(screen.getByText('Add Proxy Host')).toBeInTheDocument() }) // Find scheme select - it defaults to HTTP // We can find it by label "Scheme" const schemeSelect = screen.getByLabelText('Scheme') as HTMLSelectElement await userEvent.selectOptions(schemeSelect, 'https') expect(schemeSelect).toHaveValue('https') }) it('prompts to save new base domain', async () => { await renderWithClientAct( ) const domainInput = screen.getByPlaceholderText('example.com, www.example.com') // Enter a subdomain of a new base domain await userEvent.type(domainInput, 'sub.newdomain.com') await userEvent.tab() await waitFor(() => { expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument() expect(screen.getByText('newdomain.com')).toBeInTheDocument() }) // Click "Yes, save it" await userEvent.click(screen.getByText('Yes, save it')) await waitFor(() => { expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument() }) }) it('respects "Dont ask me again" for new domains', async () => { await renderWithClientAct( ) const domainInput = screen.getByPlaceholderText('example.com, www.example.com') // Trigger prompt await userEvent.type(domainInput, 'sub.another.com') await userEvent.tab() await waitFor(() => { expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument() }) // Check "Don't ask me again" await userEvent.click(screen.getByLabelText("Don't ask me again")) // Click "No, thanks" await userEvent.click(screen.getByText('No, thanks')) await waitFor(() => { expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument() }) // Try another new domain - should not prompt await userEvent.type(domainInput, 'sub.yetanother.com') await userEvent.tab() // Should not see prompt expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument() }) it('tests connection successfully', async () => { vi.mocked(testProxyHostConnection).mockResolvedValue(undefined) await renderWithClientAct( ) // Fill required fields for test connection await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5') await userEvent.clear(screen.getByLabelText(/^Port$/)) await userEvent.type(screen.getByLabelText(/^Port$/), '80') const testBtn = screen.getByTitle('Test connection to the forward host') await userEvent.click(testBtn) await waitFor(() => { expect(testProxyHostConnection).toHaveBeenCalledWith('10.0.0.5', 80) }) }) it('handles connection test failure', async () => { vi.mocked(testProxyHostConnection).mockRejectedValue(new Error('Connection failed')) await renderWithClientAct( ) await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5') await userEvent.clear(screen.getByLabelText(/^Port$/)) await userEvent.type(screen.getByLabelText(/^Port$/), '80') const testBtn = screen.getByTitle('Test connection to the forward host') await userEvent.click(testBtn) await waitFor(() => { expect(testProxyHostConnection).toHaveBeenCalled() }) // Should show error state (red button) - we can check class or icon // The button changes class to bg-red-600 await waitFor(() => { expect(testBtn).toHaveClass('bg-red-600') }) }) it('handles base domain selection', async () => { await renderWithClientAct( ) await waitFor(() => { expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument() }) await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com') // Should not update domain names yet as no container selected expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('') // Select container then base domain await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local') await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'container-123') await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com') expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com') }) // Application Preset Tests describe('Application Presets', () => { it('renders application preset dropdown with all options', async () => { await renderWithClientAct( ) const presetSelect = screen.getByLabelText(/Application Preset/i) expect(presetSelect).toBeInTheDocument() // Check that all presets are available expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument() expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument() expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument() expect(screen.getByText('Emby - Media server')).toBeInTheDocument() expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument() expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument() expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument() }) it('defaults to none preset', async () => { await renderWithClientAct( ) const presetSelect = screen.getByLabelText(/Application Preset/i) expect(presetSelect).toHaveValue('none') }) it('enables websockets when selecting plex preset', async () => { await renderWithClientAct( ) // First uncheck websockets const websocketCheckbox = screen.getByLabelText(/Websockets Support/i) if (websocketCheckbox.getAttribute('checked') !== null) { await userEvent.click(websocketCheckbox) } // Select Plex preset await act(async () => { await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') }) // Websockets should be enabled expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked() }) it('shows plex config helper with external URL when preset is selected', async () => { await renderWithClientAct( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com') // Select Plex preset await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') // Should show the helper with external URL await waitFor(() => { expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument() expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument() }) }) it('shows jellyfin config helper with internal IP', async () => { await renderWithClientAct( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'jellyfin.mydomain.com') // Select Jellyfin preset await act(async () => { await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'jellyfin') }) // Wait for health API fetch and show helper await waitFor(() => { expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument() expect(screen.getByText('192.168.1.50')).toBeInTheDocument() }) }) it('shows home assistant config helper with yaml snippet', async () => { await renderWithClientAct( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ha.mydomain.com') // Select Home Assistant preset await act(async () => { await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'homeassistant') }) // Wait for health API fetch and show helper await waitFor(() => { expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument() expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument() expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument() }) }) it('shows nextcloud config helper with php snippet', async () => { await renderWithClientAct( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'nextcloud.mydomain.com') // Select Nextcloud preset await act(async () => { await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'nextcloud') }) // Wait for health API fetch and show helper await waitFor(() => { expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument() expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument() expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument() }) }) it('shows vaultwarden helper text', async () => { await renderWithClientAct( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'vault.mydomain.com') // Select Vaultwarden preset await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'vaultwarden') // Wait for helper text await waitFor(() => { expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument() expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument() expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument() }) }) it('auto-detects plex preset from container image', async () => { // Mock useDocker to return a Plex container const { useDocker } = await import('../../hooks/useDocker') vi.mocked(useDocker).mockReturnValue({ containers: [ { id: 'plex-container', names: ['plex'], image: 'linuxserver/plex:latest', state: 'running', status: 'Up 1 hour', network: 'bridge', ip: '172.17.0.3', ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }] } ], isLoading: false, error: null, refetch: vi.fn(), }) renderWithClient( ) // Select local source await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local') // Select the plex container await waitFor(() => { expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument() }) await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'plex-container') // The preset should be auto-detected as plex expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex') }) it('auto-populates advanced_config when selecting plex preset and field empty', async () => { await renderWithClientAct( ) // Ensure advanced config is empty const textarea = screen.getByLabelText(/Advanced Caddy Config/i) expect(textarea).toHaveValue('') // Select Plex preset await act(async () => { await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') }) // Value should now be populated with a snippet containing X-Real-IP which is used by the preset await waitFor(() => { expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP') }) }) it('prompts to confirm overwrite when selecting preset and advanced_config is non-empty', async () => { const existingHost = { uuid: 'test-uuid', name: 'ConfTest', domain_names: 'test.example.com', forward_scheme: 'http', forward_host: '192.168.1.2', forward_port: 8080, advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}', advanced_config_backup: '', ssl_forced: true, http2_support: true, hsts_enabled: true, hsts_subdomains: false, block_exploits: true, websocket_support: true, application: 'none' as const, locations: [], enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01', } renderWithClient( ) // Select Plex preset (should prompt since advanced_config is non-empty) await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') await waitFor(() => { expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument() }) // Click Overwrite await userEvent.click(screen.getByText('Overwrite')) // After overwrite, the textarea should contain the preset 'X-Real-IP' snippet await waitFor(() => { expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP') }) }) it('restores previous advanced_config from backup when clicking restore', async () => { const existingHost = { uuid: 'test-uuid', name: 'RestoreTest', domain_names: 'test.example.com', forward_scheme: 'http', forward_host: '192.168.1.2', forward_port: 8080, advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}', advanced_config_backup: '{"handler":"headers","request":{"set":{"X-Prev":"backup"}}}', ssl_forced: true, http2_support: true, hsts_enabled: true, hsts_subdomains: false, block_exploits: true, websocket_support: true, application: 'none' as const, locations: [], enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01', } renderWithClient( ) // The restore button should be visible const restoreBtn = await screen.findByText('Restore previous config') expect(restoreBtn).toBeInTheDocument() // Click restore and expect the textarea to have backup value await userEvent.click(restoreBtn) await waitFor(() => { expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Prev') }) }) it('includes application field in form submission', async () => { renderWithClient( ) // Fill required fields await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Plex Server') await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.test.com') await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100') await userEvent.clear(screen.getByLabelText(/^Port$/)) await userEvent.type(screen.getByLabelText(/^Port$/), '32400') // Select Plex preset await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') // Submit form await userEvent.click(screen.getByText('Save')) await waitFor(() => { expect(mockOnSubmit).toHaveBeenCalledWith( expect.objectContaining({ application: 'plex', websocket_support: true, }) ) }) }) it('loads existing host application preset', async () => { const existingHost = { uuid: 'test-uuid', name: 'Existing Plex', domain_names: 'plex.example.com', forward_scheme: 'http', forward_host: '192.168.1.100', forward_port: 32400, ssl_forced: true, http2_support: true, hsts_enabled: true, hsts_subdomains: false, block_exploits: true, websocket_support: true, application: 'plex' as const, locations: [], enabled: true, created_at: '2025-01-01', updated_at: '2025-01-01', } renderWithClient( ) // The preset should be pre-selected expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex') // The config helper should be visible await waitFor(() => { expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument() }) }) it('does not show config helper when preset is none', async () => { await renderWithClientAct( ) // Fill in domain names await act(async () => { await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'test.mydomain.com') }) // Preset defaults to none, so no helper should be shown expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument() expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument() expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument() }) it('copies external URL to clipboard for plex', async () => { // Mock clipboard API const mockWriteText = vi.fn().mockResolvedValue(undefined) Object.assign(navigator, { clipboard: { writeText: mockWriteText }, }) renderWithClient( ) // Fill in domain names await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com') // Select Plex preset await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex') // Wait for helper to appear await waitFor(() => { expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument() }) // Click the copy button const copyButtons = screen.getAllByText('Copy') await userEvent.click(copyButtons[0]) await waitFor(() => { expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443') expect(screen.getByText('Copied!')).toBeInTheDocument() }) }) }) })