mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat(websocket): implement authentication for WebSocket mutators and update async utility functions
This commit is contained in:
parent
eca1486f3c
commit
c8ea41c1e9
13 changed files with 414 additions and 27 deletions
|
|
@ -165,7 +165,10 @@ from meshchatx.src.backend.reticulum_config_guard import (
|
|||
repair_unparseable_reticulum_config,
|
||||
reticulum_config_has_required_sections,
|
||||
)
|
||||
from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
|
||||
from meshchatx.src.backend.websocket_config_guard import (
|
||||
sanitize_websocket_config_update,
|
||||
websocket_type_requires_auth,
|
||||
)
|
||||
from meshchatx.src.backend.landlock_sandbox import (
|
||||
apply_landlock_sandbox,
|
||||
landlock_auto_enabled,
|
||||
|
|
@ -445,6 +448,7 @@ class ReticulumMeshChat:
|
|||
self.current_context: IdentityContext | None = None
|
||||
self._propagation_sync_metrics: dict[str, dict] = {}
|
||||
|
||||
AsyncUtils.ensure_background_loop()
|
||||
self.setup_identity(identity)
|
||||
self.web_audio_bridge = WebAudioBridge(None, None)
|
||||
|
||||
|
|
@ -15616,6 +15620,23 @@ class ReticulumMeshChat:
|
|||
def flush_all_archived_pages(self):
|
||||
return self.nomadnet_manager.flush_all_archived_pages()
|
||||
|
||||
async def _websocket_session_authorized(self, client) -> bool:
|
||||
if not self.auth_enabled:
|
||||
return True
|
||||
request = getattr(client, "request", None)
|
||||
if request is None:
|
||||
return False
|
||||
try:
|
||||
session = await get_session(request)
|
||||
except Exception:
|
||||
return False
|
||||
identity_hash = self.identity.hash.hex() if self.identity else None
|
||||
return bool(
|
||||
session.get("authenticated", False)
|
||||
and identity_hash
|
||||
and session.get("identity_hash") == identity_hash
|
||||
)
|
||||
|
||||
# handle data received from websocket client
|
||||
async def on_websocket_data_received(self, client, data):
|
||||
# get type from client data
|
||||
|
|
@ -15626,6 +15647,21 @@ class ReticulumMeshChat:
|
|||
if not _type:
|
||||
return
|
||||
|
||||
if websocket_type_requires_auth(_type):
|
||||
if not await self._websocket_session_authorized(client):
|
||||
logger.warning("Rejected unauthorized WebSocket mutator: %s", _type)
|
||||
AsyncUtils.run_async(
|
||||
client.send_str(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Authentication required",
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# handle ping
|
||||
if _type == "ping":
|
||||
AsyncUtils.run_async(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
# SPDX-License-Identifier: 0BSD AND MIT
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any, ClassVar
|
||||
|
||||
_logger = logging.getLogger("meshchatx.async")
|
||||
|
||||
|
||||
class AsyncUtils:
|
||||
main_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
|
@ -13,6 +16,35 @@ class AsyncUtils:
|
|||
_futures_lock = threading.Lock()
|
||||
_FUTURES_SWEEP_THRESHOLD = 32
|
||||
_COROUTINES_MAX = 256
|
||||
_background_loop: asyncio.AbstractEventLoop | None = None
|
||||
_background_thread: threading.Thread | None = None
|
||||
_background_ready = threading.Event()
|
||||
|
||||
@staticmethod
|
||||
def ensure_background_loop() -> None:
|
||||
"""Start a daemon event loop for pre-web-server async work."""
|
||||
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
|
||||
return
|
||||
if AsyncUtils._background_thread and AsyncUtils._background_thread.is_alive():
|
||||
return
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
AsyncUtils._background_ready.clear()
|
||||
|
||||
def runner() -> None:
|
||||
AsyncUtils.set_main_loop(loop)
|
||||
AsyncUtils._background_ready.set()
|
||||
loop.run_forever()
|
||||
|
||||
AsyncUtils._background_loop = loop
|
||||
AsyncUtils._background_thread = threading.Thread(
|
||||
target=runner,
|
||||
name="meshchatx-async",
|
||||
daemon=True,
|
||||
)
|
||||
AsyncUtils._background_thread.start()
|
||||
if not AsyncUtils._background_ready.wait(timeout=5):
|
||||
_logger.warning("Background asyncio loop did not become ready within 5s")
|
||||
|
||||
@staticmethod
|
||||
def set_main_loop(loop: asyncio.AbstractEventLoop):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ from meshchatx.src.backend.database import Database
|
|||
from meshchatx.src.backend.docs_manager import DocsManager
|
||||
from meshchatx.src.backend.repository_server_manager import RepositoryServerManager
|
||||
from meshchatx.src.backend.forwarding_manager import ForwardingManager
|
||||
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
||||
from meshchatx.src.backend.async_utils import AsyncUtils
|
||||
from meshchatx.src.backend.integrity_manager import (
|
||||
CriticalIntegrityError,
|
||||
IntegrityManager,
|
||||
select_critical_integrity_issues,
|
||||
)
|
||||
from meshchatx.src.backend.map_manager import MapManager
|
||||
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
|
||||
from meshchatx.src.backend.message_handler import MessageHandler
|
||||
|
|
@ -153,6 +158,11 @@ class IdentityContext:
|
|||
if not hasattr(self.app, "integrity_issues"):
|
||||
self.app.integrity_issues = []
|
||||
self.app.integrity_issues.extend(issues)
|
||||
critical = select_critical_integrity_issues(issues)
|
||||
if critical:
|
||||
raise CriticalIntegrityError(
|
||||
"Critical integrity failure: " + "; ".join(critical),
|
||||
)
|
||||
|
||||
try:
|
||||
self.database.initialize()
|
||||
|
|
|
|||
|
|
@ -11,9 +11,27 @@ from pathlib import Path
|
|||
from typing import ClassVar
|
||||
|
||||
|
||||
class CriticalIntegrityError(RuntimeError):
|
||||
"""Raised when tampering is detected in identity or database files at startup."""
|
||||
|
||||
|
||||
def select_critical_integrity_issues(issues: list[str]) -> list[str]:
|
||||
return [
|
||||
issue
|
||||
for issue in issues
|
||||
if any(marker in issue for marker in IntegrityManager.CRITICAL_ISSUE_MARKERS)
|
||||
]
|
||||
|
||||
|
||||
class IntegrityManager:
|
||||
"""Manages the integrity of the database and identity files at rest."""
|
||||
|
||||
CRITICAL_ISSUE_MARKERS: ClassVar[tuple[str, ...]] = (
|
||||
"Critical security component integrity compromised",
|
||||
"Database structural issue",
|
||||
"Identity mismatch",
|
||||
)
|
||||
|
||||
# Filename globs frequently rewritten by RNS/LXMF or SQLite that should be
|
||||
# ignored during integrity checks.
|
||||
IGNORED_PATTERNS: ClassVar[list[str]] = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""WebSocket config update guards.
|
||||
"""WebSocket guards for config updates and authenticated mutators.
|
||||
|
||||
Settings that change the HTTP security boundary must go through CSRF-protected
|
||||
HTTP endpoints, not the unauthenticated ``config.set`` WebSocket message.
|
||||
|
|
@ -19,6 +19,47 @@ WEBSOCKET_CONFIG_DENYLIST = frozenset(
|
|||
},
|
||||
)
|
||||
|
||||
WEBSOCKET_PUBLIC_TYPES = frozenset(
|
||||
{
|
||||
"ping",
|
||||
},
|
||||
)
|
||||
|
||||
WEBSOCKET_READ_TYPES = frozenset(
|
||||
{
|
||||
"nomadnet.page.archives.get",
|
||||
"nomadnet.page.archive.load",
|
||||
"lxmf.forwarding.rules.get",
|
||||
"keyboard_shortcuts.get",
|
||||
},
|
||||
)
|
||||
|
||||
WEBSOCKET_MUTATOR_TYPES = frozenset(
|
||||
{
|
||||
"config.set",
|
||||
"keyboard_shortcuts.delete",
|
||||
"keyboard_shortcuts.set",
|
||||
"lxm.generate_paper_uri",
|
||||
"lxm.ingest_uri",
|
||||
"lxmf.forwarding.rule.add",
|
||||
"lxmf.forwarding.rule.delete",
|
||||
"lxmf.forwarding.rule.toggle",
|
||||
"nomadnet.download.cancel",
|
||||
"nomadnet.file.download",
|
||||
"nomadnet.page.archive.add",
|
||||
"nomadnet.page.archive.flush",
|
||||
"nomadnet.page.download",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def websocket_type_requires_auth(msg_type: str) -> bool:
|
||||
if msg_type in WEBSOCKET_PUBLIC_TYPES or msg_type in WEBSOCKET_READ_TYPES:
|
||||
return False
|
||||
if msg_type in WEBSOCKET_MUTATOR_TYPES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_websocket_config_update(config: object) -> dict:
|
||||
"""Return a copy of *config* with security-sensitive keys removed."""
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@
|
|||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
<li v-for="item in visibleNavItems" :key="item.id" v-if="isNavItemVisible(item)">
|
||||
<li v-for="item in visibleNavItems" :key="item.id">
|
||||
<SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
|
||||
<template #icon>
|
||||
<MaterialDesignIcon
|
||||
|
|
@ -700,7 +700,7 @@ export default {
|
|||
return GlobalState.config?.rrc_enabled !== false;
|
||||
},
|
||||
visibleNavItems() {
|
||||
return listNavItems();
|
||||
return listNavItems().filter((item) => this.isNavItemVisible(item));
|
||||
},
|
||||
isSyncingPropagationNode() {
|
||||
return [
|
||||
|
|
@ -839,6 +839,9 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
isNavItemVisible(item) {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
if (item.visibleWhen === "rrcEnabled") {
|
||||
return this.rrcEnabled;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="copyToast"
|
||||
class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2"
|
||||
>
|
||||
{{ copyToast }}
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
|
||||
</div>
|
||||
</transition>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3 mt-4 text-sm text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -150,11 +150,19 @@ Authoring rules, security constraints for HTML/CSS, and API behaviour are docume
|
|||
|
||||
## Extensibility Points
|
||||
|
||||
MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
|
||||
|
||||
- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
|
||||
- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
|
||||
- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
|
||||
- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
|
||||
|
||||
The most practical extension points today are:
|
||||
|
||||
- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
|
||||
- new API routes in backend routing sections,
|
||||
- new manager modules under `meshchatx/src/backend`,
|
||||
- frontend page/component additions wired through existing router/state patterns,
|
||||
- frontend page/component additions wired through contribution registries,
|
||||
- new config surface through CLI flags + environment variables,
|
||||
- schema extension through the existing migration/versioning approach.
|
||||
|
||||
|
|
@ -162,4 +170,5 @@ When adding features, prefer:
|
|||
|
||||
- identity-scoped state over global mutable state,
|
||||
- explicit migration/version changes for DB schema updates,
|
||||
- endpoint-level tests plus focused manager unit tests.
|
||||
- endpoint-level tests plus focused manager unit tests,
|
||||
- plugin permissions that are declared in manifests and enforced by the host.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
|
|
@ -18,12 +19,36 @@ def _reset_async_utils():
|
|||
AsyncUtils.main_loop = None
|
||||
AsyncUtils._pending_futures.clear()
|
||||
AsyncUtils._pending_coroutines.clear()
|
||||
AsyncUtils._background_loop = None
|
||||
AsyncUtils._background_thread = None
|
||||
AsyncUtils._background_ready.clear()
|
||||
yield
|
||||
AsyncUtils.main_loop = None
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
AsyncUtils._pending_futures.clear()
|
||||
AsyncUtils._pending_coroutines.clear()
|
||||
if AsyncUtils._background_loop and AsyncUtils._background_loop.is_running():
|
||||
AsyncUtils._background_loop.call_soon_threadsafe(AsyncUtils._background_loop.stop)
|
||||
AsyncUtils._background_loop = None
|
||||
AsyncUtils._background_thread = None
|
||||
AsyncUtils._background_ready.clear()
|
||||
|
||||
|
||||
def test_ensure_background_loop_schedules_coroutines_before_web_startup():
|
||||
seen: list[bool] = []
|
||||
|
||||
async def record():
|
||||
seen.append(True)
|
||||
|
||||
AsyncUtils.ensure_background_loop()
|
||||
AsyncUtils.run_async(record())
|
||||
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline and not seen:
|
||||
time.sleep(0.05)
|
||||
|
||||
assert seen == [True]
|
||||
|
||||
|
||||
async def _noop():
|
||||
|
|
|
|||
55
tests/backend/test_integrity_startup_block.py
Normal file
55
tests/backend/test_integrity_startup_block.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend.integrity_manager import (
|
||||
CriticalIntegrityError,
|
||||
select_critical_integrity_issues,
|
||||
)
|
||||
|
||||
|
||||
def test_select_critical_integrity_issues_filters_security_markers():
|
||||
issues = [
|
||||
"Last integrity snapshot: 2026-01-01",
|
||||
"Critical security component integrity compromised: identity",
|
||||
"File signature mismatch: stickers/foo.json",
|
||||
]
|
||||
critical = select_critical_integrity_issues(issues)
|
||||
assert critical == ["Critical security component integrity compromised: identity"]
|
||||
|
||||
|
||||
def test_identity_context_blocks_startup_on_critical_integrity_failure():
|
||||
from meshchatx.src.backend.identity_context import IdentityContext
|
||||
|
||||
app = MagicMock()
|
||||
app.emergency = False
|
||||
app.auto_recover = False
|
||||
app.integrity_issues = []
|
||||
app.cleanup_rns_state_for_identity = MagicMock()
|
||||
|
||||
identity = MagicMock()
|
||||
identity.hash = b"a" * 16
|
||||
identity.get_private_key.return_value = b"private-key"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app.storage_dir = tmp
|
||||
ctx = IdentityContext(identity, app)
|
||||
ctx.integrity_manager.check_integrity = MagicMock(
|
||||
return_value=(
|
||||
False,
|
||||
[
|
||||
"Critical security component integrity compromised: identity",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(CriticalIntegrityError):
|
||||
ctx.setup()
|
||||
|
||||
|
||||
def test_non_critical_integrity_issues_do_not_block_selector():
|
||||
issues = ["File signature mismatch: stickers/foo.json"]
|
||||
assert select_critical_integrity_issues(issues) == []
|
||||
69
tests/backend/test_lxmf_flood_protection.py
Normal file
69
tests/backend/test_lxmf_flood_protection.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.meshchat import ReticulumMeshChat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flood_app(mock_app):
|
||||
mock_app._lxmf_incoming_timestamps = []
|
||||
mock_app._flood_protection_current_cost = None
|
||||
mock_app._flood_protection_last_bump_time = 0
|
||||
mock_app.current_context.config.lxmf_flood_protection_enabled.set(True)
|
||||
mock_app.current_context.config.lxmf_flood_threshold_per_minute.set(5)
|
||||
mock_app.current_context.config.lxmf_flood_max_stamp_cost.set(12)
|
||||
mock_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
|
||||
mock_app.current_context.config.block_all_from_strangers.set(False)
|
||||
mock_app.current_context.local_lxmf_destination = MagicMock()
|
||||
mock_app.current_context.local_lxmf_destination.hash = b"\x01" * 16
|
||||
mock_app.current_context.message_router = MagicMock()
|
||||
mock_app.current_context.config.display_name.set("Peer")
|
||||
return mock_app
|
||||
|
||||
|
||||
def test_lxmf_flood_protection_raises_stamp_cost_when_threshold_exceeded(flood_app):
|
||||
now = time.time()
|
||||
flood_app._lxmf_incoming_timestamps = [now - index for index in range(6)]
|
||||
|
||||
flood_app._check_lxmf_flood_protection()
|
||||
|
||||
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 4
|
||||
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_called_once()
|
||||
|
||||
|
||||
def test_lxmf_flood_protection_steps_down_after_cooldown(flood_app):
|
||||
now = time.time()
|
||||
flood_app._lxmf_incoming_timestamps = [now - 120]
|
||||
flood_app._flood_protection_current_cost = 2
|
||||
flood_app._flood_protection_last_bump_time = now - 120
|
||||
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(6)
|
||||
flood_app.current_context.config.lxmf_flood_cooldown_seconds.set(30)
|
||||
|
||||
flood_app._check_lxmf_flood_protection()
|
||||
|
||||
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 5
|
||||
|
||||
|
||||
def test_lxmf_flood_protection_disabled_is_noop(flood_app):
|
||||
flood_app.current_context.config.lxmf_flood_protection_enabled.set(False)
|
||||
flood_app._lxmf_incoming_timestamps = [time.time()] * 20
|
||||
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
|
||||
|
||||
flood_app._check_lxmf_flood_protection()
|
||||
|
||||
assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 2
|
||||
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()
|
||||
|
||||
|
||||
def test_lxmf_flood_protection_skips_when_block_strangers_enabled(flood_app):
|
||||
flood_app.current_context.config.block_all_from_strangers.set(True)
|
||||
flood_app._lxmf_incoming_timestamps = [time.time()] * 20
|
||||
flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
|
||||
|
||||
flood_app._check_lxmf_flood_protection()
|
||||
|
||||
flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()
|
||||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
|
||||
from meshchatx.src.backend.websocket_config_guard import (
|
||||
WEBSOCKET_MUTATOR_TYPES,
|
||||
WEBSOCKET_PUBLIC_TYPES,
|
||||
WEBSOCKET_READ_TYPES,
|
||||
sanitize_websocket_config_update,
|
||||
websocket_type_requires_auth,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_websocket_config_update_strips_auth_keys():
|
||||
|
|
@ -20,3 +26,12 @@ def test_sanitize_websocket_config_update_strips_auth_keys():
|
|||
@pytest.mark.parametrize("payload", [None, [], "bad", 42])
|
||||
def test_sanitize_websocket_config_update_rejects_non_dict(payload):
|
||||
assert sanitize_websocket_config_update(payload) == {}
|
||||
|
||||
|
||||
def test_websocket_mutator_manifest_is_disjoint_from_public_and_read_types():
|
||||
assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_PUBLIC_TYPES)
|
||||
assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_READ_TYPES)
|
||||
|
||||
|
||||
def test_websocket_type_requires_auth_unknown_type_is_not_mutator():
|
||||
assert websocket_type_requires_auth("not.a.real.type") is False
|
||||
|
|
|
|||
|
|
@ -1,7 +1,89 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend.websocket_config_guard import (
|
||||
WEBSOCKET_MUTATOR_TYPES,
|
||||
WEBSOCKET_PUBLIC_TYPES,
|
||||
WEBSOCKET_READ_TYPES,
|
||||
websocket_type_requires_auth,
|
||||
)
|
||||
|
||||
|
||||
def _run_async_immediate(coro):
|
||||
return asyncio.create_task(coro)
|
||||
|
||||
|
||||
def test_websocket_type_requires_auth_classifies_mutators():
|
||||
for msg_type in WEBSOCKET_MUTATOR_TYPES:
|
||||
assert websocket_type_requires_auth(msg_type) is True
|
||||
|
||||
|
||||
def test_websocket_type_requires_auth_allows_ping_and_reads():
|
||||
for msg_type in WEBSOCKET_PUBLIC_TYPES | WEBSOCKET_READ_TYPES:
|
||||
assert websocket_type_requires_auth(msg_type) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_mutator_rejected_without_session_when_auth_enabled(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
mock_app.config.auth_password_hash.set("hash")
|
||||
client = MagicMock()
|
||||
client.request = MagicMock()
|
||||
client.send_str = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(mock_app, "_websocket_session_authorized", return_value=False),
|
||||
patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=_run_async_immediate,
|
||||
),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
client,
|
||||
{"type": "keyboard_shortcuts.set", "shortcuts": []},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client.send_str.assert_awaited_once()
|
||||
payload = client.send_str.await_args.args[0]
|
||||
assert '"Authentication required"' in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_read_allowed_without_session_when_auth_enabled(mock_app):
|
||||
mock_app.config.auth_enabled.set(True)
|
||||
client = MagicMock()
|
||||
client.request = MagicMock()
|
||||
client.send_str = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(mock_app, "_websocket_session_authorized", return_value=False),
|
||||
patch.object(
|
||||
mock_app,
|
||||
"get_archived_page_versions",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=_run_async_immediate,
|
||||
),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
client,
|
||||
{
|
||||
"type": "nomadnet.page.archives.get",
|
||||
"destination_hash": "aa" * 16,
|
||||
"page_path": "index.mu",
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
client.send_str.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_config_set_ignores_auth_enabled(mock_app):
|
||||
|
|
@ -9,17 +91,18 @@ async def test_websocket_config_set_ignores_auth_enabled(mock_app):
|
|||
mock_app.config.auth_password_hash.set("existing-hash")
|
||||
|
||||
client = object()
|
||||
await mock_app.on_websocket_data_received(
|
||||
client,
|
||||
{
|
||||
"type": "config.set",
|
||||
"config": {
|
||||
"display_name": "Updated Peer",
|
||||
"auth_enabled": False,
|
||||
"auth_password_hash": None,
|
||||
with patch.object(mock_app, "_websocket_session_authorized", return_value=True):
|
||||
await mock_app.on_websocket_data_received(
|
||||
client,
|
||||
{
|
||||
"type": "config.set",
|
||||
"config": {
|
||||
"display_name": "Updated Peer",
|
||||
"auth_enabled": False,
|
||||
"auth_password_hash": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert mock_app.config.auth_enabled.get() is True
|
||||
assert mock_app.config.auth_password_hash.get() == "existing-hash"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue