"""Unit tests for fieldwitness.fieldkit.tor — all run without Tor installed.""" from __future__ import annotations import sys import types from dataclasses import fields from pathlib import Path from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _reload_tor_module(monkeypatch: pytest.MonkeyPatch, *, stem_available: bool): """Reload the tor module with stem either importable or not. Because the module checks `import stem` at module scope, we must manipulate sys.modules before importing (or re-importing) the module. """ # Remove the cached module so the import guard re-evaluates. for key in list(sys.modules.keys()): if key == "stem" or key.startswith("fieldwitness.fieldkit.tor"): del sys.modules[key] if not stem_available: # Make `import stem` raise ImportError monkeypatch.setitem(sys.modules, "stem", None) # type: ignore[call-overload] else: # Install a minimal stub that satisfies `import stem` stub = types.ModuleType("stem") monkeypatch.setitem(sys.modules, "stem", stub) stub_control = types.ModuleType("stem.control") stub_control.Controller = MagicMock() # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "stem.control", stub_control) import fieldwitness.fieldkit.tor as tor_module return tor_module # --------------------------------------------------------------------------- # test_has_tor_returns_false_without_stem # --------------------------------------------------------------------------- class TestHasTorWithoutStem: def test_has_tor_false_when_stem_absent(self, monkeypatch: pytest.MonkeyPatch): """has_tor() must return False when stem is not importable.""" # Remove any cached stem import for key in list(sys.modules.keys()): if key == "stem" or key.startswith("stem."): del sys.modules[key] monkeypatch.setitem(sys.modules, "stem", None) # type: ignore[call-overload] # Re-import _availability after evicting the cached module for key in list(sys.modules.keys()): if key == "fieldwitness._availability": del sys.modules[key] from fieldwitness._availability import has_tor assert has_tor() is False # --------------------------------------------------------------------------- # test_start_onion_service_without_stem_raises # --------------------------------------------------------------------------- class TestStartOnionServiceWithoutStem: def test_raises_tor_not_available(self, monkeypatch: pytest.MonkeyPatch): """start_onion_service() must raise TorNotAvailableError when stem is absent.""" tor = _reload_tor_module(monkeypatch, stem_available=False) with pytest.raises(tor.TorNotAvailableError): tor.start_onion_service(target_port=5000) def test_error_message_mentions_install(self, monkeypatch: pytest.MonkeyPatch): """The error message must guide the operator to install stem.""" tor = _reload_tor_module(monkeypatch, stem_available=False) with pytest.raises(tor.TorNotAvailableError, match="pip install"): tor.start_onion_service(target_port=5000) def test_tor_not_available_is_not_tor_control_error(self, monkeypatch: pytest.MonkeyPatch): """TorNotAvailableError and TorControlError must be distinct exception types.""" tor = _reload_tor_module(monkeypatch, stem_available=False) assert tor.TorNotAvailableError is not tor.TorControlError # --------------------------------------------------------------------------- # test_onion_service_info_dataclass # --------------------------------------------------------------------------- class TestOnionServiceInfoDataclass: def test_fields_exist(self): from fieldwitness.fieldkit.tor import OnionServiceInfo field_names = {f.name for f in fields(OnionServiceInfo)} assert "onion_address" in field_names assert "target_port" in field_names assert "is_persistent" in field_names def test_onion_url_property(self): from fieldwitness.fieldkit.tor import OnionServiceInfo info = OnionServiceInfo( onion_address="abc123.onion", target_port=5000, is_persistent=True, ) assert info.onion_url == "http://abc123.onion" def test_frozen_dataclass_rejects_mutation(self): from fieldwitness.fieldkit.tor import OnionServiceInfo info = OnionServiceInfo( onion_address="abc123.onion", target_port=5000, is_persistent=False, ) with pytest.raises((AttributeError, TypeError)): info.onion_address = "evil.onion" # type: ignore[misc] def test_is_persistent_false(self): from fieldwitness.fieldkit.tor import OnionServiceInfo info = OnionServiceInfo( onion_address="xyz.onion", target_port=8080, is_persistent=False, ) assert info.is_persistent is False def test_target_port_stored(self): from fieldwitness.fieldkit.tor import OnionServiceInfo info = OnionServiceInfo( onion_address="test.onion", target_port=9999, is_persistent=True, ) assert info.target_port == 9999 # --------------------------------------------------------------------------- # test_persistent_key_storage_path # --------------------------------------------------------------------------- class TestPersistentKeyStoragePath: def test_key_stored_under_tor_hidden_service_dir( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ): """The persistent key must be written inside paths.TOR_HIDDEN_SERVICE_DIR.""" import fieldwitness.paths as paths # Redirect BASE_DIR to a temp location monkeypatch.setattr(paths, "BASE_DIR", tmp_path / ".fwmetadata") tor_dir = paths.TOR_HIDDEN_SERVICE_DIR # Verify the resolved path sits under BASE_DIR / fieldkit / tor / hidden_service assert str(tor_dir).startswith(str(tmp_path)) assert "fieldkit" in str(tor_dir) assert "tor" in str(tor_dir) assert "hidden_service" in str(tor_dir) def test_tor_dir_is_child_of_base_dir(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path): import fieldwitness.paths as paths monkeypatch.setattr(paths, "BASE_DIR", tmp_path / ".fwmetadata") assert str(paths.TOR_HIDDEN_SERVICE_DIR).startswith(str(paths.BASE_DIR)) def test_key_filename_in_expected_location( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ): """The key file used by _start_persistent_service must be 'hs_ed25519_secret_key'.""" import fieldwitness.paths as paths monkeypatch.setattr(paths, "BASE_DIR", tmp_path / ".fwmetadata") expected_key_file = paths.TOR_HIDDEN_SERVICE_DIR / "hs_ed25519_secret_key" # We're verifying the path structure, not the file's existence assert expected_key_file.name == "hs_ed25519_secret_key" assert expected_key_file.parent == paths.TOR_HIDDEN_SERVICE_DIR