from __future__ import annotations import re from fastapi.testclient import TestClient from device.manager import DeviceManager from host_agent.config import HostAgentConfig from host_agent.history import ConsoleHistoryStore from host_agent.identity import HostIdentityStore from host_agent.local_account import LocalAccountStore from host_agent.status import AgentStatusTracker from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app from host_agent.web.auth import SessionManager from storage.device_config import DeviceConfigStore CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"') def _build_client(tmp_path, *, create_account: bool = True) -> tuple[TestClient, dict]: config = HostAgentConfig( control_plane_url="https://control.example", host_id="host-a", token="secret", enrollment_managed=False, console_session_ttl_seconds=3600.0, ) manager = DeviceManager() config_store = DeviceConfigStore(tmp_path / "devices.sqlite3") local_account_store = LocalAccountStore(tmp_path / "host_local_account.json") if create_account: local_account_store.create("operator", "correct horse battery staple") identity_store = HostIdentityStore(tmp_path / "host_identity.json") history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3") status_tracker = AgentStatusTracker() session_manager = SessionManager(ttl_seconds=3600.0) app = create_console_app( config=config, manager=manager, config_store=config_store, local_account_store=local_account_store, identity_store=identity_store, history_store=history_store, status_tracker=status_tracker, session_manager=session_manager, enrollment_client=None, ) client = TestClient(app) context = { "manager": manager, "config_store": config_store, "local_account_store": local_account_store, "history_store": history_store, "session_manager": session_manager, } return client, context def _login( client: TestClient, *, username: str = "operator", password: str = "correct horse battery staple", ) -> str: response = client.post("/login", data={"username": username, "password": password}) assert response.status_code == 200 match = CSRF_PATTERN.search(response.text) assert match is not None return match.group(1) def test_unauthenticated_get_root_redirects_to_login(tmp_path) -> None: client, _ = _build_client(tmp_path) response = client.get("/", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/login" def test_login_with_no_account_shows_setup_message_and_rejects_post(tmp_path) -> None: client, context = _build_client(tmp_path, create_account=False) get_response = client.get("/login") assert "device-host-agent setup" in get_response.text post_response = client.post( "/login", data={"username": "operator", "password": "anything"} ) assert "device-host-agent setup" in post_response.text assert SESSION_COOKIE_NAME not in client.cookies def test_login_with_wrong_password_fails_and_sets_no_cookie(tmp_path) -> None: client, _ = _build_client(tmp_path) response = client.post("/login", data={"username": "operator", "password": "wrong"}) assert response.status_code == 200 assert "Invalid username or password" in response.text assert SESSION_COOKIE_NAME not in client.cookies def test_login_with_correct_password_sets_cookie_and_dashboard_succeeds( tmp_path, ) -> None: client, _ = _build_client(tmp_path) response = client.post( "/login", data={"username": "operator", "password": "correct horse battery staple"}, ) assert response.status_code == 200 assert SESSION_COOKIE_NAME in client.cookies assert "Status" in response.text assert "control.example" in response.text def test_mutating_post_without_csrf_token_is_rejected_and_makes_no_change( tmp_path, ) -> None: client, context = _build_client(tmp_path) _login(client) response = client.post( "/devices/save", data={ "device_id": "device-a", "driver_type": "wda", "name": "Lab iPhone", "connection_info": "{}", }, ) assert response.status_code == 403 assert context["config_store"].get("device-a") is None assert context["manager"].list_devices() == [] def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None: client, context = _build_client(tmp_path) csrf_token = _login(client) response = client.post( "/devices/save", data={ "device_id": "device-a", "driver_type": "wda", "name": "Lab iPhone", "connection_info": "{}", "csrf_token": csrf_token, }, ) assert response.status_code == 200 assert "device-a" in response.text assert context["config_store"].get("device-a") is not None assert [device.id for device in context["manager"].list_devices()] == ["device-a"] def test_remove_device_unregisters_from_manager(tmp_path) -> None: client, context = _build_client(tmp_path) csrf_token = _login(client) client.post( "/devices/save", data={ "device_id": "device-a", "driver_type": "wda", "name": "Lab iPhone", "connection_info": "{}", "csrf_token": csrf_token, }, ) assert context["config_store"].get("device-a") is not None response = client.post( "/devices/remove", data={"device_id": "device-a", "csrf_token": csrf_token}, ) assert response.status_code == 200 assert context["config_store"].get("device-a") is None assert context["manager"].list_devices() == [] def test_change_password_wrong_current_fails_old_password_still_works(tmp_path) -> None: client, context = _build_client(tmp_path) csrf_token = _login(client) response = client.post( "/account", data={ "current_password": "wrong", "new_password": "new password", "confirm_password": "new password", "csrf_token": csrf_token, }, ) assert response.status_code == 400 assert "incorrect" in response.text account_store: LocalAccountStore = context["local_account_store"] account = account_store.load() assert account is not None assert account_store.verify(account, "correct horse battery staple") is True def test_change_password_correct_succeeds_old_password_no_longer_works( tmp_path, ) -> None: client, context = _build_client(tmp_path) csrf_token = _login(client) response = client.post( "/account", data={ "current_password": "correct horse battery staple", "new_password": "new password", "confirm_password": "new password", "csrf_token": csrf_token, }, ) assert response.status_code == 200 assert "Password updated" in response.text account_store: LocalAccountStore = context["local_account_store"] account = account_store.load() assert account is not None assert account_store.verify(account, "new password") is True assert account_store.verify(account, "correct horse battery staple") is False def test_history_reflects_entries_written_beforehand(tmp_path) -> None: client, context = _build_client(tmp_path) context["history_store"].record_heartbeat(device_count=3) _login(client) response = client.get("/history") assert response.status_code == 200 assert "heartbeat: 3 devices" in response.text def test_logout_invalidates_session_so_subsequent_request_redirects_to_login( tmp_path, ) -> None: client, _ = _build_client(tmp_path) csrf_token = _login(client) logout_response = client.post( "/logout", data={"csrf_token": csrf_token}, follow_redirects=False ) assert logout_response.status_code == 303 assert logout_response.headers["location"] == "/login" response = client.get("/", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/login"