diff --git a/backend/internal/api/handlers/proxy_host_handler_test.go b/backend/internal/api/handlers/proxy_host_handler_test.go index 2a10a52f..022f1141 100644 --- a/backend/internal/api/handlers/proxy_host_handler_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_test.go @@ -44,6 +44,163 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) { return r, db } +func setupTestRouterWithReferenceTables(t *testing.T) (*gin.Engine, *gorm.DB) { + t.Helper() + + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &models.AccessList{}, + &models.SecurityHeaderProfile{}, + &models.Notification{}, + &models.NotificationProvider{}, + )) + + ns := services.NewNotificationService(db) + h := NewProxyHostHandler(db, nil, ns, nil) + r := gin.New() + api := r.Group("/api/v1") + h.RegisterRoutes(api) + + return r, db +} + +func TestProxyHostHandler_ResolveAccessListReference_TargetedBranches(t *testing.T) { + t.Parallel() + + _, db := setupTestRouterWithReferenceTables(t) + h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil) + + resolved, err := h.resolveAccessListReference(true) + require.Error(t, err) + require.Nil(t, resolved) + require.Contains(t, err.Error(), "invalid access_list_id") + + resolved, err = h.resolveAccessListReference(" ") + require.NoError(t, err) + require.Nil(t, resolved) + + acl := models.AccessList{UUID: uuid.NewString(), Name: "resolve-acl", Type: "ip", Enabled: true} + require.NoError(t, db.Create(&acl).Error) + + resolved, err = h.resolveAccessListReference(acl.UUID) + require.NoError(t, err) + require.NotNil(t, resolved) + require.Equal(t, acl.ID, *resolved) +} + +func TestProxyHostHandler_ResolveSecurityHeaderReference_TargetedBranches(t *testing.T) { + t.Parallel() + + _, db := setupTestRouterWithReferenceTables(t) + h := NewProxyHostHandler(db, nil, services.NewNotificationService(db), nil) + + resolved, err := h.resolveSecurityHeaderProfileReference(" ") + require.NoError(t, err) + require.Nil(t, resolved) + + profile := models.SecurityHeaderProfile{ + UUID: uuid.NewString(), + Name: "resolve-security-profile", + IsPreset: false, + SecurityScore: 90, + } + require.NoError(t, db.Create(&profile).Error) + + resolved, err = h.resolveSecurityHeaderProfileReference(profile.UUID) + require.NoError(t, err) + require.NotNil(t, resolved) + require.Equal(t, profile.ID, *resolved) + + resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString()) + require.Error(t, err) + require.Nil(t, resolved) + require.Contains(t, err.Error(), "security header profile not found") + + require.NoError(t, db.Migrator().DropTable(&models.SecurityHeaderProfile{})) + resolved, err = h.resolveSecurityHeaderProfileReference(uuid.NewString()) + require.Error(t, err) + require.Nil(t, resolved) + require.Contains(t, err.Error(), "failed to resolve security header profile") +} + +func TestProxyHostCreate_ReferenceResolution_TargetedBranches(t *testing.T) { + t.Parallel() + + router, db := setupTestRouterWithReferenceTables(t) + + acl := models.AccessList{UUID: uuid.NewString(), Name: "create-acl", Type: "ip", Enabled: true} + require.NoError(t, db.Create(&acl).Error) + + profile := models.SecurityHeaderProfile{ + UUID: uuid.NewString(), + Name: "create-security-profile", + IsPreset: false, + SecurityScore: 85, + } + require.NoError(t, db.Create(&profile).Error) + + t.Run("creates host when references are valid UUIDs", func(t *testing.T) { + body := map[string]any{ + "name": "Create Ref Success", + "domain_names": "create-ref-success.example.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "enabled": true, + "access_list_id": acl.UUID, + "security_header_profile_id": profile.UUID, + } + payload, err := json.Marshal(body) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusCreated, resp.Code) + + var created models.ProxyHost + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created)) + require.NotNil(t, created.AccessListID) + require.Equal(t, acl.ID, *created.AccessListID) + require.NotNil(t, created.SecurityHeaderProfileID) + require.Equal(t, profile.ID, *created.SecurityHeaderProfileID) + }) + + t.Run("returns bad request for invalid access list reference type", func(t *testing.T) { + body := `{"name":"Create ACL Type Error","domain_names":"create-acl-type-error.example.com","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true,"access_list_id":true}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("returns bad request for missing security header profile", func(t *testing.T) { + body := map[string]any{ + "name": "Create Security Missing", + "domain_names": "create-security-missing.example.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "enabled": true, + "security_header_profile_id": uuid.NewString(), + } + payload, err := json.Marshal(body) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + require.Equal(t, http.StatusBadRequest, resp.Code) + }) +} + func TestProxyHostLifecycle(t *testing.T) { t.Parallel() router, _ := setupTestRouter(t) diff --git a/backend/internal/api/handlers/proxy_host_handler_update_test.go b/backend/internal/api/handlers/proxy_host_handler_update_test.go index 536f54a9..ced2f799 100644 --- a/backend/internal/api/handlers/proxy_host_handler_update_test.go +++ b/backend/internal/api/handlers/proxy_host_handler_update_test.go @@ -151,6 +151,64 @@ func TestProxyHostUpdate_AccessListID_Transitions_NoUnrelatedMutation(t *testing assertUnrelatedFields(t, updated) } +func TestProxyHostUpdate_AccessListID_UUIDNotFound_ReturnsBadRequest(t *testing.T) { + t.Parallel() + router, db := setupUpdateTestRouter(t) + + host := createTestProxyHost(t, db, "acl-uuid-not-found") + + updateBody := map[string]any{ + "name": "ACL UUID Not Found", + "domain_names": "acl-uuid-not-found.test.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "access_list_id": uuid.NewString(), + } + body, _ := json.Marshal(updateBody) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusBadRequest, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Contains(t, result["error"], "access list not found") +} + +func TestProxyHostUpdate_AccessListID_ResolveQueryFailure_ReturnsBadRequest(t *testing.T) { + t.Parallel() + router, db := setupUpdateTestRouter(t) + + host := createTestProxyHost(t, db, "acl-resolve-query-failure") + + require.NoError(t, db.Migrator().DropTable(&models.AccessList{})) + + updateBody := map[string]any{ + "name": "ACL Resolve Query Failure", + "domain_names": "acl-resolve-query-failure.test.com", + "forward_scheme": "http", + "forward_host": "localhost", + "forward_port": 8080, + "access_list_id": uuid.NewString(), + } + body, _ := json.Marshal(updateBody) + + req := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusBadRequest, resp.Code) + + var result map[string]any + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &result)) + assert.Contains(t, result["error"], "failed to resolve access list") +} + func TestProxyHostUpdate_SecurityHeaderProfileID_Transitions_NoUnrelatedMutation(t *testing.T) { t.Parallel() router, db := setupUpdateTestRouter(t) diff --git a/frontend/src/components/__tests__/AccessListSelector-token-coverage.test.tsx b/frontend/src/components/__tests__/AccessListSelector-token-coverage.test.tsx new file mode 100644 index 00000000..fdb48b3b --- /dev/null +++ b/frontend/src/components/__tests__/AccessListSelector-token-coverage.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import AccessListSelector from '../AccessListSelector'; +import * as useAccessListsHook from '../../hooks/useAccessLists'; + +vi.mock('../../hooks/useAccessLists'); + +vi.mock('../ui/Select', () => { + const findText = (children: React.ReactNode): string => { + if (typeof children === 'string') { + return children; + } + + if (Array.isArray(children)) { + return children.map((child) => findText(child)).join(' '); + } + + if (children && typeof children === 'object' && 'props' in children) { + const node = children as { props?: { children?: React.ReactNode } }; + return findText(node.props?.children); + } + + return ''; + }; + + const Select = ({ value, onValueChange, children }: { value?: string; onValueChange?: (value: string) => void; children?: React.ReactNode }) => { + const text = findText(children); + const isAccessList = text.includes('No Access Control (Public)'); + + return ( +
+ {isAccessList && ( + <> +
{value}
+ + + + + )} + {children} +
+ ); + }; + + const SelectTrigger = ({ children, ...rest }: React.ComponentProps<'button'>) => ; + const SelectContent = ({ children }: { children?: React.ReactNode }) =>
{children}
; + const SelectItem = ({ children }: { value: string; children?: React.ReactNode }) =>
{children}
; + const SelectValue = ({ placeholder }: { placeholder?: string }) => {placeholder}; + + return { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + }; +}); + +describe('AccessListSelector token coverage branches', () => { + beforeEach(() => { + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: [ + { + id: 7, + uuid: 'acl-uuid-7', + name: 'ACL Seven', + description: 'Coverage ACL', + type: 'whitelist', + enabled: true, + }, + ], + } as unknown as ReturnType); + }); + + it('normalizes whitespace and prefixed UUID values in resolver', () => { + const onChange = vi.fn(); + const { rerender } = render(); + + expect(screen.getByTestId('access-list-select-value')).toHaveTextContent('none'); + + rerender(); + expect(screen.getByTestId('access-list-select-value')).toHaveTextContent('id:7'); + }); + + it('maps emitted UUID, numeric, and fallback tokens through handleValueChange', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'emit-uuid-token' })); + await user.click(screen.getByRole('button', { name: 'emit-numeric-token' })); + await user.click(screen.getByRole('button', { name: 'emit-custom-token' })); + + expect(onChange).toHaveBeenNthCalledWith(1, 7); + expect(onChange).toHaveBeenNthCalledWith(2, 123); + expect(onChange).toHaveBeenNthCalledWith(3, 'custom-token'); + }); +}); diff --git a/frontend/src/components/__tests__/AccessListSelector.test.tsx b/frontend/src/components/__tests__/AccessListSelector.test.tsx index d7ac9174..15c06316 100644 --- a/frontend/src/components/__tests__/AccessListSelector.test.tsx +++ b/frontend/src/components/__tests__/AccessListSelector.test.tsx @@ -231,4 +231,207 @@ describe('AccessListSelector', () => { expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('Hybrid ACL'); }); + + it('handles prefixed and numeric-string form values as stable selections', () => { + const mockLists = [ + { + id: 7, + uuid: 'uuid-7', + name: 'ACL Seven', + description: 'Has both ID and UUID', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const Wrapper = createWrapper(); + const mockOnChange = vi.fn(); + + const { rerender } = render( + + + + ); + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('ACL Seven'); + + rerender( + + + + ); + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('ACL Seven'); + }); + + it('treats whitespace-only values as no selection', () => { + const mockLists = [ + { + id: 1, + uuid: 'uuid-1', + name: 'ACL One', + description: 'Baseline ACL', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const Wrapper = createWrapper(); + const mockOnChange = vi.fn(); + + render( + + + + ); + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('No Access Control (Public)'); + }); + + it('resolves prefixed uuid values to matching id-backed ACL tokens', () => { + const mockLists = [ + { + id: 42, + uuid: 'acl-uuid-42', + name: 'Resolved ACL', + description: 'UUID maps to numeric token', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const Wrapper = createWrapper(); + const mockOnChange = vi.fn(); + + render( + + + + ); + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('Resolved ACL'); + }); + + it('supports UUID-only ACL selection and local-network details', async () => { + const uuidOnly = '9f63b8c9-1d26-4b2f-a2c8-001122334455'; + const mockLists = [ + { + id: undefined, + uuid: uuidOnly, + name: 'Local UUID ACL', + description: 'Only internal network', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: true, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const mockOnChange = vi.fn(); + const Wrapper = createWrapper(); + const user = userEvent.setup(); + + const { rerender } = render( + + + + ); + + await user.click(screen.getByRole('combobox', { name: /Access Control List/i })); + await user.click(await screen.findByRole('option', { name: 'Local UUID ACL (whitelist)' })); + + expect(mockOnChange).toHaveBeenCalledWith(uuidOnly); + + rerender( + + + + ); + + expect(screen.getByText(/Local Network Only \(RFC1918\)/)).toBeInTheDocument(); + }); + + it('skips malformed ACL entries without id or uuid tokens', async () => { + const mockLists = [ + { + id: 4, + uuid: 'valid-uuid-4', + name: 'Valid ACL', + description: 'valid option', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + { + id: undefined, + uuid: undefined, + name: 'Malformed ACL', + description: 'should be ignored', + type: 'whitelist', + ip_rules: '[]', + country_codes: '', + local_network_only: false, + enabled: true, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + ]; + + vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({ + data: mockLists as unknown as AccessList[], + } as unknown as ReturnType); + + const mockOnChange = vi.fn(); + const Wrapper = createWrapper(); + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('combobox', { name: /Access Control List/i })); + + expect(screen.getByRole('option', { name: 'Valid ACL (whitelist)' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Malformed ACL (whitelist)' })).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/__tests__/ProxyHostForm-dns.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-dns.test.tsx index 30c0aead..77bb92a5 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-dns.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-dns.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import ProxyHostForm from '../ProxyHostForm' import type { ProxyHost } from '../../api/proxyHosts' import { mockRemoteServers } from '../../test/mockData' +import { toast } from 'react-hot-toast' // Mock the hooks vi.mock('../../hooks/useRemoteServers', () => ({ @@ -103,6 +104,36 @@ vi.mock('../../hooks/useDNSDetection', () => ({ })), })) +vi.mock('../DNSDetectionResult', () => ({ + DNSDetectionResult: ({ result, onUseSuggested, onSelectManually }: { + result?: { suggested_provider?: { id: number; name: string } } + isLoading: boolean + onUseSuggested: (provider: { id: number; name: string }) => void + onSelectManually: () => void + }) => ( +
+ + +
+ ), +})) + +vi.mock('react-hot-toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + vi.mock('../../api/dnsDetection', () => ({ detectDNSProvider: vi.fn().mockResolvedValue({ domain: 'example.com', @@ -436,4 +467,139 @@ describe('ProxyHostForm - DNS Provider Integration', () => { }) }) }) + + describe('DNS Detection Branches', () => { + it('skips detection call when wildcard has provider set and no suggestion', async () => { + vi.useFakeTimers() + const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection') + const detectSpy = vi.fn().mockResolvedValue({ + domain: 'example.com', + detected: false, + nameservers: [], + confidence: 'none', + }) + + vi.mocked(useDetectDNSProvider).mockReturnValue({ + mutateAsync: detectSpy, + isPending: false, + data: undefined, + reset: vi.fn(), + } as unknown as ReturnType) + + const existingHost: ProxyHost = { + uuid: 'test-uuid-skip-detect', + name: 'Existing Wildcard Provider', + domain_names: '*.example.com', + forward_scheme: 'http', + forward_host: '192.168.1.100', + forward_port: 8080, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: false, + block_exploits: true, + websocket_support: false, + application: 'none', + locations: [], + enabled: true, + dns_provider_id: 1, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + } + + renderWithClient( + + ) + + await vi.advanceTimersByTimeAsync(600) + + expect(detectSpy).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('logs detection errors when detectProvider rejects', async () => { + const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection') + const detectSpy = vi.fn().mockRejectedValue(new Error('detect failed')) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + vi.mocked(useDetectDNSProvider).mockReturnValue({ + mutateAsync: detectSpy, + isPending: false, + data: undefined, + reset: vi.fn(), + } as unknown as ReturnType) + + renderWithClient() + + const domainInput = screen.getByPlaceholderText('example.com, www.example.com') + await userEvent.type(domainInput, '*.example.com') + + await new Promise((resolve) => setTimeout(resolve, 700)) + + await waitFor(() => { + expect(errorSpy).toHaveBeenCalledWith('DNS detection failed:', expect.any(Error)) + }) + + errorSpy.mockRestore() + }) + + it('auto-selects high confidence suggestion and emits success toast', async () => { + const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection') + vi.mocked(useDetectDNSProvider).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + data: { + domain: 'example.com', + detected: true, + nameservers: ['ns1.cloudflare.com'], + confidence: 'high', + suggested_provider: { id: 1, name: 'Cloudflare' }, + }, + reset: vi.fn(), + } as unknown as ReturnType) + + renderWithClient() + + await userEvent.type(screen.getByPlaceholderText('My Service'), 'Auto Select') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), '*.example.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100') + await userEvent.clear(screen.getByLabelText(/^Port$/)) + await userEvent.type(screen.getByLabelText(/^Port$/), '8080') + await userEvent.click(screen.getByText('Save')) + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Auto-selected: Cloudflare') + expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({ dns_provider_id: 1 })) + }) + }) + + it('handles suggested and manual selection callbacks from detection result card', async () => { + const { useDetectDNSProvider } = await import('../../hooks/useDNSDetection') + vi.mocked(useDetectDNSProvider).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + data: { + domain: 'example.com', + detected: true, + nameservers: ['ns1.cloudflare.com'], + confidence: 'medium', + suggested_provider: { id: 1, name: 'Cloudflare' }, + }, + reset: vi.fn(), + } as unknown as ReturnType) + + renderWithClient() + + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), '*.example.com') + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Use Suggested DNS' })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('button', { name: 'Use Suggested DNS' })) + expect(toast.success).toHaveBeenCalledWith('Selected: Cloudflare') + + await userEvent.click(screen.getByRole('button', { name: 'Select Manually DNS' })) + }) + }) }) diff --git a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx index 1662a29c..fa97d136 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-dropdown-changes.test.tsx @@ -663,4 +663,147 @@ describe('ProxyHostForm Dropdown Change Bug Fix', () => { ) }) }) + + it('initializes edit mode from nested ACL and security header UUID references', async () => { + const user = userEvent.setup() + const Wrapper = createWrapper() + + const existingHost = { + uuid: 'host-uuid-nested-ref', + name: 'Nested Ref Host', + domain_names: 'test.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: true, + block_exploits: true, + websocket_support: false, + enable_standard_headers: true, + application: 'none', + advanced_config: '', + enabled: true, + locations: [], + certificate_id: null, + access_list_id: null, + security_header_profile_id: null, + access_list: { uuid: 'acl-uuid-2' }, + security_header_profile: { uuid: 'profile-uuid-2' }, + dns_provider_id: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } as unknown as ProxyHost + + render( + + + + ) + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('VPN Users') + + await user.click(screen.getByRole('button', { name: /Save/i })) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + access_list_id: 'acl-uuid-2', + security_header_profile_id: 'profile-uuid-2', + }) + ) + }) + }) + + it('normalizes empty and numeric-string ACL/security references on submit', async () => { + const user = userEvent.setup() + const Wrapper = createWrapper() + + const hostWithStringReferences = { + uuid: 'host-uuid-string-refs', + name: 'String Ref Host', + domain_names: 'test.com', + forward_scheme: 'http', + forward_host: 'localhost', + forward_port: 8080, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: true, + block_exploits: true, + websocket_support: false, + enable_standard_headers: true, + application: 'none', + advanced_config: '', + enabled: true, + locations: [], + certificate_id: null, + access_list_id: '2', + security_header_profile_id: ' ', + dns_provider_id: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } as unknown as ProxyHost + + render( + + + + ) + + expect(screen.getByRole('combobox', { name: /Access Control List/i })).toHaveTextContent('VPN Users') + + await user.click(screen.getByRole('button', { name: /Save/i })) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + access_list_id: 2, + security_header_profile_id: null, + }) + ) + }) + }) + + it('filters out security profiles missing both id and uuid', async () => { + const user = userEvent.setup() + const Wrapper = createWrapper() + + vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ + data: [ + { + ...mockSecurityProfiles[0], + id: undefined, + uuid: undefined, + name: 'Broken Profile', + }, + { + ...mockSecurityProfiles[1], + id: 2, + uuid: 'profile-uuid-2', + name: 'Strict Security', + }, + ] as unknown as SecurityHeaderProfile[], + isLoading: false, + error: null, + } as unknown as ReturnType) + + render( + + + + ) + + await user.type(screen.getByLabelText(/^Name/), 'Filter Profile Host') + await user.type(screen.getByLabelText(/Domain Names/), 'test.com') + await user.type(screen.getByLabelText(/^Host$/), 'localhost') + await user.clear(screen.getByLabelText(/^Port$/)) + await user.type(screen.getByLabelText(/^Port$/), '8080') + + await user.click(screen.getByRole('combobox', { name: /Security Headers/i })) + + expect(screen.queryByRole('option', { name: /Broken Profile/i })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /Strict Security/i })).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/__tests__/ProxyHostForm-token-coverage.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-token-coverage.test.tsx new file mode 100644 index 00000000..a659b8af --- /dev/null +++ b/frontend/src/components/__tests__/ProxyHostForm-token-coverage.test.tsx @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import ProxyHostForm from '../ProxyHostForm'; +import type { ProxyHost } from '../../api/proxyHosts'; + +vi.mock('../../hooks/useRemoteServers', () => ({ + useRemoteServers: vi.fn(() => ({ + servers: [], + isLoading: false, + error: null, + })), +})); + +vi.mock('../../hooks/useDocker', () => ({ + useDocker: vi.fn(() => ({ + containers: [], + isLoading: false, + error: null, + refetch: vi.fn(), + })), +})); + +vi.mock('../../hooks/useDomains', () => ({ + useDomains: vi.fn(() => ({ + domains: [{ uuid: 'domain-1', name: 'test.com' }], + createDomain: vi.fn().mockResolvedValue({}), + isLoading: false, + error: null, + })), +})); + +vi.mock('../../hooks/useCertificates', () => ({ + useCertificates: vi.fn(() => ({ + certificates: [], + isLoading: false, + error: null, + })), +})); + +vi.mock('../../hooks/useDNSDetection', () => ({ + useDetectDNSProvider: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + data: undefined, + reset: vi.fn(), + })), +})); + +vi.mock('../../hooks/useAccessLists', () => ({ + useAccessLists: vi.fn(() => ({ + data: [ + { + id: 1, + uuid: 'acl-uuid-1', + name: 'Office Network', + description: 'Office IP range', + type: 'whitelist', + enabled: true, + }, + ], + isLoading: false, + error: null, + })), +})); + +vi.mock('../../hooks/useSecurityHeaders', () => ({ + useSecurityHeaderProfiles: vi.fn(() => ({ + data: [ + { + id: 1, + uuid: 'profile-uuid-1', + name: 'Basic Security', + description: 'Basic security headers', + is_preset: true, + preset_type: 'basic', + security_score: 60, + }, + { + id: undefined, + uuid: undefined, + name: 'Malformed Custom', + description: 'Should be skipped in options map', + is_preset: false, + preset_type: 'custom', + security_score: 10, + }, + ], + isLoading: false, + error: null, + })), +})); + +vi.mock('../ui/Select', () => { + const findText = (children: React.ReactNode): string => { + if (typeof children === 'string') { + return children; + } + + if (Array.isArray(children)) { + return children.map((child) => findText(child)).join(' '); + } + + if (children && typeof children === 'object' && 'props' in children) { + const node = children as { props?: { children?: React.ReactNode } }; + return findText(node.props?.children); + } + + return ''; + }; + + const Select = ({ value, onValueChange, children }: { value?: string; onValueChange?: (value: string) => void; children?: React.ReactNode }) => { + const text = findText(children); + const isSecurityHeaders = text.includes('None (No Security Headers)'); + + return ( +
+ {isSecurityHeaders && ( + <> +
{value}
+ + + + )} + {children} +
+ ); + }; + + const SelectTrigger = ({ children, ...rest }: React.ComponentProps<'button'>) => ; + const SelectContent = ({ children }: { children?: React.ReactNode }) =>
{children}
; + const SelectItem = ({ children }: { value: string; children?: React.ReactNode }) =>
{children}
; + const SelectValue = () => ; + + return { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + }; +}); + +vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) }))); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +const fillRequiredFields = async () => { + await userEvent.type(screen.getByLabelText(/^Name/), 'Coverage Host'); + await userEvent.type(screen.getByLabelText(/Domain Names/), 'test.com'); + await userEvent.type(screen.getByLabelText(/^Host$/), 'localhost'); + await userEvent.clear(screen.getByLabelText(/^Port$/)); + await userEvent.type(screen.getByLabelText(/^Port$/), '8080'); +}; + +describe('ProxyHostForm token coverage branches', () => { + const onCancel = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('normalizes prefixed and numeric-string security header IDs', async () => { + const onSubmit = vi.fn<(data: Partial) => Promise>().mockResolvedValue(); + const Wrapper = createWrapper(); + + const { rerender } = render( + + + + ); + + expect(screen.getByTestId('security-select-value')).toHaveTextContent('id:7'); + + rerender( + + + + ); + + expect(screen.getByTestId('security-select-value')).toHaveTextContent('id:12'); + }); + + it('converts plain numeric and custom security tokens on submit', async () => { + const onSubmit = vi.fn<(data: Partial) => Promise>().mockResolvedValue(); + const Wrapper = createWrapper(); + + render( + + + + ); + + await fillRequiredFields(); + + await userEvent.click(screen.getByRole('button', { name: 'emit-security-plain-numeric' })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ security_header_profile_id: 42 }) + ); + }); + + onSubmit.mockClear(); + + await userEvent.click(screen.getByRole('button', { name: 'emit-security-custom' })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ security_header_profile_id: 'custom-header-token' }) + ); + }); + }); +}); diff --git a/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx b/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx index 0dd6eacb..5d77e3c5 100644 --- a/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm-uptime.test.tsx @@ -109,4 +109,39 @@ describe('ProxyHostForm Add Uptime flow', () => { expect(submittedPayload).not.toHaveProperty('uptimeInterval') expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries') }) + + it('shows uptime sync fallback error toast when monitor request fails with empty string error', async () => { + const onSubmit = vi.fn(() => Promise.resolve()) + const onCancel = vi.fn() + + const uptime = await import('../../api/uptime') + const syncMock = uptime.syncMonitors as unknown as import('vitest').Mock + syncMock.mockRejectedValueOnce('') + + const toastModule = await import('react-hot-toast') + const errorSpy = vi.spyOn(toastModule.toast, 'error') + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + ) + + await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1') + await userEvent.clear(screen.getByLabelText(/^Port$/)) + await userEvent.type(screen.getByLabelText(/^Port$/), '8080') + + await userEvent.click(screen.getByLabelText(/Add Uptime monitoring for this host/i)) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + expect(syncMock).toHaveBeenCalled() + expect(errorSpy).toHaveBeenCalledWith('Failed to request uptime creation') + }) + }) }) diff --git a/frontend/src/components/__tests__/ProxyHostForm.test.tsx b/frontend/src/components/__tests__/ProxyHostForm.test.tsx index 27b4736b..9e7f57b8 100644 --- a/frontend/src/components/__tests__/ProxyHostForm.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm.test.tsx @@ -123,6 +123,13 @@ vi.mock('../../api/proxyHosts', () => ({ testProxyHostConnection: vi.fn(), })) +vi.mock('react-hot-toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + // Mock global fetch for health API const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) @@ -552,6 +559,51 @@ describe('ProxyHostForm', () => { }) }) + it('closes preset overwrite modal when cancel is clicked', async () => { + const existingHost = { + uuid: 'test-uuid', + name: 'CancelOverwrite', + 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( + + ) + + await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access') + + await waitFor(() => { + expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument() + }) + + const modal = screen.getByText('Confirm Preset Overwrite').closest('div')?.parentElement + if (!modal) { + throw new Error('Preset overwrite modal not found') + } + + await userEvent.click(within(modal).getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + expect(screen.queryByText('Confirm Preset Overwrite')).not.toBeInTheDocument() + }) + }) + it('restores previous advanced_config from backup when clicking restore', async () => { const existingHost = { uuid: 'test-uuid', @@ -700,6 +752,83 @@ describe('ProxyHostForm', () => { expect(screen.getByText('Copied!')).toBeInTheDocument() }) }) + + it('copies plex trusted proxy IP helper snippet', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }) + + renderWithClient( + + ) + + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com') + + await selectComboboxOption(/Application Preset/i, 'Plex - Media server with remote access') + await userEvent.click(screen.getAllByRole('button', { name: /Copy/i })[1]) + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith('192.168.1.50') + }) + }) + + it('copies jellyfin trusted proxy IP helper snippet', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }) + + renderWithClient( + + ) + + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com') + await selectComboboxOption(/Application Preset/i, 'Jellyfin - Open source media server') + await userEvent.click(screen.getByRole('button', { name: /Copy/i })) + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith('192.168.1.50') + }) + }) + + it('copies home assistant helper yaml snippet', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }) + + renderWithClient( + + ) + + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com') + await selectComboboxOption(/Application Preset/i, 'Home Assistant - Home automation') + await userEvent.click(screen.getByRole('button', { name: /Copy/i })) + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith('http:\n use_x_forwarded_for: true\n trusted_proxies:\n - 192.168.1.50') + }) + }) + + it('copies nextcloud helper php snippet', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, + }) + + renderWithClient( + + ) + + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'apps.mydomain.com') + await selectComboboxOption(/Application Preset/i, 'Nextcloud - File sync and share') + await userEvent.click(screen.getByRole('button', { name: /Copy/i })) + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith("'trusted_proxies' => ['192.168.1.50'],\n'overwriteprotocol' => 'https',") + }) + }) }) describe('Security Options', () => { @@ -943,6 +1072,85 @@ describe('ProxyHostForm', () => { await selectComboboxOption(/Security Headers/i, 'Custom Profile (Score: 70/100)') expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Custom Profile') }) + + it('resolves prefixed security header id tokens from existing host values', async () => { + const existingHost = { + uuid: 'security-token-host', + name: 'Token Host', + domain_names: 'token.example.com', + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 80, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: true, + block_exploits: true, + websocket_support: true, + application: 'none' as const, + locations: [], + enabled: true, + security_header_profile_id: 'id:100', + created_at: '2025-01-01', + updated_at: '2025-01-01', + } + + renderWithClient( + + ) + + expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Strict Profile') + }) + + it('resolves numeric-string security header ids from existing host values', async () => { + const existingHost = { + uuid: 'security-numeric-host', + name: 'Numeric Host', + domain_names: 'numeric.example.com', + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 80, + ssl_forced: true, + http2_support: true, + hsts_enabled: true, + hsts_subdomains: true, + block_exploits: true, + websocket_support: true, + application: 'none' as const, + locations: [], + enabled: true, + security_header_profile_id: '100', + created_at: '2025-01-01', + updated_at: '2025-01-01', + } + + renderWithClient( + + ) + + expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Strict Profile') + }) + + it('skips non-preset profiles that have neither id nor uuid', async () => { + const { useSecurityHeaderProfiles } = await import('../../hooks/useSecurityHeaders') + vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ + data: [ + { id: 100, name: 'Strict Profile', description: 'Very strict', security_score: 90, is_preset: true, preset_type: 'strict' }, + { name: 'Invalid Custom', description: 'No identity token', security_score: 10, is_preset: false }, + ], + isLoading: false, + error: null, + } as unknown as ReturnType) + + renderWithClient( + + ) + + await userEvent.click(screen.getByRole('combobox', { name: /Security Headers/i })) + + expect(screen.queryByRole('option', { name: /Invalid Custom/i })).not.toBeInTheDocument() + }) + }) describe('Edit Mode vs Create Mode', () => { @@ -1247,6 +1455,55 @@ describe('ProxyHostForm', () => { })) }) }) + + it('updates domain using selected container when base domain changes', async () => { + const { useDocker } = await import('../../hooks/useDocker') + vi.mocked(useDocker).mockReturnValue({ + 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(), + }) + + await renderWithClientAct( + + ) + + await selectComboboxOption('Source', 'Local (Docker Socket)') + await selectComboboxOption('Containers', 'my-app (nginx:latest)') + await selectComboboxOption(/Base Domain/i, 'existing.com') + + expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com') + }) + + it('prompts to save a new base domain when user enters a base domain directly', async () => { + localStorage.removeItem('charon_dont_ask_domain') + localStorage.removeItem('cpmp_dont_ask_domain') + + await renderWithClientAct( + + ) + + const domainInput = screen.getByPlaceholderText('example.com, www.example.com') + await userEvent.type(domainInput, 'brandnewdomain.com') + await userEvent.tab() + + await waitFor(() => { + expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument() + expect(screen.getByText('brandnewdomain.com')).toBeInTheDocument() + }) + }) }) describe('Host and Port Combination', () => {