feat(tests): add loopback TCP fixture for integration tests and apply it to relevant test cases

This commit is contained in:
Ivan committed 2026-04-16 23:32:13 -05:00
1 parent 6843d03749
commit 0f5034642f
15 files changed
+244 -15

No files matched your search

+30
View File
@@ -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 (
@@ -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)
+9 -3
View File
@@ -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)
+5
View File
@@ -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(
@@ -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(
@@ -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
+1
View File
@@ -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,
):
+1 -1
View File
@@ -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");
+80
View File
@@ -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" });
+7
View File
@@ -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("<a ");
});
it("stops URL at space so no script in same line", () => {
const text = "https://example.com javascript:alert(1)";
const result = LinkUtils.renderStandardLinks(text);
+2 -4
View File
@@ -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);
});
+10
View File
@@ -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 = [
{
@@ -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 () => {
@@ -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,
@@ -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");
});
});