From 45680a22cfed98c0d6baaecea4c773c0bb258c91 Mon Sep 17 00:00:00 2001 From: SaarMesh-Bot Date: Mon, 12 Oct 2026 15:25:59 +0200 Subject: [PATCH] fix(#2191): apply the channel filter to live WebSocket packets (#2192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2191. ## Problem On the Packets page the channel filter only applies to the initial load (`/api/packets?channel=` → server-side `transmissions.channel_hash` match). The WebSocket handler re-checks time window, type, observer, region and node, but not `filters.channel`, and the channel filter is not re-applied at render time. With a channel selected, every new live packet is appended regardless of its channel. ## Fix - New pure helper `packetMatchesChannel(p, channel)` in `public/packets.js`, exported as `window._packetsMatchesChannelForTest` (same pattern as `_packetsBuildChannelOptionsForTest`). - It derives the value the server filters on from `decoded_json`, because WS packets don't carry `channel_hash`: - `channel` when the server decrypted the `GRP_TXT`, - otherwise `'enc_' + channelHashHex.toUpperCase()`. - Only `payload_type === 5` can match, mirroring the server (the column is only set for `GRP_TXT`). If a packet ever does carry `channel_hash`, that wins. - The WS filter calls it: `if (filters.channel && !packetMatchesChannel(p, filters.channel)) return false;` Checked the derivation against a production DB (live.saarmesh.de): it equals `channel_hash` for all 3,193 `GRP_TXT` rows of the last two days, 0 mismatches. ## Performance WS hot path, but the new check runs only while a channel filter is active, and for non-`GRP_TXT` packets it returns before any parsing. For `GRP_TXT` it is one `JSON.parse` of the already-received `decoded_json` string, O(1) per packet. WS messages are already debounced/coalesced by `debouncedOnWS`, so at typical rates (a few packets per second) this is negligible. No new state, no allocations beyond the parse, no API calls. ## Tests New `tests/unit/test-packets-live-channel-filter.js` (registered in `test-all.sh`), 10 cases: - no filter accepts everything - decrypted name match / mismatch - non-`GRP_TXT` rejected while a channel filter is active - `enc_` match incl. hex case normalisation and `enc_00` - `channel_hash` passthrough takes precedence - already-parsed `decoded_json` object - malformed / missing `decoded_json` doesn't throw and doesn't match - source check that the WS filter block actually applies the helper Without the `packets.js` change the new test fails; with it all pass. Existing `test-packets.js` (117), `test-packets-local-channels.js` (14), `test-packet-filter.js` (99) and `test-frontend-helpers.js` still pass. ## Browser validation The same change is deployed on live.saarmesh.de (hot-patched `packets.js`, server restarted so `__BUST__` refreshed; the served file contains the fix). I have not taken a screenshot of the filtered live table myself, so treat the in-browser check as pending until a reviewer or operator confirms. No config or customizer implications. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HJcbiRjQC5cSmydVFXbPbR Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude --- public/packets.js | 22 +++ test-all.sh | 1 + .../unit/test-packets-live-channel-filter.js | 127 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 tests/unit/test-packets-live-channel-filter.js diff --git a/public/packets.js b/public/packets.js index c35ff262..8c5be8b9 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1246,6 +1246,27 @@ window._packetsCollectLocalChannelsForTest = collectLocalChannels; } + /** + * Does a live (WebSocket) packet belong to the selected channel filter? + * + * /api/packets?channel=X matches `transmissions.channel_hash = X` server-side: + * the channel name when the server decrypted the GRP_TXT, otherwise the + * synthetic "enc_". That column is only set for payload_type 5. WS + * packets don't carry it, so derive the same value from decoded_json — + * without this, every new live packet leaks past an active channel filter. + */ + function packetMatchesChannel(p, channel) { + if (!channel) return true; + if (!p || p.payload_type !== 5) return false; + if (p.channel_hash) return p.channel_hash === channel; + let d = p.decoded_json; + if (typeof d === 'string') { try { d = JSON.parse(d); } catch (e) { d = null; } } + d = d || {}; + const ch = d.channel || (d.channelHashHex != null ? 'enc_' + String(d.channelHashHex).toUpperCase() : null); + return ch === channel; + } + if (typeof window !== 'undefined') window._packetsMatchesChannelForTest = packetMatchesChannel; + function renderTimestampCell(isoString) { if (typeof formatTimestampWithTooltip !== 'function' || typeof getTimestampMode !== 'function') { return escapeHtml(typeof timeAgo === 'function' ? timeAgo(isoString) : '—'); @@ -1467,6 +1488,7 @@ if (!obs || !selectedRegions.includes(obs.iata)) return false; } if (filters.node && !(p.decoded_json || '').includes(filters.node)) return false; + if (filters.channel && !packetMatchesChannel(p, filters.channel)) return false; return true; }); if (!filtered.length) return; diff --git a/test-all.sh b/test-all.sh index 50c47f1e..2b17ee0a 100755 --- a/test-all.sh +++ b/test-all.sh @@ -174,6 +174,7 @@ node tests/unit/test-packet-filter-time.js node tests/unit/test-packet-filter-ux.js node tests/unit/test-packet-filter.js node tests/unit/test-packets-local-channels.js +node tests/unit/test-packets-live-channel-filter.js node tests/unit/test-packets.js node tests/unit/test-panel-corner.js node tests/unit/test-path-inspector.js diff --git a/tests/unit/test-packets-live-channel-filter.js b/tests/unit/test-packets-live-channel-filter.js new file mode 100644 index 00000000..93bd82d6 --- /dev/null +++ b/tests/unit/test-packets-live-channel-filter.js @@ -0,0 +1,127 @@ +/** + * Unit tests for the live (WebSocket) channel filter on the packets page. + * + * The initial load honours ?channel= server-side (transmissions.channel_hash: + * channel name when decrypted, else "enc_"). Live WS packets don't carry + * that column, so the WS filter in packets.js used to ignore the channel + * selection and every new packet leaked into a channel-filtered table. + * + * packetMatchesChannel() derives the same value from decoded_json; it is pure + * and exported as window._packetsMatchesChannelForTest. + * + * Covers: no filter, decrypted name match/mismatch, non-GRP_TXT rejection, + * encrypted "enc_" match (case-normalised), channel_hash passthrough, + * already-parsed decoded_json, malformed JSON, and that the WS filter + * actually calls the helper. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +// Same tolerant browser sandbox as test-packets-local-channels.js: the helper +// is exported before any DOM-dependent code runs. +const noop = () => {}; +const fakeEl = { + addEventListener: noop, removeEventListener: noop, appendChild: noop, remove: noop, + querySelector: () => null, querySelectorAll: () => [], setAttribute: noop, + getAttribute: () => null, classList: { add: noop, remove: noop, toggle: noop, contains: () => false }, + style: {}, dataset: {}, textContent: '', innerHTML: '', +}; +const doc = { + readyState: 'complete', createElement: () => ({ ...fakeEl }), head: fakeEl, body: fakeEl, + getElementById: () => null, querySelector: () => null, querySelectorAll: () => [], + addEventListener: noop, removeEventListener: noop, +}; +const win = { + addEventListener: noop, removeEventListener: noop, + matchMedia: () => ({ matches: false, addEventListener: noop, removeEventListener: noop }), + PayloadLabels: { SHORT_BY_ID: {} }, +}; +const ctx = { + window: win, document: doc, console, Date, Math, JSON, Set, Map, Array, Object, Promise, Error, + setTimeout, clearTimeout, setInterval, clearInterval, + history: { replaceState: noop, pushState: noop }, + location: { hash: '', href: '', pathname: '/' }, + navigator: { userAgent: 'node' }, + localStorage: { getItem: () => null, setItem: noop, removeItem: noop }, + registerPage: noop, + api: () => Promise.resolve({}), + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), +}; +ctx.self = ctx; +vm.createContext(ctx); +const src = fs.readFileSync('public/packets.js', 'utf8'); +try { + vm.runInContext(src, ctx); +} catch (e) { + // Downstream init may throw on the stub DOM — the helper is already exported. +} + +const match = win._packetsMatchesChannelForTest; +assert.strictEqual(typeof match, 'function', 'packetMatchesChannel must be exported for tests'); + +let passed = 0; +function test(name, fn) { + fn(); + passed++; + console.log(' ✓ ' + name); +} + +const grp = (decoded) => ({ payload_type: 5, decoded_json: JSON.stringify(decoded) }); + +console.log('packetMatchesChannel'); + +test('no channel filter accepts everything', () => { + assert.strictEqual(match({ payload_type: 4, decoded_json: '{}' }, undefined), true); + assert.strictEqual(match(grp({ channel: '#test' }), ''), true); +}); + +test('decrypted GRP_TXT matches its channel name', () => { + assert.strictEqual(match(grp({ type: 'CHAN', channel: '#saarland', decryptionStatus: 'decrypted' }), '#saarland'), true); +}); + +test('decrypted GRP_TXT of another channel is rejected', () => { + assert.strictEqual(match(grp({ type: 'CHAN', channel: '#test', decryptionStatus: 'decrypted' }), '#saarland'), false); +}); + +test('non-GRP_TXT packets are rejected while a channel filter is active', () => { + assert.strictEqual(match({ payload_type: 4, decoded_json: JSON.stringify({ type: 'ADVERT' }) }, '#saarland'), false); + assert.strictEqual(match({ payload_type: 2, decoded_json: '{}' }, 'enc_5F'), false); + assert.strictEqual(match(null, '#saarland'), false); +}); + +test('encrypted GRP_TXT matches enc_ (hex case-normalised)', () => { + const p = grp({ type: 'GRP_TXT', channelHash: 95, channelHashHex: '5f', decryptionStatus: 'decryption_failed' }); + assert.strictEqual(match(p, 'enc_5F'), true); + assert.strictEqual(match(p, 'enc_76'), false); + assert.strictEqual(match(p, '#saarland'), false); +}); + +test('encrypted channel hash 00 still derives enc_00', () => { + assert.strictEqual(match(grp({ channelHash: 0, channelHashHex: '00' }), 'enc_00'), true); +}); + +test('server-provided channel_hash takes precedence', () => { + assert.strictEqual(match({ payload_type: 5, channel_hash: '#saarland', decoded_json: '{}' }, '#saarland'), true); + assert.strictEqual(match({ payload_type: 5, channel_hash: '#test', decoded_json: JSON.stringify({ channel: '#saarland' }) }, '#saarland'), false); +}); + +test('already-parsed decoded_json object is accepted', () => { + assert.strictEqual(match({ payload_type: 5, decoded_json: { channel: 'Public' } }, 'Public'), true); +}); + +test('malformed decoded_json does not throw and does not match', () => { + assert.strictEqual(match({ payload_type: 5, decoded_json: '{not json' }, '#saarland'), false); + assert.strictEqual(match({ payload_type: 5 }, '#saarland'), false); +}); + +test('WS live filter applies the channel filter', () => { + const start = src.indexOf('Check if new packets pass current filters'); + assert.ok(start > 0, 'WS filter block not found'); + const block = src.slice(start, src.indexOf('if (!filtered.length) return;', start)); + assert.ok(/filters\.channel\s*&&\s*!packetMatchesChannel\(p,\s*filters\.channel\)/.test(block), + 'WS filter must reject packets that do not match filters.channel'); +}); + +console.log(`\n${passed} passed`);