mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 09:49:58 +00:00
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_<HH>` 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
SaarMesh-Bot
Claude
parent
60af9bd469
commit
45680a22cf
@@ -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_<HH>". 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<HH>"). 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_<HH>" 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_<HH> (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`);
|
||||
Reference in New Issue
Block a user