import asyncio import json import os import re import time from http import HTTPStatus from pathlib import Path import psutil import pytest import requests from conftest import cleanup_eos_eosdash from loguru import logger from pydantic import ValidationError from akkudoktoreos.config.configabc import is_home_assistant_addon from akkudoktoreos.core.version import __version__ from akkudoktoreos.server.server import ( ServerCommonSettings, get_default_host, get_default_port, get_host_ip, wait_for_port_free, ) class TestServer: def test_server_setup_for_class(self, server_setup_for_class): """Ensure server is started.""" server = server_setup_for_class["server"] eos_dir = server_setup_for_class["eos_dir"] # Assure server is running result = requests.get(f"{server}/v1/health", timeout=2) assert result.status_code == HTTPStatus.OK health = result.json() assert health["status"] == "alive" assert health["version"] == __version__ result = requests.get(f"{server}/v1/config", timeout=2) assert result.status_code == HTTPStatus.OK # Get testing config config_json = result.json() config_folder_path = Path(config_json["general"]["config_folder_path"]) config_file_path = Path(config_json["general"]["config_file_path"]) data_folder_path = Path(config_json["general"]["data_folder_path"]) data_ouput_path = Path(config_json["general"]["data_output_path"]) # Assure we are working in test environment assert str(config_folder_path).startswith(eos_dir) assert str(config_file_path).startswith(eos_dir) assert str(data_folder_path).startswith(eos_dir) assert str(data_ouput_path).startswith(eos_dir) class TestServerSettingsValidation: """Test the port restrictions in ServerCommonSettings when running as HA addon.""" def test_ha_addon_default_ports_ok(self, config_eos, monkeypatch): """Default ports are accepted in HA addon mode.""" monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) assert config_eos.server.port == get_default_port() # 8503 assert config_eos.server.eosdash_port == get_default_port() + 1 # 8504 def test_server_port_restriction_in_ha_addon(self, config_eos, monkeypatch): """Server port must be the default (8503) in HA addon mode.""" monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) with pytest.raises(ValidationError) as excinfo: config_eos.server.port = 9000 assert "Server port number `8503` for Home Assistant add-on can not be changed" in str( excinfo.value ) def test_eosdash_port_restriction_in_ha_addon(self, config_eos, monkeypatch): """EOSdash port must be the default (8504) in HA addon mode.""" monkeypatch.setattr("akkudoktoreos.server.server.is_home_assistant_addon", lambda: True) with pytest.raises(ValidationError) as excinfo: config_eos.server.eosdash_port = 9001 assert "EOSdash port number `8504` for Home Assistant add-on can not be changed" in str( excinfo.value ) def test_ports_allowed_when_not_ha_addon(self, config_eos): """Custom ports are allowed when not in HA addon mode.""" # is_home_assistant_addon() returns False by default in this test environment config_eos.server.port = 9000 config_eos.server.eosdash_port = 9001 assert config_eos.server.port == 9000 assert config_eos.server.eosdash_port == 9001 class TestServerStartStop: @pytest.mark.asyncio async def test_forward_stream_truncates_very_long_line(self, monkeypatch, tmp_path): """Test logging from EOSdash can also handle very long lines.""" eos_dir = tmp_path monkeypatch.setenv("EOS_DIR", str(eos_dir)) monkeypatch.setenv("EOS_CONFIG_DIR", str(eos_dir)) # Import after env vars are set from akkudoktoreos.server.rest.starteosdash import ( EOSDASH_LOG_MAX_LINE_BYTES, _eosdash_log_worker, eosdash_log_queue, forward_stream, ) # ---- Ensure queue + worker are initialized ---- if eosdash_log_queue is None: from akkudoktoreos.server.rest import starteosdash starteosdash.eosdash_log_queue = asyncio.Queue(maxsize=10) worker_task = asyncio.create_task(_eosdash_log_worker()) else: worker_task = None long_message = "X" * (EOSDASH_LOG_MAX_LINE_BYTES + 10_000) raw_line = f"INFO some.module:123 some_func - {long_message}\n" raw_bytes = raw_line.encode() reader = asyncio.StreamReader() reader.feed_data(raw_bytes) reader.feed_eof() # ---- Capture Loguru output ---- records = [] def sink(message): records.append(message.record) logger_id = logger.add(sink, level="INFO") try: await forward_stream(reader) # Allow log worker to flush queue await asyncio.sleep(0) finally: logger.remove(logger_id) # Clean shutdown of worker (important for pytest) if worker_task: from akkudoktoreos.server.rest import starteosdash if starteosdash.eosdash_log_queue: starteosdash.eosdash_log_queue.put_nowait(None) await worker_task # ---- Assert ---- assert len(records) == 1, "Expected exactly one log record" record = records[0] msg = record["message"] assert msg.endswith("[TRUNCATED]"), "Expected truncation marker" assert len(msg) <= EOSDASH_LOG_MAX_LINE_BYTES + 20 @pytest.mark.asyncio async def test_server_start_eosdash(self, config_eos, monkeypatch, tmp_path): """Test the EOSdash server startup from EOS. Do not use any fixture as this will make pytest the owner of the EOSdash port. Tests that: 1. EOSdash starts via the supervisor 2. The /eosdash/health endpoint returns OK 3. EOSdash reports correct status and version 4. EOSdash can be terminated cleanly """ eos_dir = tmp_path monkeypatch.setenv("EOS_DIR", str(eos_dir)) monkeypatch.setenv("EOS_CONFIG_DIR", str(eos_dir)) # Import with environment vars set to prevent creation of EOS.config.json in wrong dir. from akkudoktoreos.server.rest import starteosdash config_eos.server.host = get_default_host() config_eos.server.port = 8503 config_eos.server.eosdash_host = config_eos.server.host config_eos.server.eosdash_port = 8504 timeout = 120 eosdash_server = f"http://{config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}" # Port may be blocked assert wait_for_port_free( config_eos.server.eosdash_port, timeout=120, waiting_app_name="EOSdash" ) owned_processes: list[psutil.Process] = [] try: await starteosdash.supervise_eosdash() process = starteosdash.eosdash_proc assert process is not None, "EOSdash supervisor did not start a process" owned_processes.append(psutil.Process(process.pid)) startup = False error = "" for _ in range(int(timeout / 3)): try: resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2) if resp.status_code == HTTPStatus.OK: startup = True break error = f"{resp.status_code}, {str(resp.content)}" except requests.RequestException as ex: error = str(ex) await asyncio.sleep(3) assert startup, f"Connection to {eosdash_server}/eosdash/health failed: {error}" health = resp.json() assert health.get("status") == "alive" assert health.get("version") == __version__ assert health.get("pid") == process.pid # Terminate the process started by this test, then reap it via asyncio. process.terminate() await asyncio.wait_for(process.wait(), timeout=timeout) try: resp = requests.get(f"{eosdash_server}/eosdash/health", timeout=2) except requests.RequestException: pass else: assert resp.status_code != HTTPStatus.OK finally: cleanup_eos_eosdash( host=config_eos.server.host, port=config_eos.server.port, eosdash_host=config_eos.server.eosdash_host, eosdash_port=config_eos.server.eosdash_port, server_timeout=timeout, owned_processes=owned_processes, config_dir=str(config_eos.general.config_folder_path), ) if starteosdash.eosdash_proc is not None: await asyncio.wait_for(starteosdash.eosdash_proc.wait(), timeout=timeout) @pytest.mark.skipif(os.name == "nt", reason="Server restart not supported on Windows") def test_server_restart(self, server_setup_for_function, is_system_test): """Test server restart.""" server = server_setup_for_function["server"] eos_dir = server_setup_for_function["eos_dir"] timeout = server_setup_for_function["timeout"] result = requests.get(f"{server}/v1/config") assert result.status_code == HTTPStatus.OK # Get testing config config_json = result.json() config_folder_path = Path(config_json["general"]["config_folder_path"]) config_file_path = Path(config_json["general"]["config_file_path"]) data_folder_path = Path(config_json["general"]["data_folder_path"]) data_ouput_path = Path(config_json["general"]["data_output_path"]) cache_file_path = data_folder_path.joinpath(config_json["cache"]["subpath"]).joinpath( "cachefilestore.json" ) # Assure we are working in test environment assert str(config_folder_path).startswith(eos_dir) assert str(config_file_path).startswith(eos_dir) assert str(data_folder_path).startswith(eos_dir) assert str(data_ouput_path).startswith(eos_dir) if is_system_test: # Prepare cache entry and get cached data result = requests.put(f"{server}/v1/config/weather/provider", json="BrightSky") assert result.status_code == HTTPStatus.OK result = requests.post(f"{server}/v1/prediction/update/BrightSky") assert result.status_code == HTTPStatus.OK result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air") assert result.status_code == HTTPStatus.OK data = result.json() assert data["data"] != {} result = requests.put(f"{server}/v1/config/file") assert result.status_code == HTTPStatus.OK # Save cache result = requests.post(f"{server}/v1/admin/cache/save") assert result.status_code == HTTPStatus.OK cache = result.json() assert cache_file_path.exists() result = requests.get(f"{server}/v1/admin/cache") assert result.status_code == HTTPStatus.OK cache = result.json() result = requests.get(f"{server}/v1/health") assert result.status_code == HTTPStatus.OK pid = result.json()["pid"] result = requests.post(f"{server}/v1/admin/server/restart") assert result.status_code == HTTPStatus.OK assert "Restarting EOS.." in result.json()["message"] new_pid = result.json()["pid"] # Wait for server to shut down for retries in range(10): try: result = requests.get(f"{server}/v1/health", timeout=2) if result.status_code == HTTPStatus.OK: pid = result.json()["pid"] if pid == new_pid: # Already started break else: break except Exception as ex: break time.sleep(3) # Assure EOS is up again startup = False error = "" for retries in range(int(timeout / 5)): try: result = requests.get(f"{server}/v1/health", timeout=2) if result.status_code == HTTPStatus.OK: startup = True break error = f"{result.status_code}, {str(result.content)}" except Exception as ex: error = str(ex) time.sleep(5) assert startup, f"Connection to {server}/v1/health failed: {error}" assert result.json()["status"] == "alive" pid = result.json()["pid"] assert pid == new_pid result = requests.get(f"{server}/v1/admin/cache") assert result.status_code == HTTPStatus.OK new_cache = result.json() assert cache.items() <= new_cache.items() if is_system_test: result = requests.get(f"{server}/v1/config") assert result.status_code == HTTPStatus.OK assert result.json()["weather"]["provider"] == "BrightSky" # Wait for initialisation task to have finished time.sleep(5) result = requests.get(f"{server}/v1/prediction/series?key=weather_temp_air") assert result.status_code == HTTPStatus.OK assert result.json() == data # Shutdown the newly created server result = requests.post(f"{server}/v1/admin/server/shutdown") assert result.status_code == HTTPStatus.OK assert "Stopping EOS.." in result.json()["message"] new_pid = result.json()["pid"] class TestServerWithEnv: eos_env = { "EOS_SERVER__EOSDASH_PORT": "8555", } def test_server_setup_for_class(self, server_setup_for_class): """Ensure server is started with environment passed to configuration.""" server = server_setup_for_class["server"] assert server_setup_for_class["eosdash_port"] == int( self.eos_env["EOS_SERVER__EOSDASH_PORT"] ) result = requests.get(f"{server}/v1/config") assert result.status_code == HTTPStatus.OK # Get testing config config_json = result.json() # Assure config got configuration from environment assert config_json["server"]["eosdash_port"] == int( self.eos_env["EOS_SERVER__EOSDASH_PORT"] ) class TestEosdashRedirect: """Redirects to EOSdash must target an address the client can reach. See https://github.com/Akkudoktor-EOS/EOS/issues/1320: the redirect was built from the EOSdash bind address, so remote clients were sent to their own localhost. """ @pytest.fixture def client(self, config_eos): from fastapi.testclient import TestClient from akkudoktoreos.server.eos import app config_eos.server.eosdash_host = "127.0.0.1" config_eos.server.eosdash_port = 8504 return TestClient(app, follow_redirects=False) @pytest.mark.parametrize("host", ["localhost:8503", "127.0.0.1:8503"]) def test_root_redirect_uses_request_host(self, client, host): """The root redirect points to the host the client used, not to the bind address.""" response = client.get("/", headers={"Host": host}) assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == f"http://{host.split(':')[0]}:8504/" def test_root_redirect_uses_host_ip_of_the_eos_machine(self, client): """Access by the IP address of the EOS machine redirects to that address.""" host_ip = get_host_ip() response = client.get("/", headers={"Host": f"{host_ip}:8503"}) assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == f"http://{host_ip}:8504/" def test_root_redirect_ignores_untrusted_forwarded_headers(self, client): """Raw forwarding headers cannot override the public dashboard address.""" response = client.get( "/", headers={ "Host": "localhost", "X-Forwarded-Host": "eos.example.com", "X-Forwarded-Proto": "https", }, ) assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == "http://localhost:8504/" def test_root_redirect_keeps_local_host(self, client): """Local access still redirects to the local EOSdash.""" response = client.get("/", headers={"Host": "127.0.0.1:8503"}) assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == "http://127.0.0.1:8504/" def test_untrusted_request_host_is_not_reflected(self, client): """An unknown Host header must not become the redirect target.""" response = client.get("/", headers={"Host": "attacker.example"}) assert response.status_code == HTTPStatus.NOT_FOUND assert "attacker.example:8504" not in response.text assert "eosdash_public_url" in response.text def test_untrusted_request_host_on_unknown_path(self, client): """The 404 page offers no link for an unknown Host header.""" response = client.get("/no-such-page", headers={"Host": "attacker.example"}) assert response.status_code == HTTPStatus.NOT_FOUND assert "attacker.example:8504" not in response.text def test_eosdash_path_redirect_keeps_path(self, client): """The path is preserved when redirecting to EOSdash.""" response = client.get("/eosdash/health", headers={"Host": "localhost:8503"}) assert response.status_code == HTTPStatus.SEE_OTHER assert response.headers["location"] == "http://localhost:8504/eosdash/health" def test_unknown_path_error_page_links_to_request_host(self, client): """The 404 page links to EOSdash on the host the client used.""" response = client.get("/no-such-page", headers={"Host": "localhost:8503"}) assert response.status_code == HTTPStatus.NOT_FOUND # The link must be real HTML, the error page escapes the message it is given. assert "<a href" not in response.text # Compare the whole link target, a substring check would also accept a foreign host. hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"] assert hrefs == ["http://localhost:8504/"] def test_error_page_escapes_request_url(self, client): """A crafted URL is shown as text, never as markup.""" response = client.get( "/%3Cscript%3Ealert(1)%3C/script%3E", headers={"Host": "localhost:8503"} ) assert response.status_code == HTTPStatus.NOT_FOUND assert "" not in response.text @pytest.mark.parametrize("host", ["[::1]", "[::1]:8503"]) def test_direct_ipv6_preserves_address(self, client, host): """An IPv6 address keeps its brackets in the redirect.""" response = client.get("/", headers={"Host": host}) assert response.headers["location"] == "http://[::1]:8504/" def test_untrusted_ipv6_host_is_not_reflected(self, client): """An IPv6 address that the configuration does not know is not reflected.""" response = client.get("/", headers={"Host": "[2001:db8::1234]:8503"}) assert response.status_code == HTTPStatus.NOT_FOUND assert "2001:db8::1234" not in response.headers.get("location", "") @pytest.mark.parametrize( "public_url", [ "https://energy.example.com", "https://energy.example.com:443", "https://energy.example.com:9443/dashboard", "https://[2001:db8::1234]/dashboard", ], ) def test_public_url_preserves_proxy_port_and_prefix(self, client, config_eos, public_url): config_eos.server.eosdash_public_url = public_url + "/" headers = {"Host": "internal:8503", "X-Forwarded-Host": "wrong.example"} response = client.get("/", headers=headers) assert response.headers["location"] == public_url + "/" response = client.get("/eosdash/health", headers=headers) assert response.headers["location"] == public_url + "/eosdash/health" response = client.get("/missing", headers=headers) hrefs = [href for href in re.findall(r'href="([^"]*)"', response.text) if href != "/docs"] assert hrefs == [public_url + "/"] @pytest.mark.parametrize( "value", [ "", "/dashboard", "//example.com", "ftp://example.com", "https://user:password@example.com", "https://example.com?token=a", "https://example.com#fragment", "https://example.com:99999", "https://example.com:0", "https://example.com\\evil", "https://example.com/\nheader", "https://example.com/a b", ], ) def test_invalid_public_url_rejected(self, config_eos, value): with pytest.raises(ValueError): config_eos.server.eosdash_public_url = value