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', () => {