Gate scoping-safety on firmware ref, and and make per-channel override tristate.

This commit is contained in:
Jack Kingsman
2026-07-08 15:45:58 -07:00
parent 30e9a77cdc
commit 6edd2927b1
18 changed files with 313 additions and 49 deletions
+6 -1
View File
@@ -343,7 +343,12 @@ class Channel(BaseModel):
on_radio: bool = False
flood_scope_override: str | None = Field(
default=None,
description="Per-channel outbound flood scope override (null = use global app setting)",
description=(
"Per-channel outbound flood scope override, tri-state: null = inherit the "
"global app setting; '*' (UNSCOPED_OVERRIDE_MARKER) = force unscoped/plain "
"flood even over a scoped global; a region name (e.g. '#Esperance') = scope "
"this channel."
),
)
path_hash_mode_override: int | None = Field(
default=None,
+4
View File
@@ -171,6 +171,9 @@ class RadioManager:
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
# Companion protocol version (FIRMWARE_VER_CODE). Gates version-dependent
# host commands such as the mode-1 unscoped flood-scope frame (ver 12+).
self.firmware_ver_code: int | None = None
self.max_channels: int = 40
self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False
@@ -217,6 +220,7 @@ class RadioManager:
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.firmware_ver_code = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
+17 -1
View File
@@ -1,6 +1,22 @@
"""Helpers for normalizing MeshCore flood-scope / region names."""
_UNSCOPED_SENTINELS = {"", "0", "*"}
# Canonical persisted marker for "force unscoped/plain flood". Stored verbatim in
# the per-channel ``flood_scope_override`` column to mean "this channel is unscoped
# even if a global region is set" — distinct from NULL, which means "inherit global".
UNSCOPED_OVERRIDE_MARKER = "*"
# All values that denote explicit unscoped/plain flood, matching firmware parity.
_UNSCOPED_SENTINELS = {"", "0", UNSCOPED_OVERRIDE_MARKER}
def is_unscoped(scope: str | None) -> bool:
"""True if ``scope`` denotes an explicit unscoped/plain-flood request.
Note: an empty string counts as unscoped here. Callers that need to treat
blank as "no opinion / inherit" (e.g. the channel-override API) must check for
blank *before* calling this.
"""
return (scope or "").strip() in _UNSCOPED_SENTINELS
def normalize_region_scope(scope: str | None) -> str:
+21 -3
View File
@@ -14,7 +14,7 @@ from app.channel_constants import (
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.region_scope import UNSCOPED_OVERRIDE_MARKER, is_unscoped, normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
@@ -56,7 +56,13 @@ class BulkCreateHashtagChannelsResponse(BaseModel):
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
description=(
"Tri-state channel override. Blank clears the override (inherit the global "
"scope); '*' forces unscoped/plain flood even when a global region is set; "
"any other value scopes the channel to that region. Note the deliberate "
"asymmetry vs. the send layer: here blank means 'inherit', so an explicit "
"unscoped request must use '*'."
)
)
@@ -345,7 +351,19 @@ async def set_channel_flood_scope_override(
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
override = normalize_region_scope(request.flood_scope_override) or None
# Tri-state persisted override:
# blank -> None: clear the override, inherit the global scope
# "*" / "0" -> canonical unscoped marker: force unscoped even over a global
# region name -> "#Region": scope this channel
# NOTE: at this (channel-override) layer blank means "clear/inherit", so we must
# check for blank *before* is_unscoped() (which also treats "" as unscoped).
raw_override = (request.flood_scope_override or "").strip()
if raw_override == "":
override: str | None = None
elif is_unscoped(raw_override):
override = UNSCOPED_OVERRIDE_MARKER
else:
override = normalize_region_scope(raw_override)
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
+3 -1
View File
@@ -289,7 +289,9 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
try:
scope = result.flood_scope
async with radio_manager.radio_operation("set_flood_scope") as mc:
await set_radio_flood_scope(mc, scope)
await set_radio_flood_scope(
mc, scope, fw_ver=radio_manager.firmware_ver_code
)
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
except Exception as e:
logger.warning("Failed to apply flood_scope to radio: %s", e)
+35 -7
View File
@@ -1,5 +1,6 @@
"""Firmware-compatible flood-scope command helpers."""
import logging
from typing import Any
from meshcore import EventType
@@ -7,23 +8,50 @@ from meshcore.packets import CommandType
from app.region_scope import normalize_region_scope
logger = logging.getLogger(__name__)
SET_FLOOD_SCOPE_MODE_UNSCOPED = 1
FORCE_UNSCOPED_FRAME = bytes([CommandType.SET_FLOOD_SCOPE.value, SET_FLOOD_SCOPE_MODE_UNSCOPED])
# CMD_SET_FLOOD_SCOPE_KEY mode 1 (the firmware ``send_unscoped`` flag) is companion
# firmware ver 12+. On older firmware the mode-1 frame is rejected, so we fall back
# to resetting the scope override (mode 0, zero key), which makes the radio use its
# configured default scope. True "unscoped while a default scope is set" is not
# achievable pre-v12.
FIRMWARE_VER_UNSCOPED_MODE = 12
async def set_radio_flood_scope(mc, scope: str | None) -> Any:
def firmware_supports_unscoped_mode(fw_ver: int | None) -> bool:
"""Whether the radio's protocol version supports the mode-1 unscoped command."""
return fw_ver is not None and fw_ver >= FIRMWARE_VER_UNSCOPED_MODE
async def set_radio_flood_scope(mc, scope: str | None, *, fw_ver: int | None = None) -> Any:
"""Apply the standing radio flood-scope state.
A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``.
An empty scope means explicit unscoped/plain flood and must use firmware's
mode-1 command. Sending a mode-0 all-zero key would fall back to the radio's
configured default scope on firmware-compatible companions.
"""
A non-empty scope is delegated to meshcore_py's mode-0 ``set_flood_scope``. An
empty scope means explicit unscoped/plain flood:
- firmware >= 12: use the dedicated mode-1 command (``force_radio_unscoped``).
- older / unknown firmware: no dedicated unscoped command exists, so reset the
scope override (mode 0, zero key); the radio falls back to its configured
default scope. ``fw_ver=None`` (version unknown) is treated conservatively as
unsupported so we never emit a frame the radio might reject.
"""
normalized_scope = normalize_region_scope(scope)
if normalized_scope:
return await mc.commands.set_flood_scope(normalized_scope)
return await force_radio_unscoped(mc)
if firmware_supports_unscoped_mode(fw_ver):
return await force_radio_unscoped(mc)
logger.debug(
"Radio fw_ver=%s < %d: no dedicated unscoped command; resetting scope override "
"(radio falls back to its configured default scope)",
fw_ver,
FIRMWARE_VER_UNSCOPED_MODE,
)
return await mc.commands.set_flood_scope("")
async def force_radio_unscoped(mc) -> Any:
+21 -5
View File
@@ -11,7 +11,7 @@ from meshcore import EventType
from app.models import ResendChannelMessageResponse
from app.radio import RadioOperationBusyError
from app.region_scope import normalize_region_scope
from app.region_scope import is_unscoped, normalize_region_scope
from app.repository import (
AppSettingsRepository,
ChannelRepository,
@@ -161,8 +161,20 @@ async def send_channel_message_with_effective_scope(
back to the channel's persisted override.
"""
if isinstance(flood_scope_override, _ScopeUnset):
desired_scope = normalize_region_scope(channel.flood_scope_override)
scope_explicit = False
# Fall back to the channel's persisted override, which is tri-state:
# None -> inherit the global scope (leave radio untouched)
# unscoped marker ("*") -> force unscoped even over a scoped global
# region name -> scope this channel
channel_override = channel.flood_scope_override
if channel_override is None:
desired_scope = ""
scope_explicit = False
elif is_unscoped(channel_override):
desired_scope = ""
scope_explicit = True
else:
desired_scope = normalize_region_scope(channel_override)
scope_explicit = True
else:
desired_scope = normalize_region_scope(flood_scope_override)
scope_explicit = True
@@ -185,7 +197,9 @@ async def send_channel_message_with_effective_scope(
desired_scope or "(unscoped)",
channel.name,
)
override_result = await set_radio_flood_scope(mc, desired_scope)
override_result = await set_radio_flood_scope(
mc, desired_scope, fw_ver=radio_manager.firmware_ver_code
)
if override_result is not None and override_result.type == EventType.ERROR:
logger.warning(
"Failed to apply flood_scope %r for %s: %s",
@@ -314,7 +328,9 @@ async def send_channel_message_with_effective_scope(
restored = False
for attempt in range(3):
try:
restore_result = await set_radio_flood_scope(mc, baseline_scope)
restore_result = await set_radio_flood_scope(
mc, baseline_scope, fw_ver=radio_manager.firmware_ver_code
)
if restore_result is not None and restore_result.type == EventType.ERROR:
logger.warning(
"Attempt %d/3: failed to restore flood_scope after sending to %s: %s",
+19 -14
View File
@@ -67,20 +67,6 @@ async def run_post_connect_setup(radio_manager) -> None:
# Sync radio clock with system time
await sync_radio_time(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support the relevant flood-scope command)
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
from app.services.flood_scope import set_radio_flood_scope
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await set_radio_flood_scope(mc, scope)
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning("Failed to apply configured flood scope to radio: %s", exc)
# Query path hash mode support (best-effort; older firmware won't report it).
# If the library's parsed payload is missing path_hash_mode (e.g. stale
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
@@ -101,6 +87,7 @@ async def run_post_connect_setup(radio_manager) -> None:
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.firmware_ver_code = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
@@ -128,6 +115,7 @@ async def run_post_connect_setup(radio_manager) -> None:
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
radio_manager.firmware_ver_code = fw_ver
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
@@ -141,6 +129,7 @@ async def run_post_connect_setup(radio_manager) -> None:
fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3:
radio_manager.device_info_loaded = True
radio_manager.firmware_ver_code = fw_ver
if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
@@ -206,6 +195,22 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
# Apply flood scope from settings (best-effort; older firmware may
# not support the mode-1 unscoped command). Done after the device
# query so radio_manager.firmware_ver_code is known and the unscoped
# path can pick the correct firmware command.
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository
from app.services.flood_scope import set_radio_flood_scope
app_settings = await AppSettingsRepository.get()
scope = normalize_region_scope(app_settings.flood_scope)
try:
await set_radio_flood_scope(mc, scope, fw_ver=radio_manager.firmware_ver_code)
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning("Failed to apply configured flood scope to radio: %s", exc)
from app.config import settings as app_settings_config
if app_settings_config.skip_post_connect_sync:
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react';
import { stripRegionScopePrefix } from '../utils/regionScope';
import {
UNSCOPED_OVERRIDE_MARKER,
isUnscopedMarker,
stripRegionScopePrefix,
} from '../utils/regionScope';
import { Button } from './ui/button';
import {
Dialog,
@@ -34,11 +38,18 @@ export function ChannelFloodScopeOverrideModal({
if (!open) {
return;
}
setRegion(stripRegionScopePrefix(currentOverride));
// The unscoped marker isn't a region name, so start the input blank for it.
setRegion(isUnscopedMarker(currentOverride) ? '' : stripRegionScopePrefix(currentOverride));
}, [currentOverride, open]);
const trimmedRegion = region.trim();
const currentOverrideLabel = isUnscopedMarker(currentOverride)
? 'unscoped (plain flood)'
: currentOverride
? stripRegionScopePrefix(currentOverride)
: 'inherit global setting';
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
@@ -46,7 +57,9 @@ export function ChannelFloodScopeOverrideModal({
<DialogTitle>Regional Override</DialogTitle>
<DialogDescription>
Channel-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow channel sends.
restores it after. This can noticeably slow channel sends. Choose one of three modes
below: scope to a region, force unscoped (plain flood, ignoring your global region), or
inherit the global setting.
</DialogDescription>
</DialogHeader>
@@ -54,8 +67,7 @@ export function ChannelFloodScopeOverrideModal({
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{roomName}</div>
<div className="mt-1 text-muted-foreground">
Current regional override:{' '}
{currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'}
Current setting: {currentOverrideLabel}
</div>
</div>
@@ -83,8 +95,19 @@ export function ChannelFloodScopeOverrideModal({
}}
>
{trimmedRegion.length > 0
? `Use ${trimmedRegion} region for ${roomName}`
: `Use region for ${roomName}`}
? `Scope ${roomName} to ${trimmedRegion}`
: `Scope ${roomName} to a region`}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
onSetOverride(UNSCOPED_OVERRIDE_MARKER);
onClose();
}}
>
Always send {roomName} unscoped (ignore global region)
</Button>
<Button
type="button"
@@ -95,7 +118,7 @@ export function ChannelFloodScopeOverrideModal({
onClose();
}}
>
Do not use region routing for {roomName}
Use global region setting for {roomName}
</Button>
</div>
</DialogFooter>
@@ -1163,7 +1163,9 @@ export function SettingsRadioSection({
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing messages with a region name (e.g. MyRegion). Repeaters configured for that
region can forward the traffic, while repeaters configured to deny other regions may drop
it. Leave empty to disable.
it. Leave empty to send unscoped (plain flood). Forcing unscoped when a default scope is
configured on the radio requires firmware v12 or newer. Individual channels can override
this with their own region or an "always unscoped" setting.
</p>
</div>
@@ -360,8 +360,34 @@ describe('ChatHeader key visibility', () => {
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'Esperance' } });
fireEvent.click(screen.getByRole('button', { name: 'Use Esperance region for #flightless' }));
fireEvent.click(screen.getByRole('button', { name: 'Scope #flightless to Esperance' }));
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
});
it('forces the channel unscoped via the modal (issue #303)', async () => {
const key = 'CD'.repeat(16);
const channel = makeChannel(key, '#flightless', true);
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
const onSetChannelFloodScopeOverride = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[channel]}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
/>
);
fireEvent.click(screen.getByTitle('Set regional override'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', {
name: 'Always send #flightless unscoped (ignore global region)',
})
);
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '*');
});
});
+9
View File
@@ -1,3 +1,12 @@
// Canonical persisted marker meaning "force this channel unscoped / plain flood"
// (mirrors the backend UNSCOPED_OVERRIDE_MARKER). Distinct from null, which means
// "inherit the global scope".
export const UNSCOPED_OVERRIDE_MARKER = '*';
export function isUnscopedMarker(scope: string | null | undefined): boolean {
return scope === UNSCOPED_OVERRIDE_MARKER;
}
export function stripRegionScopePrefix(scope: string | null | undefined): string {
if (!scope) return '';
return scope.startsWith('#') ? scope.slice(1) : scope;
+20
View File
@@ -32,6 +32,26 @@ class TestChannelFloodScopeOverride:
mock_broadcast.assert_called_once()
assert mock_broadcast.call_args.args[0] == "channel"
@pytest.mark.asyncio
async def test_unscoped_marker_forces_channel_unscoped(self, test_db, client):
"""'*' persists as the canonical unscoped marker (issue #303), distinct
from blank (which clears the override / inherits the global scope)."""
key = "DD" * 16
await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True)
with patch("app.routers.channels.broadcast_event"):
response = await client.post(
f"/api/channels/{key}/flood-scope-override",
json={"flood_scope_override": "*"},
)
assert response.status_code == 200
assert response.json()["flood_scope_override"] == "*"
channel = await ChannelRepository.get_by_key(key)
assert channel is not None
assert channel.flood_scope_override == "*"
class TestCreateChannel:
@pytest.mark.asyncio
+39 -6
View File
@@ -3,7 +3,17 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from meshcore import EventType
from app.services.flood_scope import FORCE_UNSCOPED_FRAME, set_radio_flood_scope
from app.services.flood_scope import (
FIRMWARE_VER_UNSCOPED_MODE,
FORCE_UNSCOPED_FRAME,
firmware_supports_unscoped_mode,
set_radio_flood_scope,
)
# A protocol version that supports the mode-1 unscoped command (>= 12).
FW_SUPPORTS_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE
# A protocol version that predates the mode-1 command.
FW_NO_UNSCOPED = FIRMWARE_VER_UNSCOPED_MODE - 1
@pytest.mark.asyncio
@@ -12,7 +22,8 @@ async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions():
mc.commands.set_flood_scope = AsyncMock(return_value="ok")
mc.commands.send = AsyncMock()
result = await set_radio_flood_scope(mc, "Esperance")
# Region path is version-independent.
result = await set_radio_flood_scope(mc, "Esperance", fw_ver=FW_NO_UNSCOPED)
assert result == "ok"
mc.commands.set_flood_scope.assert_awaited_once_with("#Esperance")
@@ -20,12 +31,12 @@ async def test_set_radio_flood_scope_uses_meshcore_scope_for_regions():
@pytest.mark.asyncio
async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode():
async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode_on_v12():
mc = MagicMock()
mc.commands.set_flood_scope = AsyncMock()
mc.commands.send = AsyncMock(return_value="ok")
result = await set_radio_flood_scope(mc, "")
result = await set_radio_flood_scope(mc, "", fw_ver=FW_SUPPORTS_UNSCOPED)
assert result == "ok"
mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
@@ -34,13 +45,35 @@ async def test_set_radio_flood_scope_empty_uses_firmware_unscoped_mode():
@pytest.mark.asyncio
@pytest.mark.parametrize("scope", [None, " ", "0", "*"])
async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode(scope):
async def test_set_radio_flood_scope_unscoped_sentinels_use_firmware_mode_on_v12(scope):
mc = MagicMock()
mc.commands.set_flood_scope = AsyncMock()
mc.commands.send = AsyncMock(return_value="ok")
result = await set_radio_flood_scope(mc, scope)
result = await set_radio_flood_scope(mc, scope, fw_ver=FW_SUPPORTS_UNSCOPED)
assert result == "ok"
mc.commands.send.assert_awaited_once_with(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
mc.commands.set_flood_scope.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("fw_ver", [None, FW_NO_UNSCOPED])
async def test_set_radio_flood_scope_unscoped_falls_back_on_old_or_unknown_firmware(fw_ver):
"""Pre-v12 (or unknown) firmware has no mode-1 command; reset scope via mode 0."""
mc = MagicMock()
mc.commands.set_flood_scope = AsyncMock(return_value="ok")
mc.commands.send = AsyncMock()
result = await set_radio_flood_scope(mc, "", fw_ver=fw_ver)
assert result == "ok"
mc.commands.set_flood_scope.assert_awaited_once_with("")
mc.commands.send.assert_not_awaited()
def test_firmware_supports_unscoped_mode():
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE) is True
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE + 1) is True
assert firmware_supports_unscoped_mode(FIRMWARE_VER_UNSCOPED_MODE - 1) is False
assert firmware_supports_unscoped_mode(None) is False
+5
View File
@@ -981,6 +981,11 @@ class TestPostConnectSetupOrdering:
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
mock_mc.commands.send = AsyncMock()
# Flood scope is applied after the device query, so report a protocol
# version that supports the mode-1 unscoped command (>= 12).
device_query = MagicMock()
device_query.payload = {"fw ver": 13}
mock_mc.commands.send_device_query = AsyncMock(return_value=device_query)
rm._meshcore = mock_mc
mock_settings = AppSettings(flood_scope="")
+22 -1
View File
@@ -1,4 +1,8 @@
from app.region_scope import normalize_region_scope
from app.region_scope import (
UNSCOPED_OVERRIDE_MARKER,
is_unscoped,
normalize_region_scope,
)
def test_normalize_region_scope_preserves_regions():
@@ -12,3 +16,20 @@ def test_normalize_region_scope_unscoped_sentinels():
assert normalize_region_scope(" ") == ""
assert normalize_region_scope("0") == ""
assert normalize_region_scope("*") == ""
def test_is_unscoped():
assert is_unscoped(None) is True
assert is_unscoped("") is True
assert is_unscoped(" ") is True
assert is_unscoped("0") is True
assert is_unscoped("*") is True
assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True
assert is_unscoped("Esperance") is False
assert is_unscoped("#Esperance") is False
def test_unscoped_override_marker_is_recognized_and_not_a_region():
# The canonical persisted marker must round-trip as unscoped, never as a region.
assert is_unscoped(UNSCOPED_OVERRIDE_MARKER) is True
assert normalize_region_scope(UNSCOPED_OVERRIDE_MARKER) == ""
+29
View File
@@ -664,6 +664,7 @@ class TestOutgoingChannelBroadcast:
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "firmware_ver_code", 13),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(
@@ -689,6 +690,7 @@ class TestOutgoingChannelBroadcast:
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "firmware_ver_code", 13),
patch("app.routers.messages.broadcast_event"),
):
request = SendChannelMessageRequest(
@@ -702,6 +704,33 @@ class TestOutgoingChannelBroadcast:
]
assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")]
@pytest.mark.asyncio
async def test_persisted_unscoped_channel_forces_plain_flood_over_scoped_global(self, test_db):
"""A channel persistently marked unscoped ('*') sends unscoped even when the
companion has a global region set the core of issue #303. No per-send
override is supplied, so this exercises the persisted-override path."""
mc = _make_mc(name="MyNode")
chan_key = "d3" * 16
await ChannelRepository.upsert(key=chan_key, name="#plain")
await ChannelRepository.update_flood_scope_override(chan_key, "*")
await AppSettingsRepository.update(flood_scope="Baseline")
with (
patch("app.routers.messages.radio_manager.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "firmware_ver_code", 13),
patch("app.routers.messages.broadcast_event"),
):
# No per-send flood_scope_override: fall back to the channel's persisted "*".
request = SendChannelMessageRequest(channel_key=chan_key, text="hello")
await send_channel_message(request)
# Force unscoped via mode 1, then restore the global region baseline.
assert mc.commands.send.await_args_list == [
call(FORCE_UNSCOPED_FRAME, [EventType.OK, EventType.ERROR])
]
assert mc.commands.set_flood_scope.await_args_list == [call("#Baseline")]
@pytest.mark.asyncio
async def test_send_channel_msg_aborts_when_override_apply_fails(self, test_db):
mc = _make_mc(name="MyNode")
+2
View File
@@ -142,6 +142,7 @@ class TestUpdateSettings:
mock_rm = AsyncMock()
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
from contextlib import asynccontextmanager
@@ -168,6 +169,7 @@ class TestUpdateSettings:
mock_rm = AsyncMock()
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
mock_rm.firmware_ver_code = 13 # supports mode-1 unscoped
from contextlib import asynccontextmanager