From e0fb093612de21f6f06803638f54cb339416cbb5 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 4 Mar 2026 10:16:17 -0800 Subject: [PATCH] Fix non-cache refresh on unfocused active threads; doc and test improvements --- AGENTS.md | 7 +- README.md | 2 +- app/AGENTS.md | 3 + frontend/AGENTS.md | 16 ++- frontend/src/App.tsx | 11 ++ frontend/src/messageCache.ts | 3 + frontend/src/test/messageCache.test.ts | 17 +++ tests/test_real_crypto.py | 185 +++++++++++++++++++++++++ 8 files changed, 236 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 422a55e..ae8aa39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ If instructed to "run all tests" or "get ready for a commit" or other summative, ./scripts/all_quality.sh ``` -This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green. +This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green. ## Overview @@ -170,7 +170,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ │ └── ... │ └── vite.config.ts ├── scripts/ -│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized) +│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential) │ ├── collect_licenses.sh # Gather third-party license attributions │ ├── e2e.sh # End-to-end test runner │ └── publish.sh # Version bump, changelog, docker build & push @@ -242,6 +242,7 @@ Key test files: - `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field - `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast) - `tests/test_real_crypto.py` - Real cryptographic operations +- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature ### Frontend (Vitest) @@ -252,7 +253,7 @@ npm run test:run ### Before Completing Changes -**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors. +**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds sequentially, catching type mismatches, breaking changes, and compilation errors. ## API Summary diff --git a/README.md b/README.md index 88c923b..4dcf0f9 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development. Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend. -Run everything at once (parallelized): +Run everything at once: ```bash ./scripts/all_quality.sh diff --git a/app/AGENTS.md b/app/AGENTS.md index 09d6f6f..8b7efe1 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -212,6 +212,8 @@ app/ - `message` — new message (channel or DM, from packet processor or send endpoints) - `message_acked` — ACK/echo update for existing message (ack count + paths) - `raw_packet` — every incoming RF packet (for real-time packet feed UI) +- `contact_deleted` — contact removed from database (payload: `{ public_key }`) +- `channel_deleted` — channel removed from database (payload: `{ key }`) - `error` — toast notification (reconnect failure, missing private key, etc.) - `success` — toast notification (historical decrypt complete, etc.) @@ -271,6 +273,7 @@ tests/ ├── test_config.py # Configuration validation ├── test_contacts_router.py # Contacts router endpoints ├── test_decoder.py # Packet parsing/decryption +├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature ├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent) ├── test_event_handlers.py # ACK tracking, event registration, cleanup ├── test_frontend_static.py # Frontend static file serving diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 209e18e..73998c2 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -27,6 +27,7 @@ frontend/src/ ├── prefetch.ts # Consumes prefetched API promises started in index.html ├── index.css # Global styles/utilities ├── styles.css # Additional global app styles +├── themes.css # Color theme definitions ├── lib/ │ └── utils.ts # cn() — clsx + tailwind-merge helper ├── hooks/ @@ -53,7 +54,8 @@ frontend/src/ │ ├── lastViewedConversation.ts # localStorage for last-viewed conversation │ ├── contactMerge.ts # Merge WS contact updates into list │ ├── localLabel.ts # Local label (text + color) in localStorage -│ └── radioPresets.ts # LoRa radio preset configurations +│ ├── radioPresets.ts # LoRa radio preset configurations +│ └── theme.ts # Theme switching helpers ├── components/ │ ├── StatusBar.tsx │ ├── Sidebar.tsx @@ -75,6 +77,7 @@ frontend/src/ │ ├── ContactStatusInfo.tsx # Contact status info component │ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes │ ├── RepeaterLogin.tsx # Repeater login form (password + guest) +│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders) │ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations │ ├── settings/ │ │ ├── settingsConstants.ts # Settings section type, ordering, labels @@ -85,7 +88,8 @@ frontend/src/ │ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label │ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset │ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats -│ │ └── SettingsAboutSection.tsx # Version, author, license, links +│ │ ├── SettingsAboutSection.tsx # Version, author, license, links +│ │ └── ThemeSelector.tsx # Color theme picker │ ├── repeater/ │ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers │ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts @@ -125,6 +129,10 @@ frontend/src/ ├── sidebar.test.tsx ├── unreadCounts.test.ts ├── urlHash.test.ts + ├── appSearchJump.test.tsx + ├── channelInfoKeyVisibility.test.tsx + ├── chatHeaderKeyVisibility.test.tsx + ├── searchView.test.tsx ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts ├── useRepeaterDashboard.test.ts @@ -177,7 +185,7 @@ frontend/src/ - Auto reconnect (3s) with cleanup guard on unmount. - Heartbeat ping every 30s. -- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored). +- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored). - For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat. ## URL Hash Navigation (`utils/urlHash.ts`) @@ -310,7 +318,7 @@ Do not rely on old class-only layout assumptions. ## Testing -Run all quality checks (backend + frontend, parallelized) from the repo root: +Run all quality checks (backend + frontend) from the repo root: ```bash ./scripts/all_quality.sh diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d99c363..5f34150 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -307,10 +307,20 @@ export function App() { onContactDeleted: (publicKey: string) => { setContacts((prev) => prev.filter((c) => c.public_key !== publicKey)); messageCache.remove(publicKey); + const active = activeConversationRef.current; + if (active?.type === 'contact' && active.id === publicKey) { + pendingDeleteFallbackRef.current = true; + setActiveConversation(null); + } }, onChannelDeleted: (key: string) => { setChannels((prev) => prev.filter((c) => c.key !== key)); messageCache.remove(key); + const active = activeConversationRef.current; + if (active?.type === 'channel' && active.id === key) { + pendingDeleteFallbackRef.current = true; + setActiveConversation(null); + } }, onRawPacket: (packet: RawPacket) => { setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS)); @@ -331,6 +341,7 @@ export function App() { setConfig, activeConversationRef, hasNewerMessagesRef, + setActiveConversation, setContacts, setChannels, triggerReconcile, diff --git a/frontend/src/messageCache.ts b/frontend/src/messageCache.ts index 6528915..81ebf9c 100644 --- a/frontend/src/messageCache.ts +++ b/frontend/src/messageCache.ts @@ -76,6 +76,9 @@ export function addMessage(id: string, msg: Message, contentKey: string): boolea .sort((a, b) => b.received_at - a.received_at) .slice(0, MAX_MESSAGES_PER_ENTRY); } + // Promote to MRU so actively-messaged conversations aren't evicted + cache.delete(id); + cache.set(id, entry); return true; } diff --git a/frontend/src/test/messageCache.test.ts b/frontend/src/test/messageCache.test.ts index 89e5cea..2f3da25 100644 --- a/frontend/src/test/messageCache.test.ts +++ b/frontend/src/test/messageCache.test.ts @@ -239,6 +239,23 @@ describe('messageCache', () => { expect(entry!.seenContent.has('CHAN-channel123-First contact-1700000000')).toBe(true); }); + it('promotes entry to MRU on addMessage', () => { + // Fill cache to capacity + for (let i = 0; i < MAX_CACHED_CONVERSATIONS; i++) { + messageCache.set(`conv${i}`, createEntry([createMessage({ id: i })])); + } + + // addMessage to conv0 (currently LRU) should promote it + const msg = createMessage({ id: 999, text: 'Incoming WS message' }); + messageCache.addMessage('conv0', msg, 'CHAN-channel123-Incoming WS message-1700000000'); + + // Add one more — conv1 should now be LRU and get evicted, not conv0 + messageCache.set('conv_new', createEntry()); + + expect(messageCache.get('conv0')).toBeDefined(); // Was promoted by addMessage + expect(messageCache.get('conv1')).toBeUndefined(); // Was LRU, evicted + }); + it('returns false for duplicate delivery to auto-created entry', () => { const msg = createMessage({ id: 10, text: 'Echo' }); const contentKey = 'CHAN-channel123-Echo-1700000000'; diff --git a/tests/test_real_crypto.py b/tests/test_real_crypto.py index 24d26f1..6aae1c8 100644 --- a/tests/test_real_crypto.py +++ b/tests/test_real_crypto.py @@ -346,6 +346,191 @@ class TestHistoricalDMDecryptionPipeline: assert "1 message" in args[1] +class TestLiveDMDecryptionPipeline: + """Integration test: process a real DM packet through the live + process_raw_packet → _process_direct_message → try_decrypt_dm pipeline + with no crypto mocking.""" + + @pytest.fixture(autouse=True) + def _reset_keystore(self): + """Reset the global keystore state between tests.""" + import app.keystore as ks + + orig_priv, orig_pub = ks._private_key, ks._public_key + ks._private_key = None + ks._public_key = None + yield + ks._private_key, ks._public_key = orig_priv, orig_pub + + @pytest.mark.asyncio + async def test_process_dm_packet_end_to_end(self, test_db, captured_broadcasts): + """process_raw_packet decrypts a real DM packet and stores the message + with correct text, direction, and contact attribution.""" + from app.keystore import set_private_key + + # Set up: client2 is "us" (receiver), client1 is the sender + set_private_key(CLIENT2_PRIVATE) + + # Register client1 as a known contact + await ContactRepository.upsert( + { + "public_key": CLIENT1_PUBLIC_HEX, + "name": "Client1", + "type": 1, + } + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + from app.packet_processor import process_raw_packet + + result = await process_raw_packet(raw_bytes=DM_PACKET) + + # Verify process_raw_packet reports successful decryption + assert result is not None + assert result["decrypted"] is True + assert result["contact_name"] == "Client1" + assert result["message_id"] is not None + + # Verify message stored in DB with correct fields + messages = await MessageRepository.get_all( + msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10 + ) + assert len(messages) == 1 + msg = messages[0] + assert msg.text == DM_PLAINTEXT + assert msg.outgoing is False # We are client2, message is FROM client1 + assert msg.type == "PRIV" + + # Verify a "message" broadcast was sent + msg_broadcasts = [b for b in broadcasts if b["type"] == "message"] + assert len(msg_broadcasts) == 1 + assert msg_broadcasts[0]["data"]["text"] == DM_PLAINTEXT + assert msg_broadcasts[0]["data"]["outgoing"] is False + + @pytest.mark.asyncio + async def test_dm_from_unknown_contact_not_decrypted(self, test_db, captured_broadcasts): + """DM from an unknown contact (not in DB) is stored but not decrypted.""" + from app.keystore import set_private_key + + set_private_key(CLIENT2_PRIVATE) + # No contacts registered — client1 is unknown + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + from app.packet_processor import process_raw_packet + + result = await process_raw_packet(raw_bytes=DM_PACKET) + + # Raw packet is stored but not decrypted + assert result is not None + assert result["decrypted"] is False + + # No messages created (can't decrypt without knowing the contact) + messages = await MessageRepository.get_all( + msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10 + ) + assert len(messages) == 0 + + @pytest.mark.asyncio + async def test_dm_without_private_key_not_decrypted(self, test_db, captured_broadcasts): + """Without a private key in the keystore, DMs are stored but not decrypted.""" + # Don't call set_private_key — keystore is empty + + await ContactRepository.upsert( + { + "public_key": CLIENT1_PUBLIC_HEX, + "name": "Client1", + "type": 1, + } + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + from app.packet_processor import process_raw_packet + + result = await process_raw_packet(raw_bytes=DM_PACKET) + + assert result is not None + assert result["decrypted"] is False + + @pytest.mark.asyncio + async def test_dm_duplicate_packet_deduplicates(self, test_db, captured_broadcasts): + """Processing the same DM packet twice doesn't create duplicate messages.""" + from app.keystore import set_private_key + + set_private_key(CLIENT2_PRIVATE) + + await ContactRepository.upsert( + { + "public_key": CLIENT1_PUBLIC_HEX, + "name": "Client1", + "type": 1, + } + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + from app.packet_processor import process_raw_packet + + result1 = await process_raw_packet(raw_bytes=DM_PACKET) + await process_raw_packet(raw_bytes=DM_PACKET) + + # First processing succeeds + assert result1["decrypted"] is True + assert result1["message_id"] is not None + + # Only one message stored (dedup via unique constraint) + messages = await MessageRepository.get_all( + msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10 + ) + assert len(messages) == 1 + + @pytest.mark.asyncio + async def test_historical_then_live_deduplicates(self, test_db, captured_broadcasts): + """A DM decrypted historically and then received live doesn't duplicate.""" + from app.keystore import set_private_key + from app.packet_processor import process_raw_packet, run_historical_dm_decryption + + set_private_key(CLIENT2_PRIVATE) + + await ContactRepository.upsert( + { + "public_key": CLIENT1_PUBLIC_HEX, + "name": "Client1", + "type": 1, + } + ) + + broadcasts, mock_broadcast = captured_broadcasts + + with patch("app.packet_processor.broadcast_event", mock_broadcast): + # First: store packet undecrypted, then run historical decryption + await RawPacketRepository.create(DM_PACKET, 1700000000) + + with patch("app.websocket.broadcast_success"): + await run_historical_dm_decryption( + private_key_bytes=CLIENT2_PRIVATE, + contact_public_key_bytes=CLIENT1_PUBLIC, + contact_public_key_hex=CLIENT1_PUBLIC_HEX, + display_name="Client1", + ) + + # Then: same packet arrives again via live pipeline + await process_raw_packet(raw_bytes=DM_PACKET) + + # Only one message stored despite both paths processing it + messages = await MessageRepository.get_all( + msg_type="PRIV", conversation_key=CLIENT1_PUBLIC_HEX.lower(), limit=10 + ) + assert len(messages) == 1 + assert messages[0].text == DM_PLAINTEXT + + class TestHistoricalChannelDecryptionPipeline: """Integration test: store a real channel packet, process it through the channel message pipeline, verify correct message in DB."""