mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 20:24:43 +00:00
Closes #1029. ## Problem PSK-decrypted channels show new messages only after a full page refresh. The WebSocket live feed delivers `GRP_TXT` packets as encrypted blobs and the channel UI has no hook to auto-decrypt them with stored keys. The REST fetch path (used on initial load + on `selectChannel`) already decrypts; the WS path silently dropped on the floor. ## Fix Two new helpers in `public/channel-decrypt.js`: - `buildKeyMap()` → `Map<channelHashByte, { channelName, keyBytes, keyHex }>` built from `getStoredKeys()`. Cached and invalidated on `saveKey` / `removeKey`, so the WS hot path is O(1) per packet after the first build. - `tryDecryptLive(payload, keyMap)` → returns `{ sender, text, channelName, channelHashByte }` when the payload is an encrypted `GRP_TXT` whose channel hash matches a stored key and whose MAC verifies; `null` otherwise. `public/channels.js` wraps `debouncedOnWS` with an async pre-pass (`decryptLivePSKBatch`) that: 1. Skips the work entirely when no encrypted `GRP_TXT` is in the batch or no PSK keys are stored. 2. For each match, rewrites `payload.channel`, `payload.sender`, and `payload.text` so the existing `processWSBatch` consumes the packet exactly the same way it consumes a server-decrypted `CHAN`. 3. Bumps a per-channel `unread` counter for any decrypted message whose channel is not currently selected. The badge renders in the sidebar (`.ch-unread-badge`) and resets on `selectChannel`. `processWSBatch` itself is untouched, so the existing channel-view behavior, dedup-by-packet-hash, region filtering, and timestamp ticker all continue to work as before. ## TDD - **Red** (`2e1ff05`): `test-channel-live-decrypt.js` asserts the new helpers + the channels.js integration contract. With stub `buildKeyMap`/`tryDecryptLive` returning empty/null, the test compiles and runs to completion with **8/14 assertion failures** (no crashes, no missing-symbol errors). - **Green** (`1783658`): real implementation lands; **14/14 pass**. ## Verification (Rule 18) - `node test-channel-live-decrypt.js` → 14/14 pass - All other channel tests still pass: - `test-channel-decrypt-ecb.js` 7/7 - `test-channel-decrypt-insecure-context.js` 8/8 - `test-channel-decrypt-m345.js` 24/24 - `test-channel-psk-ux.js` 19/19 - `cd cmd/server && go build ./...` clean - Booted the server against the fixture DB and curled `/channel-decrypt.js`, `/channels.js`, `/style.css` — all three serve the new code with the auto-injected `__BUST__` cache buster. ## Performance The WS pre-pass is gated by a quick scan: zero-cost when no encrypted `GRP_TXT` is present in the batch. When PSK keys exist, the key map is cached (sig-keyed on the stored-keys snapshot) so `crypto.subtle.digest` runs once per stored key per change, not per packet. Each match costs one MAC verify + one ECB decrypt — the same work `fetchAndDecryptChannel` already does, just amortized over time instead of in a single batch. ## Out of scope - Decoupling the badge from the live feed (server should ideally tag packets with `decryptionStatus` before broadcast). Tracked separately. - Persisting the `unread` counter across reloads (currently in-memory). --------- Co-authored-by: clawbot <bot@corescope.local>
This commit is contained in:
@@ -210,12 +210,93 @@ window.ChannelDecrypt = (function () {
|
||||
// Alias used by channels.js
|
||||
var decryptPacket = decrypt;
|
||||
|
||||
// ---- Live PSK decrypt (WS path) ----
|
||||
//
|
||||
// Build a Map<channelHashByte, { channelName, keyBytes, keyHex }> from all
|
||||
// stored PSK keys so the WebSocket handler can do an O(1) lookup on each
|
||||
// incoming GRP_TXT packet. Hash byte derivation is async, so we cache the
|
||||
// map between calls and only rebuild when the stored-keys set changes.
|
||||
var _keyMapCache = null;
|
||||
var _keyMapSig = '';
|
||||
|
||||
function _keysSignature(keys) {
|
||||
var names = Object.keys(keys).sort();
|
||||
var sig = '';
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
sig += names[i] + '=' + keys[names[i]] + ';';
|
||||
}
|
||||
return sig;
|
||||
}
|
||||
|
||||
async function buildKeyMap() {
|
||||
var keys = getKeys();
|
||||
var sig = _keysSignature(keys);
|
||||
if (_keyMapCache && _keyMapSig === sig) return _keyMapCache;
|
||||
var map = new Map();
|
||||
var names = Object.keys(keys);
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var channelName = names[i];
|
||||
var keyHex = keys[channelName];
|
||||
if (!keyHex || typeof keyHex !== 'string') continue;
|
||||
var keyBytes;
|
||||
try { keyBytes = hexToBytes(keyHex); } catch (e) { continue; }
|
||||
if (keyBytes.length !== 16) continue;
|
||||
var hashByte;
|
||||
try { hashByte = await computeChannelHash(keyBytes); } catch (e) { continue; }
|
||||
// First-write-wins on collision (rare): different channel names can
|
||||
// hash to the same byte. The downstream MAC check still gates rendering.
|
||||
if (!map.has(hashByte)) {
|
||||
map.set(hashByte, { channelName: channelName, keyBytes: keyBytes, keyHex: keyHex });
|
||||
}
|
||||
}
|
||||
_keyMapCache = map;
|
||||
_keyMapSig = sig;
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to decrypt a live GRP_TXT payload using a prebuilt key map.
|
||||
* Returns { sender, text, channelName, channelHashByte } on success,
|
||||
* or null when no key matches, MAC verification fails, or the payload
|
||||
* is not an encrypted GRP_TXT.
|
||||
*/
|
||||
async function tryDecryptLive(payload, keyMap) {
|
||||
if (!payload || payload.type !== 'GRP_TXT') return null;
|
||||
if (!payload.encryptedData || !payload.mac) return null;
|
||||
if (!keyMap || typeof keyMap.get !== 'function') return null;
|
||||
var hashByte = payload.channelHash;
|
||||
// channelHash arrives as either a number or a hex string in some paths;
|
||||
// normalize to number so Map.get hits.
|
||||
if (typeof hashByte === 'string') {
|
||||
var n = parseInt(hashByte, 16);
|
||||
if (!isFinite(n)) return null;
|
||||
hashByte = n;
|
||||
}
|
||||
if (typeof hashByte !== 'number') return null;
|
||||
var entry = keyMap.get(hashByte);
|
||||
if (!entry) return null;
|
||||
var result;
|
||||
try {
|
||||
result = await decrypt(entry.keyBytes, payload.mac, payload.encryptedData);
|
||||
} catch (e) { return null; }
|
||||
if (!result) return null;
|
||||
return {
|
||||
sender: result.sender || 'Unknown',
|
||||
text: result.message || '',
|
||||
channelName: entry.channelName,
|
||||
channelHashByte: hashByte,
|
||||
timestamp: result.timestamp || null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ---- Key storage (localStorage) ----
|
||||
|
||||
function saveKey(channelName, keyHex, label) {
|
||||
var keys = getKeys();
|
||||
keys[channelName] = keyHex;
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ }
|
||||
_keyMapCache = null; // invalidate live-decrypt index
|
||||
if (typeof label === 'string' && label.trim()) {
|
||||
saveLabel(channelName, label.trim());
|
||||
}
|
||||
@@ -238,6 +319,7 @@ window.ChannelDecrypt = (function () {
|
||||
var keys = getKeys();
|
||||
delete keys[channelName];
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ }
|
||||
_keyMapCache = null; // invalidate live-decrypt index
|
||||
// Also clear cached messages and any label for this channel (#1020)
|
||||
clearChannelCache(channelName);
|
||||
var labels = getLabels();
|
||||
@@ -350,6 +432,8 @@ window.ChannelDecrypt = (function () {
|
||||
cacheMessages: cacheMessages,
|
||||
getCachedMessages: getCachedMessages,
|
||||
setCache: setCache,
|
||||
getCache: getCache
|
||||
getCache: getCache,
|
||||
buildKeyMap: buildKeyMap,
|
||||
tryDecryptLive: tryDecryptLive
|
||||
};
|
||||
})();
|
||||
|
||||
+69
-2
@@ -1032,8 +1032,68 @@
|
||||
processWSBatch(msgs, selectedRegions);
|
||||
}
|
||||
|
||||
// Pre-pass: rewrite encrypted GRP_TXT live packets into decrypted form
|
||||
// when a stored PSK key matches their channel hash byte (#1029 — live
|
||||
// PSK decrypt). Without this, users viewing a PSK-decrypted channel
|
||||
// had to refresh the page to see new messages.
|
||||
async function decryptLivePSKBatch(msgs) {
|
||||
if (typeof ChannelDecrypt === 'undefined' ||
|
||||
typeof ChannelDecrypt.tryDecryptLive !== 'function') {
|
||||
return;
|
||||
}
|
||||
// Quick scan: do any messages look like encrypted GRP_TXT?
|
||||
var anyEncrypted = false;
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
|
||||
if (p && p.type === 'GRP_TXT' && p.encryptedData && p.mac) { anyEncrypted = true; break; }
|
||||
}
|
||||
if (!anyEncrypted) return;
|
||||
var keyMap;
|
||||
try { keyMap = await ChannelDecrypt.buildKeyMap(); } catch (e) { return; }
|
||||
if (!keyMap || keyMap.size === 0) return;
|
||||
for (var j = 0; j < msgs.length; j++) {
|
||||
var m = msgs[j];
|
||||
var payload = m && m.data && m.data.decoded && m.data.decoded.payload;
|
||||
if (!payload || payload.type !== 'GRP_TXT' || !payload.encryptedData || !payload.mac) continue;
|
||||
var dec;
|
||||
try { dec = await ChannelDecrypt.tryDecryptLive(payload, keyMap); } catch (e) { dec = null; }
|
||||
if (!dec) continue;
|
||||
// Rewrite payload into a CHAN-like shape so processWSBatch picks it
|
||||
// up as a real message instead of an encrypted blob. Keep the original
|
||||
// hash byte for any downstream consumer that wants it.
|
||||
payload.channel = dec.channelName;
|
||||
payload.sender = dec.sender;
|
||||
payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text;
|
||||
payload.decryptedLocally = true;
|
||||
if (m.data.decoded.header) {
|
||||
// Leave payloadTypeName as GRP_TXT — processWSBatch already
|
||||
// accepts both 'message' and GRP_TXT-typed packet messages.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
handleWSBatch(msgs);
|
||||
var selectedRegions = getSelectedRegionsSnapshot();
|
||||
var prior = selectedHash;
|
||||
decryptLivePSKBatch(msgs).then(function () {
|
||||
// Bump unread for live-decrypted channels the user is NOT viewing.
|
||||
// Done here (not inside processWSBatch) so the count reflects ONLY
|
||||
// newly-decrypted live packets, not historical-fetch path.
|
||||
var bumped = false;
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload;
|
||||
if (!p || !p.decryptedLocally) continue;
|
||||
var chName = p.channel;
|
||||
if (!chName || chName === prior) continue;
|
||||
var ch = channels.find(function (c) { return c.hash === chName || c.name === chName || c.hash === ('user:' + chName); });
|
||||
if (ch) {
|
||||
ch.unread = (ch.unread || 0) + 1;
|
||||
bumped = true;
|
||||
}
|
||||
}
|
||||
processWSBatch(msgs, selectedRegions);
|
||||
if (bumped) renderChannelList();
|
||||
});
|
||||
});
|
||||
window._channelsHandleWSBatchForTest = handleWSBatch;
|
||||
window._channelsProcessWSBatchForTest = processWSBatch;
|
||||
@@ -1137,12 +1197,16 @@
|
||||
// #1020: explicit badge marker for "your key" so it's distinguishable
|
||||
// from server-known encrypted rows at a glance and for screen readers.
|
||||
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
|
||||
// #1029 Unread badge — bumped by live PSK decrypt for channels not currently selected.
|
||||
const unreadBadge = (ch.unread && ch.unread > 0)
|
||||
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
|
||||
: '';
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
|
||||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${removeBtn}
|
||||
</div>
|
||||
@@ -1156,6 +1220,9 @@
|
||||
const rp = RegionFilter.getRegionParam() || '';
|
||||
const request = beginMessageRequest(hash, rp);
|
||||
selectedHash = hash;
|
||||
// Clear unread badge on the channel we're about to view (#1029).
|
||||
var __selCh = channels.find(function (c) { return c.hash === hash; });
|
||||
if (__selCh && __selCh.unread) { __selCh.unread = 0; }
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
|
||||
@@ -524,6 +524,19 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.ch-item-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; }
|
||||
.ch-item-name { font-weight: 600; font-size: 14px; }
|
||||
.ch-item-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
||||
.ch-unread-badge {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 4px;
|
||||
background: var(--accent, #3b82f6);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 9px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ch-remove-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 13px; padding: 0 2px; margin-left: 4px; opacity: 0; transition: opacity 0.15s; line-height: 1; }
|
||||
button.ch-item:hover .ch-remove-btn { opacity: 0.6; }
|
||||
.ch-remove-btn:hover { opacity: 1 !important; color: var(--danger, #dc2626); }
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Tests for live PSK decrypt on WebSocket-delivered GRP_TXT packets.
|
||||
*
|
||||
* Bug: when a user has a stored PSK key for a channel and a new encrypted
|
||||
* GRP_TXT packet arrives via the WebSocket feed, the existing UI path
|
||||
* leaves it as an encrypted blob and only renders sender="Unknown" with
|
||||
* empty text. The user has to refresh the page to get the message decrypted
|
||||
* via the REST fetch path.
|
||||
*
|
||||
* Fix:
|
||||
* - ChannelDecrypt.buildKeyMap() -> Map<hashByte, { channelName, keyBytes, keyHex }>
|
||||
* - ChannelDecrypt.tryDecryptLive(payload, keyMap)
|
||||
* For GRP_TXT payloads with encryptedData/mac/channelHash matching
|
||||
* a stored key, returns { sender, text, channelName, channelHashByte }.
|
||||
* Returns null when no key matches or when MAC verification fails.
|
||||
* - channels.js processWSBatch() uses these to upgrade encrypted live
|
||||
* packets in-place before rendering, and bumps an unread badge for
|
||||
* channels the user is not currently viewing.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const vm = require('vm');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { subtle } = require('crypto').webcrypto;
|
||||
const { createCipheriv, createHmac, createHash } = require('crypto');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
function createSandbox() {
|
||||
const storage = {};
|
||||
const localStorage = {
|
||||
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
|
||||
setItem: (k, v) => { storage[k] = String(v); },
|
||||
removeItem: (k) => { delete storage[k]; },
|
||||
_data: storage,
|
||||
};
|
||||
const ctx = {
|
||||
window: {},
|
||||
crypto: { subtle },
|
||||
TextEncoder, TextDecoder, Uint8Array, Map, Set,
|
||||
localStorage,
|
||||
console, Date, JSON, parseInt, Math, String, Number, Object, Array, RegExp, Error, Promise, setTimeout,
|
||||
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
|
||||
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
|
||||
};
|
||||
ctx.window = ctx;
|
||||
ctx.self = ctx;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function buildEncryptedGrpTxt(channelName, sender, message) {
|
||||
const key = createHash('sha256').update(channelName).digest().slice(0, 16);
|
||||
const channelHash = createHash('sha256').update(key).digest()[0];
|
||||
const text = `${sender}: ${message}`;
|
||||
const inner = 5 + Buffer.byteLength(text, 'utf8') + 1; // ts(4)+flags(1)+text+null
|
||||
const padded = Math.ceil(inner / 16) * 16;
|
||||
const pt = Buffer.alloc(padded);
|
||||
pt.writeUInt32LE(Math.floor(Date.now() / 1000), 0);
|
||||
pt[4] = 0;
|
||||
pt.write(text, 5, 'utf8');
|
||||
// remaining bytes already 0 (includes null terminator + ECB padding)
|
||||
const cipher = createCipheriv('aes-128-ecb', key, null);
|
||||
cipher.setAutoPadding(false);
|
||||
const ct = Buffer.concat([cipher.update(pt), cipher.final()]);
|
||||
const secret = Buffer.concat([key, Buffer.alloc(16)]);
|
||||
const mac = createHmac('sha256', secret).update(ct).digest().slice(0, 2);
|
||||
return {
|
||||
payload: {
|
||||
type: 'GRP_TXT',
|
||||
channelHash,
|
||||
channelHashHex: channelHash.toString(16).padStart(2, '0'),
|
||||
mac: mac.toString('hex'),
|
||||
encryptedData: ct.toString('hex'),
|
||||
decryptionStatus: 'no_key',
|
||||
},
|
||||
keyHex: key.toString('hex'),
|
||||
channelHash,
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('\n=== Live PSK decrypt: ChannelDecrypt helpers ===');
|
||||
|
||||
const cdSrc = fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8');
|
||||
const aesSrc = fs.readFileSync(path.join(__dirname, 'public/vendor/aes-ecb.js'), 'utf8');
|
||||
const sandbox = createSandbox();
|
||||
const ctx = vm.createContext(sandbox);
|
||||
vm.runInContext(aesSrc, ctx);
|
||||
vm.runInContext(cdSrc, ctx);
|
||||
const CD = sandbox.window.ChannelDecrypt;
|
||||
|
||||
assert(typeof CD.buildKeyMap === 'function',
|
||||
'ChannelDecrypt.buildKeyMap exists');
|
||||
assert(typeof CD.tryDecryptLive === 'function',
|
||||
'ChannelDecrypt.tryDecryptLive exists');
|
||||
|
||||
// Store a key for #LiveTest
|
||||
const channelName = '#LiveTest';
|
||||
const keyBytes = await CD.deriveKey(channelName);
|
||||
const keyHex = CD.bytesToHex(keyBytes);
|
||||
CD.storeKey(channelName, keyHex);
|
||||
|
||||
const map = await CD.buildKeyMap();
|
||||
const expectedHashByte = await CD.computeChannelHash(keyBytes);
|
||||
assert(map && typeof map.get === 'function',
|
||||
'buildKeyMap returns a Map');
|
||||
assert(map.get(expectedHashByte) && map.get(expectedHashByte).channelName === channelName,
|
||||
'buildKeyMap entry indexed by channel hash byte → channelName');
|
||||
|
||||
// Fabricate a live encrypted GRP_TXT packet on this channel
|
||||
const fixture = buildEncryptedGrpTxt(channelName, 'Alice', 'hello world');
|
||||
|
||||
const decrypted = await CD.tryDecryptLive(fixture.payload, map);
|
||||
assert(decrypted && decrypted.sender === 'Alice',
|
||||
'tryDecryptLive recovers sender from matching stored key');
|
||||
assert(decrypted && decrypted.text === 'hello world',
|
||||
'tryDecryptLive recovers message text');
|
||||
assert(decrypted && decrypted.channelName === channelName,
|
||||
'tryDecryptLive returns the matching channelName');
|
||||
assert(decrypted && decrypted.channelHashByte === expectedHashByte,
|
||||
'tryDecryptLive returns channelHashByte for unread bookkeeping');
|
||||
|
||||
// No match → null (different channel hash)
|
||||
const otherFixture = buildEncryptedGrpTxt('#NotStored', 'Bob', 'silent');
|
||||
const noMatch = await CD.tryDecryptLive(otherFixture.payload, map);
|
||||
assert(noMatch === null,
|
||||
'tryDecryptLive returns null when no stored key matches the channel hash');
|
||||
|
||||
// Non-GRP_TXT payload → null (defensive)
|
||||
const skip = await CD.tryDecryptLive({ type: 'CHAN', channel: channelName, text: 'already decrypted' }, map);
|
||||
assert(skip === null,
|
||||
'tryDecryptLive returns null for non-GRP_TXT payloads (already-decrypted CHAN)');
|
||||
|
||||
// Empty/missing fields → null (no crash)
|
||||
const empty = await CD.tryDecryptLive({ type: 'GRP_TXT' }, map);
|
||||
assert(empty === null,
|
||||
'tryDecryptLive returns null when encryptedData/mac missing');
|
||||
|
||||
console.log('\n=== Live PSK decrypt: channels.js integration contract ===');
|
||||
const chSrc = fs.readFileSync(path.join(__dirname, 'public/channels.js'), 'utf8');
|
||||
assert(/tryDecryptLive\s*\(/.test(chSrc),
|
||||
'channels.js calls ChannelDecrypt.tryDecryptLive() in the WS path');
|
||||
assert(/buildKeyMap\s*\(/.test(chSrc),
|
||||
'channels.js calls ChannelDecrypt.buildKeyMap() to refresh the lookup index');
|
||||
assert(/unread/i.test(chSrc),
|
||||
'channels.js tracks an unread counter for live-decrypted channels');
|
||||
|
||||
console.log('\n=== Results ===');
|
||||
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user