From a067afaa1f5ef7dfe5d6fbaba0c6986f2c7b0998 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 2 May 2026 04:38:28 -0500 Subject: [PATCH] feat(tests): add/update tests --- tests/backend/test_announce_dao_extended.py | 13 ++ tests/backend/test_http_url_guard.py | 64 +++++++++ tests/backend/test_meshchat_coverage.py | 33 +++++ tests/backend/test_translator_handler.py | 2 + .../test_translator_handler_extended.py | 135 +++++++++++++++++- .../ConversationViewerButtons.test.js | 2 +- tests/frontend/TranslatorPage.test.js | 13 +- .../frontend/fixtures/settingsPageTestApi.js | 1 + 8 files changed, 254 insertions(+), 9 deletions(-) diff --git a/tests/backend/test_announce_dao_extended.py b/tests/backend/test_announce_dao_extended.py index a1fc92a..ec06988 100644 --- a/tests/backend/test_announce_dao_extended.py +++ b/tests/backend/test_announce_dao_extended.py @@ -141,3 +141,16 @@ def test_get_announce_count_by_aspect(announce_dao): assert announce_dao.get_announce_count_by_aspect("lxmf.delivery") == 2 assert announce_dao.get_announce_count_by_aspect("nomadnetwork.node") == 1 assert announce_dao.get_announce_count_by_aspect("lxmf.propagation") == 0 + + +def test_get_favourite_by_destination_hash(announce_dao): + assert announce_dao.get_favourite_by_destination_hash("missing") is None + announce_dao.upsert_favourite("dh1", "Original", "nomadnetwork.node") + row = announce_dao.get_favourite_by_destination_hash("dh1") + assert row["destination_hash"] == "dh1" + assert row["display_name"] == "Original" + assert row["aspect"] == "nomadnetwork.node" + announce_dao.upsert_custom_display_name("dh1", "Renamed") + announce_dao.upsert_favourite("dh1", "Renamed", row["aspect"]) + row2 = announce_dao.get_favourite_by_destination_hash("dh1") + assert row2["display_name"] == "Renamed" diff --git a/tests/backend/test_http_url_guard.py b/tests/backend/test_http_url_guard.py index 780eac1..14630c7 100644 --- a/tests/backend/test_http_url_guard.py +++ b/tests/backend/test_http_url_guard.py @@ -5,6 +5,7 @@ import pytest from meshchatx.src.backend.http_url_guard import ( UnsafeOutboundUrlError, normalize_loopback_http_service_base, + normalize_libretranslate_http_service_base, ) @@ -72,3 +73,66 @@ def test_normalize_accepts_loopback_variants(edge): def test_normalize_rejects_scheme_or_crlf_injection(bad): with pytest.raises(UnsafeOutboundUrlError): normalize_loopback_http_service_base(bad) + + +def test_normalize_libretranslate_public_https(): + assert normalize_libretranslate_http_service_base( + "https://libretranslate.com/path" + ) == ("https://libretranslate.com") + + +def test_normalize_libretranslate_remote_host_port_strip_path(): + assert normalize_libretranslate_http_service_base( + "http://superfishy.example:5002/languages", + ) == ("http://superfishy.example:5002") + + +def test_normalize_libretranslate_private_and_loopback_ips(): + assert normalize_libretranslate_http_service_base("http://10.20.30.40:5123/") == ( + "http://10.20.30.40:5123" + ) + assert normalize_libretranslate_http_service_base("http://127.0.0.1:5000") == ( + "http://127.0.0.1:5000" + ) + + +@pytest.mark.parametrize( + "bad", + [ + "http://169.254.169.254/latest", + "http://239.255.0.1:5000/", + "http://0.0.0.0/", + "http://240.0.0.1:1", + ], +) +def test_normalize_libretranslate_rejects_ssrf_lit_ips(bad): + with pytest.raises(UnsafeOutboundUrlError): + normalize_libretranslate_http_service_base(bad) + + +@pytest.mark.parametrize( + "bad", + [ + "ftp://example.com/", + "http://user:pass@192.168.1.1:5000", + ], +) +def test_normalize_libretranslate_rejects_scheme_or_creds(bad): + with pytest.raises(UnsafeOutboundUrlError): + normalize_libretranslate_http_service_base(bad) + + +@pytest.mark.parametrize( + "bad", + ["", " ", "http+unix://%2Ftmp%2Fs.sock"], +) +def test_normalize_libretranslate_rejects_scheme_or_empty(bad): + with pytest.raises(UnsafeOutboundUrlError): + normalize_libretranslate_http_service_base(bad) + + +def test_normalize_libretranslate_rejects_encoded_crlf_in_host(): + with pytest.raises(UnsafeOutboundUrlError): + normalize_libretranslate_http_service_base( + "http://127.0.0.1%0d%0a.evil.com:80/" + ) diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py index 5ca2ebd..78151b9 100644 --- a/tests/backend/test_meshchat_coverage.py +++ b/tests/backend/test_meshchat_coverage.py @@ -73,6 +73,38 @@ async def test_update_config_theme(mock_app): mock_app.config.theme.set.assert_called_with("dark") +@pytest.mark.asyncio +async def test_update_config_libretranslate_api_key(mock_app): + mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future()) + mock_app.send_config_to_websocket_clients.return_value.set_result(None) + mock_app.config.libretranslate_api_key = MagicMock() + mock_app.translator_handler = MagicMock() + await mock_app.update_config({"libretranslate_api_key": " sek "}) + mock_app.config.libretranslate_api_key.set.assert_called_once_with("sek") + assert mock_app.translator_handler.libretranslate_api_key == "sek" + + +@pytest.mark.asyncio +async def test_update_config_libretranslate_api_key_empty_clears(mock_app): + mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future()) + mock_app.send_config_to_websocket_clients.return_value.set_result(None) + mock_app.config.libretranslate_api_key = MagicMock() + mock_app.translator_handler = MagicMock() + await mock_app.update_config({"libretranslate_api_key": ""}) + mock_app.config.libretranslate_api_key.set.assert_called_once_with(None) + assert mock_app.translator_handler.libretranslate_api_key is None + + +@pytest.mark.asyncio +async def test_update_config_libretranslate_api_key_length_limit(mock_app): + mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future()) + mock_app.send_config_to_websocket_clients.return_value.set_result(None) + mock_app.config.libretranslate_api_key = MagicMock() + mock_app.translator_handler = MagicMock() + with pytest.raises(ValueError): + await mock_app.update_config({"libretranslate_api_key": "z" * 513}) + + def test_get_config_dict_no_context(mock_app): mock_app.current_context = None assert mock_app.get_config_dict() == {} @@ -149,6 +181,7 @@ def test_get_config_dict_basic(mock_app): "translator_argos_enabled", "translator_libretranslate_enabled", "libretranslate_url", + "libretranslate_api_key", "desktop_open_calls_in_separate_window", "desktop_hardware_acceleration_enabled", "blackhole_integration_enabled", diff --git a/tests/backend/test_translator_handler.py b/tests/backend/test_translator_handler.py index 2f36057..51e4773 100644 --- a/tests/backend/test_translator_handler.py +++ b/tests/backend/test_translator_handler.py @@ -68,6 +68,8 @@ class TestTranslatorHandler(unittest.TestCase): result = self.handler.translate_text("Hello", "en", "de", use_argos=False) self.assertEqual(result["translated_text"], "Hallo") self.assertEqual(result["source"], "libretranslate") + body = mock_session.post.call_args.kwargs.get("json") + self.assertNotIn("api_key", body) if __name__ == "__main__": diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py index 82629a9..1e1d15a 100644 --- a/tests/backend/test_translator_handler_extended.py +++ b/tests/backend/test_translator_handler_extended.py @@ -4,7 +4,10 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from meshchatx.src.backend.translator_handler import TranslatorHandler +from meshchatx.src.backend.translator_handler import ( + TranslatorHandler, + _normalize_optional_libretranslate_api_key, +) def test_translator_handler_init(): @@ -17,6 +20,11 @@ def test_translator_handler_init(): assert handler.translator_argos_enabled is True +def test_translator_handler_init_optional_api_key_stripped(): + handler = TranslatorHandler(libretranslate_api_key=" trim ") + assert handler.libretranslate_api_key == "trim" + + def test_get_supported_languages_no_backends(): handler = TranslatorHandler() handler.has_requests = False @@ -202,7 +210,7 @@ def test_language_code_to_name(): @patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") -def test_get_supported_languages_skips_non_loopback_stored_url(mock_session_cls): +def test_get_supported_languages_skips_link_local_lit_stored_url(mock_session_cls): handler = TranslatorHandler( libretranslate_url="http://169.254.169.254/", translator_libretranslate_enabled=True, @@ -215,15 +223,30 @@ def test_get_supported_languages_skips_non_loopback_stored_url(mock_session_cls) mock_session_cls.assert_not_called() -def test_translate_text_rejects_non_loopback_libre_url(): +@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") +def test_translate_text_accepts_remote_libre_url(mock_session_cls): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"translatedText": "Hallo"}) + mock_post = MagicMock() + mock_post.__aenter__ = AsyncMock(return_value=mock_response) + mock_post.__aexit__ = AsyncMock(return_value=None) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_post) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session_cls.return_value = mock_session + handler = TranslatorHandler( libretranslate_url="http://example.com:5000/", translator_libretranslate_enabled=True, translator_argos_enabled=False, ) handler.has_requests = True - with pytest.raises(ValueError, match="URL host must be"): - handler.translate_text("Hello", "en", "de", use_argos=False) + result = handler.translate_text("Hello", "en", "de", use_argos=False) + assert result["translated_text"] == "Hallo" + called_url = mock_session.post.call_args.args[0] + assert called_url.startswith("http://example.com:5000") def test_get_translator_languages_response_explicit_bad_override_raises(): @@ -232,7 +255,105 @@ def test_get_translator_languages_response_explicit_bad_override_raises(): translator_libretranslate_enabled=True, ) handler.has_requests = True - with pytest.raises(ValueError, match="URL host must be"): + with pytest.raises(ValueError, match="IPv4 link-local"): handler.get_translator_languages_response( - libretranslate_url="http://metadata.example/latest", + libretranslate_url="http://169.254.169.254:5000", ) + + +def test_normalize_optional_libretranslate_api_key(): + assert _normalize_optional_libretranslate_api_key(None) is None + assert _normalize_optional_libretranslate_api_key(" ") is None + assert _normalize_optional_libretranslate_api_key(" xyz ") == "xyz" + with pytest.raises(ValueError): + _normalize_optional_libretranslate_api_key("k" * 513) + + +@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") +def test_get_supported_languages_sends_api_key_for_languages_when_set(mock_session_cls): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value=[{"code": "en", "name": "English"}]) + mock_get = MagicMock() + mock_get.__aenter__ = AsyncMock(return_value=mock_response) + mock_get.__aexit__ = AsyncMock(return_value=None) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_get) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session_cls.return_value = mock_session + + handler = TranslatorHandler( + translator_libretranslate_enabled=True, + libretranslate_api_key="srv-key", + ) + handler.has_requests = True + handler.has_argos = False + handler.has_argos_lib = False + handler.has_argos_cli = False + handler.get_supported_languages() + + kw = mock_session.get.call_args.kwargs + assert kw.get("params") == {"api_key": "srv-key"} + + +@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") +def test_translate_sends_optional_api_key_in_json_body(mock_session_cls): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock( + return_value={ + "translatedText": "Bonjour", + "detectedLanguage": {"language": "en"}, + }, + ) + mock_post_ctx = MagicMock() + mock_post_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_post_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_post_ctx) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session_cls.return_value = mock_session + + handler = TranslatorHandler( + translator_libretranslate_enabled=True, + libretranslate_api_key="abc123", + translator_argos_enabled=False, + ) + handler.has_requests = True + handler.translate_text("Hello", "en", "fr", use_argos=False) + + body = mock_session.post.call_args.kwargs.get("json") + assert body.get("api_key") == "abc123" + + +@patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") +def test_translate_explicit_api_key_overrides_handler_default(mock_session_cls): + mock_response = MagicMock() + mock_response.status = 200 + mock_response.json = AsyncMock(return_value={"translatedText": "Hi"}) + mock_post_ctx = MagicMock() + mock_post_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_post_ctx.__aexit__ = AsyncMock(return_value=None) + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_post_ctx) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session_cls.return_value = mock_session + + handler = TranslatorHandler( + translator_libretranslate_enabled=True, + libretranslate_api_key="stored", + translator_argos_enabled=False, + ) + handler.has_requests = True + handler.translate_text( + "Hello", + "en", + "de", + use_argos=False, + libretranslate_api_key=" one-off ", + ) + body = mock_session.post.call_args.kwargs.get("json") + assert body.get("api_key") == "one-off" diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js index fa16c80..f88e82a 100644 --- a/tests/frontend/ConversationViewerButtons.test.js +++ b/tests/frontend/ConversationViewerButtons.test.js @@ -365,7 +365,7 @@ describe("ConversationViewer.vue button interactions", () => { "/api/v1/translator/translate", expect.objectContaining({ text: "hello", - source_lang: "en", + source_lang: "auto", target_lang: "de", use_argos: true, }) diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js index 1195e84..787852b 100644 --- a/tests/frontend/TranslatorPage.test.js +++ b/tests/frontend/TranslatorPage.test.js @@ -20,6 +20,7 @@ describe("TranslatorPage.vue", () => { translator_argos_enabled: true, translator_libretranslate_enabled: true, libretranslate_url: "http://localhost:5000", + libretranslate_api_key: null, }, }, }); @@ -49,10 +50,19 @@ describe("TranslatorPage.vue", () => { }); const mountTranslatorPage = () => { + const tMap = { + "translator.api_server": "LibreTranslate API Server", + "translator.api_server_description": + "Enter the base URL of your LibreTranslate server (e.g., http://localhost:5000)", + "translator.api_key_optional": "LibreTranslate API key (optional)", + "translator.api_key_placeholder": "Leave empty unless your provider requires one", + "translator.api_key_description": "If required, LibreTranslate expects api_key in the JSON translate body.", + "translator.failed_load_languages": "Failed", + }; return mount(TranslatorPage, { global: { mocks: { - $t: (key) => key, + $t: (key) => (key in tMap ? tMap[key] : key), }, stubs: { MaterialDesignIcon: { @@ -80,6 +90,7 @@ describe("TranslatorPage.vue", () => { translator_argos_enabled: false, translator_libretranslate_enabled: false, libretranslate_url: "http://127.0.0.1:5000", + libretranslate_api_key: null, }, }, }); diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js index c2bb221..0e7f66a 100644 --- a/tests/frontend/fixtures/settingsPageTestApi.js +++ b/tests/frontend/fixtures/settingsPageTestApi.js @@ -91,6 +91,7 @@ export function buildFullServerConfig(overrides = {}) { translator_argos_enabled: false, translator_libretranslate_enabled: false, libretranslate_url: "http://localhost:5000", + libretranslate_api_key: null, desktop_open_calls_in_separate_window: false, desktop_hardware_acceleration_enabled: true, blackhole_integration_enabled: true,