From c1e93f20589410c4f0ef8a067572a1da5ca5fdc9 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Tue, 9 Jun 2026 05:14:22 -0400 Subject: [PATCH] Fix stale DNS zone after wizard completes (#8) _bootstrap_dns runs at container start before the wizard, writing the default cell name ('mycell') into cell.zone. When the wizard completed it fired IDENTITY_CHANGED for Caddy but never updated the DNS zone, so DNS records kept showing 'mycell.cell' even after naming the cell. After successful wizard completion, call network_manager.apply_cell_name to rename the hostname record in the primary zone file, then reload CoreDNS. The empty old_name triggers auto-detection so it works even when the zone was written with the env-var default. Adds test_setup_route.py covering: apply_cell_name called on success, not called on failure, 410 on repeat completion, and IDENTITY_CHANGED publication. Co-Authored-By: Claude Sonnet 4.6 --- api/routes/setup.py | 8 ++- tests/test_setup_route.py | 133 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tests/test_setup_route.py diff --git a/api/routes/setup.py b/api/routes/setup.py index fa2afb8..de14975 100644 --- a/api/routes/setup.py +++ b/api/routes/setup.py @@ -90,15 +90,19 @@ def complete_setup(): result = sm.complete_setup(payload) if result.get('success'): try: - from app import config_manager, service_bus, EventType + from app import config_manager, service_bus, EventType, network_manager identity = config_manager.configs.get('_identity', {}) + cell_name = identity.get('cell_name', '') service_bus.publish_event(EventType.IDENTITY_CHANGED, 'setup', { - 'cell_name': identity.get('cell_name'), + 'cell_name': cell_name, 'domain': identity.get('domain'), 'domain_name': identity.get('domain_name'), 'domain_mode': identity.get('domain_mode'), 'effective_domain': config_manager.get_effective_domain(), }) + # Bootstrap wrote the zone with 'mycell'; rename to the real cell name. + if cell_name: + network_manager.apply_cell_name('', cell_name) except Exception as exc: logger.warning(f'Failed to publish IDENTITY_CHANGED after setup: {exc}') status_code = 200 if result.get('success') else 400 diff --git a/tests/test_setup_route.py b/tests/test_setup_route.py new file mode 100644 index 0000000..863aa1a --- /dev/null +++ b/tests/test_setup_route.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Tests for the /api/setup/complete route in routes/setup.py. + +Verifies that completing the wizard fires IDENTITY_CHANGED AND updates the +primary DNS zone with the real cell name (fixing the 'mycell' placeholder +written by _bootstrap_dns before the wizard runs). +""" + +import sys +import json +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) + +from app import app + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _valid_payload(**overrides): + base = { + 'cell_name': 'mycel', + 'password': 'SecurePass1!', + 'domain_mode': 'lan', + 'timezone': 'UTC', + 'ddns_provider': 'none', + } + base.update(overrides) + return base + + +@pytest.fixture() +def client(): + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'test-secret' + with app.test_client() as c: + yield c + + +def _post_complete(client, payload): + return client.post( + '/api/setup/complete', + data=json.dumps(payload), + content_type='application/json', + ) + + +# ── tests ────────────────────────────────────────────────────────────────────── + +def test_complete_setup_calls_apply_cell_name_on_success(client, tmp_path): + """After successful wizard, DNS zone is updated with the real cell name.""" + mock_sm = MagicMock() + mock_sm.is_setup_complete.return_value = False + mock_sm.complete_setup.return_value = {'success': True, 'redirect': '/login'} + + mock_cm = MagicMock() + mock_cm.configs = {'_identity': {'cell_name': 'mycel'}} + mock_cm.get_effective_domain.return_value = 'mycel.pic.ngo' + + mock_nm = MagicMock() + mock_sbus = MagicMock() + + with ( + patch('app.setup_manager', mock_sm), + patch('app.config_manager', mock_cm), + patch('app.network_manager', mock_nm), + patch('app.service_bus', mock_sbus), + patch.dict('sys.modules', {'app': __import__('app')}), + ): + resp = _post_complete(client, _valid_payload()) + + assert resp.status_code == 200 + mock_nm.apply_cell_name.assert_called_once_with('', 'mycel') + + +def test_complete_setup_does_not_call_apply_cell_name_on_failure(client, tmp_path): + """DNS zone is NOT touched when setup fails.""" + mock_sm = MagicMock() + mock_sm.is_setup_complete.return_value = False + mock_sm.complete_setup.return_value = {'success': False, 'errors': ['bad']} + + mock_nm = MagicMock() + + with ( + patch('app.setup_manager', mock_sm), + patch('app.network_manager', mock_nm), + ): + resp = _post_complete(client, _valid_payload()) + + assert resp.status_code == 400 + mock_nm.apply_cell_name.assert_not_called() + + +def test_complete_setup_returns_410_when_already_complete(client): + """Route returns 410 if setup was already done.""" + mock_sm = MagicMock() + mock_sm.is_setup_complete.return_value = True + + with patch('app.setup_manager', mock_sm): + resp = _post_complete(client, _valid_payload()) + + assert resp.status_code == 410 + + +def test_complete_setup_fires_identity_changed_on_success(client): + """IDENTITY_CHANGED is published after successful wizard completion.""" + mock_sm = MagicMock() + mock_sm.is_setup_complete.return_value = False + mock_sm.complete_setup.return_value = {'success': True, 'redirect': '/login'} + + mock_cm = MagicMock() + mock_cm.configs = {'_identity': {'cell_name': 'mycel', 'domain': 'cell'}} + mock_cm.get_effective_domain.return_value = 'cell' + + mock_sbus = MagicMock() + mock_nm = MagicMock() + + with ( + patch('app.setup_manager', mock_sm), + patch('app.config_manager', mock_cm), + patch('app.network_manager', mock_nm), + patch('app.service_bus', mock_sbus), + ): + resp = _post_complete(client, _valid_payload()) + + assert resp.status_code == 200 + mock_sbus.publish_event.assert_called_once() + event_args = mock_sbus.publish_event.call_args + assert event_args[0][1] == 'setup'