From 0f5034642f21e3ad4f4a71dfe0cee8e5b0f0ee5c Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 16 Apr 2026 23:32:13 -0500 Subject: [PATCH] feat(tests): add loopback TCP fixture for integration tests and apply it to relevant test cases --- tests/backend/conftest.py | 30 +++++++ .../test_access_attempts_enforcement.py | 4 + tests/backend/test_bot_handler_extended.py | 12 ++- tests/backend/test_http_auth_security.py | 5 ++ tests/backend/test_https_wss_side_sniffing.py | 2 + .../test_lxmf_propagation_sync_integration.py | 28 +++++-- tests/backend/test_notifications.py | 1 + tests/frontend/BotsPage.test.js | 2 +- tests/frontend/ConversationViewer.test.js | 80 +++++++++++++++++++ tests/frontend/LinkUtils.test.js | 7 ++ tests/frontend/MarkdownRenderer.test.js | 6 +- tests/frontend/MessagesSidebar.test.js | 10 +++ .../SettingsPage.config-persistence.test.js | 10 +++ .../frontend/fixtures/settingsPageTestApi.js | 2 + tests/frontend/loadingStatusNotice.test.js | 60 ++++++++++++++ 15 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 tests/frontend/loadingStatusNotice.test.js diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py index 7a81b77..e09e0c3 100644 --- a/tests/backend/conftest.py +++ b/tests/backend/conftest.py @@ -2,6 +2,7 @@ import asyncio import os +import socket import tempfile from contextlib import ExitStack from unittest.mock import MagicMock, patch @@ -20,6 +21,35 @@ from meshchatx.src.backend.database.schema import DatabaseSchema os.environ["MESHCHAT_LOG_DIR"] = tempfile.mkdtemp() +@pytest.fixture(scope="session") +def loopback_available(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + accepted = None + try: + server.settimeout(0.5) + client.settimeout(0.5) + server.bind(("127.0.0.1", 0)) + server.listen(1) + port = server.getsockname()[1] + client.connect(("127.0.0.1", port)) + accepted, _ = server.accept() + return True + except OSError: + return False + finally: + if accepted is not None: + accepted.close() + client.close() + server.close() + + +@pytest.fixture +def require_loopback_tcp(loopback_available): + if not loopback_available: + pytest.skip("Loopback TCP is blocked by local firewall/policy; skipping localhost integration test.") + + @pytest.fixture(autouse=True) def global_mocks(): with ( diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py index 0b2b3a0..8b76559 100644 --- a/tests/backend/test_access_attempts_enforcement.py +++ b/tests/backend/test_access_attempts_enforcement.py @@ -240,6 +240,7 @@ def _make_aio_app(mock_app, use_https: bool): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_login_records_access_attempt_and_debug_list(mock_app): mock_app.config.auth_enabled.set(True) pw = b"smoke-access-attempt-pw-ok" @@ -267,6 +268,7 @@ async def test_login_records_access_attempt_and_debug_list(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_lockout_login_returns_429_smoke(mock_app): mock_app.config.auth_enabled.set(True) pw = b"lockout-smoke-pw" @@ -297,6 +299,7 @@ async def test_lockout_login_returns_429_smoke(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_rate_limited_login_returns_429_smoke(mock_app): mock_app.config.auth_enabled.set(True) pw = b"rl-smoke-pw" @@ -346,6 +349,7 @@ async def test_rate_limited_login_returns_429_smoke(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_debug_access_attempts_endpoint_returns_shape(mock_app): mock_app.config.auth_enabled.set(False) aio_app = _make_aio_app(mock_app, use_https=False) diff --git a/tests/backend/test_bot_handler_extended.py b/tests/backend/test_bot_handler_extended.py index 09fafc5..de27e25 100644 --- a/tests/backend/test_bot_handler_extended.py +++ b/tests/backend/test_bot_handler_extended.py @@ -111,7 +111,9 @@ def test_get_status_reads_sidecar_lxmf_address(temp_identity_dir): storage = os.path.join(handler.bots_dir, sid) os.makedirs(storage, exist_ok=True) hx = "a" * 32 - with open(os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8") as f: + with open( + os.path.join(storage, "meshchatx_lxmf_address.txt"), "w", encoding="utf-8" + ) as f: f.write(hx) handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage}] status = handler.get_status() @@ -176,7 +178,9 @@ def test_request_announce_writes_trigger(mock_alive, temp_identity_dir): sid = "b1" storage = os.path.join(handler.bots_dir, sid) os.makedirs(storage, exist_ok=True) - handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999}] + handler.bots_state = [ + {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": 99999} + ] handler.request_announce(sid) req = os.path.join(storage, "meshchatx_request_announce") assert os.path.isfile(req) @@ -189,6 +193,8 @@ def test_request_announce_not_running(temp_identity_dir): sid = "b1" storage = os.path.join(handler.bots_dir, sid) os.makedirs(storage, exist_ok=True) - handler.bots_state = [{"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None}] + handler.bots_state = [ + {"id": sid, "template_id": "echo", "storage_dir": storage, "pid": None} + ] with pytest.raises(RuntimeError, match="not running"): handler.request_announce(sid) diff --git a/tests/backend/test_http_auth_security.py b/tests/backend/test_http_auth_security.py index 08374a2..1f9deda 100644 --- a/tests/backend/test_http_auth_security.py +++ b/tests/backend/test_http_auth_security.py @@ -52,6 +52,7 @@ def test_encrypted_cookie_storage_http_flags(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_login_sets_cookie_and_allows_protected_api(mock_app): mock_app.config.auth_enabled.set(True) pw = b"integration-test-password-ok" @@ -79,6 +80,7 @@ async def test_login_sets_cookie_and_allows_protected_api(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_ws_returns_401_without_session_when_auth_enabled(mock_app): mock_app.config.auth_enabled.set(True) mock_app.config.auth_password_hash.set( @@ -92,6 +94,7 @@ async def test_ws_returns_401_without_session_when_auth_enabled(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_logout_clears_session_for_protected_api(mock_app): mock_app.config.auth_enabled.set(True) pw = b"logout-test-password-ok" @@ -111,6 +114,7 @@ async def test_logout_clears_session_for_protected_api(mock_app): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_auth_login_invalid_json_returns_400(mock_app): mock_app.config.auth_enabled.set(True) mock_app.config.auth_password_hash.set( @@ -135,6 +139,7 @@ async def test_auth_login_invalid_json_returns_400(mock_app): deadline=None, ) @given(body=st.binary(min_size=0, max_size=12000)) +@pytest.mark.usefixtures("require_loopback_tcp") def test_auth_login_fuzz_never_500(mock_app, body): mock_app.config.auth_enabled.set(True) mock_app.config.auth_password_hash.set( diff --git a/tests/backend/test_https_wss_side_sniffing.py b/tests/backend/test_https_wss_side_sniffing.py index c170703..7e7c196 100644 --- a/tests/backend/test_https_wss_side_sniffing.py +++ b/tests/backend/test_https_wss_side_sniffing.py @@ -17,6 +17,8 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.x509.oid import NameOID +pytestmark = pytest.mark.usefixtures("require_loopback_tcp") + def _make_self_signed_cert_and_key(cert_path: str, key_path: str) -> None: private_key = rsa.generate_private_key( diff --git a/tests/backend/test_lxmf_propagation_sync_integration.py b/tests/backend/test_lxmf_propagation_sync_integration.py index 01c9f1c..f702e4f 100644 --- a/tests/backend/test_lxmf_propagation_sync_integration.py +++ b/tests/backend/test_lxmf_propagation_sync_integration.py @@ -104,12 +104,16 @@ def integration_app(temp_dir): def _route_handler(app, path, method="GET"): - return next(r.handler for r in app.get_routes() if r.path == path and r.method == method) + return next( + r.handler for r in app.get_routes() if r.path == path and r.method == method + ) @pytest.mark.asyncio @pytest.mark.integration -async def test_remote_propagation_sync_transitions_path_requested_to_complete(integration_app): +async def test_remote_propagation_sync_transitions_path_requested_to_complete( + integration_app, +): app, fake_router = integration_app remote_hash = b"\x22" * 16 fake_router.set_outbound_propagation_node(remote_hash) @@ -131,18 +135,24 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in with ( patch("meshchatx.meshchat.RNS.Transport.has_path", side_effect=has_path), - patch("meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path), + patch( + "meshchatx.meshchat.RNS.Transport.request_path", side_effect=request_path + ), ): first_sync = await sync_handler(None) assert first_sync.status == 200 - first_status = json.loads((await status_handler(None)).body)["propagation_node_status"] + first_status = json.loads((await status_handler(None)).body)[ + "propagation_node_status" + ] assert first_status["state"] == "path_requested" second_sync = await sync_handler(None) assert second_sync.status == 200 - second_status = json.loads((await status_handler(None)).body)["propagation_node_status"] + second_status = json.loads((await status_handler(None)).body)[ + "propagation_node_status" + ] assert second_status["state"] == "complete" assert second_status["progress"] == 100.0 assert fake_router.request_messages_calls >= 2 @@ -150,7 +160,9 @@ async def test_remote_propagation_sync_transitions_path_requested_to_complete(in @pytest.mark.asyncio @pytest.mark.integration -async def test_local_preferred_propagation_sync_completes_without_remote_lookup(integration_app): +async def test_local_preferred_propagation_sync_completes_without_remote_lookup( + integration_app, +): app, fake_router = integration_app local_hash = fake_router.propagation_destination.hash fake_router.set_outbound_propagation_node(local_hash) @@ -165,7 +177,9 @@ async def test_local_preferred_propagation_sync_completes_without_remote_lookup( response = await sync_handler(None) assert response.status == 200 - status_data = json.loads((await status_handler(None)).body)["propagation_node_status"] + status_data = json.loads((await status_handler(None)).body)[ + "propagation_node_status" + ] assert status_data["state"] == "complete" assert status_data["progress"] == 100.0 assert fake_router.request_messages_calls == 0 diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py index 56477ad..11f32bf 100644 --- a/tests/backend/test_notifications.py +++ b/tests/backend/test_notifications.py @@ -445,6 +445,7 @@ def test_random_notification_operations(db, operations): @pytest.mark.asyncio +@pytest.mark.usefixtures("require_loopback_tcp") async def test_auth_middleware_returns_401_for_api_without_session_when_auth_enabled( mock_app, ): diff --git a/tests/frontend/BotsPage.test.js b/tests/frontend/BotsPage.test.js index 16cfb7f..c15dc4f 100644 --- a/tests/frontend/BotsPage.test.js +++ b/tests/frontend/BotsPage.test.js @@ -77,7 +77,7 @@ describe("BotsPage.vue", () => { const cards = wrapper.findAll("div.cursor-pointer"); const templateCard = cards.filter( - (d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages"), + (d) => d.text().includes("Echo Bot") && d.text().includes("Echos messages") )[0]; await templateCard.trigger("click"); diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index 21d2bbb..ee26b44 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import ConversationViewer from "@/components/messages/ConversationViewer.vue"; import WebSocketConnection from "@/js/WebSocketConnection"; import GlobalState from "@/js/GlobalState"; +import DialogUtils from "@/js/DialogUtils"; vi.mock("@/js/DialogUtils", () => ({ default: { @@ -17,6 +18,7 @@ describe("ConversationViewer.vue", () => { GlobalState.config.theme = "light"; GlobalState.config.message_outbound_bubble_color = "#4f46e5"; GlobalState.config.message_waiting_bubble_color = "#e5e7eb"; + GlobalState.config.warn_on_stranger_links = true; WebSocketConnection.connect(); axiosMock = { get: vi.fn().mockImplementation((url) => { @@ -39,6 +41,7 @@ describe("ConversationViewer.vue", () => { // Mock URL.createObjectURL window.URL.createObjectURL = vi.fn(() => "mock-url"); + vi.spyOn(window, "open").mockImplementation(() => null); // Mock FileReader const mockFileReader = { @@ -57,6 +60,9 @@ describe("ConversationViewer.vue", () => { afterEach(() => { delete window.api; + if (window.open?.mockRestore) { + window.open.mockRestore(); + } vi.unstubAllGlobals(); WebSocketConnection.destroy(); }); @@ -173,6 +179,80 @@ describe("ConversationViewer.vue", () => { expect(wrapper.vm.composerImageDropActive).toBe(false); }); + it("warns before opening http links from strangers when enabled", async () => { + const wrapper = mountConversationViewer(); + wrapper.vm.isStrangerPeer = true; + GlobalState.config.warn_on_stranger_links = true; + DialogUtils.confirm.mockClear(); + window.open.mockClear(); + DialogUtils.confirm.mockResolvedValueOnce(true); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "https://example.com/path"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm"); + expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener"); + }); + + it("does not open stranger http link when warning confirm is rejected", async () => { + const wrapper = mountConversationViewer(); + wrapper.vm.isStrangerPeer = true; + GlobalState.config.warn_on_stranger_links = true; + DialogUtils.confirm.mockClear(); + window.open.mockClear(); + DialogUtils.confirm.mockResolvedValueOnce(false); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "https://example.com/path"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("opens stranger http link without prompt when warning is disabled", async () => { + const wrapper = mountConversationViewer(); + wrapper.vm.isStrangerPeer = true; + GlobalState.config.warn_on_stranger_links = false; + DialogUtils.confirm.mockClear(); + window.open.mockClear(); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "https://example.com/path"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(DialogUtils.confirm).not.toHaveBeenCalled(); + expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener"); + }); + + it("blocks non-http href payloads like data urls in message anchors", async () => { + const wrapper = mountConversationViewer(); + wrapper.vm.isStrangerPeer = true; + GlobalState.config.warn_on_stranger_links = true; + DialogUtils.confirm.mockClear(); + window.open.mockClear(); + + const anchor = document.createElement("a"); + anchor.setAttribute("href", "data:image/png;base64,AAAA"); + const event = { target: anchor, preventDefault: vi.fn() }; + + await wrapper.vm.handleMessageClick(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(DialogUtils.confirm).not.toHaveBeenCalled(); + expect(window.open).not.toHaveBeenCalled(); + }); + it("onComposerImageDrop ignores non-image files", () => { const wrapper = mountConversationViewer(); const pdf = new File([""], "doc.pdf", { type: "application/pdf" }); diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js index 75a2383..4fa2382 100644 --- a/tests/frontend/LinkUtils.test.js +++ b/tests/frontend/LinkUtils.test.js @@ -126,6 +126,13 @@ describe("LinkUtils.js", () => { expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i); }); + it("does not produce data: href for data image payload text", () => { + const text = "inline image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA"; + const result = LinkUtils.renderStandardLinks(text); + expect(result).not.toMatch(/\bhref\s*=\s*["']?\s*data\s*:/i); + expect(result).not.toContain(" { const text = "https://example.com javascript:alert(1)"; const result = LinkUtils.renderStandardLinks(text); diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js index 038212b..559022c 100644 --- a/tests/frontend/MarkdownRenderer.test.js +++ b/tests/frontend/MarkdownRenderer.test.js @@ -49,8 +49,7 @@ describe("MarkdownRenderer.js", () => { }); it("keeps underscores intact in long https links", () => { - const url = - "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"; + const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"; const result = MarkdownRenderer.render(`visit ${url}`); expect(result).toContain(`href="${url}"`); expect(result).toContain(url); @@ -390,8 +389,7 @@ describe("MarkdownRenderer.js", () => { }); it("strip keeps intraword underscores intact", () => { - const url = - "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"; + const url = "https://git.quad4.io/RNS-Things/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md"; expect(MarkdownRenderer.strip(url)).toBe(url); }); diff --git a/tests/frontend/MessagesSidebar.test.js b/tests/frontend/MessagesSidebar.test.js index 0cebbbc..56aabd4 100644 --- a/tests/frontend/MessagesSidebar.test.js +++ b/tests/frontend/MessagesSidebar.test.js @@ -171,6 +171,16 @@ describe("MessagesSidebar UI", () => { expect(conversationsPanel.exists()).toBe(true); }); + it("uses right-edge collapse icons when sidebar position is right", () => { + const left = mountSidebar({ sidebarPosition: "left" }); + expect(left.vm.expandedTabBarChevronIcon).toBe("chevron-left"); + expect(left.vm.collapsedStripChevronIcon).toBe("chevron-right"); + + const right = mountSidebar({ sidebarPosition: "right" }); + expect(right.vm.expandedTabBarChevronIcon).toBe("chevron-right"); + expect(right.vm.collapsedStripChevronIcon).toBe("chevron-left"); + }); + it("emits conversation-click when a conversation row is clicked", async () => { const conversations = [ { diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js index 66de439..6e3f5f2 100644 --- a/tests/frontend/SettingsPage.config-persistence.test.js +++ b/tests/frontend/SettingsPage.config-persistence.test.js @@ -137,6 +137,13 @@ describe("SettingsPage — config persistence (PATCH and related)", () => { expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { ui_glass_enabled: false }); }); + it("onMessagesSidebarPositionChange PATCHes messages_sidebar_position", async () => { + const w = await mountSettingsPage(api); + w.vm.config.messages_sidebar_position = "right"; + await w.vm.onMessagesSidebarPositionChange(); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { messages_sidebar_position: "right" }); + }); + it("resetAppearanceDefaults PATCHes full appearance payload", async () => { const w = await mountSettingsPage(api); await w.vm.resetAppearanceDefaults(); @@ -144,6 +151,7 @@ describe("SettingsPage — config persistence (PATCH and related)", () => { "/api/v1/config", expect.objectContaining({ theme: "light", + messages_sidebar_position: "left", message_font_size: 14, message_icon_size: 28, ui_transparency: 0, @@ -347,6 +355,8 @@ describe("SettingsPage — config persistence (PATCH and related)", () => { expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { block_all_from_strangers: true }); await w.vm.onShowUnknownContactBannerChange(false); expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { show_unknown_contact_banner: false }); + await w.vm.onWarnOnStrangerLinksChange(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { warn_on_stranger_links: false }); }); it("banishment PATCHes toggle and debounced text/color", async () => { diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js index 0c16d73..02377ac 100644 --- a/tests/frontend/fixtures/settingsPageTestApi.js +++ b/tests/frontend/fixtures/settingsPageTestApi.js @@ -75,10 +75,12 @@ export function buildFullServerConfig(overrides = {}) { block_attachments_from_strangers: true, block_all_from_strangers: false, show_unknown_contact_banner: true, + warn_on_stranger_links: true, banished_effect_enabled: true, banished_text: "BANISHED", banished_color: "#dc2626", message_font_size: 14, + messages_sidebar_position: "left", message_icon_size: 28, ui_transparency: 0, ui_glass_enabled: true, diff --git a/tests/frontend/loadingStatusNotice.test.js b/tests/frontend/loadingStatusNotice.test.js new file mode 100644 index 0000000..70e45ef --- /dev/null +++ b/tests/frontend/loadingStatusNotice.test.js @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { classifyConnectionIssue, classifyFetchError } = require("../../electron/loadingStatusNotice.js"); + +describe("loadingStatusNotice", () => { + it("classifies backend exits before connection succeeds", () => { + const issue = classifyConnectionIssue([], { + running: false, + lastExitCode: 1, + }); + expect(issue.reason).toBe("backend-exited"); + expect(issue.headline).toContain("stopped"); + }); + + it("classifies address-unreachable network errors as loopback blocks", () => { + const issue = classifyConnectionIssue([{ kind: "address-unreachable" }, { kind: "network-error" }]); + expect(issue.reason).toBe("loopback-blocked"); + expect(issue.detail).toContain("firewall"); + }); + + it("classifies HTTP 5xx failures as backend-side startup errors", () => { + const issue = classifyConnectionIssue([{ kind: "http-error", status: 503 }]); + expect(issue.reason).toBe("backend-http-error"); + expect(issue.headline).toContain("internal error"); + }); + + it("delays generic network-blocked warnings while backend may still be starting", () => { + const issue = classifyConnectionIssue([{ kind: "network-error" }], null, { + attemptCount: 10, + networkWarnAfterAttempts: 24, + }); + expect(issue.reason).toBe("starting"); + }); + + it("shows generic network-blocked warning after startup grace period", () => { + const issue = classifyConnectionIssue([{ kind: "network-error" }], null, { + attemptCount: 30, + networkWarnAfterAttempts: 24, + }); + expect(issue.reason).toBe("network-blocked"); + expect(issue.detail).toContain("firewall"); + }); + + it("classifies invalid payload responses as backend response issues", () => { + const issue = classifyConnectionIssue([{ kind: "invalid-payload" }]); + expect(issue.reason).toBe("backend-invalid-response"); + }); + + it("parses ERR_ADDRESS_UNREACHABLE fetch errors", () => { + const kind = classifyFetchError(new TypeError("net::ERR_ADDRESS_UNREACHABLE")); + expect(kind).toBe("address-unreachable"); + }); + + it("treats other fetch failures as generic network errors", () => { + const kind = classifyFetchError(new TypeError("Failed to fetch")); + expect(kind).toBe("network-error"); + }); +});