mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 03:11:36 +00:00
e74e860725
## What Two Phosphor lint-gate breaches found by the v3.8.4 manual-test executor — app-controlled UI labels still shipping raw emoji glyphs that the M6 final sweep (#1648) missed. One PR, two sprite swaps, same playbook as #1657. ### Findings | Test ID | Surface | Glyph | File:line | Fix | |---|---|---|---|---| | v384-1.2 | `/observers` `.obs-clock-naive-chip` | `⚠️` (U+26A0) ×14 | `public/observers.js:30` | `ph-warning` sprite | | v384-12.18 | `/analytics?tab=channels` encrypted row name cells | `🔒` (U+1F512) ×158 | `public/analytics.js:978–979` | `ph-lock` sprite | Finding 2 is a different surface from the M3/#1657 fix (which swapped the section-header label, not the per-row `displayName`). The unknown-encrypted row's `displayName` carried a raw `🔒 Encrypted (0xNN)` text label that then flowed through `esc()` into the rendered name cell as an escaped emoji glyph — exactly the same `innerText → innerHTML` class of bug. Refactored to mirror the section-header pattern: `displayNameHtml` carries the sprite-bearing raw HTML; `displayName` stays plain text for sort/aria/tests. ## TDD - **RED** `cde12370` — `test-issue-1648-followup-phosphor-leaks.js` asserts ph-warning sprite + zero ⚠ in chip output, and ph-lock sprite + zero 🔒 in analytics row labels. 6 assertions failed on master. - **GREEN** `f1c64b17` — sprite swaps applied. All 9 assertions pass. - **Anti-tautology proven both directions**: reverting only `public/observers.js` → 2 chip-related assertions fail; reverting only `public/analytics.js` → 4 analytics-related assertions fail. ## Verify - ✅ `node test-issue-1648-followup-phosphor-leaks.js` — 9/9 pass - ✅ `node test-issue-1648-m6-final-sweep.js` — 0 violations - ✅ `node test-observer-naive-clock-1478.js` — 8/8 pass (existing chip test accepts ph-warning sprite) - ✅ `node test-analytics-channels-integration.js` — pre-existing unrelated `Channel Analytics` failure only; encrypted-row assertions all pass with new plain-text `displayName` - ✅ pr-preflight all gates green (PII, branch-scope, red-commit, CSS-var, LIKE-on-JSON, async-migration, XSS sinks) - ✅ Browser-verified on staging: 11 chips render ph-warning sprite (0 emoji), 156 ph-lock sprites in row name cells (0 lock emoji on page) Browser verified: http://analyzer-stg.00id.net/#/observers + /#/analytics?tab=channels (hot-patched) E2E assertion added: `test-issue-1648-followup-phosphor-leaks.js:67` (chip), `test-issue-1648-followup-phosphor-leaks.js:147` (row cell) --------- Co-authored-by: meshcore-bot <bot@meshcore.dev>
189 lines
7.9 KiB
JavaScript
189 lines
7.9 KiB
JavaScript
/**
|
|
* Analytics → Channels section integration with PSK decrypt UX.
|
|
*
|
|
* Bug: the analytics channels list shows nonsense names like "ch185" for
|
|
* every encrypted channel and ignores the user's locally-decrypted PSK
|
|
* channels (from ChannelDecrypt.getStoredKeys() + label store).
|
|
*
|
|
* Fix:
|
|
* 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel
|
|
* is encrypted and the server only knows its hash byte.
|
|
* 2. For channels matching a locally-stored PSK key, show the user's
|
|
* label / key-name instead of the hash-byte placeholder.
|
|
* 3. Group rendering: My Channels → Network → Encrypted, each sorted by
|
|
* message count descending.
|
|
* 4. Add a link from the Channels page to the Analytics page so users can
|
|
* jump to channel activity stats.
|
|
*/
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
let passed = 0, failed = 0;
|
|
function assert(cond, msg) {
|
|
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
|
else { failed++; console.error(' ✗ ' + msg); }
|
|
}
|
|
|
|
// ── Set up a tiny browser-ish global so analytics.js loads cleanly ──────────
|
|
global.window = global;
|
|
global.document = {
|
|
documentElement: {},
|
|
createElement: () => ({ style: {}, addEventListener() {} }),
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
getElementById: () => null,
|
|
};
|
|
global.localStorage = {
|
|
_s: {},
|
|
getItem(k) { return this._s[k] || null; },
|
|
setItem(k, v) { this._s[k] = String(v); },
|
|
removeItem(k) { delete this._s[k]; },
|
|
};
|
|
global.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
|
global.registerPage = () => {};
|
|
global.api = async () => ({});
|
|
global.fetch = async () => ({ ok: true, json: async () => ({}) });
|
|
global.CLIENT_TTL = {};
|
|
global.RegionFilter = { getRegionParam: () => '' };
|
|
global.Storage = function () {};
|
|
global.timeAgo = () => '';
|
|
global.histogram = () => ({ svg: '' });
|
|
|
|
// Load analytics.js — it self-registers global helpers we test.
|
|
const analyticsSrc = fs.readFileSync(
|
|
path.join(__dirname, 'public/analytics.js'),
|
|
'utf8'
|
|
);
|
|
// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's
|
|
// fine to eval as-is.
|
|
// eslint-disable-next-line no-eval
|
|
eval(analyticsSrc); // sets window._analyticsDecorateChannels etc.
|
|
|
|
console.log('\n=== Analytics channels: decorate with PSK keys ===');
|
|
|
|
const decorate = global._analyticsDecorateChannels;
|
|
assert(typeof decorate === 'function',
|
|
'_analyticsDecorateChannels exposed for testing');
|
|
|
|
// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN".
|
|
const sampleChannels = [
|
|
{ hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false },
|
|
{ hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false },
|
|
{ hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true },
|
|
{ hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true },
|
|
{ hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true },
|
|
{ hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false },
|
|
// Rainbow-table hit on an ENCRYPTED channel: server resolved a real name.
|
|
{ hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true },
|
|
// Encrypted channel with empty name — must not render an empty <strong>.
|
|
{ hash: 200, name: '', messages: 5, senders: 0, encrypted: true },
|
|
];
|
|
|
|
// User has two PSK keys locally: one matches hash=185 (named "Levski"),
|
|
// one matches hash=30 (named "secret-room", with label "Garage").
|
|
const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' };
|
|
const labels = { 'secret-room': 'Garage' };
|
|
|
|
const out = decorate(sampleChannels, myKeyHashToName, labels);
|
|
assert(Array.isArray(out), 'decorate returns an array');
|
|
assert(out.length === sampleChannels.length, 'decorate keeps every channel');
|
|
|
|
// Find by original hash (and optionally original name) for assertions.
|
|
// Decoration preserves c.name as-is and writes the user-facing string to
|
|
// c.displayName, so matching on c.name is unambiguous.
|
|
function find(hash, name) {
|
|
return out.find(c => c.hash === hash && (name == null || c.name === name));
|
|
}
|
|
|
|
const mine185 = find(185, 'ch185');
|
|
assert(mine185 && mine185.displayName === 'Levski',
|
|
'hash 185 + stored key → displayName = "Levski" (not "ch185")');
|
|
assert(mine185 && mine185.group === 'mine',
|
|
'hash 185 grouped as "mine"');
|
|
|
|
const mine30 = find(30, 'ch30');
|
|
assert(mine30 && mine30.displayName === 'Garage',
|
|
'hash 30 with stored key + label → displayName = "Garage" (label wins)');
|
|
assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"');
|
|
|
|
const ch64 = find(64, 'ch64');
|
|
assert(ch64 && ch64.displayName === 'Encrypted (0x40)',
|
|
'unknown encrypted ch64 → "Encrypted (0x40)" (no nonsense "ch64")');
|
|
assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"');
|
|
|
|
const pub = find(17, 'public');
|
|
assert(pub && pub.displayName === 'public', 'cleartext public name preserved');
|
|
assert(pub && pub.group === 'network', 'cleartext public grouped as "network"');
|
|
|
|
const test = find(217, '#test');
|
|
assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"');
|
|
|
|
// Rainbow-table hit on an ENCRYPTED channel — actually exercises the
|
|
// "encrypted but server has the real name" branch (was previously dead-untested).
|
|
const rainbow = find(12, 'public-meshcore');
|
|
assert(rainbow && rainbow.encrypted === true,
|
|
'rainbow row preserves encrypted=true');
|
|
assert(rainbow && rainbow.displayName === 'public-meshcore',
|
|
'rainbow-decoded encrypted row → displayName = real name');
|
|
assert(rainbow && rainbow.group === 'network',
|
|
'rainbow-decoded encrypted row → group = "network"');
|
|
|
|
// Empty-name encrypted: must NOT leak through with displayName = ''.
|
|
const empty = find(200, '');
|
|
assert(empty && empty.displayName === 'Encrypted (0xC8)',
|
|
'encrypted with empty name → render as opaque encrypted placeholder');
|
|
assert(empty && empty.group === 'encrypted',
|
|
'encrypted with empty name → group = "encrypted"');
|
|
|
|
// No "chNNN" leaks into displayName for any row.
|
|
const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName));
|
|
assert(!leak, 'no displayName matches the raw chNNN placeholder');
|
|
|
|
console.log('\n=== Grouped table render: order + sort ===');
|
|
|
|
const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', {
|
|
grouped: true,
|
|
});
|
|
assert(typeof tbody === 'string' && tbody.length > 0,
|
|
'channelTbodyHtml accepts grouped option and returns html');
|
|
|
|
// Group headers must appear in order: My Channels, Network, Encrypted.
|
|
const iMine = tbody.indexOf('My Channels');
|
|
const iNet = tbody.indexOf('Network');
|
|
const iEnc = tbody.indexOf('Encrypted');
|
|
assert(iMine >= 0 && iNet > iMine && iEnc > iNet,
|
|
'group headers render in order: My Channels → Network → Encrypted');
|
|
|
|
// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs).
|
|
const i30 = tbody.indexOf('Garage');
|
|
const i185 = tbody.indexOf('Levski');
|
|
assert(i30 > 0 && i185 > i30,
|
|
'within "My Channels" sort by messages desc (Garage 75 before Levski 50)');
|
|
|
|
// Within "network" section, #test (200) > public (100) > #earthquake (10).
|
|
const iT = tbody.indexOf('#test');
|
|
const iP = tbody.indexOf('public');
|
|
const iE = tbody.indexOf('#earthquake');
|
|
assert(iT > 0 && iP > iT && iE > iP,
|
|
'within "Network" sort by messages desc (#test → public → #earthquake)');
|
|
|
|
// Within "encrypted" section, ch64 (300 msgs) appears (only one entry).
|
|
assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40');
|
|
|
|
console.log('\n=== Channels page links to Analytics ===');
|
|
|
|
const channelsSrc = fs.readFileSync(
|
|
path.join(__dirname, 'public/channels.js'),
|
|
'utf8'
|
|
);
|
|
assert(/#\/analytics/.test(channelsSrc) &&
|
|
/Channel Analytics|channel analytics/i.test(channelsSrc),
|
|
'channels.js sidebar links to #/analytics with "Channel Analytics" text');
|
|
|
|
console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed');
|
|
process.exit(failed ? 1 : 0);
|