diff --git a/api/config_manager.py b/api/config_manager.py index 7860ce9..ae12ad2 100644 --- a/api/config_manager.py +++ b/api/config_manager.py @@ -294,15 +294,24 @@ class ConfigManager: ] for src, dest in restore_map: if src.exists(): - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest) + try: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dest) + except (PermissionError, OSError) as copy_err: + logger.warning(f"Could not restore {dest}: {copy_err} (skipping)") zones_backup = backup_path / 'dns_zones' if zones_backup.is_dir(): dns_data = data_dir / 'dns' - dns_data.mkdir(parents=True, exist_ok=True) - for zone_file in zones_backup.glob('*.zone'): - shutil.copy2(zone_file, dns_data / zone_file.name) + try: + dns_data.mkdir(parents=True, exist_ok=True) + for zone_file in zones_backup.glob('*.zone'): + try: + shutil.copy2(zone_file, dns_data / zone_file.name) + except (PermissionError, OSError) as zone_err: + logger.warning(f"Could not restore zone {zone_file.name}: {zone_err} (skipping)") + except (PermissionError, OSError) as dir_err: + logger.warning(f"Could not create dns data dir {dns_data}: {dir_err} (skipping)") self.configs = self._load_all_configs() logger.info(f"Restored configuration from backup: {backup_id}") diff --git a/tests/e2e/ui/test_admin_navigation.py b/tests/e2e/ui/test_admin_navigation.py index 62916b5..6730876 100644 --- a/tests/e2e/ui/test_admin_navigation.py +++ b/tests/e2e/ui/test_admin_navigation.py @@ -59,8 +59,10 @@ def test_admin_sidebar_shows_admin_links(admin_page, webui_base): page.goto(f"{webui_base}/") page.wait_for_load_state('networkidle') # These link names come from the adminNavigation array in App.jsx. + # Use .first to avoid strict-mode errors when both desktop and mobile nav + # are mounted simultaneously (both contain the same link names). for link_name in ('Peers', 'Settings', 'WireGuard'): - assert page.get_by_role('link', name=link_name).is_visible(), ( + assert page.get_by_role('link', name=link_name).first.is_visible(), ( f"Admin sidebar link '{link_name}' not visible" ) @@ -70,6 +72,6 @@ def test_admin_sidebar_does_not_show_my_services(admin_page, webui_base): page = admin_page page.goto(f"{webui_base}/") page.wait_for_load_state('networkidle') - assert not page.get_by_role('link', name='My Services').is_visible(), ( + assert not page.get_by_role('link', name='My Services').first.is_visible(), ( "Admin sidebar should not show the peer-only 'My Services' link" ) diff --git a/tests/e2e/ui/test_peer_acl.py b/tests/e2e/ui/test_peer_acl.py index 173309e..d75cd2f 100644 --- a/tests/e2e/ui/test_peer_acl.py +++ b/tests/e2e/ui/test_peer_acl.py @@ -82,7 +82,7 @@ def test_peer_nav_does_not_show_admin_only_links(peer_page, webui_base): page.wait_for_load_state('networkidle') for link_name in ADMIN_ONLY_NAV_LINKS: - assert not page.get_by_role('link', name=link_name).is_visible(), ( + assert not page.get_by_role('link', name=link_name).first.is_visible(), ( f"Admin-only sidebar link '{link_name}' should NOT be visible to a peer" ) @@ -96,8 +96,9 @@ def test_peer_nav_shows_allowed_links(peer_page, webui_base): page.goto(f"{webui_base}/") page.wait_for_load_state('networkidle') + # Use .first to avoid strict-mode errors when desktop + mobile nav are both mounted. for link_name in ('Dashboard', 'My Services', 'Account'): - assert page.get_by_role('link', name=link_name).is_visible(), ( + assert page.get_by_role('link', name=link_name).first.is_visible(), ( f"Peer sidebar should show link '{link_name}'" ) diff --git a/tests/e2e/wg/conftest.py b/tests/e2e/wg/conftest.py index 058e00f..359e01d 100644 --- a/tests/e2e/wg/conftest.py +++ b/tests/e2e/wg/conftest.py @@ -14,16 +14,25 @@ def cleanup_stale_wg_interfaces(): @pytest.fixture(scope='session') def wg_server_info(admin_client, pic_host): - """Get server public key and endpoint from the running API.""" - r = admin_client.get('/api/wireguard/status') - data = r.json() - # status might be nested — check common shapes - server_pubkey = ( - data.get('public_key') or - data.get('server_public_key') or - data.get('status', {}).get('public_key', '') - ) - port = data.get('port') or data.get('listen_port') or 51820 + """Get server public key and listen port from the running API.""" + # Public key lives at /api/wireguard/keys + keys_r = admin_client.get('/api/wireguard/keys') + keys = keys_r.json() + server_pubkey = keys.get('public_key', '') + + # Port comes from the WireGuard config or status + port = 51820 + try: + status = admin_client.get('/api/wireguard/status').json() + port = ( + status.get('listen_port') or + status.get('port') or + status.get('ListenPort') or + 51820 + ) + except Exception: + pass + return { 'public_key': server_pubkey, 'endpoint': pic_host, diff --git a/tests/integration/test_live_api.py b/tests/integration/test_live_api.py index b20d042..7aed406 100644 --- a/tests/integration/test_live_api.py +++ b/tests/integration/test_live_api.py @@ -18,7 +18,7 @@ _S = None @pytest.fixture(scope='module', autouse=True) def _auth_session(): global _S - _S = requests.Session() + _S = _req.Session() _S.headers['Content-Type'] = 'application/json' r = _S.post(f"{API_BASE}/api/auth/login", json={'username': 'admin', 'password': _resolve_admin_pass()}) diff --git a/tests/integration/test_negative_scenarios.py b/tests/integration/test_negative_scenarios.py index 40ec7c6..2150fbc 100644 --- a/tests/integration/test_negative_scenarios.py +++ b/tests/integration/test_negative_scenarios.py @@ -90,7 +90,7 @@ class TestPeerNegative: _assert_error_response(r, 400) def test_create_peer_empty_body_returns_400(self): - r = requests.post( + r = _S.post( f"{API_BASE}/api/peers", data='', headers={'Content-Type': 'application/json'}, @@ -129,7 +129,7 @@ class TestPeerNegative: def test_create_peer_plain_text_body_returns_400(self): """Sending plain text instead of JSON should produce a 400.""" - r = requests.post( + r = _S.post( f"{API_BASE}/api/peers", data='name=foo&public_key=bar', headers={'Content-Type': 'text/plain'}, @@ -143,7 +143,7 @@ class TestPeerNegative: class TestConfigNegative: def test_put_config_null_body_returns_400(self): - r = requests.put( + r = _S.put( f"{API_BASE}/api/config", data='null', headers={'Content-Type': 'application/json'}, @@ -151,7 +151,7 @@ class TestConfigNegative: assert r.status_code == 400 def test_put_config_completely_invalid_json_returns_400(self): - r = requests.put( + r = _S.put( f"{API_BASE}/api/config", data='{bad json}}}', headers={'Content-Type': 'application/json'}, @@ -223,7 +223,7 @@ class TestConfigNegative: class TestDnsRecordsNegative: def test_delete_dns_record_empty_body_does_not_crash(self): """Sending an empty JSON body to DELETE /api/dns/records must not 500.""" - r = requests.delete( + r = _S.delete( f"{API_BASE}/api/dns/records", json={}, headers={'Content-Type': 'application/json'}, @@ -235,7 +235,7 @@ class TestDnsRecordsNegative: def test_delete_dns_record_no_content_type_does_not_crash(self): """Sending DELETE with no body at all must return a parseable response.""" - r = requests.delete(f"{API_BASE}/api/dns/records") + r = _S.delete(f"{API_BASE}/api/dns/records") assert r.status_code in (200, 400, 404, 500) r.json() @@ -246,7 +246,7 @@ class TestDnsRecordsNegative: class TestDhcpReservationsNegative: def test_add_reservation_no_body_returns_400(self): - r = requests.post( + r = _S.post( f"{API_BASE}/api/dhcp/reservations", data='', headers={'Content-Type': 'application/json'}, @@ -269,7 +269,7 @@ class TestDhcpReservationsNegative: _assert_json_error(r) def test_delete_reservation_empty_body_returns_400(self): - r = requests.delete( + r = _S.delete( f"{API_BASE}/api/dhcp/reservations", data='', headers={'Content-Type': 'application/json'}, @@ -310,7 +310,7 @@ class TestContainersNegative: class TestWireGuardKeyGenNegative: def test_generate_keys_empty_body_returns_400(self): - r = requests.post( + r = _S.post( f"{API_BASE}/api/wireguard/keys/peer", json={}, headers={'Content-Type': 'application/json'},