feat: port conflict validation, autosave on Apply, extended integration tests

Port conflict validation:
- api/port_registry.py: detect_conflicts() checks all service sections for shared port values
- api/app.py: returns HTTP 409 on port conflict after existing range validation
- webui/src/pages/Settings.jsx: JS-side detectPortConflicts() with useMemo shows inline
  conflict errors and blocks Save before the request is made; catch blocks surface server
  error messages (including 409) instead of generic fallbacks

Config autosave on Apply:
- webui/src/contexts/DraftConfigContext.jsx: new context; Settings registers flush callbacks
  per section; App calls flushAll() before applyPending() when any section is dirty
- webui/src/App.jsx: wraps tree with DraftConfigProvider, handleApply shows 'saving' banner
  state and awaits flushAll()
- webui/src/pages/Settings.jsx: registers identity + per-service flushers; propagates dirty
  state into context via setDirty; uses refs to avoid stale closures

Extended integration test coverage (114 new tests):
- tests/integration/test_config_api.py: GET/PUT config, export, import, backup lifecycle
- tests/integration/test_network_services.py: DNS records + DHCP reservations CRUD
- tests/integration/test_containers.py: list, restart, logs, stats; recovery polling
- tests/integration/test_negative_scenarios.py: error-path coverage for all endpoints
- tests/test_port_conflicts.py: 20 unit tests for port_registry.detect_conflicts()

Pre-commit hook updated to skip tests/integration/ (live-stack tests require a running
stack and must be run explicitly via `make test-integration`).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 04:45:47 -04:00
parent 596b06f171
commit 768571f2b7
11 changed files with 1584 additions and 12 deletions
+67
View File
@@ -0,0 +1,67 @@
"""
Port conflict detection for PIC.
Maps each service section to the port field names it exposes, and provides
detect_conflicts() to find cases where two distinct (section, field) slots
resolve to the same integer port value.
"""
# Maps section → list of port field names within that section's config dict.
# Must stay in sync with the _port_fields dict in app.py's update_config().
PORT_FIELDS = {
'network': ['dns_port'],
'wireguard': ['port'],
'email': ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'],
'calendar': ['port'],
'files': ['port', 'manager_port'],
}
def detect_conflicts(effective_config, incoming_patch):
"""
Detect port conflicts across all tracked service sections.
Parameters
----------
effective_config : dict
The current full config as stored (e.g. config_manager.configs).
Each key is a section name; the value is a dict of that section's
config fields.
incoming_patch : dict
The partial update the user is trying to save. Values here override
whatever is in effective_config for the purpose of conflict checking.
Returns
-------
list of dict
Each element is {'port': <int>, 'conflicts': [(section, field), ...]}.
Only entries where 2+ (section, field) pairs share the same port are
included. Returns an empty list when there are no conflicts.
"""
# Build merged view: start from stored config, overlay the patch
merged = {}
for section in PORT_FIELDS:
stored = effective_config.get(section, {}) or {}
patch = incoming_patch.get(section, {}) or {}
merged[section] = {**stored, **patch}
# Collect port → [(section, field)] mapping
port_map = {}
for section, fields in PORT_FIELDS.items():
for field in fields:
raw = merged[section].get(field)
if raw is None or raw == '':
continue
try:
port_val = int(raw)
except (ValueError, TypeError):
continue
port_map.setdefault(port_val, []).append((section, field))
# Return only entries that have more than one (section, field) slot
conflicts = []
for port_val, slots in port_map.items():
if len(slots) >= 2:
conflicts.append({'port': port_val, 'conflicts': slots})
return conflicts