Add some tests and improve docs

This commit is contained in:
Jack Kingsman
2026-02-27 16:54:18 -08:00
parent 60455cdd7b
commit 884972f9e0
9 changed files with 1109 additions and 39 deletions
+20 -20
View File
@@ -123,8 +123,8 @@ To improve repeater disambiguation in the network visualizer, the backend stores
### Incoming Messages
1. Radio receives message → MeshCore library emits event
2. `event_handlers.py` catches event → stores in database
1. Radio receives raw bytes → `packet_processor.py` parses, decrypts, deduplicates, and stores in database (primary path via `RX_LOG_DATA` event)
2. `event_handlers.py` handles higher-level events (`CONTACT_MSG_RECV`, `ACK`) as a fallback/supplement
3. `ws_manager` broadcasts to connected clients
4. Frontend `useWebSocket` receives → updates React state
@@ -270,25 +270,25 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| GET | `/api/contacts/{key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{key}/detail` | Comprehensive contact profile (stats, name history, paths) |
| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for a contact |
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| DELETE | `/api/contacts/{key}` | Delete contact |
| DELETE | `/api/contacts/{public_key}` | Delete contact |
| POST | `/api/contacts/sync` | Pull from radio |
| POST | `/api/contacts/{key}/add-to-radio` | Push contact to radio |
| POST | `/api/contacts/{key}/remove-from-radio` | Remove contact from radio |
| POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{key}/trace` | Trace route to contact |
| POST | `/api/contacts/{key}/repeater/login` | Log in to a repeater |
| POST | `/api/contacts/{key}/repeater/status` | Fetch repeater status telemetry |
| POST | `/api/contacts/{key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
| POST | `/api/contacts/{key}/repeater/neighbors` | Fetch repeater neighbors |
| POST | `/api/contacts/{key}/repeater/acl` | Fetch repeater ACL |
| POST | `/api/contacts/{key}/repeater/radio-settings` | Fetch radio settings via CLI |
| POST | `/api/contacts/{key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{key}/repeater/owner-info` | Fetch owner info |
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
| 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/neighbors` | Fetch repeater neighbors |
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}` | Get channel by key |
@@ -343,7 +343,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
Read state (`last_read_at`) is tracked **server-side** for consistency across devices:
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
- Updated via `POST /api/contacts/{key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Bulk update via `POST /api/read-state/mark-all-read`
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
+1 -1
View File
@@ -94,7 +94,7 @@ Access at http://localhost:8000
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP).Then:
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
```bash
docker compose up -d
+4 -4
View File
@@ -58,7 +58,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
// Connection established (or re-established after disconnect)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
@@ -70,7 +70,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
};
ws.onclose = () => {
console.log('WebSocket disconnected');
// Connection lost — will auto-reconnect after delay
wsRef.current = null;
if (!shouldReconnectRef.current) {
@@ -82,7 +82,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
clearTimeout(reconnectTimeoutRef.current);
}
reconnectTimeoutRef.current = window.setTimeout(() => {
console.log('Attempting WebSocket reconnect...');
// Reconnect attempt after disconnect
connect();
}, 3000);
};
@@ -129,7 +129,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
// Heartbeat response, ignore
break;
default:
console.log('Unknown WebSocket message type:', msg.type);
console.warn('Unknown WebSocket message type:', msg.type);
}
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
-14
View File
@@ -20,17 +20,3 @@ def cleanup_test_db_dir():
"""Clean up temporary pytest DB directory after the test session."""
yield
shutil.rmtree(_TEST_DB_DIR, ignore_errors=True)
@pytest.fixture
def sample_channel_key():
"""A sample 16-byte channel key for testing."""
return bytes.fromhex("0123456789abcdef0123456789abcdef")
@pytest.fixture
def sample_hashtag_key():
"""A channel key derived from hashtag name '#test'."""
import hashlib
return hashlib.sha256(b"#test").digest()[:16]
+257
View File
@@ -0,0 +1,257 @@
"""Tests for the channels router sync endpoint.
Verifies that POST /api/channels/sync correctly reads channel slots
from the radio and upserts them into the database.
"""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from meshcore import EventType
from app.database import Database
from app.radio import radio_manager
from app.repository import ChannelRepository
@pytest.fixture
async def test_db():
"""Create an in-memory test database with schema + migrations."""
import app.repository as repo_module
db = Database(":memory:")
await db.connect()
original_db = repo_module.db
repo_module.db = db
try:
yield db
finally:
repo_module.db = original_db
await db.disconnect()
@pytest.fixture(autouse=True)
def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
@pytest.fixture
def client():
"""Create an httpx AsyncClient for testing the app."""
from app.main import app
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
def _make_channel_info(name: str, secret: bytes):
"""Create a mock channel info response."""
result = MagicMock()
result.type = EventType.CHANNEL_INFO
result.payload = {
"channel_name": name,
"channel_secret": secret,
}
return result
def _make_empty_channel():
"""Create a mock empty channel response."""
result = MagicMock()
result.type = EventType.CHANNEL_INFO
result.payload = {
"channel_name": "\x00\x00\x00\x00",
"channel_secret": b"",
}
return result
def _make_error_response():
"""Create a mock error response (channel slot unused)."""
result = MagicMock()
result.type = EventType.ERROR
result.payload = {}
return result
@asynccontextmanager
async def _noop_radio_operation(mc):
"""No-op radio_operation context manager that yields mc."""
yield mc
class TestSyncChannelsFromRadio:
"""Test POST /api/channels/sync."""
@pytest.mark.asyncio
async def test_sync_channels_basic(self, test_db, client):
"""Sync creates channels from radio slots."""
secret_a = bytes.fromhex("0123456789abcdef0123456789abcdef")
secret_b = bytes.fromhex("fedcba9876543210fedcba9876543210")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("#general", secret_a)
if idx == 1:
return _make_channel_info("Private", secret_b)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=5")
assert response.status_code == 200
data = response.json()
assert data["synced"] == 2
# Verify channels in DB
channels = await ChannelRepository.get_all()
assert len(channels) == 2
keys = {ch.key for ch in channels}
assert secret_a.hex().upper() in keys
assert secret_b.hex().upper() in keys
@pytest.mark.asyncio
async def test_sync_skips_empty_channels(self, test_db, client):
"""Empty channel slots are skipped during sync."""
secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("#test", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=5")
assert response.status_code == 200
assert response.json()["synced"] == 1
@pytest.mark.asyncio
async def test_sync_hashtag_flag(self, test_db, client):
"""Channels starting with # are marked as hashtag channels."""
secret = bytes.fromhex("1122334455667788aabbccddeeff0011")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("#hashtag-room", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
response = await client.post("/api/channels/sync?max_channels=3")
assert response.status_code == 200
channel = await ChannelRepository.get_by_key(secret.hex().upper())
assert channel is not None
assert channel.is_hashtag is True
assert channel.name == "#hashtag-room"
assert channel.on_radio is True
@pytest.mark.asyncio
async def test_sync_marks_channels_on_radio(self, test_db, client):
"""Synced channels have on_radio=True."""
secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("MyChannel", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
await client.post("/api/channels/sync?max_channels=3")
channel = await ChannelRepository.get_by_key(secret.hex().upper())
assert channel.on_radio is True
@pytest.mark.asyncio
async def test_sync_requires_connection(self, test_db, client):
"""Sync returns 503 when radio is not connected."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
response = await client.post("/api/channels/sync")
assert response.status_code == 503
@pytest.mark.asyncio
async def test_sync_key_normalized_uppercase(self, test_db, client):
"""Channel keys are normalized to uppercase hex."""
secret = bytes.fromhex("aabbccddaabbccddaabbccddaabbccdd")
mock_mc = MagicMock()
async def mock_get_channel(idx):
if idx == 0:
return _make_channel_info("Test", secret)
return _make_empty_channel()
mock_mc.commands.get_channel = AsyncMock(side_effect=mock_get_channel)
radio_manager._meshcore = mock_mc
with (
patch("app.dependencies.radio_manager") as mock_dep_rm,
patch("app.routers.channels.radio_manager") as mock_ch_rm,
):
mock_dep_rm.is_connected = True
mock_dep_rm.meshcore = mock_mc
mock_ch_rm.radio_operation = lambda desc: _noop_radio_operation(mock_mc)
await client.post("/api/channels/sync?max_channels=3")
channel = await ChannelRepository.get_by_key("AABBCCDDAABBCCDDAABBCCDDAABBCCDD")
assert channel is not None
+88
View File
@@ -546,6 +546,94 @@ class TestSyncContacts:
assert messages[0].conversation_key == KEY_A.lower()
class TestCreateContactWithHistorical:
"""Test POST /api/contacts with try_historical=true."""
@pytest.mark.asyncio
async def test_new_contact_triggers_historical_decrypt(self, test_db, client):
"""Creating a new contact with try_historical triggers DM decryption."""
with patch(
"app.routers.contacts.start_historical_dm_decryption", new_callable=AsyncMock
) as mock_start:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "Alice", "try_historical": True},
)
assert response.status_code == 200
assert response.json()["public_key"] == KEY_A
mock_start.assert_awaited_once()
# Verify correct args: (background_tasks, public_key, name)
call_args = mock_start.call_args
assert call_args[0][1] == KEY_A # public_key
assert call_args[0][2] == "Alice" # display_name
@pytest.mark.asyncio
async def test_new_contact_without_historical(self, test_db, client):
"""Creating a new contact without try_historical does not trigger decryption."""
with patch(
"app.routers.contacts.start_historical_dm_decryption", new_callable=AsyncMock
) as mock_start:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "Alice", "try_historical": False},
)
assert response.status_code == 200
mock_start.assert_not_awaited()
@pytest.mark.asyncio
async def test_existing_contact_with_historical(self, test_db, client):
"""Existing contact with try_historical still triggers decryption."""
await _insert_contact(KEY_A, "Alice")
with patch(
"app.routers.contacts.start_historical_dm_decryption", new_callable=AsyncMock
) as mock_start:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "Alice", "try_historical": True},
)
assert response.status_code == 200
mock_start.assert_awaited_once()
@pytest.mark.asyncio
async def test_existing_contact_updates_name_and_decrypts(self, test_db, client):
"""Existing contact with try_historical updates name AND triggers decryption."""
await _insert_contact(KEY_A, "OldName")
with patch(
"app.routers.contacts.start_historical_dm_decryption", new_callable=AsyncMock
) as mock_start:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "NewName", "try_historical": True},
)
assert response.status_code == 200
mock_start.assert_awaited_once()
# Verify name was also updated
contact = await ContactRepository.get_by_key(KEY_A)
assert contact.name == "NewName"
@pytest.mark.asyncio
async def test_default_try_historical_is_false(self, test_db, client):
"""try_historical defaults to false when not provided."""
with patch(
"app.routers.contacts.start_historical_dm_decryption", new_callable=AsyncMock
) as mock_start:
response = await client.post(
"/api/contacts",
json={"public_key": KEY_A, "name": "Alice"},
)
assert response.status_code == 200
mock_start.assert_not_awaited()
class TestAddRemoveRadio:
"""Test add-to-radio and remove-from-radio endpoints."""
+166
View File
@@ -890,3 +890,169 @@ class TestConcurrentDMDedup:
msg_type="CHAN", conversation_key=CHANNEL_KEY, limit=10
)
assert len(messages) == 1
class TestMessageAckedBroadcastShape:
"""Verify that message_acked broadcasts from _handle_duplicate_message
match the frontend's MessageAckedEvent interface.
The on_ack handler (event_handlers.py) broadcasts {message_id, ack_count},
while _handle_duplicate_message broadcasts {message_id, ack_count, paths}.
Both must match what the frontend expects in useWebSocket.ts.
"""
# Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117)
# The 'paths' key is optional in the TypeScript interface
REQUIRED_KEYS = {"message_id", "ack_count"}
OPTIONAL_KEYS = {"paths"}
@pytest.mark.asyncio
async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts):
"""Outgoing echo broadcast has all required keys plus paths."""
from app.packet_processor import create_message_from_decrypted
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="Sender: Shape test",
conversation_key=CHANNEL_KEY,
sender_timestamp=SENDER_TIMESTAMP,
received_at=SENDER_TIMESTAMP,
outgoing=True,
)
pkt_id, _ = await RawPacketRepository.create(b"shape_echo", SENDER_TIMESTAMP + 1)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_message_from_decrypted(
packet_id=pkt_id,
channel_key=CHANNEL_KEY,
sender="Sender",
message_text="Shape test",
timestamp=SENDER_TIMESTAMP,
received_at=SENDER_TIMESTAMP + 1,
path="aabb",
)
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
assert len(ack_broadcasts) == 1
payload = ack_broadcasts[0]["data"]
payload_keys = set(payload.keys())
# Must have all required keys
assert payload_keys >= self.REQUIRED_KEYS
# Must only have expected keys
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
# Verify types
assert isinstance(payload["message_id"], int)
assert isinstance(payload["ack_count"], int)
assert payload["message_id"] == msg_id
assert payload["ack_count"] == 1
# paths should be a list of dicts with path and received_at keys
assert isinstance(payload["paths"], list)
for p in payload["paths"]:
assert "path" in p
assert "received_at" in p
@pytest.mark.asyncio
async def test_incoming_echo_broadcast_shape(self, test_db, captured_broadcasts):
"""Incoming echo broadcast (with path) has the correct shape."""
from app.packet_processor import create_message_from_decrypted
pkt1, _ = await RawPacketRepository.create(b"shape_inc_1", SENDER_TIMESTAMP)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_message_from_decrypted(
packet_id=pkt1,
channel_key=CHANNEL_KEY,
sender="Other",
message_text="Incoming shape",
timestamp=SENDER_TIMESTAMP,
received_at=SENDER_TIMESTAMP,
path="aa",
)
broadcasts.clear()
pkt2, _ = await RawPacketRepository.create(b"shape_inc_2", SENDER_TIMESTAMP + 1)
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_message_from_decrypted(
packet_id=pkt2,
channel_key=CHANNEL_KEY,
sender="Other",
message_text="Incoming shape",
timestamp=SENDER_TIMESTAMP,
received_at=SENDER_TIMESTAMP + 1,
path="bbcc",
)
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
assert len(ack_broadcasts) == 1
payload = ack_broadcasts[0]["data"]
payload_keys = set(payload.keys())
assert payload_keys >= self.REQUIRED_KEYS
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
assert payload["ack_count"] == 0 # Not outgoing, no ack increment
@pytest.mark.asyncio
async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts):
"""DM duplicate broadcast has the same shape as channel echo."""
from app.packet_processor import create_dm_message_from_decrypted
pkt1, _ = await RawPacketRepository.create(b"dm_shape_1", SENDER_TIMESTAMP)
decrypted = DecryptedDirectMessage(
timestamp=SENDER_TIMESTAMP,
flags=0,
message="DM shape test",
dest_hash="fa",
src_hash="a1",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=pkt1,
decrypted=decrypted,
their_public_key=CONTACT_PUB,
our_public_key=OUR_PUB,
received_at=SENDER_TIMESTAMP,
outgoing=True,
path="aabb",
)
assert msg_id is not None
broadcasts.clear()
pkt2, _ = await RawPacketRepository.create(b"dm_shape_2", SENDER_TIMESTAMP + 1)
with patch("app.packet_processor.broadcast_event", mock_broadcast):
await create_dm_message_from_decrypted(
packet_id=pkt2,
decrypted=decrypted,
their_public_key=CONTACT_PUB,
our_public_key=OUR_PUB,
received_at=SENDER_TIMESTAMP + 1,
outgoing=True,
path="ccddee",
)
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
assert len(ack_broadcasts) == 1
payload = ack_broadcasts[0]["data"]
payload_keys = set(payload.keys())
assert payload_keys >= self.REQUIRED_KEYS
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
assert isinstance(payload["message_id"], int)
assert isinstance(payload["ack_count"], int)
assert payload["ack_count"] == 1 # Outgoing DM echo increments ack
+49
View File
@@ -387,6 +387,55 @@ class TestAdvertisementPipeline:
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact.last_path_len == 1 # Still the shorter path
@pytest.mark.asyncio
async def test_advertisement_default_path_len_treated_as_infinity(
self, test_db, captured_broadcasts
):
"""Contact with last_path_len=-1 (unset) is treated as infinite length.
Any new advertisement should replace the default -1 path since
the code converts -1 to float('inf') for comparison.
"""
from app.packet_processor import _process_advertisement
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
await ContactRepository.upsert(
{
"public_key": test_pubkey,
"name": "TestNode",
"type": 1,
"last_seen": 1000,
"last_path_len": -1, # Default unset value
"last_path": None,
}
)
from app.decoder import ParsedAdvertisement
broadcasts, mock_broadcast = captured_broadcasts
packet_info = MagicMock()
packet_info.path_length = 3
packet_info.path = bytes.fromhex("aabbcc")
with patch("app.packet_processor.broadcast_event", mock_broadcast):
with patch("app.packet_processor.parse_advertisement") as mock_parse:
mock_parse.return_value = ParsedAdvertisement(
public_key=test_pubkey,
name="TestNode",
timestamp=1050,
lat=None,
lon=None,
device_role=1,
)
# Process within 60s window (last_seen=1000, now=1050)
await _process_advertisement(b"", timestamp=1050, packet_info=packet_info)
# Since -1 is treated as infinity, the new path (len=3) should replace it
contact = await ContactRepository.get_by_key(test_pubkey)
assert contact.last_path_len == 3
assert contact.last_path == "aabbcc"
@pytest.mark.asyncio
async def test_advertisement_replaces_stale_path_outside_window(
self, test_db, captured_broadcasts
+524
View File
@@ -0,0 +1,524 @@
"""Tests for the packets router.
Covers the historical channel decryption endpoint, background task,
undecrypted count endpoint, and the maintenance endpoint.
"""
import time
from unittest.mock import patch
import httpx
import pytest
from app.database import Database
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
@pytest.fixture
async def test_db():
"""Create an in-memory test database with schema + migrations."""
import app.repository as repo_module
db = Database(":memory:")
await db.connect()
original_db = repo_module.db
repo_module.db = db
# Also patch the db reference used by the packets router for VACUUM
import app.routers.packets as packets_module
original_packets_db = packets_module.db
packets_module.db = db
try:
yield db
finally:
repo_module.db = original_db
packets_module.db = original_packets_db
await db.disconnect()
@pytest.fixture
def client():
"""Create an httpx AsyncClient for testing the app."""
from app.main import app
transport = httpx.ASGITransport(app=app)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def _insert_raw_packets(count: int, decrypted: bool = False, age_days: int = 0) -> list[int]:
"""Insert raw packets and return their IDs."""
ids = []
base_ts = int(time.time()) - (age_days * 86400)
for i in range(count):
packet_id, _ = await RawPacketRepository.create(
f"packet_data_{i}_{age_days}_{decrypted}".encode(), base_ts + i
)
if decrypted:
# Create a message and link it
msg_id = await MessageRepository.create(
msg_type="CHAN",
text=f"decrypted msg {i}",
conversation_key="DEADBEEF" * 4,
sender_timestamp=base_ts + i,
received_at=base_ts + i,
)
if msg_id is not None:
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
ids.append(packet_id)
return ids
class TestUndecryptedCount:
"""Test GET /api/packets/undecrypted/count."""
@pytest.mark.asyncio
async def test_returns_zero_when_empty(self, test_db, client):
response = await client.get("/api/packets/undecrypted/count")
assert response.status_code == 200
assert response.json()["count"] == 0
@pytest.mark.asyncio
async def test_counts_only_undecrypted(self, test_db, client):
await _insert_raw_packets(3, decrypted=False)
await _insert_raw_packets(2, decrypted=True)
response = await client.get("/api/packets/undecrypted/count")
assert response.status_code == 200
assert response.json()["count"] == 3
class TestDecryptHistoricalPackets:
"""Test POST /api/packets/decrypt/historical."""
@pytest.mark.asyncio
async def test_channel_decrypt_with_hex_key(self, test_db, client):
"""Channel decryption with a valid hex key starts background task."""
await _insert_raw_packets(5)
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_key": "0123456789abcdef0123456789abcdef",
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is True
assert data["total_packets"] == 5
assert "background" in data["message"].lower()
@pytest.mark.asyncio
async def test_channel_decrypt_with_hashtag_name(self, test_db, client):
"""Channel decryption with a channel name derives key from hash."""
await _insert_raw_packets(3)
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_name": "#general",
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is True
assert data["total_packets"] == 3
@pytest.mark.asyncio
async def test_channel_decrypt_invalid_hex(self, test_db, client):
"""Invalid hex string for channel key returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_key": "not_valid_hex",
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "invalid" in data["message"].lower()
@pytest.mark.asyncio
async def test_channel_decrypt_wrong_key_length(self, test_db, client):
"""Channel key with wrong length returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_key": "aabbccdd", # Only 4 bytes, need 16
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "16 bytes" in data["message"]
@pytest.mark.asyncio
async def test_channel_decrypt_no_key_or_name(self, test_db, client):
"""Channel decryption without key or name returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={"key_type": "channel"},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "must provide" in data["message"].lower()
@pytest.mark.asyncio
async def test_channel_decrypt_no_undecrypted_packets(self, test_db, client):
"""Channel decryption with no undecrypted packets returns not started."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_key": "0123456789abcdef0123456789abcdef",
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert data["total_packets"] == 0
@pytest.mark.asyncio
async def test_channel_decrypt_resolves_channel_name(self, test_db, client):
"""Channel decryption finds display name from DB when channel exists."""
key_hex = "0123456789ABCDEF0123456789ABCDEF"
await ChannelRepository.upsert(key=key_hex, name="#test-channel", is_hashtag=True)
await _insert_raw_packets(1)
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "channel",
"channel_key": key_hex.lower(),
},
)
assert response.status_code == 200
assert response.json()["started"] is True
@pytest.mark.asyncio
async def test_contact_decrypt_missing_private_key(self, test_db, client):
"""Contact decryption without private key returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "contact",
"contact_public_key": "aa" * 32,
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "private_key" in data["message"].lower()
@pytest.mark.asyncio
async def test_contact_decrypt_missing_contact_key(self, test_db, client):
"""Contact decryption without contact public key returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "contact",
"private_key": "aa" * 64,
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "contact_public_key" in data["message"].lower()
@pytest.mark.asyncio
async def test_contact_decrypt_wrong_private_key_length(self, test_db, client):
"""Private key with wrong length returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "contact",
"private_key": "aa" * 32, # 32 bytes, need 64
"contact_public_key": "bb" * 32,
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "64 bytes" in data["message"]
@pytest.mark.asyncio
async def test_contact_decrypt_wrong_public_key_length(self, test_db, client):
"""Contact public key with wrong length returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "contact",
"private_key": "aa" * 64,
"contact_public_key": "bb" * 16, # 16 bytes, need 32
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "32 bytes" in data["message"]
@pytest.mark.asyncio
async def test_contact_decrypt_invalid_hex(self, test_db, client):
"""Invalid hex for private key returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={
"key_type": "contact",
"private_key": "zz" * 64,
"contact_public_key": "bb" * 32,
},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "invalid" in data["message"].lower()
@pytest.mark.asyncio
async def test_invalid_key_type(self, test_db, client):
"""Invalid key_type returns error."""
response = await client.post(
"/api/packets/decrypt/historical",
json={"key_type": "invalid"},
)
assert response.status_code == 200
data = response.json()
assert data["started"] is False
assert "key_type" in data["message"].lower()
class TestRunHistoricalChannelDecryption:
"""Test the _run_historical_channel_decryption background task."""
@pytest.mark.asyncio
async def test_decrypts_matching_packets(self, test_db):
"""Background task decrypts packets that match the channel key."""
from app.routers.packets import _run_historical_channel_decryption
# Insert undecrypted packets
await _insert_raw_packets(3)
channel_key_hex = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
channel_key_bytes = bytes.fromhex(channel_key_hex)
# Each packet must have unique content to avoid message deduplication
call_count = 0
def make_unique_result(*_args, **_kwargs):
nonlocal call_count
call_count += 1
return type(
"DecryptResult",
(),
{
"sender": f"User{call_count}",
"message": f"Hello {call_count}",
"timestamp": 1700000000 + call_count,
},
)()
with (
patch(
"app.routers.packets.try_decrypt_packet_with_channel_key",
side_effect=make_unique_result,
),
patch(
"app.routers.packets.parse_packet",
return_value=None,
),
patch("app.routers.packets.broadcast_success") as mock_success,
):
await _run_historical_channel_decryption(channel_key_bytes, channel_key_hex, "#test")
mock_success.assert_called_once()
assert "3" in mock_success.call_args[0][1] # "Decrypted 3 messages"
@pytest.mark.asyncio
async def test_skips_non_matching_packets(self, test_db):
"""Background task skips packets that don't match the channel key."""
from app.routers.packets import _run_historical_channel_decryption
await _insert_raw_packets(2)
channel_key_hex = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
channel_key_bytes = bytes.fromhex(channel_key_hex)
with (
patch(
"app.routers.packets.try_decrypt_packet_with_channel_key",
return_value=None, # No match
),
patch("app.routers.packets.broadcast_success") as mock_success,
):
await _run_historical_channel_decryption(channel_key_bytes, channel_key_hex, "#test")
# No success broadcast when nothing was decrypted
mock_success.assert_not_called()
@pytest.mark.asyncio
async def test_no_packets_returns_early(self, test_db):
"""Background task returns early when no undecrypted packets exist."""
from app.routers.packets import _run_historical_channel_decryption
channel_key_hex = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
channel_key_bytes = bytes.fromhex(channel_key_hex)
with patch("app.routers.packets.broadcast_success") as mock_success:
await _run_historical_channel_decryption(channel_key_bytes, channel_key_hex)
mock_success.assert_not_called()
@pytest.mark.asyncio
async def test_display_name_fallback(self, test_db):
"""Uses channel key prefix when no display name is provided."""
from app.routers.packets import _run_historical_channel_decryption
await _insert_raw_packets(1)
channel_key_hex = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
channel_key_bytes = bytes.fromhex(channel_key_hex)
mock_result = type(
"DecryptResult",
(),
{
"sender": "User",
"message": "msg",
"timestamp": 1700000000,
},
)()
with (
patch(
"app.routers.packets.try_decrypt_packet_with_channel_key",
return_value=mock_result,
),
patch("app.routers.packets.parse_packet", return_value=None),
patch("app.routers.packets.broadcast_success") as mock_success,
):
await _run_historical_channel_decryption(
channel_key_bytes,
channel_key_hex,
None, # No display name
)
# Should use key prefix as display name
call_msg = mock_success.call_args[0][0]
assert channel_key_hex[:12] in call_msg
class TestMaintenanceEndpoint:
"""Test POST /api/packets/maintenance."""
@pytest.mark.asyncio
async def test_prune_old_undecrypted(self, test_db, client):
"""Prune deletes undecrypted packets older than threshold."""
await _insert_raw_packets(3, decrypted=False, age_days=30)
await _insert_raw_packets(2, decrypted=False, age_days=0)
response = await client.post(
"/api/packets/maintenance",
json={"prune_undecrypted_days": 7},
)
assert response.status_code == 200
data = response.json()
assert data["packets_deleted"] == 3
# Verify only recent packets remain
remaining = await RawPacketRepository.get_undecrypted_count()
assert remaining == 2
@pytest.mark.asyncio
async def test_purge_linked_raw_packets(self, test_db, client):
"""Purge deletes raw packets that are linked to stored messages."""
await _insert_raw_packets(3, decrypted=True)
await _insert_raw_packets(2, decrypted=False)
response = await client.post(
"/api/packets/maintenance",
json={"purge_linked_raw_packets": True},
)
assert response.status_code == 200
data = response.json()
assert data["packets_deleted"] == 3
# Undecrypted packets should remain
remaining = await RawPacketRepository.get_undecrypted_count()
assert remaining == 2
@pytest.mark.asyncio
async def test_both_prune_and_purge(self, test_db, client):
"""Both prune and purge can run in a single request."""
await _insert_raw_packets(2, decrypted=True)
await _insert_raw_packets(3, decrypted=False, age_days=30)
await _insert_raw_packets(1, decrypted=False, age_days=0)
response = await client.post(
"/api/packets/maintenance",
json={
"prune_undecrypted_days": 7,
"purge_linked_raw_packets": True,
},
)
assert response.status_code == 200
data = response.json()
# 2 linked + 3 old undecrypted = 5 deleted
assert data["packets_deleted"] == 5
@pytest.mark.asyncio
async def test_no_options_deletes_nothing(self, test_db, client):
"""No options specified means no deletions (only vacuum)."""
await _insert_raw_packets(5)
response = await client.post(
"/api/packets/maintenance",
json={},
)
assert response.status_code == 200
data = response.json()
assert data["packets_deleted"] == 0
@pytest.mark.asyncio
async def test_vacuum_reports_status(self, test_db, client):
"""Maintenance endpoint reports vacuum status."""
response = await client.post(
"/api/packets/maintenance",
json={},
)
assert response.status_code == 200
data = response.json()
# vacuumed is a boolean (may be True or False depending on DB state)
assert isinstance(data["vacuumed"], bool)
@pytest.mark.asyncio
async def test_prune_days_validation(self, test_db, client):
"""prune_undecrypted_days must be >= 1."""
response = await client.post(
"/api/packets/maintenance",
json={"prune_undecrypted_days": 0},
)
assert response.status_code == 422