mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-10 10:43:01 +02:00
Add retry on flood for repeater login
This commit is contained in:
@@ -341,7 +341,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||||
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater (escalates to one flood retry if the first attempt draws no reply) |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||||
@@ -354,7 +354,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
|||||||
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
|
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
|
||||||
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
|
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
|
||||||
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
|
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
|
||||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server (escalates to one flood retry if the first attempt draws no reply) |
|
||||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||||
|
|||||||
+19
-2
@@ -142,6 +142,23 @@ app/
|
|||||||
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
||||||
- DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies.
|
- DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies.
|
||||||
|
|
||||||
|
### Server login route escalation
|
||||||
|
|
||||||
|
`prepare_authenticated_contact_connection` (`routers/server_control.py`, shared by repeater and room login) sends one login over the contact's effective route. If that draws **no reply at all**, it calls `reset_path(...)` and retries exactly once as flood.
|
||||||
|
|
||||||
|
This is intentionally *more* than the reference implementations do — do not "correct" it back to single-shot:
|
||||||
|
- Firmware `BaseChatMesh::sendLogin` picks flood only when `out_path_len == OUT_PATH_UNKNOWN` and never retries; `CMD_SEND_LOGIN` calls it once.
|
||||||
|
- `meshcore_py` has `send_msg_with_retry` (with `flood_after` + `reset_path`) but no login equivalent — `send_login`/`send_login_sync` are single-shot.
|
||||||
|
- Firmware only clears a stale path when the *host* asks (`CMD_RESET_PATH`); client-side path learning is otherwise passive via `onContactPathRecv`.
|
||||||
|
|
||||||
|
Escalating is still correct because the **server** side treats an inbound flood as its cue to relearn the return path (`simple_repeater`/`simple_room_server`: `if (is_flood) client->out_path_len = OUT_PATH_UNKNOWN`). A flood login is therefore what repairs a broken route in both directions, and login gates the whole repeater dashboard.
|
||||||
|
|
||||||
|
Escalation is bounded to one extra attempt and only fires when:
|
||||||
|
- the first attempt **timed out**. `LOGIN_FAILED` means the server heard us and refused, so the route is fine and retrying only hammers it with bad credentials; a send error is a local radio problem a different route will not fix.
|
||||||
|
- the contact was **not already on flood** (`effective_route_source != "flood"`), since the retry would otherwise be byte-identical.
|
||||||
|
|
||||||
|
The retry deliberately does not re-run `_ensure_on_radio` — re-adding the contact would restore the route just cleared. `reset_path` clears the route on the radio only; the stored contact route is untouched, so the next `add_contact` re-stages it. That mirrors the DM retry and keeps one bad login from discarding a route that may be fine.
|
||||||
|
|
||||||
### Echo/repeat dedup
|
### Echo/repeat dedup
|
||||||
|
|
||||||
- Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`.
|
- Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`.
|
||||||
@@ -242,7 +259,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `POST /contacts/{public_key}/routing-override`
|
- `POST /contacts/{public_key}/routing-override`
|
||||||
- `POST /contacts/{public_key}/trace`
|
- `POST /contacts/{public_key}/trace`
|
||||||
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
||||||
- `POST /contacts/{public_key}/repeater/login`
|
- `POST /contacts/{public_key}/repeater/login` — one attempt on the effective route, then one flood retry on timeout
|
||||||
- `POST /contacts/{public_key}/repeater/status`
|
- `POST /contacts/{public_key}/repeater/status`
|
||||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||||
@@ -255,7 +272,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
|||||||
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
|
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
|
||||||
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
|
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
|
||||||
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
|
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
|
||||||
- `POST /contacts/{public_key}/room/login`
|
- `POST /contacts/{public_key}/room/login` — one attempt on the effective route, then one flood retry on timeout
|
||||||
- `POST /contacts/{public_key}/room/status`
|
- `POST /contacts/{public_key}/room/status`
|
||||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||||
- `POST /contacts/{public_key}/room/acl`
|
- `POST /contacts/{public_key}/room/acl`
|
||||||
|
|||||||
+117
-15
@@ -80,6 +80,15 @@ def _login_timeout_message(label: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _login_flood_retry_timeout_message(label: str) -> str:
|
||||||
|
return (
|
||||||
|
f"No login confirmation was heard from the {label}, including a retry sent as flood "
|
||||||
|
"in case the stored route was stale. That can mean the password was wrong, the "
|
||||||
|
f"{label} is out of range, or the reply was missed in transit. "
|
||||||
|
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_response_text(event) -> str:
|
def extract_response_text(event) -> str:
|
||||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||||
text = event.payload.get("text", str(event.payload))
|
text = event.payload.get("text", str(event.payload))
|
||||||
@@ -214,17 +223,20 @@ async def fetch_contact_cli_response(
|
|||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
|
|
||||||
|
|
||||||
async def prepare_authenticated_contact_connection(
|
async def _attempt_server_login(
|
||||||
mc,
|
mc,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
password: str,
|
password: str,
|
||||||
*,
|
*,
|
||||||
label: str | None = None,
|
contact_label: str,
|
||||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
response_timeout: float,
|
||||||
) -> RepeaterLoginResponse:
|
) -> RepeaterLoginResponse:
|
||||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
"""Send one login and wait for the reply.
|
||||||
|
|
||||||
|
Subscriptions are per-attempt because the resolving future can only be
|
||||||
|
settled once — a retry needs a fresh pair.
|
||||||
|
"""
|
||||||
pubkey_prefix = contact.public_key[:12].lower()
|
pubkey_prefix = contact.public_key[:12].lower()
|
||||||
contact_label = label or get_server_contact_label(contact)
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
login_future = loop.create_future()
|
login_future = loop.create_future()
|
||||||
|
|
||||||
@@ -254,9 +266,6 @@ async def prepare_authenticated_contact_connection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
|
||||||
await _ensure_on_radio(mc, contact)
|
|
||||||
|
|
||||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||||
|
|
||||||
@@ -268,10 +277,7 @@ async def prepare_authenticated_contact_connection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(login_future, timeout=response_timeout)
|
||||||
login_future,
|
|
||||||
timeout=response_timeout,
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"No login response from %s %s within %.1fs",
|
"No login response from %s %s within %.1fs",
|
||||||
@@ -284,6 +290,105 @@ async def prepare_authenticated_contact_connection(
|
|||||||
authenticated=False,
|
authenticated=False,
|
||||||
message=_login_timeout_message(contact_label),
|
message=_login_timeout_message(contact_label),
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
success_subscription.unsubscribe()
|
||||||
|
failed_subscription.unsubscribe()
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_authenticated_contact_connection(
|
||||||
|
mc,
|
||||||
|
contact: Contact,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
label: str | None = None,
|
||||||
|
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||||
|
) -> RepeaterLoginResponse:
|
||||||
|
"""Prepare connection to a server-capable contact by adding it to the radio and logging in.
|
||||||
|
|
||||||
|
A login that draws no reply at all may mean the stored direct route has gone
|
||||||
|
stale, so it escalates to one flood retry — mirroring the DM send path, which
|
||||||
|
already resets the path before its final attempt. This is deliberately more
|
||||||
|
than the reference clients do: firmware ``sendLogin`` and ``send_login_sync``
|
||||||
|
are both single-shot, and firmware only clears a stale path when the *host*
|
||||||
|
asks it to (``CMD_RESET_PATH``). Escalating is still the right call because
|
||||||
|
the server side treats an inbound flood as its cue to relearn the return path
|
||||||
|
(``simple_repeater``/``simple_room_server``: ``if (is_flood)
|
||||||
|
client->out_path_len = OUT_PATH_UNKNOWN``), so a flood login is exactly what
|
||||||
|
repairs a broken route in both directions.
|
||||||
|
|
||||||
|
Escalation is bounded to a single extra attempt and only fires when:
|
||||||
|
- the first attempt timed out. An explicit ``LOGIN_FAILED`` means the
|
||||||
|
server heard us and refused, so the route is fine and retrying would
|
||||||
|
just hammer it with bad credentials. A send error is a local radio
|
||||||
|
problem that a different route will not fix.
|
||||||
|
- the first attempt actually used a route. If the contact was already on
|
||||||
|
flood, the retry would be byte-identical for no gain.
|
||||||
|
|
||||||
|
``reset_path`` clears the route on the radio only; the stored contact route
|
||||||
|
is left alone, so the next ``add_contact`` re-stages it. That matches the DM
|
||||||
|
retry and keeps a single bad login from discarding a route that may be fine.
|
||||||
|
"""
|
||||||
|
contact_label = label or get_server_contact_label(contact)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||||
|
await _ensure_on_radio(mc, contact)
|
||||||
|
|
||||||
|
response = await _attempt_server_login(
|
||||||
|
mc,
|
||||||
|
contact,
|
||||||
|
password,
|
||||||
|
contact_label=contact_label,
|
||||||
|
response_timeout=response_timeout,
|
||||||
|
)
|
||||||
|
if response.status != "timeout":
|
||||||
|
return response
|
||||||
|
|
||||||
|
if contact.effective_route_source == "flood":
|
||||||
|
logger.debug(
|
||||||
|
"Login to %s %s timed out on flood; no route to escalate from",
|
||||||
|
contact_label,
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Login to %s %s timed out on the %s route; resetting path and retrying as flood",
|
||||||
|
contact_label,
|
||||||
|
contact.public_key[:12],
|
||||||
|
contact.effective_route_source,
|
||||||
|
)
|
||||||
|
reset_result = await mc.commands.reset_path(contact.public_key)
|
||||||
|
if reset_result is None:
|
||||||
|
logger.warning(
|
||||||
|
"No response from radio for reset_path to %s before flood login retry",
|
||||||
|
contact.public_key[:12],
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
if reset_result.type == EventType.ERROR:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to reset path before flood login retry to %s: %s",
|
||||||
|
contact.public_key[:12],
|
||||||
|
reset_result.payload,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Deliberately no _ensure_on_radio here — re-adding the contact would
|
||||||
|
# restore the very route we just cleared, and the retry would go direct.
|
||||||
|
flood_response = await _attempt_server_login(
|
||||||
|
mc,
|
||||||
|
contact,
|
||||||
|
password,
|
||||||
|
contact_label=contact_label,
|
||||||
|
response_timeout=response_timeout,
|
||||||
|
)
|
||||||
|
if flood_response.status == "timeout":
|
||||||
|
return RepeaterLoginResponse(
|
||||||
|
status="timeout",
|
||||||
|
authenticated=False,
|
||||||
|
message=_login_flood_retry_timeout_message(contact_label),
|
||||||
|
)
|
||||||
|
return flood_response
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s login setup failed for %s: %s",
|
"%s login setup failed for %s: %s",
|
||||||
@@ -296,9 +401,6 @@ async def prepare_authenticated_contact_connection(
|
|||||||
authenticated=False,
|
authenticated=False,
|
||||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
success_subscription.unsubscribe()
|
|
||||||
failed_subscription.unsubscribe()
|
|
||||||
|
|
||||||
|
|
||||||
async def batch_cli_fetch(
|
async def batch_cli_fetch(
|
||||||
|
|||||||
+1
-1
@@ -422,7 +422,7 @@ State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`.
|
|||||||
|
|
||||||
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||||
|
|
||||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. The frontend sends exactly one request; the backend internally escalates a timed-out login to one flood retry (see `app/AGENTS.md` § "Server login route escalation"), so a single call may take up to two response windows. Do not add a client-side login retry loop on top — a `LOGIN_FAILED` result means the password was refused, not that the route needs another attempt.
|
||||||
|
|
||||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||||
|
|
||||||
|
|||||||
@@ -818,6 +818,162 @@ class TestPrepareRepeaterConnection:
|
|||||||
assert "No login confirmation was heard from the repeater" in (response.message or "")
|
assert "No login confirmation was heard from the repeater" in (response.message or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_routed_contact() -> Contact:
|
||||||
|
"""A repeater with a learned direct route, so a login goes out direct."""
|
||||||
|
contact = Contact(
|
||||||
|
public_key=KEY_A,
|
||||||
|
name="Repeater",
|
||||||
|
type=2,
|
||||||
|
direct_path="aabb",
|
||||||
|
direct_path_len=2,
|
||||||
|
direct_path_hash_mode=0,
|
||||||
|
)
|
||||||
|
assert contact.effective_route_source == "direct"
|
||||||
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepeaterLoginFloodEscalation:
|
||||||
|
"""A login that draws no reply escalates to one flood retry.
|
||||||
|
|
||||||
|
Firmware's own ``sendLogin`` is single-shot, so this is RT going beyond the
|
||||||
|
reference clients. It works because the *server* treats an inbound flood as
|
||||||
|
its cue to relearn the return path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_on_direct_route_resets_path_and_retries_as_flood(self):
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
subscriptions: dict[EventType, tuple[object, object]] = {}
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
def _subscribe(event_type, callback, attribute_filters=None):
|
||||||
|
subscriptions[event_type] = (callback, attribute_filters)
|
||||||
|
return MagicMock(unsubscribe=MagicMock())
|
||||||
|
|
||||||
|
async def _send_login(*args, **kwargs):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
# First attempt (direct) is never answered; the flood retry lands.
|
||||||
|
if attempts > 1:
|
||||||
|
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
|
||||||
|
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": KEY_A[:12]}))
|
||||||
|
return _radio_result(EventType.MSG_SENT)
|
||||||
|
|
||||||
|
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||||
|
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
|
||||||
|
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "ok"
|
||||||
|
assert response.authenticated is True
|
||||||
|
assert attempts == 2
|
||||||
|
mc.commands.reset_path.assert_awaited_once_with(KEY_A)
|
||||||
|
# The contact must not be re-added between attempts — that would restore
|
||||||
|
# the route we just cleared and send the retry direct again.
|
||||||
|
assert mc.commands.add_contact.await_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flood_retry_timeout_reports_that_flood_was_tried(self):
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
|
||||||
|
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "timeout"
|
||||||
|
assert response.authenticated is False
|
||||||
|
assert "retry sent as flood" in (response.message or "")
|
||||||
|
assert mc.commands.send_login.await_count == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_escalation_when_contact_is_already_flood(self):
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_contact() # no direct route -> already flooding
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
|
||||||
|
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "timeout"
|
||||||
|
# A flood retry would be byte-identical, so don't bother the repeater
|
||||||
|
assert mc.commands.send_login.await_count == 1
|
||||||
|
mc.commands.reset_path.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rejected_login_does_not_escalate(self):
|
||||||
|
"""LOGIN_FAILED means the route works and the password doesn't."""
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
subscriptions: dict[EventType, tuple[object, object]] = {}
|
||||||
|
|
||||||
|
def _subscribe(event_type, callback, attribute_filters=None):
|
||||||
|
subscriptions[event_type] = (callback, attribute_filters)
|
||||||
|
return MagicMock(unsubscribe=MagicMock())
|
||||||
|
|
||||||
|
async def _send_login(*args, **kwargs):
|
||||||
|
callback, _filters = subscriptions[EventType.LOGIN_FAILED]
|
||||||
|
callback(_radio_result(EventType.LOGIN_FAILED, {"pubkey_prefix": KEY_A[:12]}))
|
||||||
|
return _radio_result(EventType.MSG_SENT)
|
||||||
|
|
||||||
|
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||||
|
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "bad")
|
||||||
|
|
||||||
|
assert response.status == "error"
|
||||||
|
assert mc.commands.send_login.await_count == 1
|
||||||
|
mc.commands.reset_path.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_error_does_not_escalate(self):
|
||||||
|
"""A local radio failure is not fixed by picking a different route."""
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
mc.commands.send_login = AsyncMock(
|
||||||
|
return_value=_radio_result(EventType.ERROR, {"err": "busy"})
|
||||||
|
)
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||||
|
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "error"
|
||||||
|
assert mc.commands.send_login.await_count == 1
|
||||||
|
mc.commands.reset_path.assert_not_awaited()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_reset_path_returns_original_timeout(self):
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
mc.commands.reset_path = AsyncMock(
|
||||||
|
return_value=_radio_result(EventType.ERROR, {"err": "nope"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "timeout"
|
||||||
|
# Without a cleared path the retry would just go direct again
|
||||||
|
assert mc.commands.send_login.await_count == 1
|
||||||
|
assert "retry sent as flood" not in (response.message or "")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_reset_path_response_returns_original_timeout(self):
|
||||||
|
mc = _mock_mc()
|
||||||
|
contact = _make_routed_contact()
|
||||||
|
mc.commands.reset_path = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||||
|
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||||
|
|
||||||
|
assert response.status == "timeout"
|
||||||
|
assert mc.commands.send_login.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
class TestRepeaterStatus:
|
class TestRepeaterStatus:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_success_with_field_mapping(self, test_db):
|
async def test_success_with_field_mapping(self, test_db):
|
||||||
|
|||||||
Reference in New Issue
Block a user