From 9247abd9447c816b520d4d4c71dc5069db63fde0 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Wed, 30 Sep 2026 18:50:08 +0800 Subject: [PATCH] feat: add server extras configuration for trusted proxies and discovery settings - Introduced new visual configuration fields for trusted proxies and discovery options. - Implemented validation for trusted proxies and discovery service type. - Updated localization files for English, Russian, Simplified Chinese, and Traditional Chinese to include new fields and validation messages. - Enhanced the visual configuration hooks to handle new server fields and validation errors. - Added tests for server configuration notifications and UI components to ensure proper functionality and localization. --- .../sections/SectionConnectivity.tsx | 47 +++- .../components/sections/SectionDiscovery.tsx | 152 +++++++++++ src/features/config/constants.ts | 11 +- .../config/hooks/useConfigDocument.ts | 25 ++ src/features/config/searchIndex.ts | 63 +++++ src/features/config/visualConfigServer.ts | 151 +++++++++++ src/hooks/useVisualConfig.ts | 22 ++ src/i18n/locales/en.json | 43 ++++ src/i18n/locales/ru.json | 43 ++++ src/i18n/locales/zh-CN.json | 43 ++++ src/i18n/locales/zh-TW.json | 43 ++++ src/types/visualConfig.ts | 24 ++ tests/configServerNotifications.test.ts | 90 +++++++ tests/configServerUi.test.ts | 147 +++++++++++ tests/visualConfigServer.test.ts | 242 ++++++++++++++++++ 15 files changed, 1144 insertions(+), 2 deletions(-) create mode 100644 src/features/config/components/sections/SectionDiscovery.tsx create mode 100644 src/features/config/visualConfigServer.ts create mode 100644 tests/configServerNotifications.test.ts create mode 100644 tests/configServerUi.test.ts create mode 100644 tests/visualConfigServer.test.ts diff --git a/src/features/config/components/sections/SectionConnectivity.tsx b/src/features/config/components/sections/SectionConnectivity.tsx index 94bd2bb0..d6714225 100644 --- a/src/features/config/components/sections/SectionConnectivity.tsx +++ b/src/features/config/components/sections/SectionConnectivity.tsx @@ -1,13 +1,24 @@ +import { useId } from 'react'; import { useTranslation } from 'react-i18next'; import { Collapsible } from '@/components/ui/Collapsible'; import { Input } from '@/components/ui/Input'; import { CONFIG_TAB_ICONS, SECTION_INDEX_LABELS } from '../../constants'; import type { ConfigSectionProps } from '../../types'; import { SectionCard } from '../SectionCard'; -import { Divider, FieldAnchor, FieldGrid, FieldStack, ToggleRow } from '../fields/FieldPrimitives'; +import { + Divider, + FieldAnchor, + FieldGrid, + FieldShell, + FieldStack, + ToggleRow, +} from '../fields/FieldPrimitives'; import { ApiKeysField, HostField, PortField } from '../fields/sharedFields'; import { getValidationMessage } from '../blocks/shared'; +import { StringListEditor } from '../blocks/StringListEditor'; +import { SectionDiscovery } from './SectionDiscovery'; + const Icon = CONFIG_TAB_ICONS.connectivity; /** 01 接入与认证:服务地址、端口、认证目录、API 密钥 + TLS / 远程管理折叠组。 */ @@ -19,6 +30,8 @@ export function SectionConnectivity({ onChange, }: ConfigSectionProps) { const { t } = useTranslation(); + const id = useId(); + const trustedProxiesError = getValidationMessage(t, validationErrors?.trustedProxies); const portError = getValidationMessage(t, validationErrors?.port); return ( @@ -48,6 +61,32 @@ export function SectionConnectivity({ + + +
+ onChange({ trustedProxies })} + /> +
+
+
+ + ); diff --git a/src/features/config/components/sections/SectionDiscovery.tsx b/src/features/config/components/sections/SectionDiscovery.tsx new file mode 100644 index 00000000..d89cc496 --- /dev/null +++ b/src/features/config/components/sections/SectionDiscovery.tsx @@ -0,0 +1,152 @@ +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Collapsible } from '@/components/ui/Collapsible'; +import { Input } from '@/components/ui/Input'; +import type { ConfigSectionProps } from '../../types'; +import { FieldAnchor, FieldShell, FieldStack, ToggleRow } from '../fields/FieldPrimitives'; +import { StringListEditor } from '../blocks/StringListEditor'; +import { getValidationMessage } from '../blocks/shared'; + +/** Kept mounted inside native details so search can reveal every discovery field. */ +export function SectionDiscovery({ + values, + validationErrors, + disabled, + onChange, +}: ConfigSectionProps) { + const { t } = useTranslation(); + const id = useId(); + const serviceTypeError = getValidationMessage(t, validationErrors?.discoveryServiceType); + + return ( + + + + onChange({ discoveryEnabled })} + /> + + + onChange({ discoveryServiceName: event.target.value })} + /> + + + onChange({ discoveryServiceType: event.target.value })} + /> + + + +
+ onChange({ discoverySubtypes })} + /> +
+
+
+ + +
+ onChange({ discoveryInterfacesInclude })} + /> +
+
+
+ + +
+ onChange({ discoveryInterfacesExclude })} + /> +
+
+
+ + onChange({ discoveryAuthRequired })} + /> + + + onChange({ discoveryAdvertiseManagement })} + /> + +
+
+ ); +} diff --git a/src/features/config/constants.ts b/src/features/config/constants.ts index ecac7307..a4a9eb4e 100644 --- a/src/features/config/constants.ts +++ b/src/features/config/constants.ts @@ -71,7 +71,7 @@ export const COMMON_FIELD_IDS = [ */ export const SECTION_VALIDATION_FIELDS: Record = { - connectivity: ['port'], + connectivity: ['port', 'trustedProxies', 'discoveryServiceType'], network: [ 'requestRetry', 'maxRetryCredentials', @@ -135,6 +135,15 @@ export const FIELD_VALUE_KEYS: Record = { codexLiveMediaRelayICEServers: ['codexLiveMediaRelayICEServers'], // ── connectivity ────────────────────────────────────────────────────────── + trustedProxies: ['trustedProxies'], + discoveryEnabled: ['discoveryEnabled'], + discoveryServiceName: ['discoveryServiceName'], + discoveryServiceType: ['discoveryServiceType'], + discoverySubtypes: ['discoverySubtypes'], + discoveryInterfacesInclude: ['discoveryInterfacesInclude'], + discoveryInterfacesExclude: ['discoveryInterfacesExclude'], + discoveryAuthRequired: ['discoveryAuthRequired'], + discoveryAdvertiseManagement: ['discoveryAdvertiseManagement'], host: ['host'], port: ['port'], authDir: ['authDir'], diff --git a/src/features/config/hooks/useConfigDocument.ts b/src/features/config/hooks/useConfigDocument.ts index d8f53dc6..dd6d34cf 100644 --- a/src/features/config/hooks/useConfigDocument.ts +++ b/src/features/config/hooks/useConfigDocument.ts @@ -28,6 +28,24 @@ export function readCommercialModeFromYaml(yamlContent: string): boolean { } } +export function hasTrustedProxiesChange(beforeYaml: string, afterYaml: string): boolean { + const read = (yaml: string): string[] => { + try { + const parsed: unknown = parseYaml(yaml); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; + const server: unknown = (parsed as Record).server; + if (!server || typeof server !== 'object' || Array.isArray(server)) return []; + const value: unknown = (server as Record)['trusted-proxies']; + return Array.isArray(value) + ? [...new Set(value.filter((entry): entry is string => typeof entry === 'string'))].sort() + : []; + } catch { + return []; + } + }; + return JSON.stringify(read(beforeYaml)) !== JSON.stringify(read(afterYaml)); +} + function normalizeYamlForVisualDiff(yamlContent: string): string { try { const doc = parseDocument(yamlContent); @@ -199,6 +217,7 @@ export function useConfigDocument({ const previousCommercialMode = readCommercialModeFromYaml(latestServerYaml); const nextCommercialMode = readCommercialModeFromYaml(mergedYaml); const commercialModeChanged = previousCommercialMode !== nextCommercialMode; + const trustedProxiesChanged = hasTrustedProxiesChange(latestServerYaml, mergedYaml); if (previewMode === 'visual' && !previewPlan) return; writeAttempted = true; @@ -243,6 +262,9 @@ export function useConfigDocument({ if (commercialModeChanged) { showNotification(t('notification.commercial_mode_restart_required'), 'warning'); } + if (trustedProxiesChanged) { + showNotification(t('notification.trusted_proxies_restart_required'), 'warning'); + } } catch (err: unknown) { if (!operation.isCurrent()) return; const message = @@ -267,6 +289,9 @@ export function useConfigDocument({ ) { showNotification(t('notification.commercial_mode_restart_required'), 'warning'); } + if (hasTrustedProxiesChange(previewServerYaml, latestYaml)) { + showNotification(t('notification.trusted_proxies_restart_required'), 'warning'); + } } catch (recoveryError) { if (!operation.isCurrent()) return; // Until readback succeeds, freeze editing against the stale baseline. Saving retries diff --git a/src/features/config/searchIndex.ts b/src/features/config/searchIndex.ts index f4026bcb..d2a69034 100644 --- a/src/features/config/searchIndex.ts +++ b/src/features/config/searchIndex.ts @@ -222,6 +222,69 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [ }, // ── connectivity ────────────────────────────────────────────────────────── + { + fieldId: 'trustedProxies', + sectionId: 'connectivity', + labelKey: L('serverExtras.trustedProxies.label'), + hintKey: L('serverExtras.trustedProxies.hint'), + yamlKeys: ['server', 'trusted-proxies'], + }, + { + fieldId: 'discoveryEnabled', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryEnabled.label'), + hintKey: L('serverExtras.discoveryEnabled.hint'), + yamlKeys: ['server', 'discovery', 'enabled'], + }, + { + fieldId: 'discoveryServiceName', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryServiceName.label'), + hintKey: L('serverExtras.discoveryServiceName.hint'), + yamlKeys: ['server', 'discovery', 'service-name'], + }, + { + fieldId: 'discoveryServiceType', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryServiceType.label'), + hintKey: L('serverExtras.discoveryServiceType.hint'), + yamlKeys: ['server', 'discovery', 'service-type'], + }, + { + fieldId: 'discoverySubtypes', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoverySubtypes.label'), + hintKey: L('serverExtras.discoverySubtypes.hint'), + yamlKeys: ['server', 'discovery', 'subtypes'], + }, + { + fieldId: 'discoveryInterfacesInclude', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryInterfacesInclude.label'), + hintKey: L('serverExtras.discoveryInterfacesInclude.hint'), + yamlKeys: ['server', 'discovery', 'interfaces', 'include'], + }, + { + fieldId: 'discoveryInterfacesExclude', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryInterfacesExclude.label'), + hintKey: L('serverExtras.discoveryInterfacesExclude.hint'), + yamlKeys: ['server', 'discovery', 'interfaces', 'exclude'], + }, + { + fieldId: 'discoveryAuthRequired', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryAuthRequired.label'), + hintKey: L('serverExtras.discoveryAuthRequired.hint'), + yamlKeys: ['server', 'discovery', 'auth-required'], + }, + { + fieldId: 'discoveryAdvertiseManagement', + sectionId: 'connectivity', + labelKey: L('serverExtras.discoveryAdvertiseManagement.label'), + hintKey: L('serverExtras.discoveryAdvertiseManagement.hint'), + yamlKeys: ['server', 'discovery', 'advertise-management'], + }, { fieldId: 'host', sectionId: 'connectivity', diff --git a/src/features/config/visualConfigServer.ts b/src/features/config/visualConfigServer.ts new file mode 100644 index 00000000..0737dde7 --- /dev/null +++ b/src/features/config/visualConfigServer.ts @@ -0,0 +1,151 @@ +import { isMap, isScalar, isSeq, parseDocument } from 'yaml'; +import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; +import type { VisualConfigValues, VisualConfigValidationErrors } from '@/types/visualConfig'; +import { assertConfigListsUnchanged } from '@/services/api/configPatch'; + +// Backend: config_v8.go, trusted_proxies.go and discovery/service.go. +export const SERVER_FIELDS = [ + { key: 'trustedProxies', path: ['server', 'trusted-proxies'], kind: 'list' }, + { key: 'discoveryEnabled', path: ['server', 'discovery', 'enabled'], kind: 'boolean' }, + { key: 'discoveryServiceName', path: ['server', 'discovery', 'service-name'], kind: 'string' }, + { key: 'discoveryServiceType', path: ['server', 'discovery', 'service-type'], kind: 'string' }, + { key: 'discoverySubtypes', path: ['server', 'discovery', 'subtypes'], kind: 'list' }, + { + key: 'discoveryInterfacesInclude', + path: ['server', 'discovery', 'interfaces', 'include'], + kind: 'list', + }, + { + key: 'discoveryInterfacesExclude', + path: ['server', 'discovery', 'interfaces', 'exclude'], + kind: 'list', + }, + { key: 'discoveryAuthRequired', path: ['server', 'discovery', 'auth-required'], kind: 'boolean' }, + { + key: 'discoveryAdvertiseManagement', + path: ['server', 'discovery', 'advertise-management'], + kind: 'boolean', + }, +] as const; + +type Doc = ReturnType; +const cleanList = (values: string[]) => values.map((value) => value.trim()).filter(Boolean); + +export function readVisualServer(doc: Doc) { + const values = {} as Pick; + for (const { key, path, kind } of SERVER_FIELDS) { + const raw = doc.getIn(path); + Object.assign(values, { + [key]: + kind === 'list' + ? isSeq(raw) + ? cleanList( + raw.items.flatMap((item) => + isScalar(item) && typeof item.value === 'string' ? [item.value] : [] + ) + ) + : [] + : typeof raw === kind + ? raw + : DEFAULT_VISUAL_VALUES[key], + }); + } + return values; +} + +function parents(doc: Doc, path: readonly string[]) { + for (let i = 1; i < path.length; i++) { + const parent = path.slice(0, i); + const old = doc.getIn(parent, true); + if (!isMap(old)) { + const replacement = doc.createNode({}); + if (isScalar(old) || isSeq(old)) { + replacement.comment = old.comment; + replacement.commentBefore = old.commentBefore; + replacement.spaceBefore = old.spaceBefore; + } + doc.setIn(parent, replacement); + } + } +} + +/** Lists are atomic, guarded against concurrent edits, and use the retained draft AST. */ +export function writeVisualServer( + doc: Doc, + values: VisualConfigValues, + dirty: ReadonlySet, + sourceYaml: string, + serverYaml: string, + guard: boolean +) { + const source = parseDocument(sourceYaml); + const latestYaml = doc.toString(); + for (const { key, path, kind } of SERVER_FIELDS) { + if (!dirty.has(key)) continue; + const value = values[key]; + if (kind === 'list' && Array.isArray(value)) { + const old = source.getIn(path, true); + const seq = isSeq(old) ? old : source.createNode([]); + // Match duplicate occurrences independently; never deduplicate or interpret patterns. + const available = [...seq.items]; + seq.items = cleanList(value).map((text) => { + const index = available.findIndex((item) => isScalar(item) && item.value === text); + return index < 0 ? source.createNode(text) : available.splice(index, 1)[0]; + }); + parents(source, path); + source.setIn(path, seq); + if (guard) assertConfigListsUnchanged(serverYaml, source.toString(), latestYaml, [path]); + parents(doc, path); + doc.setIn(path, seq); + } else { + parents(doc, path); + const old = doc.getIn(path, true); + const desired = typeof value === 'string' ? value.trim() : value; + if (isScalar(old)) old.value = desired; + else doc.setIn(path, desired); + } + } +} + +function validIPv4(raw: string): boolean { + return ( + /^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(raw) && + raw.split('.').every((part) => Number(part) <= 255) + ); +} + +function validIPv6(raw: string): boolean { + if (!raw.includes(':') || !/^[\da-fA-F:.]+$/.test(raw)) return false; + if (raw.includes('.') && !validIPv4(raw.slice(raw.lastIndexOf(':') + 1))) return false; + // URL's bracketed-host parser implements IPv6 syntax; the checks above reject + // zones and its otherwise permissive embedded IPv4 spelling. + try { + return new URL(`http://[${raw}]/`).hostname.startsWith('['); + } catch { + return false; + } +} + +export function validTrustedProxy(raw: string): boolean { + const parts = raw.trim().split('/'); + if (parts.length > 2) return false; + const [ip, prefix] = parts; + const ipv6 = ip.includes(':'); + if (!(ipv6 ? validIPv6(ip) : validIPv4(ip))) return false; + return prefix === undefined || (/^\d+$/.test(prefix) && Number(prefix) <= (ipv6 ? 128 : 32)); +} + +export function validateVisualServer(values: VisualConfigValues): VisualConfigValidationErrors { + const serviceType = values.discoveryServiceType.trim(); + return { + trustedProxies: cleanList(values.trustedProxies).every(validTrustedProxy) + ? undefined + : 'invalid_trusted_proxies', + discoveryServiceType: + !values.discoveryEnabled || + !serviceType || + /^_[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,13}[a-zA-Z0-9])?\._tcp$/.test(serviceType) + ? undefined + : 'invalid_discovery_service_type', + }; +} diff --git a/src/hooks/useVisualConfig.ts b/src/hooks/useVisualConfig.ts index 9afb431d..57bee737 100644 --- a/src/hooks/useVisualConfig.ts +++ b/src/hooks/useVisualConfig.ts @@ -27,6 +27,13 @@ import { validateVisualAdditions, } from '@/features/config/visualConfigAdditions'; +import { + SERVER_FIELDS, + readVisualServer, + writeVisualServer, + validateVisualServer, +} from '@/features/config/visualConfigServer'; + function asRecord(value: unknown): Record | null { if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; return value as Record; @@ -181,6 +188,7 @@ export function getVisualConfigValidationErrors( ): VisualConfigValidationErrors { return { ...validateVisualAdditions(values), + ...validateVisualServer(values), port: getPortError(values.port), errorLogsMaxFiles: getNonNegativeIntegerError(values.errorLogsMaxFiles), logsMaxTotalSizeMb: getNonNegativeIntegerError(values.logsMaxTotalSizeMb), @@ -1184,6 +1192,11 @@ function getNextDirtyFields( } }; + SERVER_FIELDS.forEach(({ key }) => { + if (Object.prototype.hasOwnProperty.call(patch, key)) { + updateDirty(key, JSON.stringify(nextValues[key]) === JSON.stringify(baselineValues[key])); + } + }); ADDITION_FIELDS.forEach(({ key }) => updateScalarDirty(key)); if (Object.prototype.hasOwnProperty.call(patch, ICE_KEY)) { updateDirty( @@ -1423,6 +1436,7 @@ function parseVisualValuesFromYaml(yamlContent: string): VisualConfigValues { const newValues: VisualConfigValues = { ...readVisualAdditions(document), + ...readVisualServer(document), host: typeof v8Server?.['host'] === 'string' ? v8Server?.['host'] : '', port: String(v8Server?.['port'] ?? ''), @@ -1627,6 +1641,14 @@ export function useVisualConfig() { } const values = visualValues; writeVisualAdditions(doc, values, dirtyFields); + writeVisualServer( + doc, + values, + dirtyFields, + rebasedPayload?.yaml ?? baselineYaml, + rebasedPayload?.serverYaml ?? baselineYaml, + target === 'server' + ); if (dirtyFields.has(ICE_KEY)) { writeICEServers( doc, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cb0ae9c0..36a1e0ff 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1043,6 +1043,46 @@ "no_changes": "No changes detected" }, "visual": { + "serverExtras": { + "trustedProxies": { + "label": "Trusted proxies", + "hint": "IP addresses or CIDRs; empty means forwarded headers are not trusted. Trusting all-address ranges risks client IP spoofing. Requires a backend restart." + }, + "discoveryEnabled": { + "label": "Enable discovery", + "hint": "Broadcast via mDNS / DNS-SD on the LAN. Off by default; changes apply without a restart." + }, + "discoveryServiceName": { + "label": "Service name prefix", + "hint": "The backend appends a short ID. Empty uses CPA-." + }, + "discoveryServiceType": { + "label": "Service type", + "hint": "Empty uses _ai-gateway._tcp." + }, + "discoverySubtypes": { + "label": "Protocol subtypes", + "hint": "Empty uses the five default protocols. Missing leading _ is added; invalid entries are ignored." + }, + "discoveryInterfacesInclude": { + "label": "Include interfaces", + "hint": "Empty auto-selects physical LAN interfaces. Use names or prefix patterns ending in *." + }, + "discoveryInterfacesExclude": { + "label": "Exclude interfaces", + "hint": "Use names or prefix patterns ending in *. Exclusions take priority over inclusions." + }, + "discoveryAuthRequired": { + "label": "Advertise authentication requirement", + "hint": "On by default. Broadcast metadata only; does not change actual API authentication." + }, + "discoveryAdvertiseManagement": { + "label": "Advertise management service", + "hint": "Off by default. Only announces the management service exists; does not change remote access permissions." + }, + "discoveryTitle": "LAN discovery", + "discoveryHint": "Requires multicast-capable physical interfaces and a non-loopback listener. Docker usually needs host networking; reachability is not guaranteed." + }, "additions": { "oauthTitle": "OAuth provider behavior", "oauthHint": "These provider settings apply only to OAuth/file-backed credentials, not API-key credentials.", @@ -1435,6 +1475,8 @@ "boolean_false": "false" }, "validation": { + "invalid_trusted_proxies": "Each trusted proxy must be a valid IP address or CIDR.", + "invalid_discovery_service_type": "Use _name._tcp: name must be 1–15 letters, digits or hyphens, with no leading or trailing hyphen.", "validation_blocked": "Fix validation errors before saving", "invalid_duration": "Enter a valid duration, such as 30s or 3h", "positive_duration": "Enter a duration greater than zero", @@ -1702,6 +1744,7 @@ "clear_login_confirm": "Clear local login data and sign out now?" }, "notification": { + "trusted_proxies_restart_required": "Trusted proxy settings saved. Restart the backend to apply them.", "region_label": "Notifications", "type_success": "Success", "type_info": "Information", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1ad31f73..a72c5313 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1046,6 +1046,46 @@ "no_changes": "Изменений не обнаружено" }, "visual": { + "serverExtras": { + "trustedProxies": { + "label": "Доверенные прокси", + "hint": "IP-адреса или CIDR; пустой список отключает доверие к заголовкам пересылки. Доверие ко всем адресам создаёт риск подмены IP клиента. Нужен перезапуск бэкенда." + }, + "discoveryEnabled": { + "label": "Включить обнаружение", + "hint": "Объявлять сервис через mDNS / DNS-SD в локальной сети. По умолчанию выключено; изменения применяются без перезапуска." + }, + "discoveryServiceName": { + "label": "Префикс имени сервиса", + "hint": "Бэкенд добавляет короткий ID. Если пусто, используется CPA-." + }, + "discoveryServiceType": { + "label": "Тип сервиса", + "hint": "Если пусто, используется _ai-gateway._tcp." + }, + "discoverySubtypes": { + "label": "Подтипы протоколов", + "hint": "Если пусто, используются пять стандартных протоколов. Начальный _ добавляется автоматически; неверные записи игнорируются." + }, + "discoveryInterfacesInclude": { + "label": "Включить интерфейсы", + "hint": "Если пусто, физические интерфейсы локальной сети выбираются автоматически. Укажите имена или префиксы с * в конце." + }, + "discoveryInterfacesExclude": { + "label": "Исключить интерфейсы", + "hint": "Укажите имена или префиксы с * в конце. Исключения имеют приоритет над включениями." + }, + "discoveryAuthRequired": { + "label": "Объявлять требование аутентификации", + "hint": "По умолчанию включено. Только метаданные объявления; реальная аутентификация API не меняется." + }, + "discoveryAdvertiseManagement": { + "label": "Объявлять сервис управления", + "hint": "По умолчанию выключено. Объявляет лишь наличие сервиса управления; права удалённого доступа не меняются." + }, + "discoveryTitle": "Обнаружение в локальной сети", + "discoveryHint": "Нужны физические интерфейсы с поддержкой multicast и прослушивание не на loopback. Docker обычно требует сеть host; доступность не гарантируется." + }, "additions": { "oauthTitle": "Поведение OAuth-провайдеров", "oauthHint": "Эти настройки провайдеров применяются только к OAuth и учётным данным из файлов, но не к API-ключам.", @@ -1438,6 +1478,8 @@ "boolean_false": "false" }, "validation": { + "invalid_trusted_proxies": "Каждый доверенный прокси должен быть корректным IP-адресом или CIDR.", + "invalid_discovery_service_type": "Используйте _имя._tcp: имя — 1–15 латинских букв, цифр или дефисов, без дефиса в начале и конце.", "validation_blocked": "Исправьте ошибки валидации перед сохранением", "invalid_duration": "Введите корректную длительность, например 30s или 3h", "positive_duration": "Введите длительность больше нуля", @@ -1696,6 +1738,7 @@ "clear_login_confirm": "Очистить локальные данные входа и выйти?" }, "notification": { + "trusted_proxies_restart_required": "Настройки доверенных прокси сохранены. Перезапустите бэкенд для их применения.", "region_label": "Уведомления", "type_success": "Успешно", "type_info": "Информация", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 724839c1..7f4e3980 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1041,6 +1041,46 @@ "no_changes": "未检测到变更" }, "visual": { + "serverExtras": { + "trustedProxies": { + "label": "可信代理", + "hint": "填写 IP 地址或 CIDR;留空不信任转发头。信任覆盖所有地址的网段有客户端 IP 伪造风险。重启后端后生效。" + }, + "discoveryEnabled": { + "label": "启用服务发现", + "hint": "通过 mDNS / DNS-SD 在局域网广播。默认关闭,支持热更新。" + }, + "discoveryServiceName": { + "label": "服务名称前缀", + "hint": "后端自动追加短 ID,留空使用 CPA-。" + }, + "discoveryServiceType": { + "label": "服务类型", + "hint": "留空使用 _ai-gateway._tcp。" + }, + "discoverySubtypes": { + "label": "协议子类型", + "hint": "留空使用默认的五种协议。缺少前导 _ 时自动补齐,非法项会被忽略。" + }, + "discoveryInterfacesInclude": { + "label": "包含网卡", + "hint": "留空自动选择物理局域网网卡。支持网卡名称或以 * 结尾的前缀匹配。" + }, + "discoveryInterfacesExclude": { + "label": "排除网卡", + "hint": "支持网卡名称或以 * 结尾的前缀匹配。排除规则优先于包含规则。" + }, + "discoveryAuthRequired": { + "label": "广播鉴权要求", + "hint": "默认开启。仅广播元数据,不改变实际 API 鉴权。" + }, + "discoveryAdvertiseManagement": { + "label": "广播管理服务", + "hint": "默认关闭。仅广播管理服务存在,不修改远程访问权限。" + }, + "discoveryTitle": "局域网发现", + "discoveryHint": "需要支持组播的物理网卡及非回环监听地址。Docker 通常需使用 host 网络;不保证服务可达。" + }, "additions": { "oauthTitle": "OAuth 提供商行为", "oauthHint": "本组提供商设置仅适用于 OAuth/认证文件凭据,不影响 API Key 凭据。", @@ -1433,6 +1473,8 @@ "boolean_false": "false" }, "validation": { + "invalid_trusted_proxies": "每个可信代理必须是有效的 IP 地址或 CIDR。", + "invalid_discovery_service_type": "请使用 _名称._tcp 格式:名称为 1–15 位英文字母、数字或连字符,首尾不能是连字符。", "validation_blocked": "请先修复表单校验错误再保存", "invalid_duration": "请输入有效时长,例如 30s 或 3h", "positive_duration": "请输入大于零的时长", @@ -1700,6 +1742,7 @@ "clear_login_confirm": "确认清理本地登录信息并退出登录?" }, "notification": { + "trusted_proxies_restart_required": "可信代理配置已保存,重启后端后生效。", "region_label": "通知", "type_success": "成功", "type_info": "提示", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index d2a99384..21c71a0c 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1067,6 +1067,46 @@ "no_changes": "未偵測到變更" }, "visual": { + "serverExtras": { + "trustedProxies": { + "label": "可信任代理", + "hint": "填寫 IP 位址或 CIDR;留空不信任轉送標頭。信任涵蓋所有位址的網段有用戶端 IP 偽造風險。重新啟動後端後生效。" + }, + "discoveryEnabled": { + "label": "啟用服務探索", + "hint": "透過 mDNS / DNS-SD 在區域網路廣播。預設關閉,支援熱更新。" + }, + "discoveryServiceName": { + "label": "服務名稱前綴", + "hint": "後端自動附加短 ID,留空使用 CPA-。" + }, + "discoveryServiceType": { + "label": "服務類型", + "hint": "留空使用 _ai-gateway._tcp。" + }, + "discoverySubtypes": { + "label": "協定子類型", + "hint": "留空使用預設的五種協定。缺少開頭的 _ 時自動補齊,無效項目會被忽略。" + }, + "discoveryInterfacesInclude": { + "label": "包含網卡", + "hint": "留空自動選擇實體區域網路網卡。支援網卡名稱或以 * 結尾的前綴比對。" + }, + "discoveryInterfacesExclude": { + "label": "排除網卡", + "hint": "支援網卡名稱或以 * 結尾的前綴比對。排除規則優先於包含規則。" + }, + "discoveryAuthRequired": { + "label": "廣播驗證要求", + "hint": "預設開啟。僅廣播中繼資料,不改變實際 API 身分驗證。" + }, + "discoveryAdvertiseManagement": { + "label": "廣播管理服務", + "hint": "預設關閉。僅廣播管理服務存在,不修改遠端存取權限。" + }, + "discoveryTitle": "區域網路探索", + "discoveryHint": "需要支援多播的實體網卡及非回環監聽位址。Docker 通常需使用 host 網路;不保證服務可連線。" + }, "additions": { "oauthTitle": "OAuth 供應商行為", "oauthHint": "本組供應商設定僅適用於 OAuth/驗證檔案憑證,不影響 API Key 憑證。", @@ -1459,6 +1499,8 @@ "boolean_false": "false" }, "validation": { + "invalid_trusted_proxies": "每個可信任代理必須是有效的 IP 位址或 CIDR。", + "invalid_discovery_service_type": "請使用 _名稱._tcp 格式:名稱為 1–15 位英文字母、數字或連字號,首尾不能是連字號。", "validation_blocked": "請先修復表單驗證錯誤再儲存", "invalid_duration": "請輸入有效的時間長度,例如 30s 或 3h", "positive_duration": "請輸入大於零的時間長度", @@ -1726,6 +1768,7 @@ "clear_login_confirm": "確認清理本地登入資訊並登出?" }, "notification": { + "trusted_proxies_restart_required": "可信任代理設定已儲存,重新啟動後端後生效。", "region_label": "通知", "type_success": "成功", "type_info": "提示", diff --git a/src/types/visualConfig.ts b/src/types/visualConfig.ts index 438daf34..24b3c628 100644 --- a/src/types/visualConfig.ts +++ b/src/types/visualConfig.ts @@ -14,6 +14,8 @@ export type CodexLiveICEServerDraft = { }; export type VisualConfigFieldPath = + | 'trustedProxies' + | 'discoveryServiceType' | 'transientErrorCooldownSeconds' | 'videoResultAuthCacheTTL' | 'claudeHeaderTimezone' @@ -38,6 +40,8 @@ export type VisualConfigFieldPath = | 'streaming.nonstreamKeepaliveInterval'; export type VisualConfigValidationErrorCode = + | 'invalid_trusted_proxies' + | 'invalid_discovery_service_type' | 'invalid_duration' | 'positive_duration' | 'invalid_timezone' @@ -113,6 +117,16 @@ export type PluginStoreAuthRule = { /** UI draft keys; YAML persistence uses the v8 tree, not these flattened names. */ export type VisualConfigValues = { + trustedProxies: string[]; + discoveryEnabled: boolean; + discoveryServiceName: string; + discoveryServiceType: string; + discoverySubtypes: string[]; + discoveryInterfacesInclude: string[]; + discoveryInterfacesExclude: string[]; + discoveryAuthRequired: boolean; + discoveryAdvertiseManagement: boolean; + routingSessionAffinitySubagents: boolean; saveCooldownStatus: boolean; transientErrorCooldownSeconds: string; @@ -207,6 +221,16 @@ export const makeClientId = () => { }; export const DEFAULT_VISUAL_VALUES: VisualConfigValues = { + trustedProxies: [], + discoveryEnabled: false, + discoveryServiceName: '', + discoveryServiceType: '', + discoverySubtypes: [], + discoveryInterfacesInclude: [], + discoveryInterfacesExclude: [], + discoveryAuthRequired: true, + discoveryAdvertiseManagement: false, + routingSessionAffinitySubagents: true, saveCooldownStatus: false, transientErrorCooldownSeconds: '', diff --git a/tests/configServerNotifications.test.ts b/tests/configServerNotifications.test.ts new file mode 100644 index 00000000..d7594602 --- /dev/null +++ b/tests/configServerNotifications.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { hasTrustedProxiesChange } from '@/features/config/hooks/useConfigDocument'; +import { findConfigFieldById } from '@/features/config/searchIndex'; + +const trusted = 'server: {trusted-proxies: [127.0.0.1, "2001:db8::/32"]}'; + +describe('trusted proxy restart notification', () => { + test('detects adding, removing, and replacing trusted networks', () => { + expect(hasTrustedProxiesChange('{}', trusted)).toBe(true); + expect(hasTrustedProxiesChange(trusted, '{}')).toBe(true); + expect(hasTrustedProxiesChange(trusted, 'server: {trusted-proxies: []}')).toBe(true); + expect(hasTrustedProxiesChange(trusted, trusted.replace('127.0.0.1', '10.0.0.0/8'))).toBe(true); + }); + + test('ignores presentation changes, order, duplicates, and discovery-only edits', () => { + expect(hasTrustedProxiesChange('{}', 'server: {trusted-proxies: []}')).toBe(false); + expect(hasTrustedProxiesChange('server: null', 'server: {trusted-proxies: null}')).toBe(false); + expect(hasTrustedProxiesChange(trusted, '# retained\n' + trusted)).toBe(false); + expect( + hasTrustedProxiesChange( + trusted, + 'server: {trusted-proxies: ["2001:db8::/32", 127.0.0.1, 127.0.0.1]}' + ) + ).toBe(false); + expect( + hasTrustedProxiesChange( + trusted, + trusted.replace('server: {', 'server: {discovery: {enabled: true}, ') + ) + ).toBe(false); + expect(hasTrustedProxiesChange('{}', 'server: {discovery: {enabled: true}}')).toBe(false); + }); + + test('supports YAML aliases and tolerates invalid documents during recovery', () => { + expect( + hasTrustedProxiesChange( + '{}', + 'access: {api-keys: &fixture [127.0.0.1]}\nserver: {trusted-proxies: *fixture}' + ) + ).toBe(true); + expect(hasTrustedProxiesChange('server: [', '{}')).toBe(false); + }); + + test('warns on both successful save and readback after a partial visual write', () => { + const source = readFileSync('src/features/config/hooks/useConfigDocument.ts', 'utf8'); + expect(source).toContain('hasTrustedProxiesChange(latestServerYaml, mergedYaml)'); + expect(source).toContain('hasTrustedProxiesChange(previewServerYaml, latestYaml)'); + expect(source.match(/notification\.trusted_proxies_restart_required/g)?.length).toBe(2); + }); +}); + +describe('server configuration localization', () => { + for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru']) { + test(`${locale} has labels, hints, validation and restart text`, async () => { + const messages: unknown = await Bun.file(`src/i18n/locales/${locale}.json`).json(); + const resolve = (key: string) => + key.split('.').reduce((node, part) => { + if (node && typeof node === 'object') return (node as Record)[part]; + return undefined; + }, messages); + for (const id of [ + 'trustedProxies', + 'discoveryEnabled', + 'discoveryServiceName', + 'discoveryServiceType', + 'discoverySubtypes', + 'discoveryInterfacesInclude', + 'discoveryInterfacesExclude', + 'discoveryAuthRequired', + 'discoveryAdvertiseManagement', + ]) { + const entry = findConfigFieldById(id); + expect(entry?.sectionId).toBe('connectivity'); + expect(entry?.labelKey).toBe(`config_management.visual.serverExtras.${id}.label`); + expect(entry?.hintKey).toBe(`config_management.visual.serverExtras.${id}.hint`); + expect(resolve(entry!.labelKey)).toBeString(); + expect(resolve(entry!.hintKey!)).toBeString(); + } + for (const key of [ + 'notification.trusted_proxies_restart_required', + 'config_management.visual.serverExtras.discoveryTitle', + 'config_management.visual.serverExtras.discoveryHint', + 'config_management.visual.validation.invalid_trusted_proxies', + 'config_management.visual.validation.invalid_discovery_service_type', + ]) + expect(resolve(key)).toBeString(); + }); + } +}); diff --git a/tests/configServerUi.test.ts b/tests/configServerUi.test.ts new file mode 100644 index 00000000..e792ee6c --- /dev/null +++ b/tests/configServerUi.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '@/i18n'; +import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig'; +import { SectionConnectivity } from '@/features/config/components/sections/SectionConnectivity'; +import { SectionDiscovery } from '@/features/config/components/sections/SectionDiscovery'; +import { + CONFIG_SECTION_IDS, + FIELD_VALUE_KEYS, + SECTION_VALIDATION_FIELDS, +} from '@/features/config/constants'; +import { CONFIG_FIELD_SEARCH_INDEX, searchConfigFields } from '@/features/config/searchIndex'; + +const translations = i18n.cloneInstance({ lng: 'en' }); +const props = { values: DEFAULT_VISUAL_VALUES, disabled: false, onChange: () => {} }; +const render = (element: ReturnType) => + renderToStaticMarkup(createElement(I18nextProvider, { i18n: translations }, element)); +const escapeText = (value: string) => + renderToStaticMarkup(createElement('span', null, value)).slice(6, -7); +const extras = CONFIG_FIELD_SEARCH_INDEX.filter((entry) => + entry.labelKey.includes('.serverExtras.') +); +const populated = { + ...DEFAULT_VISUAL_VALUES, + trustedProxies: ['127.0.0.1', '192.168.0.0/24'], + discoverySubtypes: ['_responses'], + discoveryInterfacesInclude: ['en*'], + discoveryInterfacesExclude: ['docker*'], +}; + +describe('server configuration UI', () => { + test('keeps nine searchable anchors in connectivity without adding a tab', () => { + expect(CONFIG_SECTION_IDS).toHaveLength(7); + expect(extras).toHaveLength(9); + const markup = render(createElement(SectionConnectivity, props)); + for (const entry of extras) { + expect(entry.sectionId).toBe('connectivity'); + expect(FIELD_VALUE_KEYS[entry.fieldId]).toEqual([entry.fieldId]); + expect(markup.split(`id="cfg-field-${entry.fieldId}"`)).toHaveLength(2); + expect(markup).toContain(escapeText(translations.t(entry.labelKey))); + expect(markup).toContain(escapeText(translations.t(entry.hintKey!))); + const path = entry.yamlKeys!.join('.'); + expect(path).toMatch(/^server\.(trusted-proxies|discovery\.)/); + expect( + searchConfigFields(path, (key) => translations.t(key)).map((e) => e.fieldId) + ).toContain(entry.fieldId); + } + expect(SECTION_VALIDATION_FIELDS.connectivity).toEqual([ + 'port', + 'trustedProxies', + 'discoveryServiceType', + ]); + }); + + test('discovery stays mounted in its own closed details even when disabled', () => { + const markup = render(createElement(SectionDiscovery, props)); + expect(markup.match(/]*>/)?.[0]).not.toContain('open='); + expect(markup).toContain( + escapeText(translations.t('config_management.visual.serverExtras.discoveryTitle')) + ); + for (const entry of extras.filter((e) => e.fieldId !== 'trustedProxies')) { + expect(markup).toContain(`id="cfg-field-${entry.fieldId}"`); + } + expect(DEFAULT_VISUAL_VALUES.discoveryEnabled).toBe(false); + expect(DEFAULT_VISUAL_VALUES.discoveryAuthRequired).toBe(true); + expect(DEFAULT_VISUAL_VALUES.discoveryAdvertiseManagement).toBe(false); + const auth = markup + .split('id="cfg-field-discoveryAuthRequired"')[1] + .split('id="cfg-field-discoveryAdvertiseManagement"')[0]; + expect(auth).toContain('checked=""'); + for (const input of markup.match(/]*type="text"[^>]*>/g) ?? []) { + expect(input).toContain('value=""'); + } + }); + + test('gives inputs and list rows accessible names and associates list hints', () => { + const markup = render(createElement(SectionConnectivity, { ...props, values: populated })); + for (const field of [ + 'trustedProxies', + 'discoverySubtypes', + 'discoveryInterfacesInclude', + 'discoveryInterfacesExclude', + ]) { + const label = escapeText( + translations.t(`config_management.visual.serverExtras.${field}.label`) + ); + expect(markup).toContain(`aria-label="${label}"`); + const group = (markup.match(/]*role="group"[^>]*>/g) ?? []).find((tag) => + tag.includes(`${field}-label`) + ); + expect(group).toBeTruthy(); + for (const attribute of ['aria-labelledby', 'aria-describedby']) { + const id = group!.match(new RegExp(`${attribute}="([^"]+)"`))?.[1]; + expect(id).toBeTruthy(); + expect(markup).toContain(`id="${id}"`); + } + } + const discovery = render(createElement(SectionDiscovery, props)); + const inputs = discovery.match(/]*type="text"[^>]*>/g) ?? []; + expect(inputs).toHaveLength(2); + for (const input of inputs) { + const id = input.match(/\bid="([^"]+)"/)?.[1]; + expect(discovery).toContain(`for="${id}"`); + const hint = input.match(/aria-describedby="([^"]+)"/)?.[1]; + expect(discovery).toContain(`id="${hint}"`); + } + }); + + test('disables every input and list action in read-only mode', () => { + const markup = render( + createElement(SectionConnectivity, { ...props, values: populated, disabled: true }) + ); + const controls = markup.match(/<(?:input|textarea|button)\b[^>]*>/g) ?? []; + expect(controls.length).toBeGreaterThan(10); + for (const control of controls) expect(control).toContain('disabled=""'); + }); + + test('renders and associates both validation errors', () => { + const markup = render( + createElement(SectionConnectivity, { + ...props, + values: populated, + validationErrors: { + trustedProxies: 'invalid_trusted_proxies', + discoveryServiceType: 'invalid_discovery_service_type', + }, + }) + ); + for (const code of ['invalid_trusted_proxies', 'invalid_discovery_service_type']) { + expect(markup).toContain( + escapeText(translations.t(`config_management.visual.validation.${code}`)) + ); + } + const group = (markup.match(/]*role="group"[^>]*>/g) ?? []).find((tag) => + tag.includes('trustedProxies-label') + )!; + expect(group).toContain('aria-invalid="true"'); + const ids = group.match(/aria-describedby="([^"]+)"/)![1].split(' '); + expect(ids).toHaveLength(2); + for (const id of ids) expect(markup).toContain(`id="${id}"`); + const typeInput = markup.match(/]*placeholder="_ai-gateway\._tcp"[^>]*>/)?.[0]; + expect(typeInput).toContain('aria-invalid="true"'); + }); +}); diff --git a/tests/visualConfigServer.test.ts b/tests/visualConfigServer.test.ts new file mode 100644 index 00000000..300b292f --- /dev/null +++ b/tests/visualConfigServer.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, test } from 'bun:test'; +import { createElement, useState } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parseDocument } from 'yaml'; +import { useVisualConfig } from '../src/hooks/useVisualConfig'; +import { DEFAULT_VISUAL_VALUES } from '../src/types/visualConfig'; +import { + SERVER_FIELDS, + validateVisualServer, + validTrustedProxy, +} from '../src/features/config/visualConfigServer'; +import { ConfigDraftConflictError } from '../src/services/api/configPatch'; +import { runVisualConfig } from './helpers/visualConfig'; + +const yaml = `# root +server: + trusted-proxies: [127.0.0.1] # trusted + discovery: + enabled: true + service-type: _ai-gateway._tcp # type + subtypes: [responses, responses] # subtype + interfaces: + include: [en0] + exclude: [utun*] + future: retained # interface + future: retained # discovery + future: retained # server +api-keys: {codex: [{future: untouched}]} +`; + +describe('server visual codec', () => { + for (const { key, path, kind } of SERVER_FIELDS) { + test(`${key}: exact read/write path, explicit values, source roundtrip`, () => { + const doc = parseDocument(yaml); + const before = kind === 'list' ? ['one', 'one'] : kind === 'boolean' ? true : 'original'; + doc.setIn(path, before); + const source = doc.toString(); + const loaded = runVisualConfig(source); + expect(loaded.visualValues[key]).toEqual(before); + const desired = + kind === 'list' ? [' two ', '', 'two'] : kind === 'boolean' ? false : 'changed'; + const config = runVisualConfig(source, [{ [key]: desired }]); + expect([...config.visualDirtyFields]).toEqual([key]); + const output = config.applyVisualChangesToYaml(source); + expect(parseDocument(output).getIn(path, true)?.toJSON()).toEqual( + kind === 'list' ? ['two', 'two'] : desired + ); + expect(runVisualConfig(output).visualValues[key]).toEqual( + kind === 'list' ? ['two', 'two'] : desired + ); + expect(output).toContain('# root'); + expect(output).toContain('# interface'); + expect(parseDocument(output).toJS()['api-keys']).toEqual(doc.toJS()['api-keys']); + if (kind === 'list') { + const clear = runVisualConfig(source, [{ [key]: [] }]).applyVisualChangesToYaml(source); + expect(parseDocument(clear).getIn(path, true)?.toJSON()).toEqual([]); + expect(clear).toContain('[]'); + expect(runVisualConfig(source, [{ [key]: [...(before as string[])] }]).visualDirty).toBe( + false + ); + } + }); + } + test('defaults and untouched values are not materialized or normalized', () => { + for (const source of ['{}', 'server: null', 'server: {discovery: null}']) { + const v = runVisualConfig(source); + for (const { key } of SERVER_FIELDS) + expect(v.visualValues[key]).toEqual(DEFAULT_VISUAL_VALUES[key]); + expect(parseDocument(v.applyVisualChangesToYaml(source)).toJS()).toEqual( + parseDocument(source).toJS() + ); + } + const source = yaml.replace( + 'subtypes: [responses, responses]', + 'subtypes: [" odd._sub ", "", 7, future]' + ); + const v = runVisualConfig(source, [{ discoveryAuthRequired: false }]); + const output = v.applyVisualChangesToYaml(source); + expect(output).toContain('auth-required: false'); + expect(output).toContain('" odd._sub "'); + expect(output).toContain('# type'); + const original = parseDocument(source).toJS(); + original.server.discovery['auth-required'] = false; + expect(parseDocument(output).toJS()).toEqual(original); + expect(validateVisualServer(DEFAULT_VISUAL_VALUES)).toEqual({ + trustedProxies: undefined, + discoveryServiceType: undefined, + }); + }); + test('all null parent depths are safe and false overrides default true', () => { + for (const source of [ + 'server: null', + 'server: {discovery: null}', + 'server: {discovery: {interfaces: null}}', + ]) { + const v = runVisualConfig(source, [ + { discoveryInterfacesInclude: ['en*'], discoveryAuthRequired: false }, + ]); + const output = parseDocument(v.applyVisualChangesToYaml(source)); + expect(output.getIn(['server', 'discovery', 'auth-required'])).toBe(false); + expect( + output.getIn(['server', 'discovery', 'interfaces', 'include'], true)?.toJSON() + ).toEqual(['en*']); + } + }); + for (const { key, path, kind } of SERVER_FIELDS) { + if (kind !== 'list') continue; + test(`${key}: atomic concurrency guard and unrelated concurrent preservation`, () => { + const config = runVisualConfig(yaml, [{ [key]: ['changed'] }]); + const concurrent = parseDocument(yaml); + concurrent.setIn(path, ['remote']); + expect(() => config.applyVisualChangesToYaml(concurrent.toString())).toThrow( + ConfigDraftConflictError + ); + const output = config.applyVisualChangesToYaml( + yaml.replace('future: untouched', 'future: concurrent') + ); + expect(output).toContain('future: concurrent'); + expect(() => config.applyVisualChangesToYaml(output)).not.toThrow(); + expect(() => config.applyVisualChangesToYaml(concurrent.toString(), 'draft')).not.toThrow(); + }); + } + test('rebase retains list comments, detects conflicts and load resets session state', () => { + let done = false; + function Harness() { + const v = useVisualConfig(); + const [phase, next] = useState(0); + if (phase === 0) v.loadVisualValuesFromYaml(yaml); + if (phase === 1) + v.setVisualValues({ discoverySubtypes: ['custom', 'responses'], trustedProxies: [] }); + if (phase === 2) v.rebaseVisualValuesFromYaml(yaml, v.applyVisualChangesToYaml(yaml)); + if (phase === 3) { + const output = v.applyVisualChangesToYaml(yaml); + expect(output).toContain('# subtype'); + expect(parseDocument(output).getIn(['server', 'trusted-proxies'], true)?.toJSON()).toEqual( + [] + ); + expect(() => + v.applyVisualChangesToYaml(yaml.replace('[responses, responses]', '[remote]')) + ).toThrow(ConfigDraftConflictError); + v.setVisualValues({ discoverySubtypes: ['custom', 'responses', 'added'] }); + } + if (phase === 4) { + expect(v.applyVisualChangesToYaml(yaml)).toContain('added'); + v.loadVisualValuesFromYaml('server: {discovery: {service-name: new-session}}'); + } + if (phase === 5) { + expect(v.visualDirty).toBe(false); + expect(v.visualValues.discoverySubtypes).toEqual([]); + expect(v.visualValues.discoveryServiceName).toBe('new-session'); + expect(v.applyVisualChangesToYaml('{}')).not.toContain('custom'); + done = true; + } else next(phase + 1); + return null; + } + renderToStaticMarkup(createElement(Harness)); + expect(done).toBe(true); + }); +}); + +describe('backend server validation', () => { + test('strict IPv4/IPv6 and CIDR boundaries; trims and removes blank lines', () => { + for (const value of [ + '0.0.0.0/0', + '255.255.255.255/32', + '127.0.0.1', + '::/0', + '::1/128', + '2001:db8::/64', + '::ffff:192.0.2.1/128', + '1:2:3:4:5:6:7:8', + ' 192.0.2.0/24 ', + ]) { + expect(validTrustedProxy(value)).toBe(true); + } + for (const value of [ + 'localhost', + 'https://127.0.0.1', + '127.1', + '012.0.0.1', + '256.0.0.1', + '1.2.3.4/33', + '::/129', + '1.2.3.4/-1', + '::/+1', + '::/1.5', + '::/1e2', + '::/', + '::/1/2', + 'fe80::1%en0', + '1.2. 3.4', + '::ffff:192.000.2.1', + '1:2:3:4:5:6:7', + '1::2::3', + '[::1]', + ':: /64', + ]) { + expect(validTrustedProxy(value)).toBe(false); + expect( + validateVisualServer({ ...DEFAULT_VISUAL_VALUES, trustedProxies: [value] }).trustedProxies + ).toBe('invalid_trusted_proxies'); + } + expect( + validateVisualServer({ ...DEFAULT_VISUAL_VALUES, trustedProxies: [' ', '\n', ' ::1 '] }) + .trustedProxies + ).toBeUndefined(); + }); + test('enabled service type follows backend rules, including numeric names', () => { + for (const value of ['', ' ', '_a._tcp', '_123._tcp', '_a-b._tcp', '_123456789012345._tcp']) { + expect( + validateVisualServer({ + ...DEFAULT_VISUAL_VALUES, + discoveryEnabled: true, + discoveryServiceType: value, + }).discoveryServiceType + ).toBeUndefined(); + } + for (const value of [ + '_1234567890123456._tcp', + '_-a._tcp', + '_a-._tcp', + '_a_b._tcp', + 'a._tcp', + '_a._udp', + '_a._TCP', + '_é._tcp', + '_._tcp', + ]) { + expect( + validateVisualServer({ + ...DEFAULT_VISUAL_VALUES, + discoveryEnabled: true, + discoveryServiceType: value, + }).discoveryServiceType + ).toBe('invalid_discovery_service_type'); + expect( + validateVisualServer({ ...DEFAULT_VISUAL_VALUES, discoveryServiceType: value }) + .discoveryServiceType + ).toBeUndefined(); + } + }); +});