diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 18df06c03..8d2c1dd22 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -410,6 +410,7 @@ jobs: BASE_URL=http://localhost:13581 node test-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-issue-1087-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-issue-1111-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-channels-client-state-152-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-123-map-lifecycle-e2e.js 2>&1 | tee -a e2e-output.txt @@ -480,6 +481,8 @@ jobs: CHROMIUM_REQUIRE=1 node test-touch-targets.js 2>&1 | tee -a e2e-output.txt # Starts its own ingestor + server on a temp copy of the fixture. CHROMIUM_REQUIRE=1 CORESCOPE_SERVER_BIN=./corescope-server CORESCOPE_INGESTOR_BIN=./corescope-ingestor FIXTURE_DB=test-fixtures/e2e-fixture.db node test-channel-proposals-e2e.js 2>&1 | tee -a e2e-output.txt + # Starts its own ingestor + server on a temp copy of the fixture; seeds client-decryptable packets itself. + CHROMIUM_REQUIRE=1 CORESCOPE_SERVER_BIN=./corescope-server CORESCOPE_INGESTOR_BIN=./corescope-ingestor FIXTURE_DB=test-fixtures/e2e-fixture.db node test-channels-client-state-152-decrypt-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1236-map-mobile-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1329-map-controls-accordion-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1273-qr-overlay-height-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/.gitignore b/.gitignore index f50e06424..bbe6df765 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ cmd/ingestor/ingestor.exe # CI trigger !test-fixtures/e2e-fixture.db corescope-server +corescope-ingestor cmd/server/server # Local-only planning and design files docs/superpowers/ diff --git a/public/channel-decrypt.js b/public/channel-decrypt.js index b74560622..e6e9891ac 100644 --- a/public/channel-decrypt.js +++ b/public/channel-decrypt.js @@ -295,7 +295,7 @@ window.ChannelDecrypt = (function () { function saveKey(channelName, keyHex, label) { var keys = getKeys(); keys[channelName] = keyHex; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ } + setItemMakingRoom(STORAGE_KEY, JSON.stringify(keys)); _keyMapCache = null; // invalidate live-decrypt index if (typeof label === 'string' && label.trim()) { saveLabel(channelName, label.trim()); @@ -318,14 +318,14 @@ window.ChannelDecrypt = (function () { function removeKey(channelName) { var keys = getKeys(); delete keys[channelName]; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(keys)); } catch (e) { /* quota */ } + setItemMakingRoom(STORAGE_KEY, JSON.stringify(keys)); _keyMapCache = null; // invalidate live-decrypt index // Also clear cached messages and any label for this channel (#1020) clearChannelCache(channelName); var labels = getLabels(); if (labels[channelName]) { delete labels[channelName]; - try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ } + setItemMakingRoom(LABELS_KEY, JSON.stringify(labels)); } } @@ -351,64 +351,214 @@ window.ChannelDecrypt = (function () { } else { delete labels[channelName]; } - try { localStorage.setItem(LABELS_KEY, JSON.stringify(labels)); } catch (e) { /* quota */ } + setItemMakingRoom(LABELS_KEY, JSON.stringify(labels)); } - /** Remove cached messages for a specific channel (by name or hash). */ - function clearChannelCache(channelKey) { - try { - var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); - delete cache[channelKey]; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { /* quota */ } + // N2 (#152 follow-up): decrypted messages are cached per channel AND per + // region selection, as "|" ("|" for all + // regions). Region order doesn't change which observers are included, so + // it must not change the key either. + // + // #163: a channel name may contain '|', which would make the key (and + // clearChannelCache()'s prefix match) ambiguous. The channel part therefore + // has '%' and '|' percent-encoded, so the first '|' of a key is always the + // separator. Names without either character keep their plain key. + var CACHE_REGION_SEP = '|'; + + function encodeChannelPart(channelName) { + return String(channelName).replace(/[%|]/g, encodeURIComponent); + } + + function channelCacheKey(channelName, regionParam) { + var regions = regionParam ? String(regionParam).split(',').filter(Boolean).sort().join(',') : ''; + return encodeChannelPart(channelName) + CACHE_REGION_SEP + regions; } // ---- Message cache (localStorage) ---- + // + // One JSON blob under CACHE_KEY: { "|": entry }. It is + // only a cache, so it always gives way: it is kept under a fixed + // character budget, a quota failure evicts and retries, and keys and + // labels evict it before they would fail to save. + + // Cache with lastTimestamp and count (used by channels.js via getCache/setCache) + var MAX_CACHED_MESSAGES = 1000; + // N2 (#152 follow-up): keys are region-scoped, so one channel can occupy + // several entries. Cap the number of distinct entries so visiting many + // region combinations over time can't grow the blob unboundedly. + var MAX_CACHE_KEYS = 50; + // R4-2 (#153 review round 4): one entry can reach ~365 KiB (1000 + // messages) and localStorage holds ~5.2M characters per origin, shared + // with the keys and labels. Keep the whole blob under this many + // characters, least recently used entries going first. + var CACHE_BUDGET_CHARS = 1500000; + // Set once the cache blob of an earlier key format is dropped. 2: the + // unescaped "|" keys of #153; 3 (#163): escaped keys. + var CACHE_VERSION_KEY = 'corescope_channel_cache_v'; + var CACHE_VERSION = '3'; + + var _cacheMigrated = false; + var _cacheUseSeq = 0; + // Last-read stamps of this page load, folded into the entries' `at` on + // the next write so a read doesn't cost a rewrite of the whole blob. + var _cacheReadAt = {}; + + // Strictly increasing, so entries written or read in the same + // millisecond still have a defined least-recently-used order. + function nextCacheUse() { + _cacheUseSeq = Math.max(Date.now(), _cacheUseSeq + 1); + return _cacheUseSeq; + } + + function cacheLastUse(cache, key) { + var entry = cache[key] || {}; + return Math.max(entry.at || entry.ts || 0, _cacheReadAt[key] || 0); + } - function cacheMessages(channelHash, messages) { + function readCacheBlob() { try { var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); - cache[channelHash] = { messages: messages, ts: Date.now() }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { /* quota */ } + return (cache && typeof cache === 'object') ? cache : {}; + } catch (e) { return {}; } } - function getCachedMessages(channelHash) { + // Drop the whole cache blob once when its key format is older than + // CACHE_VERSION. Pre-#153 entries are keyed by the bare channel name and + // #153's by the unescaped "|"; for a name containing '|' + // the two can't be told apart from the current keys, and nothing reads + // them any more. It is only a cache, so dropping all of it is simpler than + // rewriting it, and leaves no old plaintext behind. + function ensureCacheMigrated() { + if (_cacheMigrated) return; + _cacheMigrated = true; try { - var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); - var entry = cache[channelHash]; - return entry ? entry.messages : null; - } catch (e) { return null; } + if (localStorage.getItem(CACHE_VERSION_KEY) === CACHE_VERSION) return; + localStorage.removeItem(CACHE_KEY); + } catch (e) { _cacheMigrated = false; return; /* storage unavailable: retried on next use */ } + try { localStorage.setItem(CACHE_VERSION_KEY, CACHE_VERSION); } catch (e) { /* retried next load */ } } - // Cache with lastTimestamp and count (used by channels.js via getCache/setCache) - var MAX_CACHED_MESSAGES = 1000; + /** + * Persist `cache`, evicting least recently used entries until it is within + * MAX_CACHE_KEYS and CACHE_BUDGET_CHARS and localStorage accepts it. Each + * entry is serialised once; setItem() either stores the whole new blob or + * leaves the old one, so the blob is never half-written. Returns whether + * the blob was written. + */ + function writeCacheBlob(cache) { + var parts = {}; + var size = 2; // "{}" + var keys = Object.keys(cache); + keys.forEach(function (k) { + if (cache[k] && _cacheReadAt[k]) cache[k].at = cacheLastUse(cache, k); + parts[k] = JSON.stringify(k) + ':' + JSON.stringify(cache[k]); + size += parts[k].length + 1; // + separating comma + }); + _cacheReadAt = {}; // folded into the entries above + keys.sort(function (a, b) { return cacheLastUse(cache, a) - cacheLastUse(cache, b); }); + while (keys.length > MAX_CACHE_KEYS || (keys.length && size - 1 > CACHE_BUDGET_CHARS)) { + size -= parts[keys.shift()].length + 1; + } + for (;;) { + try { + localStorage.setItem(CACHE_KEY, '{' + keys.map(function (k) { return parts[k]; }).join(',') + '}'); + return true; + } catch (e) { + // QuotaExceededError: localStorage is shared with the keys and + // labels, so give up room until the blob fits. + if (!keys.length) break; + keys.shift(); + } + } + try { localStorage.removeItem(CACHE_KEY); } catch (e) { /* nothing cached */ } + return false; + } - function setCache(key, messages, lastTimestamp, totalCount) { - try { - // Enforce cache size limit: only keep most recent MAX_CACHED_MESSAGES - var toStore = messages; - if (messages.length > MAX_CACHED_MESSAGES) { - toStore = messages.slice(messages.length - MAX_CACHED_MESSAGES); + // Drop the least recently used cache entry (or the empty blob itself). + // Returns false once there is no cache left to give up. + function evictOldestCacheEntry() { + var raw; + try { raw = localStorage.getItem(CACHE_KEY); } catch (e) { return false; } + if (raw === null) return false; + var cache = readCacheBlob(); + var keys = Object.keys(cache); + if (!keys.length) { + try { localStorage.removeItem(CACHE_KEY); } catch (e) { return false; } + return true; + } + keys.sort(function (a, b) { return cacheLastUse(cache, a) - cacheLastUse(cache, b); }); + delete cache[keys[0]]; + writeCacheBlob(cache); + return true; + } + + // setItem() for the keys and labels: when the quota is hit, evict decrypt + // cache until the write fits, so the cache can never cost the user a key. + function setItemMakingRoom(storageKey, value) { + for (;;) { + try { + localStorage.setItem(storageKey, value); + return true; + } catch (e) { + if (!evictOldestCacheEntry()) return false; } - var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); - cache[key] = { - messages: toStore, - lastTimestamp: lastTimestamp, - count: totalCount || toStore.length, - ts: Date.now() - }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { /* quota */ } + } + } + + /** + * Remove every cached message set of a channel (by name or hash): each + * region-scoped "|" entry, and nothing of any other + * channel (see encodeChannelPart()). + */ + function clearChannelCache(channelKey) { + ensureCacheMigrated(); + var cache = readCacheBlob(); + var prefix = encodeChannelPart(channelKey) + CACHE_REGION_SEP; + Object.keys(cache).forEach(function (k) { + if (k.indexOf(prefix) === 0) delete cache[k]; + }); + writeCacheBlob(cache); + } + + /** Remove one cache entry, e.g. one region of a channel. */ + function deleteCache(key) { + ensureCacheMigrated(); + var cache = readCacheBlob(); + if (!Object.prototype.hasOwnProperty.call(cache, key)) return; + delete cache[key]; + writeCacheBlob(cache); + } + + function setCache(key, messages, lastTimestamp, totalCount) { + ensureCacheMigrated(); + // Enforce cache size limit: only keep most recent MAX_CACHED_MESSAGES + var toStore = messages; + if (messages.length > MAX_CACHED_MESSAGES) { + toStore = messages.slice(messages.length - MAX_CACHED_MESSAGES); + } + var cache = readCacheBlob(); + cache[key] = { + messages: toStore, + lastTimestamp: lastTimestamp, + count: totalCount || toStore.length, + ts: Date.now(), + at: nextCacheUse() + }; + writeCacheBlob(cache); } function getCache(key) { - try { - var cache = JSON.parse(localStorage.getItem(CACHE_KEY) || '{}'); - return cache[key] || null; - } catch (e) { return null; } + ensureCacheMigrated(); + var entry = readCacheBlob()[key] || null; + if (entry) _cacheReadAt[key] = nextCacheUse(); + return entry; } + // #163: drop old-format cache entries (and their plaintext) when the module + // loads, not on the first cache use. The calls in the cache functions stay + // as the fallback for a localStorage that wasn't available yet. + ensureCacheMigrated(); + return { deriveKey: deriveKey, decrypt: decrypt, @@ -428,11 +578,11 @@ window.ChannelDecrypt = (function () { saveLabel: saveLabel, getLabel: getLabel, getLabels: getLabels, + channelCacheKey: channelCacheKey, clearChannelCache: clearChannelCache, - cacheMessages: cacheMessages, - getCachedMessages: getCachedMessages, setCache: setCache, getCache: getCache, + deleteCache: deleteCache, buildKeyMap: buildKeyMap, tryDecryptLive: tryDecryptLive }; diff --git a/public/channels.js b/public/channels.js index 10b9e2411..2252b2257 100644 --- a/public/channels.js +++ b/public/channels.js @@ -171,12 +171,80 @@ // can mutate freely without leaking changes back to the input. return survivors.length ? mergedRest.concat(survivors) : mergedRest; } + + // #152: loadChannels() replaces `channels` with the server snapshot, which + // knows nothing about state that only lives in this tab: unread counts, + // the user's PSK marks and labels, the preview of user:* rows, and live + // activity the WS path applied while the request was in flight. + // mergeWsAppendedIntoRest() above does this job for `messages` (#1498); + // this is its counterpart for `channels`. + // + // Which activity is newer is decided by a local sequence, never by + // comparing times: the WS path stamps a row with ++wsActivitySeq, and + // loadChannels() samples wsActivitySeq when its request starts. A + // browser Date.now() and the server's lastActivity come from different + // clocks, so comparing them depends on clock skew. + var wsActivitySeq = 0; + + // user:* rows exist only in this browser (mergeUserChannels() creates them + // from stored keys with a placeholder preview), so their activity always + // comes from the previous list. + function isClientOnlyChannel(ch) { + return typeof ch.hash === 'string' && ch.hash.indexOf('user:') === 0; + } + + // Carries client-only state from prevChannels onto freshChannels by hash. + // Only enriches rows freshChannels already contains: carrying a missing + // row over would resurrect channels the region filter just excluded. + // Returns a fresh array of fresh objects; never aliases or mutates input. + function mergeClientChannelState(freshChannels, prevChannels, seqAtRequestStart) { + if (!Array.isArray(freshChannels)) return []; + var prevByHash = new Map(); + if (Array.isArray(prevChannels)) { + for (var i = 0; i < prevChannels.length; i++) { + var p = prevChannels[i]; + if (p && p.hash != null) prevByHash.set(p.hash, p); + } + } + return freshChannels.map(function (c) { + var out = Object.assign({}, c); + var prev = out.hash != null ? prevByHash.get(out.hash) : undefined; + if (!prev) return out; + // Carried when present, including an explicit 0: undefined is not "read". + if (Object.prototype.hasOwnProperty.call(prev, 'unread')) out.unread = prev.unread; + // userAdded/userLabel are NOT carried from prev (F2, #152 follow-up): + // mergeUserChannels() runs before this, on the same fresh list, and + // re-derives both from storage on every load, so storage stays the + // single source of truth. Carrying a stale prev value let a removed + // key's label (and a stale userAdded) resurrect across a refresh. + var newerLive = typeof prev._wsSeq === 'number' && prev._wsSeq > seqAtRequestStart; + if (newerLive || isClientOnlyChannel(out)) { + // Moved together: a sender without its message reads as another message. + out.lastActivityMs = prev.lastActivityMs; + out.lastSender = prev.lastSender; + out.lastMessage = prev.lastMessage; + out.messageCount = prev.messageCount; + if (typeof prev._wsSeq === 'number') out._wsSeq = prev._wsSeq; + } + return out; + }); + } + let autoScroll = true; let nodeCache = {}; let selectedNode = null; let observerIataById = {}; let observerIataByName = {}; let messageRequestId = 0; + // N1 (#152 follow-up): the id of the selectChannel() request whose load is + // still running (0 when none). Lets reconcileSelectionAfterChannelRefresh() + // know, when it remaps a user:* selection mid-load, that it must restart + // loading for the remapped hash — otherwise the in-flight request's own + // staleness check discards its result once selectedHash changes, and + // nothing else ever re-fetches, leaving the pane stuck on "Decrypting + // messages…". R4-3: an id rather than a boolean, so a superseded request + // finishing can't clear the flag of the request that replaced it. + let messageLoadPending = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes function getSelectedRegionsSnapshot() { @@ -248,6 +316,31 @@ function reconcileSelectionAfterChannelRefresh() { if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false; + // F3 (#152 follow-up): a PSK whose name collides with a server-known or + // newly-approved shared channel is matched by name in mergeUserChannels() + // instead of getting its own user:* row, so the user:* hash this + // conversation opened under disappears from the fresh list even though + // the conversation is still live. Remap to the row mergeUserChannels() + // annotated instead of closing the conversation. + if (selectedHash.indexOf('user:') === 0) { + var pskName = selectedHash.substring(5); + var remapped = channels.find(function (ch) { return ch.userAdded === true && ch.name === pskName; }); + if (remapped) { + // N1 (#152 follow-up): if a decrypt for the old hash was still in + // flight at the moment of this remap, it will discard its own + // result (isStaleMessageRequest() sees selectedHash change under + // it) and nothing else ever restarts the fetch — the pane would be + // stuck on "Decrypting messages…" forever. Restart loading for the + // remapped hash in that case. A quiet remap (no decrypt in flight) + // still leaves messages untouched, as before. + var hadPendingLoad = messageLoadPending !== 0 && messageLoadPending === messageRequestId; + selectedHash = remapped.hash; + history.replaceState(null, '', `#/channels/${encodeURIComponent(selectedHash)}`); + renderChannelList(); + if (hadPendingLoad) selectChannel(selectedHash); + return false; + } + } selectedHash = null; messages = []; history.replaceState(null, '', '#/channels'); @@ -671,13 +764,71 @@ } } + // Removes a user-stored channel key (and its label) and updates the + // channel list/selection to match. Extracted from the remove-button click + // handler so it's directly testable; the handler still owns the confirm() + // gate. + function removeUserChannelKey(channelHash) { + if (!channelHash) return; + var ch = channels.find(function (c) { return c.hash === channelHash; }); + var chName = channelHash.startsWith('user:') + ? channelHash.substring(5) + : (ch && ch.name) || channelHash; + ChannelDecrypt.removeKey(chName); + if (channelHash.startsWith('user:')) { + // Pure user-added channel — drop from the list entirely. + channels = channels.filter(function (c) { return c.hash !== channelHash; }); + if (selectedHash === channelHash) { + selectedHash = null; + messages = []; + history.replaceState(null, '', '#/channels'); + var msgEl2 = document.getElementById('chMessages'); + if (msgEl2) msgEl2.innerHTML = '
Choose a channel from the sidebar to view messages
'; + var header2 = document.getElementById('chHeader'); + if (header2) header2.querySelector('.ch-header-text').textContent = 'Select a channel'; + } + } else if (ch) { + // Server-known channel: keep the row, just unmark as user-added so + // the close button disappears until they re-add a key. // EMOJI-OK: prior glyph reference + ch.userAdded = false; + // F2 (#152 follow-up): also clear the label now. mergeClientChannelState() + // no longer carries userLabel from the previous list either way, but + // clearing it here means the UI stops showing it immediately instead + // of waiting for the next channel-list refresh. + delete ch.userLabel; + // If this was the selected channel, clear decrypted messages since + // the key is gone — they can't be re-decrypted without re-adding it. + if (selectedHash === channelHash) { + messages = []; + var msgEl2 = document.getElementById('chMessages'); + if (msgEl2) msgEl2.innerHTML = '
Key removed — add a key to decrypt messages
'; + } + } + renderChannelList(); + } + // Fetch and decrypt GRP_TXT packets client-side (M5: delta fetch + cache) async function fetchAndDecryptChannel(keyHex, channelHashByte, channelName, opts) { opts = opts || {}; var keyBytes = ChannelDecrypt.hexToBytes(keyHex); + // N2 (#152 follow-up): the cache must be region-scoped. A channel's + // decrypted message set depends on which observers the current region + // filter includes, so a cache entry primed under one region (e.g. "All" + // or SJC) must never answer a fetch for a different region (e.g. OAK or + // MRY). ChannelDecrypt owns the key format so removeKey() can clear + // every region of a channel. + var rp = RegionFilter.getRegionParam(); + var cacheKey = ChannelDecrypt.channelCacheKey(channelName || String(channelHashByte), rp); + // A stale request (superseded by a newer one, or no longer the current + // selection/region by the time a write below would happen) must not + // persist its possibly-incomplete evidence into the cache. + function setCacheIfFresh(key, msgs, ts, count) { + if (opts.isStale && opts.isStale()) return; + ChannelDecrypt.setCache(key, msgs, ts, count); + } + // M5: Check cache first — serve cached messages immediately - var cacheKey = channelName || String(channelHashByte); var cached = ChannelDecrypt.getCache(cacheKey); var cachedMsgs = cached ? cached.messages : []; var lastTs = cached ? cached.lastTimestamp : ''; @@ -690,7 +841,6 @@ } // Fetch packets from API — get all payload_type=5 (GRP_TXT/CHAN) - var rp = RegionFilter.getRegionParam(); var qs = (rp ? '®ion=' + encodeURIComponent(rp) : ''); var data; try { @@ -737,7 +887,7 @@ // Nothing new to decrypt. Persist only when the API enriched the // evidence so legacy caches gain the badge on this render. if (reconciledCache.changed) { - ChannelDecrypt.setCache(cacheKey, cachedMsgs, lastTs, totalCandidates); + setCacheIfFresh(cacheKey, cachedMsgs, lastTs, totalCandidates); } return { messages: cachedMsgs, fromCache: true }; } @@ -746,7 +896,7 @@ var newDecrypted = await decryptCandidates(keyBytes, newCandidates); if (newDecrypted.wrongKey) { if (reconciledCache.changed) { - ChannelDecrypt.setCache(cacheKey, cachedMsgs, lastTs, totalCandidates); + setCacheIfFresh(cacheKey, cachedMsgs, lastTs, totalCandidates); } return { messages: cachedMsgs, wrongKey: true }; } @@ -754,12 +904,18 @@ // Merge: cached + new, deduplicate by packetHash, sort chronologically var merged = deduplicateAndMerge(cachedMsgs, newDecrypted.messages); var newLastTs = merged.length ? merged[merged.length - 1].timestamp : lastTs; - ChannelDecrypt.setCache(cacheKey, merged, newLastTs, totalCandidates); + setCacheIfFresh(cacheKey, merged, newLastTs, totalCandidates); return { messages: merged, deltaCount: newDecrypted.messages.length }; } if (candidates.length === 0) { - return { messages: cachedMsgs, empty: true }; + // N2 (#152 follow-up): zero candidates for the current (region-scoped) + // fetch must render as empty, never leftover content from a stale or + // foreign-region cache entry. R4-4: drop this region's entry too, so + // the next visit doesn't flash the outdated history before its fetch + // answers — unless a newer request owns the cache by now. + if (cached && !(opts.isStale && opts.isStale())) ChannelDecrypt.deleteCache(cacheKey); + return { messages: [], empty: true }; } // Full decrypt @@ -778,7 +934,7 @@ // M5: Cache results var newLastTimestamp = decrypted.length ? decrypted[decrypted.length - 1].timestamp : ''; - ChannelDecrypt.setCache(cacheKey, decrypted, newLastTimestamp, totalCandidates); + setCacheIfFresh(cacheKey, decrypted, newLastTimestamp, totalCandidates); return { messages: decrypted }; } @@ -1014,6 +1170,20 @@ regionChangeHandler = RegionFilter.onChange(function () { loadChannels(true).then(async function () { if (!selectedHash) return; + var selCh = channels.find(function (c) { return c.hash === selectedHash; }); + if (selCh && selCh.encrypted) { + // F1 (#152 follow-up): an encrypted (user:* or key-matched) row has + // no REST messages for refreshMessages() to refetch — it used to + // just return, which left an in-flight decrypt stuck on + // "Decrypting messages…" once its own staleness check discarded + // the result (the region changed under it), and left a finished + // decrypt showing the previous region's messages. Re-run the + // selection instead: it redoes the key lookup and decrypt fetch + // for the new region without closing the conversation + // (selectedHash/URL stay put). + await selectChannel(selectedHash); + return; + } await refreshMessages({ regionSwitch: true, forceNoCache: true }); }); }); @@ -1046,10 +1216,9 @@ view: _initUrlParams.get('view'), onApproved: function () { invalidateApiCache('/channels'); - loadChannels(true).then(function () { - mergeUserChannels(); - renderChannelList(); - }); + // loadChannels() merges the user's PSK rows itself (#152), before + // it reconciles the selection. + loadChannels(true); } }); } @@ -1311,11 +1480,11 @@ loadObserverRegions(); loadChannels().then(async function () { // Also load user-added encrypted channels into the sidebar. - // mergeUserChannels() mutates `channels` (marks userAdded, appends - // PSK-only entries) AFTER loadChannels() already rendered — so we - // MUST re-render here, otherwise the My Channels section never - // appears on first load when the route has no specific channel - // hash (regression caught by test-channel-issue-1111-e2e.js, case 2). + // Since #152 a successful loadChannels() has already merged and + // rendered them, so this is an idempotent re-render there. It stays + // for the failure path: when /channels fails, loadChannels() merges + // nothing, and this is what still lists My Channels (the section + // test-channel-issue-1111-e2e.js case 2 requires on first load). mergeUserChannels(); renderChannelList(); if (routeParam) await selectChannel(routeParam); @@ -1428,37 +1597,12 @@ // The localStorage key is the channel name. For user:-prefixed entries // strip the prefix; for server-known channels look up the channel // object so we use its display name (the hash itself isn't the key). - var ch = channels.find(function (c) { return c.hash === channelHash; }); - var chName = channelHash.startsWith('user:') + var confirmCh = channels.find(function (c) { return c.hash === channelHash; }); + var confirmName = channelHash.startsWith('user:') ? channelHash.substring(5) - : (ch && ch.name) || channelHash; - if (!confirm('Remove channel "' + chName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return; - ChannelDecrypt.removeKey(chName); - if (channelHash.startsWith('user:')) { - // Pure user-added channel — drop from the list entirely. - channels = channels.filter(function (c) { return c.hash !== channelHash; }); - if (selectedHash === channelHash) { - selectedHash = null; - messages = []; - history.replaceState(null, '', '#/channels'); - var msgEl2 = document.getElementById('chMessages'); - if (msgEl2) msgEl2.innerHTML = '
Choose a channel from the sidebar to view messages
'; - var header2 = document.getElementById('chHeader'); - if (header2) header2.querySelector('.ch-header-text').textContent = 'Select a channel'; - } - } else if (ch) { - // Server-known channel: keep the row, just unmark as user-added so - // the close button disappears until they re-add a key. // EMOJI-OK: prior glyph reference - ch.userAdded = false; - // If this was the selected channel, clear decrypted messages since - // the key is gone — they can't be re-decrypted without re-adding it. - if (selectedHash === channelHash) { - messages = []; - var msgEl2 = document.getElementById('chMessages'); - if (msgEl2) msgEl2.innerHTML = '
Key removed — add a key to decrypt messages
'; - } - } - renderChannelList(); + : (confirmCh && confirmCh.name) || channelHash; + if (!confirm('Remove channel "' + confirmName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return; + removeUserChannelKey(channelHash); return; } // Color clear button — remove color without opening picker (#681) @@ -1636,6 +1780,7 @@ ch.lastActivityMs = Date.now(); ch.lastSender = sender; ch.lastMessage = truncate(displayText, 100); + ch._wsSeq = ++wsActivitySeq; // #152: see mergeClientChannelState() channelListDirty = true; } else if (isFirstObservation) { // New channel we haven't seen @@ -1646,6 +1791,7 @@ lastActivityMs: Date.now(), lastSender: sender, lastMessage: truncate(displayText, 100), + _wsSeq: ++wsActivitySeq, }); channelListDirty = true; } @@ -1854,6 +2000,8 @@ } async function loadChannels(silent) { + // #152: WS activity stamped after this point is newer than the snapshot. + const seqAtRequestStart = wsActivitySeq; try { const rp = RegionFilter.getRegionParam(); var showEnc = localStorage.getItem('channels-show-encrypted') === 'true'; @@ -1862,15 +2010,24 @@ if (showEnc) params.push('includeEncrypted=true'); const qs = params.length ? '?' + params.join('&') : ''; const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels }); - channels = (data.channels || []).map(ch => { - ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0; - return ch; - }); + const prevChannels = channels; + // Copies: api() hands the same cached objects back on a TTL hit, and + // mergeUserChannels() below mutates rows. + channels = (data.channels || []).map(ch => Object.assign({}, ch, { + lastActivityMs: ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0 + })); // Approved shared channels are listed for everyone, even before they // carry traffic. if (window.ChannelProposals) { channels = window.ChannelProposals.mergeApprovedChannels(channels, data.approvedChannels); } + // #152: re-derive the user's PSK rows from storage, then carry the + // tab's own state over from the previous list. mergeUserChannels() + // must run first so its user:* rows get their unread and preview back, + // and both must run before reconcileSelectionAfterChannelRefresh(), + // which closes the conversation when selectedHash is not listed. + if (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt) mergeUserChannels(); + channels = mergeClientChannelState(channels, prevChannels, seqAtRequestStart); channels.sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0)); renderChannelList(); reconcileSelectionAfterChannelRefresh(); @@ -2270,8 +2427,21 @@ } async function selectChannel(hash, decryptOpts) { - const rp = RegionFilter.getRegionParam() || ''; - const request = beginMessageRequest(hash, rp); + const request = beginMessageRequest(hash, RegionFilter.getRegionParam() || ''); + // R4-3: mark this request's load as pending before its first await + // (computeChannelHash), so a remap at any point of it restarts loading. + // Only the request that owns the flag may clear it. + messageLoadPending = request.id; + try { + return await loadSelectedChannel(request, decryptOpts); + } finally { + if (messageLoadPending === request.id) messageLoadPending = 0; + } + } + + async function loadSelectedChannel(request, decryptOpts) { + const hash = request.hash; + const rp = request.regionParam; // #1498: clear messages BEFORE flipping selectedHash so any WS-pushed // messages from the previously-viewed channel can't survive into the // new channel's view via mergeWsAppendedIntoRest(). Messages don't @@ -2297,6 +2467,9 @@ // Shared helper: fetch, decrypt, and render messages for a channel key (M5: cache-first) async function decryptAndRender(keyHex, channelHashByte, channelName) { + // R4-3: callers reach this after awaiting computeChannelHash; a + // superseded request must not paint over the current one's view. + if (isStaleMessageRequest(request)) return { stale: true }; msgEl.innerHTML = '
Decrypting messages…
'; var result = await fetchAndDecryptChannel(keyHex, channelHashByte, channelName, { onCacheHit: function (cachedMsgs) { @@ -2310,7 +2483,10 @@ renderMessages(); scrollToBottom(); } - } + }, + // N2 (#152 follow-up): a superseded request must not persist its + // evidence into the (now region-scoped) decrypt cache. + isStale: function () { return isStaleMessageRequest(request); } }); if (isStaleMessageRequest(request)) return { stale: true }; if (result.wrongKey) { @@ -2357,6 +2533,8 @@ var kh = storedKeys[kn]; var kb = ChannelDecrypt.hexToBytes(kh); var hb = await ChannelDecrypt.computeChannelHash(kb); + // #163: a newer request owns the pane; stop hashing keys for it. + if (isStaleMessageRequest(request)) return; if (String(hb) === String(hash) || String(ch.hash) === String(hb)) { await decryptAndRender(kh, hb, kn); return; @@ -2401,6 +2579,9 @@ } } + // #163: the encrypted-ness lookup above falls through here when it + // fails, which can be after a newer request took over the pane. + if (isStaleMessageRequest(request)) return; msgEl.innerHTML = '
Loading messages…
'; try { @@ -2595,6 +2776,7 @@ window._channelsSelectChannelForTest = selectChannel; window._channelsRefreshMessagesForTest = refreshMessages; window._channelsMergeWsAppendedIntoRestForTest = mergeWsAppendedIntoRest; + window._channelsMergeClientChannelStateForTest = mergeClientChannelState; window._channelsNormalizeObservedPathHashSizesForTest = normalizeObservedPathHashSizes; window._channelsUnionObservedPathHashSizesForTest = unionObservedPathHashSizes; window._channelsRenderObservedPathHashBadgeForTest = renderObservedPathHashBadge; @@ -2606,6 +2788,7 @@ window._channelsBeginMessageRequestForTest = beginMessageRequest; window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest; window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh; + window._channelsRemoveKeyHandlerForTest = removeUserChannelKey; window._channelsGetStateForTest = function () { return { channels: channels, messages: messages, selectedHash: selectedHash }; }; diff --git a/test-all.sh b/test-all.sh index 4ea0fa42d..a83d7c507 100755 --- a/test-all.sh +++ b/test-all.sh @@ -125,6 +125,7 @@ run test-node-analytics-hop-chart.js run test-analytics-hop-depth-ui.js run test-channels-ping-bot-reply.js run test-channels-observed-path-hash-size.js +run test-channels-client-state-152.js run test-packet-path-map.js run test-area-nodes-map.js run test-ping-scores.js diff --git a/test-channel-decrypt-e2e.js b/test-channel-decrypt-e2e.js index f14c6eaf6..2f37172c1 100644 --- a/test-channel-decrypt-e2e.js +++ b/test-channel-decrypt-e2e.js @@ -194,12 +194,16 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } assert(result.count === 1500, 'count: ' + result.count); }); - await step('cacheMessages / getCachedMessages roundtrip', async () => { + await step('setCache / getCache roundtrip under channelCacheKey()', async () => { const out = await page.evaluate(() => { - window.ChannelDecrypt.cacheMessages('hash42', [{ a: 1 }, { a: 2 }]); - return window.ChannelDecrypt.getCachedMessages('hash42'); + const key = window.ChannelDecrypt.channelCacheKey('hash42', 'SJC'); + window.ChannelDecrypt.setCache(key, [{ a: 1 }, { a: 2 }], 'ts', 2); + const entry = window.ChannelDecrypt.getCache(key); + return { key: key, messages: entry && entry.messages, bareName: window.ChannelDecrypt.getCache('hash42') }; }); - assert(Array.isArray(out) && out.length === 2, 'cache roundtrip failed: ' + JSON.stringify(out)); + assert(out.key === 'hash42|SJC', 'channelCacheKey: ' + out.key); + assert(Array.isArray(out.messages) && out.messages.length === 2, 'cache roundtrip failed: ' + JSON.stringify(out)); + assert(out.bareName === null, 'nothing is cached under the bare channel name: ' + JSON.stringify(out.bareName)); }); await step('buildKeyMap indexes stored keys by computed hash byte', async () => { diff --git a/test-channel-decrypt-m345.js b/test-channel-decrypt-m345.js index 0dccbbdf8..87dd1b184 100644 --- a/test-channel-decrypt-m345.js +++ b/test-channel-decrypt-m345.js @@ -103,19 +103,19 @@ async function runTests() { // Store a key and some cached messages CD.storeKey('#TestChannel', 'deadbeefdeadbeefdeadbeefdeadbeef'); - CD.setCache('#TestChannel', [{ sender: 'A', text: 'hello', timestamp: '2026-01-01T00:00:00Z', packetHash: 'h1' }], '2026-01-01T00:00:00Z', 1); + CD.setCache(CD.channelCacheKey('#TestChannel', ''), [{ sender: 'A', text: 'hello', timestamp: '2026-01-01T00:00:00Z', packetHash: 'h1' }], '2026-01-01T00:00:00Z', 1); // Verify they exist var storedKeys = CD.getStoredKeys(); assert(storedKeys['#TestChannel'] === 'deadbeefdeadbeefdeadbeefdeadbeef', 'Key exists before removal'); - var cachedBefore = CD.getCache('#TestChannel'); + var cachedBefore = CD.getCache(CD.channelCacheKey('#TestChannel', '')); assert(cachedBefore && cachedBefore.messages.length === 1, 'Cache exists before removal'); // Remove the key (also clears cache) CD.removeKey('#TestChannel'); var storedAfter = CD.getStoredKeys(); assert(!storedAfter['#TestChannel'], 'Key cleared after removal'); - var cachedAfter = CD.getCache('#TestChannel'); + var cachedAfter = CD.getCache(CD.channelCacheKey('#TestChannel', '')); assert(!cachedAfter, 'Cache cleared after removal'); console.log('\n=== M5: Cache operations ==='); @@ -144,10 +144,12 @@ async function runTests() { assert(deltaCache.count === 1, 'Delta cache count correct'); // Test: clearChannelCache - CD.setCache('clearthis', [{ sender: 'X', text: 'y' }], 'ts', 1); - assert(CD.getCache('clearthis') !== null, 'Cache exists before clear'); + // The key is the real one (#153/#163): "|". + var clearKey = CD.channelCacheKey('clearthis', ''); + CD.setCache(clearKey, [{ sender: 'X', text: 'y' }], 'ts', 1); + assert(CD.getCache(clearKey) !== null, 'Cache exists before clear'); CD.clearChannelCache('clearthis'); - assert(CD.getCache('clearthis') === null, 'Cache cleared by clearChannelCache'); + assert(CD.getCache(clearKey) === null, 'Cache cleared by clearChannelCache'); console.log('\n=== Results ==='); console.log('Passed: ' + passed + ', Failed: ' + failed); diff --git a/test-channels-client-state-152-decrypt-e2e.js b/test-channels-client-state-152-decrypt-e2e.js new file mode 100644 index 000000000..6dd49113c --- /dev/null +++ b/test-channels-client-state-152-decrypt-e2e.js @@ -0,0 +1,737 @@ +#!/usr/bin/env node +/** + * #152 (decrypt race) — E2E: a PSK channel's decrypt doesn't get stuck, or + * show stale-region messages, when the region filter changes while the + * client-side decrypt fetch is still in flight. + * + * Bug (F1): opening a PSK channel calls selectChannel() -> decryptAndRender() + * -> fetchAndDecryptChannel(), which fetches /api/packets?...®ion= and + * decrypts matching GRP_TXT packets client-side (public/channel-decrypt.js). + * If the region changes while that fetch is still in flight: + * 1. the in-flight fetch's own staleness check (isStaleMessageRequest(), + * which compares RegionFilter.getRegionParam() against the region the + * request captured) discards its own result once it resolves, leaving + * the pane stuck on "Decrypting messages..." forever, AND + * 2. refreshMessages()'s `if (selCh && selCh.encrypted) return;` early + * return means nothing re-fetches for the new region either, so even + * after the dust settles the pane never shows the new region's + * messages. + * + * This test seeds two genuinely-encrypted GRP_TXT packets directly into a + * temp copy of the E2E fixture DB (one observed only from an SJC observer, + * one only from an SFO observer) under a PSK key the server does NOT know + * (so the ingestor/server never auto-decrypts it — this must go through the + * client-side Web Crypto / pure-JS AES-ECB path in channel-decrypt.js). + * It then: opens that PSK channel with no region filter selected (decrypt + * fetch — covering both packets — delayed 3s via page.route), narrows the + * region filter to SFO only mid-decrypt, and asserts the pane ends up + * showing just the SFO message (not stuck "Decrypting...", not the stale + * SJC-region message) with the conversation still open on the same hash. + * + * #153 review round 3 adds a further step after the decrypt has fully + * settled: switching to OAK (no observer saw either seeded packet) must + * show an empty pane, not a lingering SJC/SFO message from the decrypt + * cache (N2 — the cache was keyed by channel name alone, with no region + * component). + * + * Runs at desktop (1280x800) and mobile (390x844) viewports. + * + * Seeding: no sqlite3 CLI is available in this environment, so this file + * uses Node's built-in (experimental) `node:sqlite` module directly against + * a temp copy of the fixture DB, before the (read-only, mode=ro) server is + * started — consistent with AGENTS.md's read/write separation invariant. + * The raw fixture ships in a schema the ingestor migrates on startup (the + * server refuses to start against an unmigrated DB — "schema not migrated + * by ingestor"), so this file briefly runs the real ingestor binary first + * (against a minimal in-process fake MQTT broker, same trick as + * test-channel-proposals-e2e.js, with zero real MQTT traffic), stops it once + * migrations are applied, THEN seeds the encrypted packets, THEN starts the + * read-only server — all writes happen before the read-only server exists. + * + * Usage: + * cd cmd/ingestor && go build -o ../../corescope-ingestor . + * cd cmd/server && go build -o ../../corescope-server . + * node test-channels-client-state-152-decrypt-e2e.js + * + * Env: + * CORESCOPE_SERVER_BIN (default ./corescope-server) + * CORESCOPE_INGESTOR_BIN (default ./corescope-ingestor) + * FIXTURE_DB (default test-fixtures/e2e-fixture.db) + * PUBLIC_DIR (default public) + * CHROMIUM_PATH, CHROMIUM_REQUIRE=1 (fail instead of skip without Chromium) + */ +'use strict'; + +const assert = require('assert'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const net = require('net'); +const os = require('os'); +const path = require('path'); +// node:sqlite and playwright are required where they're used, so the unit +// suite can load this file's process helpers without either. + +const ROOT = __dirname; +const SERVER_BIN = path.resolve(process.env.CORESCOPE_SERVER_BIN || path.join(ROOT, 'corescope-server')); +const INGESTOR_BIN = path.resolve(process.env.CORESCOPE_INGESTOR_BIN || path.join(ROOT, 'corescope-ingestor')); +const FIXTURE_DB = path.resolve(process.env.FIXTURE_DB || path.join(ROOT, 'test-fixtures', 'e2e-fixture.db')); +const PUBLIC_DIR = path.resolve(process.env.PUBLIC_DIR || path.join(ROOT, 'public')); +const TIMEOUT = 20000; +// The firmware-default "Public" channel key — must NOT collide with our +// generated key, since the ingestor/server auto-decrypts that one +// server-side (see cmd/ingestor/main.go), which would bypass the +// client-side decrypt path this test exercises. +const DEFAULT_PUBLIC_KEY_HEX = '8b3387e9c5cdea6ac9e5edbaa115cd72'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + '\n ' + ((e && e.stack) || e)); } +} +function ok(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +// ─── crypto: the exact client-side scheme from public/channel-decrypt.js ── +// (key is a raw 16-byte PSK, NOT derived from a channel name) +// channelHash(1B) = SHA-256(key)[0] +// ciphertext = AES-128-ECB(plaintext), block-by-block, no chaining +// plaintext = timestamp(4 LE) + flags(1) + "sender: message" + 0x00, +// zero-padded to a 16-byte boundary +// mac(2B) = HMAC-SHA256(key + 16 zero bytes, ciphertext)[0:2] + +function channelHashByteFor(keyBytes) { + return crypto.createHash('sha256').update(keyBytes).digest()[0]; +} + +function buildPlaintext(timestampSec, flags, senderMessage) { + const head = Buffer.alloc(5); + head.writeUInt32LE(timestampSec >>> 0, 0); + head.writeUInt8(flags & 0xff, 4); + const body = Buffer.concat([head, Buffer.from(senderMessage, 'utf8'), Buffer.from([0x00])]); + const padded = Buffer.alloc(Math.max(16, Math.ceil(body.length / 16) * 16), 0); + body.copy(padded); + return padded; +} + +function encryptECB(keyBytes, plaintext) { + const cipher = crypto.createCipheriv('aes-128-ecb', keyBytes, null); + cipher.setAutoPadding(false); + return Buffer.concat([cipher.update(plaintext), cipher.final()]); +} + +function computeMac(keyBytes, ciphertext) { + const secret = Buffer.concat([keyBytes, Buffer.alloc(16, 0)]); + return crypto.createHmac('sha256', secret).update(ciphertext).digest().slice(0, 2); +} + +function rfc3339(ms) { + return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +// MeshCore wire header byte (cmd/server/decoder.go decodeHeader): bits 0-1 +// route type, bits 2-5 payload type, bits 6-7 payload version. RouteDirect=2, +// PayloadGRP_TXT=5 — a real "direct, no path" GRP_TXT packet, so the +// server's startup content-hash pass (ComputeContentHash, cmd/server/ +// decoder.go) parses it exactly as it would a captured one instead of +// mis-parsing an arbitrary first byte and colliding with unrelated rows. +const ROUTE_DIRECT = 2; +const PAYLOAD_GRP_TXT = 5; +const PAYLOAD_VERSION = 1; +const HEADER_BYTE = (ROUTE_DIRECT & 0x03) | ((PAYLOAD_GRP_TXT & 0x0f) << 2) | ((PAYLOAD_VERSION & 0x03) << 6); +const PATH_BYTE = 0x00; // hashSize bits=0, hashCount=0 — direct, no recorded hops + +/** Build the realistic wire bytes for a GRP_TXT packet: header + path + payload. */ +function buildRawPacket(channelHashByte, macBytes, ciphertext) { + return Buffer.concat([Buffer.from([HEADER_BYTE, PATH_BYTE, channelHashByte]), macBytes, ciphertext]); +} + +/** + * JS port of cmd/server/decoder.go's ComputeContentHash, for a packet built + * by buildRawPacket() (route=Direct, so no transport-code offset and no path + * bytes to skip). Keeping this in lock-step with the real server logic means + * the "hash" column we store is already the canonical value, so the + * server's startup hash-migration pass (hash_migrate.go) has nothing to + * change — it reads newHash === tx.Hash and leaves our seeded rows alone. + */ +function computeContentHash16(rawPacket) { + const payloadType = (rawPacket[0] >> 2) & 0x0f; + const payload = rawPacket.slice(2); // skip header byte + path byte + const toHash = Buffer.concat([Buffer.from([payloadType]), payload]); + return crypto.createHash('sha256').update(toHash).digest('hex').slice(0, 16); +} + +/** + * Seed two real, client-decryptable GRP_TXT packets into `dbPath`, under a + * PSK key the server has never seen, before the (read-only) server starts. + * One packet is observed only by an SJC-region observer, the other only by + * an SFO-region observer — so a region-scoped /api/packets?region= + * fetch returns exactly one of them. + * + * Returns { keyHex, channelName, hash, sjc: {sender,text}, sfo: {sender,text} } + */ +function seedEncryptedChannel(dbPath) { + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(dbPath); + try { + // Avoid colliding with any GRP_TXT channelHash byte already present in + // the fixture (real captured traffic on other, unknown channels) — a + // collision wouldn't break correctness (the MAC check still gates + // rendering) but it would add noise to the candidate set. + const used = new Set(); + for (const row of db.prepare( + "SELECT decoded_json FROM transmissions WHERE payload_type = 5 AND decoded_json LIKE '%channelHash%'" + ).all()) { + try { + const dj = JSON.parse(row.decoded_json); + if (typeof dj.channelHash === 'number') used.add(dj.channelHash); + } catch (e) { /* ignore malformed rows */ } + } + + let keyBytes, keyHex, hashByte; + for (let i = 0; i < 1000; i++) { + keyBytes = crypto.randomBytes(16); + keyHex = keyBytes.toString('hex'); + if (keyHex === DEFAULT_PUBLIC_KEY_HEX) continue; + hashByte = channelHashByteFor(keyBytes); + if (!used.has(hashByte)) break; + } + ok(keyBytes && !used.has(hashByte), 'could not find a non-colliding PSK key after 1000 tries'); + + const hashHex = hashByte.toString(16).toUpperCase().padStart(2, '0'); + const channelName = 'psk:' + keyHex.substring(0, 8); // matches addUserChannel()'s naming + + function observerRowid(iata) { + const row = db.prepare( + 'SELECT rowid FROM observers WHERE UPPER(TRIM(iata)) = ? ORDER BY rowid LIMIT 1' + ).get(iata); + ok(row, 'fixture has no observer with iata=' + iata); + return row.rowid; + } + const sjcObserverRowid = observerRowid('SJC'); + const sfoObserverRowid = observerRowid('SFO'); + + // #1690: post-migration schemas add transmissions.last_seen (used by the + // server's memory-cap / hot-window load query, ORDER BY last_seen DESC). + // Set it explicitly so our freshly-seeded rows always sort as "freshest" + // rather than defaulting to 0 and risking eviction behind 500 real rows. + const hasLastSeen = db.prepare("PRAGMA table_info(transmissions)").all() + .some((c) => c.name === 'last_seen'); + + const insertTx = db.prepare( + `INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, channel_hash${hasLastSeen ? ', last_seen' : ''}) + VALUES (?, ?, ?, ?, 5, 1, ?, ?${hasLastSeen ? ', ?' : ''})` + ); + const insertObs = db.prepare( + `INSERT INTO observations (transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp) + VALUES (?, ?, NULL, ?, ?, NULL, '[]', ?)` + ); + + function seedOne(observerRowid, sender, text, ageSec, snr) { + const nowMs = Date.now(); + const tsSec = Math.floor((nowMs - ageSec * 1000) / 1000); + const plaintext = buildPlaintext(tsSec, 0, sender + ': ' + text); + const ciphertext = encryptECB(keyBytes, plaintext); + const mac = computeMac(keyBytes, ciphertext); + const macHex = mac.toString('hex'); + const encryptedDataHex = ciphertext.toString('hex'); + const decoded = JSON.stringify({ + type: 'GRP_TXT', + encryptedData: encryptedDataHex, + mac: macHex, + channelHash: hashByte, + channelHashHex: hashHex, + decryptionStatus: 'no_key', + }); + const rawPacket = buildRawPacket(hashByte, mac, ciphertext); + const rawHex = rawPacket.toString('hex'); + const txHash = computeContentHash16(rawPacket); + const args = [rawHex, txHash, rfc3339(tsSec * 1000), ROUTE_DIRECT, decoded, 'enc_' + hashHex]; + if (hasLastSeen) args.push(tsSec); + const info = insertTx.run(...args); + insertObs.run(info.lastInsertRowid, observerRowid, snr, snr - 90, tsSec); + return { sender, text, hash: txHash }; + } + + const sjc = seedOne(sjcObserverRowid, 'IssueBot', 'SJC-ALPHA-MSG-152', 120, 6.5); + const sfo = seedOne(sfoObserverRowid, 'IssueBot', 'SFO-BRAVO-MSG-152', 60, 5.0); + + return { keyHex, channelName, hash: 'user:' + channelName, sjc, sfo }; + } finally { + db.close(); + } +} + +// ─── process lifecycle (subset of test-channel-proposals-e2e.js's) ──────── + +function findFallbackChromium() { + const base = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (!base || !fs.existsSync(base)) return undefined; + let dirs; + try { dirs = fs.readdirSync(base).filter((d) => d.startsWith('chromium-')); } catch (e) { return undefined; } + for (const d of dirs) { + const p = path.join(base, d, 'chrome-linux', 'chrome'); + if (fs.existsSync(p)) return p; + } + return undefined; +} + +function freePort() { + return new Promise((resolve, reject) => { + const s = net.createServer(); + s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); + s.on('error', reject); + }); +} + +const children = new Set(); +let startCount = 0; +function startProcess(label, bin, args, dir) { + const logFile = path.join(dir, label + '-' + (++startCount) + '.log'); + const out = fs.openSync(logFile, 'w'); + const proc = spawn(bin, args, { stdio: ['ignore', out, out] }); + fs.closeSync(out); + proc.label = label; + proc.logFile = logFile; + children.add(proc); + proc.once('exit', () => children.delete(proc)); + return proc; +} + +// A child killed by a signal (SIGKILL, SIGSEGV, ...) exits with exitCode +// null and signalCode set, so both must be checked. +function hasExited(proc) { + return proc.exitCode !== null || proc.signalCode !== null; +} + +function exitDescription(proc) { + return proc.signalCode !== null ? 'signal ' + proc.signalCode : 'code ' + proc.exitCode; +} + +async function stopProcess(proc) { + if (!proc || hasExited(proc)) return; + const exited = new Promise((r) => proc.once('exit', r)); + proc.kill('SIGTERM'); + const t = setTimeout(() => proc.kill('SIGKILL'), 5000); + await exited; + clearTimeout(t); +} + +// Nit (#153 review round 3): when `proc` is given, stop polling the moment +// it exits instead of burning out the full timeout in 200ms retries against +// a process that is already dead. +async function waitFor(what, fn, timeoutMs, proc) { + const deadline = Date.now() + (timeoutMs || TIMEOUT); + let last; + while (Date.now() < deadline) { + if (proc && hasExited(proc)) { + throw new Error(what + ': process "' + proc.label + '" exited early (' + exitDescription(proc) + '), see ' + proc.logFile); + } + try { const v = await fn(); if (v) return v; } catch (e) { last = e; } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error('timed out waiting for ' + what + (last ? ': ' + last.message : '')); +} + +function logHas(proc, re) { + try { return re.test(fs.readFileSync(proc.logFile, 'utf8')); } catch (e) { return false; } +} + +// ─── minimal MQTT 3.1.1 broker (lets the ingestor start — no packets are +// published; everything this test needs is seeded directly into the DB +// instead). Copied from test-channel-proposals-e2e.js. ─────────────────── +function startFakeBroker() { + return new Promise((resolve) => { + const sockets = new Set(); + const server = net.createServer((sock) => { + sockets.add(sock); + sock.on('close', () => sockets.delete(sock)); + sock.on('error', () => {}); + let buf = Buffer.alloc(0); + sock.on('data', (d) => { + buf = Buffer.concat([buf, d]); + for (;;) { + if (buf.length < 2) return; + let len = 0; + let mult = 1; + let i = 1; + let complete = false; + for (; i < buf.length && i <= 4; i++) { + len += (buf[i] & 0x7f) * mult; + mult *= 128; + if ((buf[i] & 0x80) === 0) { complete = true; i++; break; } + } + if (!complete || buf.length < i + len) return; + const type = buf[0] >> 4; + const body = buf.slice(i, i + len); + buf = buf.slice(i + len); + if (type === 1) sock.write(Buffer.from([0x20, 0x02, 0x00, 0x00])); // CONNACK accepted + else if (type === 8) { // SUBSCRIBE → SUBACK granting QoS 0 per topic + let n = 0; + for (let p = 2; p + 2 <= body.length;) { + p += 2 + body.readUInt16BE(p) + 1; + n++; + } + sock.write(Buffer.from([0x90, 2 + n, body[0], body[1]].concat(new Array(n).fill(0)))); + } else if (type === 12) sock.write(Buffer.from([0xd0, 0x00])); // PINGRESP + else if (type === 14) sock.end(); + } + }); + }); + server.listen(0, '127.0.0.1', () => resolve({ + port: server.address().port, + close: () => { sockets.forEach((s) => s.destroy()); server.close(); }, + })); + }); +} + +/** + * Run the real ingestor binary just long enough to apply its schema + * migrations to `dbPath` (the server refuses to start against an + * unmigrated DB), then stop it. No MQTT traffic is needed or sent — the + * fake broker only exists so the ingestor's startup sequence (which runs + * migrations before connecting) completes cleanly instead of timing out. + */ +async function migrateFixture(dbPath, dir) { + const broker = await startFakeBroker(); + const ingestorConfig = path.join(dir, 'ingestor.json'); + fs.writeFileSync(ingestorConfig, JSON.stringify({ + dbPath, + mqttSources: [{ name: 'e2e', broker: 'mqtt://127.0.0.1:' + broker.port, topics: ['meshcore/#'], connectTimeoutSec: 5 }], + })); + const ingestor = startProcess('ingestor', INGESTOR_BIN, ['-config', ingestorConfig], dir); + try { + await waitFor('ingestor migration + MQTT subscription', () => { + return logHas(ingestor, /MQTT \[e2e\] subscribed/); + }, undefined, ingestor); + } finally { + await stopProcess(ingestor); + broker.close(); + } +} + +// ─── page helpers ────────────────────────────────────────────────────── + +// The region pills are a MULTI-select toggle (region-filter.js's +// toggleRegion): clicking a region that isn't selected ADDS it to the +// current set (or, when nothing is selected yet, becomes the sole +// selection — the "All regions" default is a null set, not an empty one). +// Starting from "All regions" (the page's default, untouched state) and +// clicking exactly one region pill is therefore a single, clean, +// uncontested region change — no intermediate combined-region state, and +// critically no intermediate /api/packets fetch that would populate +// channel-decrypt.js's (region-unaware) message cache with the wrong +// region's content before the real switch this test cares about happens. +async function selectOnlyRegion(page, code) { + const pill = page.locator('#chRegionFilter [data-region="' + code + '"]'); + await pill.waitFor({ state: 'attached', timeout: 8000 }); + const response = page.waitForResponse((r) => { + const u = decodeURIComponent(r.url()); + return u.indexOf('/api/channels?') !== -1 && u.indexOf('/messages') === -1 && u.indexOf('region=' + code) !== -1; + }, { timeout: 8000 }); + const click = pill.click({ timeout: 2000 }).catch(() => pill.evaluate((el) => el.click())); + await Promise.all([response, click]); +} + +// Toggles an already-selected region pill back off (multi-select: clicking +// a selected pill removes it). Used to get back to a clean single-selection +// state before selectOnlyRegion's next clean click, instead of ending up +// with a combined "SFO,OAK" selection. +async function deselectRegion(page, code) { + const pill = page.locator('#chRegionFilter [data-region="' + code + '"]'); + await pill.waitFor({ state: 'attached', timeout: 8000 }); + // Deselecting the only selected region goes back to "All regions" — a + // null selection. loadChannels() then queries with no `region=` param, + // which the client's own TTL cache (CLIENT_TTL.channels) may already hold + // from the very first page load, so no new network request is guaranteed + // to fire here — wait for the pill UI to reflect the toggle instead. + await pill.click({ timeout: 2000 }).catch(() => pill.evaluate((el) => el.click())); + // Deselecting the one selected region goes back to "All regions", which + // re-renders EVERY pill (including the one just clicked) as checked — so + // "this pill is now unchecked" is never true here. Wait for the "__all__" + // pill to become the active one instead. + await page.waitForFunction(() => { + const el = document.querySelector('#chRegionFilter [data-region="__all__"]'); + return el && el.classList.contains('region-pill-active'); + }, null, { timeout: 8000 }); +} + +function messagePaneState(page) { + return page.evaluate(() => { + const msgEl = document.getElementById('chMessages'); + const row = document.querySelector('#chList .ch-row.selected, #chList .ch-item.selected, #chList [aria-selected="true"]'); + return { + urlHash: location.hash, + loading: !!(msgEl && (msgEl.querySelector('.ch-loading') || /Decrypting/.test(msgEl.textContent))), + text: msgEl ? msgEl.textContent : '', + senders: msgEl ? Array.from(msgEl.querySelectorAll('.ch-msg-sender')).map((e) => e.textContent) : [], + bubbles: msgEl ? Array.from(msgEl.querySelectorAll('.ch-msg-bubble')).map((e) => e.textContent) : [], + rowSelectedHash: row ? row.getAttribute('data-hash') : null, + }; + }); +} + +// ─── test body ───────────────────────────────────────────────────────── + +async function runViewport(browser, vp, base, seed) { + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(10000); + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await page.goto(base + '/#/channels', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#chList .ch-item, #chList .ch-row', { timeout: 10000 }); + + // Region starts at its page default ("All regions", a null selection) — + // deliberately NOT pre-selecting SJC here. The first decrypt fetch below + // therefore runs unfiltered (sees both the SJC and SFO packets), and the + // single-click switch to SFO further down is a clean, uncontested region + // change (see selectOnlyRegion's doc comment) instead of needing a second + // click to undo a prior region pre-selection — which would itself issue + // an intermediate /api/packets fetch and pollute channel-decrypt.js's + // (region-unaware, keyed only by channel name) message cache with + // combined-region content before the real switch under test happens. + + // Delay only the FIRST /api/packets response by ~3s, so it's still + // in-flight when the region switch below fires. Later calls (the + // region-change handler's own re-fetch, once F1 is fixed) pass straight + // through so the test doesn't just become an exercise in waiting. + let packetsCalls = 0; + await page.route('**/api/packets**', async (route) => { + packetsCalls++; + if (packetsCalls === 1) { + await new Promise((r) => setTimeout(r, 3000)); + } + await route.continue(); + }); + + await step(vp.name + ': adding the seeded PSK opens its conversation and starts decrypting', async () => { + await page.click('#chAddChannelBtn'); + await page.waitForSelector('#chAddChannelModal:not(.hidden)'); + await page.fill('#chPskKey', seed.keyHex); + await page.fill('#chPskName', 'Issue152 Decrypt Race'); + await page.click('#chPskAddBtn'); + await page.waitForFunction((hash) => location.hash === '#/channels/' + encodeURIComponent(hash), + seed.hash, { timeout: 10000 }); + }); + + await step(vp.name + ': caught mid-decrypt — still showing "Decrypting messages..." when region changes', async () => { + const s = await messagePaneState(page); + ok(s.loading, 'expected the decrypt to still be in flight (pane: ' + JSON.stringify(s.text.slice(0, 80)) + ')'); + ok(packetsCalls === 1, 'expected exactly 1 /api/packets call so far, got ' + packetsCalls); + }); + + await step(vp.name + ': narrow to region SFO while the (unfiltered) decrypt is still in flight', async () => { + await selectOnlyRegion(page, 'SFO'); + }); + + await step(vp.name + ': the pane eventually settles (not stuck on "Decrypting...")', async () => { + await page.waitForFunction(() => { + const m = document.getElementById('chMessages'); + return m && !m.querySelector('.ch-loading') && !/Decrypting/.test(m.textContent); + }, null, { timeout: 15000 }); + }); + + await step(vp.name + ': the pane shows the NEW region (SFO) message, not the stale SJC one', async () => { + const s = await messagePaneState(page); + ok(s.text.indexOf(seed.sfo.text) !== -1, + 'expected the SFO message in the pane, got: ' + JSON.stringify(s.text.slice(0, 200))); + ok(s.text.indexOf(seed.sjc.text) === -1, + 'the stale SJC-region message must not be shown after switching to SFO, got: ' + JSON.stringify(s.text.slice(0, 200))); + }); + + await step(vp.name + ': the conversation stayed open on the same PSK channel (not closed/reset)', async () => { + const s = await messagePaneState(page); + ok(s.urlHash === '#/channels/' + encodeURIComponent(seed.hash), 'URL must stay on the PSK channel, got ' + s.urlHash); + ok(s.text.indexOf('Choose a channel') === -1, 'conversation must not have closed'); + ok(s.rowSelectedHash === seed.hash, 'the PSK row must still be selected, got ' + s.rowSelectedHash); + }); + + // N2 (#153 review round 3): the decrypt cache was keyed by channel name + // alone, so a region with zero traffic for this channel — switched to + // well AFTER the earlier decrypt has fully settled, no race involved — + // could still show a previous region's cached messages. OAK has no + // observer for either seeded packet. + await step(vp.name + ': switching to a region with no traffic for this channel shows an empty pane, not a stale one', async () => { + // Region pills are multi-select: deselect SFO first so the OAK click + // below is a clean single-region switch, not an accumulated "SFO,OAK". + await deselectRegion(page, 'SFO'); + await selectOnlyRegion(page, 'OAK'); + await page.waitForFunction(() => { + const m = document.getElementById('chMessages'); + return m && !m.querySelector('.ch-loading') && !/Decrypting/.test(m.textContent); + }, null, { timeout: 15000 }); + const s = await messagePaneState(page); + ok(s.text.indexOf(seed.sfo.text) === -1, + 'OAK (no traffic) must not show the SFO-region cached message, got: ' + JSON.stringify(s.text.slice(0, 200))); + ok(s.text.indexOf(seed.sjc.text) === -1, + 'OAK (no traffic) must not show the SJC-region cached message, got: ' + JSON.stringify(s.text.slice(0, 200))); + ok(s.urlHash === '#/channels/' + encodeURIComponent(seed.hash), 'URL must stay on the PSK channel, got ' + s.urlHash); + }); + + // R4-1 (#153 review round 4, P2): the decrypt cache is region-scoped + // ("|") since N2, but Remove still only cleared the bare + // "" entry, leaving the decrypted plaintext in localStorage. + await step(vp.name + ': Remove leaves no decrypted plaintext for the channel in localStorage', async () => { + const before = await page.evaluate(() => JSON.stringify(Object.assign({}, localStorage))); + ok(before.indexOf(seed.sfo.text) !== -1, + 'precondition: the SFO decrypt must have cached its plaintext before Remove (localStorage keys: ' + + JSON.stringify(await page.evaluate(() => Object.keys(localStorage))) + ')'); + const btn = page.locator('#chList [data-remove-channel="' + seed.hash + '"]'); + if (await btn.count()) { + page.once('dialog', (d) => d.accept()); + await btn.click({ timeout: 2000 }); + } else { + // The mobile channel list (.ch-row) has no Remove button yet — a known + // gap outside this PR's scope. Call the handler that button invokes + // after its confirm(), so mobile still covers what Remove clears. + ok(vp.width < 640, 'desktop must render a Remove button for the PSK row'); + await page.evaluate((hash) => window._channelsRemoveKeyHandlerForTest(hash), seed.hash); + } + await page.waitForFunction((hash) => !document.querySelector('#chList [data-hash="' + hash + '"]'), + seed.hash, { timeout: 8000 }); + const after = await page.evaluate(() => Object.assign({}, localStorage)); + for (const k of Object.keys(after)) { + for (const text of [seed.sjc.text, seed.sfo.text]) { + ok(String(after[k]).indexOf(text) === -1, + 'localStorage["' + k + '"] still holds plaintext "' + text + '" after Remove: ' + String(after[k]).slice(0, 200)); + } + } + const cacheKeys = Object.keys(JSON.parse(after.corescope_channel_cache || '{}')); + ok(!cacheKeys.some((k) => k === seed.channelName || k.indexOf(seed.channelName + '|') === 0), + 'no decrypt-cache entry for ' + seed.channelName + ' may survive Remove, got ' + JSON.stringify(cacheKeys)); + }); + + await step(vp.name + ': no uncaught page errors', async () => { + // "L is not defined" is Leaflet (public/index.html loads it from + // unpkg.com) failing to load because this test's sandbox has no + // outbound internet access to third-party CDNs — unrelated to the + // channels/decrypt code this test exercises, and already the case + // with no PSK/region interaction at all. A real environment (CI, + // staging) has network access and never sees it; test-channels- + // client-state-152-e2e.js (this test's sibling for the same issue) + // likewise only logs page errors for this page rather than failing on + // them, for the same reason. + const unexpected = pageErrors.filter((m) => !/^L is not defined$/.test(m)); + assert.deepStrictEqual(unexpected, [], 'unexpected page errors: ' + JSON.stringify(pageErrors)); + }); + + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await ctx.close(); +} + +async function main() { + for (const p of [SERVER_BIN, FIXTURE_DB]) { + if (!fs.existsSync(p)) { + console.error('test-channels-client-state-152-decrypt-e2e.js: FAIL — missing ' + p); + process.exit(1); + } + } + + const { chromium } = require('playwright'); + let browser; + const launchArgs = { headless: true, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] }; + try { + browser = await chromium.launch(Object.assign({}, launchArgs, { executablePath: process.env.CHROMIUM_PATH || undefined })); + } catch (err) { + // Fallback: the resolved `playwright` package's expected browser revision + // may not match what's actually unpacked under PLAYWRIGHT_BROWSERS_PATH + // (e.g. a sibling process installed a newer playwright into this shared + // worktree's node_modules). Point at whatever chromium build IS present + // instead of giving up — an executablePath override works across minor + // version gaps for the plain navigation/fill/click/route APIs this test + // uses. + const fallback = !process.env.CHROMIUM_PATH && findFallbackChromium(); + if (fallback) { + try { + browser = await chromium.launch(Object.assign({}, launchArgs, { executablePath: fallback })); + console.log('test-channels-client-state-152-decrypt-e2e.js: using fallback Chromium at ' + fallback); + } catch (err2) { + err = err2; + } + } + if (!browser) { + if (process.env.CHROMIUM_REQUIRE === '1') { + console.error('test-channels-client-state-152-decrypt-e2e.js: FAIL — Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-channels-client-state-152-decrypt-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + } + + if (!fs.existsSync(INGESTOR_BIN)) { + console.error('test-channels-client-state-152-decrypt-e2e.js: FAIL — missing ' + INGESTOR_BIN); + process.exit(1); + } + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'corescope-decrypt152-e2e-')); + const dbPath = path.join(dir, 'meshcore.db'); + const serverDir = path.join(dir, 'server'); + fs.mkdirSync(serverDir); + fs.copyFileSync(FIXTURE_DB, dbPath); + + console.log('running the ingestor once to migrate the fixture schema ...'); + await migrateFixture(dbPath, dir); + + console.log('seeding encrypted PSK packets into ' + dbPath + ' ...'); + const seed = seedEncryptedChannel(dbPath); + console.log(' PSK key: ' + seed.keyHex); + console.log(' channel hash: ' + seed.hash); + console.log(' SJC message: "' + seed.sjc.sender + ': ' + seed.sjc.text + '"'); + console.log(' SFO message: "' + seed.sfo.sender + ': ' + seed.sfo.text + '"'); + + const port = await freePort(); + const base = 'http://127.0.0.1:' + port; + + // Nit (#153 review round 3): a thrown error here (e.g. the server never + // comes up) previously left `failed` at 0 — no step() had run yet — so the + // `finally` below deleted `dir` anyway, and the FATAL message from the + // outer catch then pointed at log files that no longer existed. Track a + // fatal separately so its logs are kept too, then re-throw unchanged. + let fatal = null; + try { + const server = startProcess('server', SERVER_BIN, + ['-port', String(port), '-db', dbPath, '-public', PUBLIC_DIR, '-config-dir', serverDir], + dir); + await waitFor('server health', async () => { + const res = await fetch(base + '/api/healthz'); + return res.ok; + }, undefined, server); + await waitFor('server reports SJC and SFO regions', async () => { + const res = await fetch(base + '/api/config/regions'); + const regions = await res.json(); + return 'SJC' in regions && 'SFO' in regions; + }, undefined, server); + + console.log('\n=== #152 decrypt-race E2E against ' + base + ' ==='); + await runViewport(browser, { name: 'desktop', width: 1280, height: 800 }, base, seed); + await runViewport(browser, { name: 'mobile', width: 390, height: 844 }, base, seed); + } catch (e) { + fatal = e; + } finally { + await browser.close().catch(() => {}); + for (const proc of Array.from(children)) await stopProcess(proc); + if (failed || fatal) console.error('logs kept in ' + dir); + else fs.rmSync(dir, { recursive: true, force: true }); + } + if (fatal) throw fatal; + + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + process.exit(failed > 0 ? 1 : 0); +} + +if (require.main === module) { + main().catch((e) => { + console.error('test-channels-client-state-152-decrypt-e2e.js: FATAL — ' + ((e && e.stack) || e)); + process.exit(1); + }); +} + +module.exports = { + seedEncryptedChannel, channelHashByteFor, buildPlaintext, encryptECB, computeMac, + buildRawPacket, computeContentHash16, + startProcess, stopProcess, waitFor, +}; diff --git a/test-channels-client-state-152-e2e.js b/test-channels-client-state-152-e2e.js new file mode 100644 index 000000000..6f968d835 --- /dev/null +++ b/test-channels-client-state-152-e2e.js @@ -0,0 +1,133 @@ +/** + * #152 — E2E: an open PSK conversation and My Channels survive a region + * change on the Channels page. + * + * Adds a test PSK through the Add Channel modal (the real UI path), which + * selects the new user:* channel, then switches region with the region + * pills. Before the fix the refresh evicted the user:* selection: the + * conversation closed ("Choose a channel from the sidebar"), the URL was + * rewritten to #/channels and the My Channels section disappeared. + * + * Runs at desktop and mobile width. + * + * Usage: BASE_URL=http://localhost:13581 node test-channels-client-state-152-e2e.js + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const PSK_KEY = '0152c0de0152c0de0152c0de0152c0de'; +const PSK_HASH = 'user:psk:0152c0de'; +const PSK_LABEL = 'Issue152 Team'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function conversationState(page) { + return page.evaluate((hash) => { + const header = document.querySelector('#chHeader .ch-header-text'); + const msgs = document.getElementById('chMessages'); + const row = document.querySelector('#chList [data-hash="' + hash + '"]'); + const mine = document.querySelector('#chList .ch-section-mychannels'); + return { + urlHash: location.hash, + header: header ? header.textContent : '', + messagesText: msgs ? msgs.textContent : '', + rowPresent: !!row, + rowSelected: !!row && (row.getAttribute('aria-selected') === 'true' || row.classList.contains('selected')), + rowInMyChannels: !!(mine && mine.querySelector('[data-hash="' + hash + '"]')), + myChannelsText: mine ? mine.textContent : '', + }; + }, PSK_HASH); +} + +async function switchRegion(page, code) { + const pill = page.locator('#chRegionFilter [data-region="' + code + '"]'); + await pill.waitFor({ state: 'attached', timeout: 8000 }); + // Each step selects a region set not requested before, so api()'s TTL + // cache cannot answer it and a real /api/channels request goes out. + const response = page.waitForResponse((r) => { + const u = decodeURIComponent(r.url()); + return u.indexOf('/api/channels?') !== -1 && u.indexOf('/messages') === -1 && u.indexOf(code) !== -1; + }, { timeout: 8000 }); + // The mobile detail view hides or covers the sidebar's region pills; fall + // back to a DOM click, which still goes through the real RegionFilter + // handler. + const click = pill.click({ timeout: 2000 }).catch(() => pill.evaluate((el) => el.click())); + await Promise.all([response, click]); + // loadChannels() renders and reconciles right after the response resolves. + await page.waitForTimeout(300); +} + +async function runViewport(browser, vp) { + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror ' + vp.name + ']', e.message)); + + await page.goto(BASE + '/#/channels', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#chList .ch-item, #chList .ch-row', { timeout: 10000 }); + + await step(vp.name + ': adding a PSK in the UI opens its conversation', async () => { + await page.click('#chAddChannelBtn'); + await page.fill('#chPskKey', PSK_KEY); + await page.fill('#chPskName', PSK_LABEL); + await page.click('#chPskAddBtn'); + await page.waitForFunction((hash) => location.hash === '#/channels/' + encodeURIComponent(hash), PSK_HASH, { timeout: 10000 }); + await page.waitForFunction((label) => { + const h = document.querySelector('#chHeader .ch-header-text'); + return h && h.textContent.indexOf(label) !== -1; + }, PSK_LABEL, { timeout: 10000 }); + // Let the decrypt pass finish so the pane snapshot below is final. + await page.waitForFunction(() => { + const m = document.getElementById('chMessages'); + return m && !m.querySelector('.ch-loading') && m.textContent.indexOf('Decrypting') === -1; + }, null, { timeout: 15000 }); + await page.waitForTimeout(500); + const s = await conversationState(page); + assert(s.rowPresent && s.rowSelected, 'PSK row must be listed and selected, got ' + JSON.stringify(s)); + if (vp.mobile === false) assert(s.rowInMyChannels, 'PSK row must be in My Channels'); + }); + + const before = await conversationState(page); + + for (const code of ['SJC', 'SFO']) { + await step(vp.name + ': region change to ' + code + ' keeps the PSK conversation and My Channels', async () => { + await switchRegion(page, code); + const s = await conversationState(page); + assert(s.urlHash === before.urlHash, 'URL must stay on the PSK channel, got ' + s.urlHash); + assert(s.header.indexOf(PSK_LABEL) !== -1, 'header must still name the PSK channel, got ' + JSON.stringify(s.header)); + assert(s.messagesText.indexOf('Choose a channel') === -1, 'conversation must not close, got ' + JSON.stringify(s.messagesText.slice(0, 120))); + assert(s.messagesText === before.messagesText, 'message pane must be unchanged: ' + JSON.stringify([before.messagesText.slice(0,120), s.messagesText.slice(0,120)])); + assert(s.rowPresent && s.rowSelected, 'PSK row must stay listed and selected, got ' + JSON.stringify(s)); + if (vp.mobile === false) { + assert(s.rowInMyChannels, 'My Channels must still contain the PSK row'); + assert(s.myChannelsText.indexOf(PSK_LABEL) !== -1, 'My Channels must still show the label'); + } + }); + } + + await page.evaluate(() => { try { localStorage.clear(); } catch (e) {} }); + await ctx.close(); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log('\n=== #152 E2E against ' + BASE + ' ==='); + await runViewport(browser, { name: 'desktop', width: 1280, height: 800, mobile: false }); + await runViewport(browser, { name: 'mobile', width: 390, height: 844, mobile: true }); + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + await browser.close(); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error('FATAL:', e); process.exit(1); }); diff --git a/test-channels-client-state-152.js b/test-channels-client-state-152.js new file mode 100644 index 000000000..fcd21ea31 --- /dev/null +++ b/test-channels-client-state-152.js @@ -0,0 +1,1549 @@ +/** + * #152 — loadChannels() must keep client-only channel state across a + * channel-list refresh. + * + * loadChannels() replaced `channels` with the server snapshot and carried + * nothing across. Every refresh (region change, show-encrypted toggle, + * shared-channel approval) therefore: + * - closed an open PSK conversation (a user:* hash is never in the server + * snapshot, so reconcileSelectionAfterChannelRefresh() evicted it, + * emptied `messages` and rewrote the URL to #/channels); + * - dropped the My Channels section and the user's labels; + * - reset unread badges and the sidebar preview of user:* rows. + * + * These tests load the real channels.js (plus channel-decrypt.js and + * channel-proposals.js) in a vm sandbox and drive the real loadChannels(), + * the real WS path and the real init() handlers through the existing test + * hooks. Nothing here re-implements production logic. + * + * Usage: node test-channels-client-state-152.js + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const nodeCrypto = require('crypto'); +const { webcrypto } = nodeCrypto; + +// Encrypts a channel message exactly the way internal/channel/channel.go / +// public/channel-decrypt.js expect it (see that file's header comment): +// AES-128-ECB(timestamp(4 LE) + flags(1) + "sender: message" + 0x00, padded +// to a block boundary) plus an HMAC-SHA256(key + 16 zero bytes) MAC +// truncated to 2 bytes. Node's built-in `crypto` implements plain AES-128 +// and HMAC-SHA256, so this round-trips through the real (decrypt-only) +// public/vendor/aes-ecb.js the way a real radio-encrypted packet would. +function encryptChannelMessage(keyHex, sender, text) { + const keyBytes = Buffer.from(keyHex, 'hex'); + const body = Buffer.from(sender + ': ' + text, 'utf8'); + const header = Buffer.alloc(5); + header.writeUInt32LE(Math.floor(Date.now() / 1000) >>> 0, 0); + let plaintext = Buffer.concat([header, body, Buffer.from([0])]); + const pad = (16 - (plaintext.length % 16)) % 16; + if (pad) plaintext = Buffer.concat([plaintext, Buffer.alloc(pad)]); + const cipher = nodeCrypto.createCipheriv('aes-128-ecb', keyBytes, null); + cipher.setAutoPadding(false); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const secret = Buffer.concat([keyBytes, Buffer.alloc(16)]); + const mac = nodeCrypto.createHmac('sha256', secret).update(ciphertext).digest().slice(0, 2).toString('hex'); + const channelHash = nodeCrypto.createHash('sha256').update(keyBytes).digest()[0]; + return { encryptedData: ciphertext.toString('hex'), mac, channelHash }; +} + +function encryptedPacket(hash, firstSeen, enc) { + return { hash, first_seen: firstSeen, decoded_json: { type: 'GRP_TXT', encryptedData: enc.encryptedData, mac: enc.mac, channelHash: enc.channelHash } }; +} + +const RealDate = Date; +const HOUR = 60 * 60 * 1000; +const PSK_NAME = 'psk:0badc0de'; +const PSK_HASH = 'user:' + PSK_NAME; +const PSK_KEY = '0badc0de0badc0de0badc0de0badc0de'; + +// A Date whose "now" is shifted by skewMs, to simulate a browser clock that +// runs ahead of (skewMs > 0) or behind (skewMs < 0) the server clock. +function makeSkewedDate(skewMs) { + function SkewedDate() { + const args = Array.prototype.slice.call(arguments); + if (!(this instanceof SkewedDate)) return new RealDate(RealDate.now() + skewMs).toString(); + if (args.length === 0) return new RealDate(RealDate.now() + skewMs); + return new (Function.prototype.bind.apply(RealDate, [null].concat(args)))(); + } + SkewedDate.now = () => RealDate.now() + skewMs; + SkewedDate.parse = RealDate.parse; + SkewedDate.UTC = RealDate.UTC; + SkewedDate.prototype = RealDate.prototype; + return SkewedDate; +} + +function deferred() { + let resolve; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +async function flush(n) { + for (let i = 0; i < (n || 20); i++) await new Promise((r) => setImmediate(r)); +} + +// Flushes until cond() holds or ~2s of real time pass. For steps that wait +// on Web Crypto (computeChannelHash), whose digest runs off the event loop +// and can outlast a fixed number of flushes on a busy machine. +async function settle(cond, ms) { + const deadline = RealDate.now() + (ms || 2000); + while (!cond() && RealDate.now() < deadline) await new Promise((r) => setTimeout(r, 5)); + return cond(); +} + +function makeHarness(opts) { + opts = opts || {}; + const storage = {}; + const elements = {}; + function makeFakeEl(id) { + return { + id: id || '', innerHTML: '', textContent: '', value: '', scrollTop: 0, + scrollHeight: 0, clientHeight: 0, + style: {}, dataset: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, removeEventListener() {}, + querySelector() { return makeFakeEl(); }, + querySelectorAll() { return []; }, + getAttribute() { return null; }, setAttribute() {}, removeAttribute() {}, + getBoundingClientRect() { return { width: 240, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; }, + appendChild() {}, removeChild() {}, remove() {}, + focus() {}, blur() {}, + checked: false, + }; + } + function el(id) { + if (!elements[id]) elements[id] = makeFakeEl(id); + return elements[id]; + } + + const historyCalls = []; + const windowListeners = {}; + const h = { + elements, + historyCalls, + regionChange: null, + approvedCallback: null, + // Each /channels request is answered by h.respondChannels(path); tests + // replace it to return a deferred promise when they need a request in + // flight. + respondChannels: () => Promise.resolve({ channels: [] }), + channelRequests: [], + // Each /packets request (the client-side decrypt fetch) is answered by + // h.respondPackets(path); tests replace it to control decrypt timing. + respondPackets: () => Promise.resolve({ packets: [] }), + packetsRequests: [], + }; + + const ctx = { + window: { + addEventListener(type, fn) { (windowListeners[type] = windowListeners[type] || []).push(fn); }, + removeEventListener() {}, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + }, + document: { + readyState: 'complete', + documentElement: { getAttribute: () => null, setAttribute() {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } } }, + createElement: () => makeFakeEl(), + head: { appendChild() {} }, + body: { appendChild() {}, removeChild() {}, contains() { return false; } }, + getElementById: el, + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, + querySelectorAll: () => [], + }, + console, + Date: makeSkewedDate(opts.skewMs || 0), + Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, Set, Map, Promise, + parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: (fn) => { Promise.resolve().then(fn); return 0; }, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }), + performance: { now: () => RealDate.now() }, + localStorage: { + getItem: (k) => Object.prototype.hasOwnProperty.call(storage, k) ? storage[k] : null, + setItem: (k, v) => { storage[k] = String(v); }, + removeItem: (k) => { delete storage[k]; }, + }, + location: { hash: '' }, + history: { replaceState(_s, _t, url) { historyCalls.push(url); }, pushState() {} }, + crypto: webcrypto, + TextEncoder, TextDecoder, + Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, ArrayBuffer, + URLSearchParams, + CustomEvent: class CustomEvent {}, + MutationObserver: class MutationObserver { observe() {} disconnect() {} }, + requestAnimationFrame: (cb) => { Promise.resolve().then(cb); return 0; }, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener() {}, dispatchEvent() {}, + getHashParams: () => new URLSearchParams(), + btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'), + atob: (s) => Buffer.from(String(s), 'base64').toString('binary'), + }; + ctx.self = ctx; + ctx.globalThis = ctx; + + // app.js / roles.js stubs. + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.debouncedOnWS = (fn) => fn; + ctx.debounce = (fn) => fn; + ctx.invalidateApiCache = () => {}; + ctx.api = (p) => { + if (p.indexOf('/channels') === 0 && p.indexOf('/messages') === -1) { + h.channelRequests.push(p); + return h.respondChannels(p); + } + if (p.indexOf('/packets') === 0) { + h.packetsRequests.push(p); + return h.respondPackets(p); + } + if (p.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); + return Promise.resolve({ messages: [], packets: [], channels: [] }); + }; + ctx.CLIENT_TTL = { channels: 15000, observers: 120000, channelMessages: 10000, nodeDetail: 10000 }; + ctx.escapeHtml = (s) => String(s == null ? '' : s); + ctx.truncate = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n) : s; }; + ctx.formatHashHex = (x) => String(x); + ctx.formatSecondsAgo = () => ''; + ctx.timeAgo = () => ''; + ctx.payloadTypeName = () => 'GRP_TXT'; + ctx.ROLE_EMOJI = {}; + ctx.ROLE_LABELS = {}; + ctx.RegionFilter = { + init() {}, + onChange(fn) { h.regionChange = fn; return fn; }, + offChange() {}, + getRegionParam() { return h.regionParam || ''; }, + getSelected() { return null; }, + }; + ctx.ChannelColors = { get() { return null; }, remove() {} }; + ctx.ChannelColorPicker = { open() {} }; + let pageMod = null; + ctx.registerPage = (name, mod) => { if (name === 'channels') pageMod = mod; }; + + vm.createContext(ctx); + function load(file) { + const src = fs.readFileSync(path.join(__dirname, file), 'utf8'); + vm.runInContext(src, ctx, { filename: file }); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + load('public/vendor/aes-ecb.js'); + load('public/channel-decrypt.js'); + load('public/channel-proposals.js'); + // Capture the onApproved callback init() hands to ChannelProposals.mount + // without letting mount() touch the DOM or start polling. The pure + // mergeApprovedChannels() stays the real one. + ctx.window.ChannelProposals.mount = (mountOpts) => { h.approvedCallback = mountOpts && mountOpts.onApproved; }; + ctx.window.ChannelProposals.unmount = () => {}; + load('public/channels.js'); + + h.ctx = ctx; + h.w = ctx.window; + h.page = pageMod; + h.showEncryptedChanged = () => (windowListeners['mc-channels-show-encrypted-changed'] || []).forEach((fn) => fn({})); + h.state = () => ctx.window._channelsGetStateForTest(); + h.setState = (s) => ctx.window._channelsSetStateForTest(s); + h.row = (hash) => h.state().channels.find((c) => c.hash === hash); + h.storeKey = (name, key, label) => ctx.window.ChannelDecrypt.storeKey(name, key, label); + h.removeKey = (name) => ctx.window.ChannelDecrypt.removeKey(name); + h.init = async () => { + await pageMod.init(el('page'), null); + await flush(); + historyCalls.length = 0; + }; + // A live CHAN message through the real WS path. + h.liveMessage = (channel, sender, text, hash) => { + ctx.window._channelsProcessWSBatchForTest([{ + type: 'message', + data: { hash: hash || ('pkt-' + channel + '-' + text), decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: { channel, sender, text: sender + ': ' + text } } }, + }], null); + }; + return h; +} + +// The decrypt cache exactly as channel-decrypt.js persisted it. +const DECRYPT_CACHE_KEY = 'corescope_channel_cache'; +function rawDecryptCache(h) { + return h.ctx.localStorage.getItem(DECRYPT_CACHE_KEY) || ''; +} +function decryptCacheKeys(h) { + return Object.keys(JSON.parse(rawDecryptCache(h) || '{}')); +} +function cacheEntry(text, ts) { + return { + messages: [{ sender: 'Alice', text, timestamp: ts || '2026-01-01T00:00:00Z', packetHash: 'p-' + text }], + lastTimestamp: ts || '2026-01-01T00:00:00Z', count: 1, ts: 1, + }; +} + +// channel-decrypt.js alone, over a localStorage that enforces a quota the +// way browsers do: a setItem() that would push the total stored characters +// past `quota` throws QuotaExceededError and leaves the old value in place. +function makeDecryptSandbox(opts) { + opts = opts || {}; + const sb = { storage: Object.assign({}, opts.storage || {}), quota: opts.quota || Infinity }; + sb.used = (exceptKey) => Object.keys(sb.storage).reduce((n, k) => n + (k === exceptKey ? 0 : k.length + sb.storage[k].length), 0); + const ctx = { + localStorage: { + getItem: (k) => Object.prototype.hasOwnProperty.call(sb.storage, k) ? sb.storage[k] : null, + setItem: (k, v) => { + v = String(v); + if (sb.used(k) + k.length + v.length > sb.quota) { + const e = new Error('The quota has been exceeded.'); + e.name = 'QuotaExceededError'; + throw e; + } + sb.storage[k] = v; + }, + removeItem: (k) => { delete sb.storage[k]; }, + }, + console, Date, JSON, Math, String, Number, Object, Array, RegExp, Error, Promise, parseInt, + crypto: webcrypto, TextEncoder, TextDecoder, Uint8Array, + }; + ctx.window = ctx; + ctx.self = ctx; + vm.createContext(ctx); + // lateStorage: the module loads before localStorage exists. + const storageApi = ctx.localStorage; + if (opts.lateStorage) delete ctx.localStorage; + sb.attachStorage = () => { ctx.localStorage = storageApi; }; + vm.runInContext(fs.readFileSync(path.join(__dirname, 'public/channel-decrypt.js'), 'utf8'), ctx, { filename: 'public/channel-decrypt.js' }); + sb.CD = ctx.ChannelDecrypt; + sb.raw = () => sb.storage[DECRYPT_CACHE_KEY] || ''; + sb.keys = () => Object.keys(JSON.parse(sb.raw() || '{}')); + return sb; +} +// One cached message whose text is `chars` long, so a cache entry +// serialises to roughly that many characters. +function bigMessages(chars, tag) { + return [{ sender: 'S', text: tag + ':' + 'x'.repeat(chars), timestamp: '2026-01-01T00:00:00Z', packetHash: 'h-' + tag }]; +} + +function serverChannel(hash, extra) { + return Object.assign({ + hash, + name: hash, + messageCount: 10, + lastActivity: new RealDate(RealDate.now() - 60000).toISOString(), + lastSender: 'Server', + lastMessage: 'from server', + }, extra || {}); +} + +function pskRow(extra) { + return Object.assign({ + hash: PSK_HASH, + name: PSK_NAME, + userLabel: 'Team', + messageCount: 3, + lastActivityMs: RealDate.now() - 5000, + lastSender: 'Bob', + lastMessage: 'decrypted preview', + encrypted: true, + userAdded: true, + }, extra || {}); +} + +// A harness with the page initialised, a stored PSK key and an open PSK +// conversation — the state a user is in when they hit the bug. +async function openPskConversation(opts) { + const h = makeHarness(opts); + h.storeKey(PSK_NAME, PSK_KEY, 'Team'); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + const messages = [ + { sender: 'Alice', text: 'first', timestamp: '2026-01-01T00:00:00Z', packetHash: 'm1' }, + { sender: 'Bob', text: 'second', timestamp: '2026-01-01T00:01:00Z', packetHash: 'm2' }, + ]; + h.setState({ + channels: [Object.assign({}, h.row('public')), pskRow()], + messages, + selectedHash: PSK_HASH, + }); + return { h, messages }; +} + +function assertConversationOpen(h, messages, label) { + const s = h.state(); + assert.strictEqual(s.selectedHash, PSK_HASH, label + ': PSK selection must survive'); + assert.strictEqual(s.messages.length, messages.length, label + ': messages must survive'); + assert.strictEqual(s.messages[0].text, 'first', label + ': messages must be the same conversation'); + assert.ok(!h.historyCalls.includes('#/channels'), label + ': URL must not be rewritten to #/channels (got ' + JSON.stringify(h.historyCalls) + ')'); + const row = h.row(PSK_HASH); + assert.ok(row && row.userAdded === true, label + ': PSK row must stay in My Channels'); + assert.ok(/ch-section-mychannels/.test(h.elements.chList.innerHTML), label + ': rendered list must contain My Channels'); +} + +let passed = 0, failed = 0; +async function test(name, fn) { + try { await fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message)); } +} + +(async () => { + console.log('\n=== #152 loadChannels keeps client-only state ==='); + + await test('open PSK conversation survives a silent loadChannels() refresh', async () => { + const { h, messages } = await openPskConversation(); + await h.w._channelsLoadChannelsForTest(true); + assertConversationOpen(h, messages, 'loadChannels'); + }); + + await test('open PSK conversation survives a region change (RegionFilter.onChange handler)', async () => { + const { h } = await openPskConversation(); + h.regionParam = 'CPH'; + assert.strictEqual(typeof h.regionChange, 'function', 'init() must register a region handler'); + h.regionChange(); + await flush(); + assert.ok(h.channelRequests[h.channelRequests.length - 1].indexOf('region=CPH') !== -1, 'region change must refetch with the region'); + // F1 (#152 follow-up): an encrypted/PSK selection now re-runs + // selectChannel() for the new region (see below) instead of just + // refreshMessages() — which has no REST messages to refetch for it — so + // unlike a plain server channel its messages are freshly re-decrypted + // for the new region (empty here, since the harness's default /packets + // response has none), not carried over verbatim. The conversation + // itself — selection, URL, My Channels membership — still survives. + const s = h.state(); + assert.strictEqual(s.selectedHash, PSK_HASH, 'region change: PSK selection must survive'); + assert.ok(!h.historyCalls.includes('#/channels'), 'region change: URL must not be rewritten to #/channels (got ' + JSON.stringify(h.historyCalls) + ')'); + const row = h.row(PSK_HASH); + assert.ok(row && row.userAdded === true, 'region change: PSK row must stay in My Channels'); + assert.ok(/ch-section-mychannels/.test(h.elements.chList.innerHTML), 'region change: rendered list must contain My Channels'); + }); + + // ── F1: a region switch mid-decrypt must restart decryption, not strand + // the pane on "Decrypting messages…" or leave it showing the previous + // region's messages. refreshMessages() has nothing to refetch for an + // encrypted row (no REST messages exist), so it must hand off to + // selectChannel() instead of silently returning. + await test('F1: region switch mid-decrypt restarts decryption for the new region', async () => { + const h = makeHarness(); + const KEY = 'f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1'; + const NAME = 'psk:f1test'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'F1 Team'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const oldMsg = encryptChannelMessage(KEY, 'Alice', 'old region message'); + const newMsg = encryptChannelMessage(KEY, 'Bob', 'new region message'); + + const pending = deferred(); + h.respondPackets = () => pending.promise; + const select = h.w._channelsSelectChannelForTest(HASH); + // selectChannel() awaits ChannelDecrypt.computeChannelHash() (a real + // crypto.subtle.digest call) before it even reaches the decrypt-and-set + // "Decrypting…" step, which takes more ticks than a plain microtask + // flush — give it a generous margin. + await flush(300); + assert.ok(/Decrypting/.test(h.elements.chMessages.innerHTML), 'pane must show "Decrypting…" while the fetch is in flight'); + + // Region changes while the first decrypt's /packets fetch is still + // pending. The region handler's loadChannels() + its own /packets + // fetch (for the new region) resolve before the stale one does. + h.regionParam = 'CPH'; + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-new', '2026-01-01T00:01:00Z', newMsg)] }); + h.regionChange(); + await flush(300); + + // The original (stale) fetch finally resolves, for the OLD region. + pending.resolve({ packets: [encryptedPacket('pkt-old', '2026-01-01T00:00:00Z', oldMsg)] }); + await select; + await flush(300); + + const msgEl = h.elements.chMessages; + assert.ok(!/Decrypting/.test(msgEl.innerHTML), 'pane must not be stuck on "Decrypting…" (got ' + msgEl.innerHTML.slice(0, 200) + ')'); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('new region message') !== -1, 'new-region message must be shown (got ' + JSON.stringify(texts) + ')'); + assert.ok(texts.indexOf('old region message') === -1, 'stale old-region message must not be shown (got ' + JSON.stringify(texts) + ')'); + assert.strictEqual(h.state().selectedHash, HASH, 'conversation must stay open on the same PSK'); + }); + + await test('open PSK conversation survives the show-encrypted toggle', async () => { + const { h, messages } = await openPskConversation(); + h.ctx.localStorage.setItem('channels-show-encrypted', 'true'); + h.showEncryptedChanged(); + await flush(); + assert.ok(h.channelRequests[h.channelRequests.length - 1].indexOf('includeEncrypted=true') !== -1, 'toggle must refetch with includeEncrypted'); + assertConversationOpen(h, messages, 'show-encrypted'); + }); + + await test('shared-channel approval (onApproved) keeps the open PSK conversation, one PSK row', async () => { + const { h, messages } = await openPskConversation(); + h.respondChannels = () => Promise.resolve({ + channels: [serverChannel('public')], + approvedChannels: [{ hash: '#shared', name: '#shared' }], + }); + assert.strictEqual(typeof h.approvedCallback, 'function', 'init() must mount ChannelProposals with onApproved'); + const before = h.channelRequests.length; + h.approvedCallback(); + await flush(); + assert.strictEqual(h.channelRequests.length, before + 1, 'approval must refetch the channel list exactly once'); + assertConversationOpen(h, messages, 'onApproved'); + assert.ok(h.row('#shared') && h.row('#shared').shared === true, 'approved channel must be listed'); + assert.strictEqual(h.state().channels.filter((c) => c.hash === PSK_HASH).length, 1, 'PSK row must appear exactly once'); + }); + + await test('My Channels rows and labels survive a refresh (user:* row and key-matched server row)', async () => { + const h = makeHarness(); + h.storeKey(PSK_NAME, PSK_KEY, 'Team'); + h.storeKey('#ops', 'aa'.repeat(16), 'Ops room'); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public'), serverChannel('#ops', { name: '#ops' })] }); + await h.init(); + await h.w._channelsLoadChannelsForTest(true); + const psk = h.row(PSK_HASH); + const ops = h.row('#ops'); + assert.ok(psk && psk.userAdded === true && psk.userLabel === 'Team', 'user:* row keeps userAdded + label'); + assert.ok(ops && ops.userAdded === true && ops.userLabel === 'Ops room', 'server row matched by a stored key keeps userAdded + label'); + assert.ok(!h.row('public').userAdded, 'unrelated server row is not marked userAdded'); + assert.ok(/ch-section-mychannels/.test(h.elements.chList.innerHTML), 'My Channels rendered'); + assert.ok(h.elements.chList.innerHTML.indexOf('Team') !== -1, 'PSK label rendered'); + }); + + await test('a label re-read from storage wins over the previous list\'s label', async () => { + const { h } = await openPskConversation(); + h.setState({ channels: [Object.assign({}, h.row('public')), pskRow({ userLabel: 'Old name' })] }); + await h.w._channelsLoadChannelsForTest(true); + assert.strictEqual(h.row(PSK_HASH).userLabel, 'Team', 'storage label must win (got ' + h.row(PSK_HASH).userLabel + ')'); + }); + + await test('unread and preview on a user:* row survive a refresh (explicit 0 on a server row kept)', async () => { + const { h } = await openPskConversation(); + h.setState({ + channels: [Object.assign({}, h.row('public'), { unread: 0 }), pskRow({ unread: 4 })], + selectedHash: null, + messages: [], + }); + await h.w._channelsLoadChannelsForTest(true); + const psk = h.row(PSK_HASH); + assert.strictEqual(psk.unread, 4, 'user:* unread kept (got ' + psk.unread + ')'); + assert.strictEqual(psk.lastMessage, 'decrypted preview', 'user:* preview kept (got ' + psk.lastMessage + ')'); + assert.strictEqual(psk.lastSender, 'Bob', 'user:* preview sender kept'); + assert.strictEqual(psk.messageCount, 3, 'user:* messageCount kept'); + const pub = h.row('public'); + assert.ok(Object.prototype.hasOwnProperty.call(pub, 'unread') && pub.unread === 0, 'explicit unread: 0 kept on a server row'); + assert.ok(/data-unread-channel="user:psk:0badc0de"/.test(h.elements.chList.innerHTML), 'unread badge rendered for the user:* row'); + }); + + await test('unread on a server row survives a refresh', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + h.setState({ channels: [Object.assign({}, h.row('public'), { unread: 7 })] }); + await h.w._channelsLoadChannelsForTest(true); + assert.strictEqual(h.row('public').unread, 7); + }); + + await test('a server channel the new snapshot omits (region filter) does not come back', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public'), serverChannel('#far')] }); + await h.init(); + h.setState({ channels: h.state().channels.map((c) => Object.assign({}, c, { unread: 2 })) }); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.w._channelsLoadChannelsForTest(true); + assert.strictEqual(h.row('#far'), undefined, 'omitted row must not be resurrected'); + assert.deepStrictEqual(h.state().channels.map((c) => c.hash), ['public']); + }); + + await test('a user:* row whose key was removed does not come back', async () => { + const { h } = await openPskConversation(); + h.setState({ selectedHash: null, messages: [] }); + h.removeKey(PSK_NAME); + await h.w._channelsLoadChannelsForTest(true); + assert.strictEqual(h.row(PSK_HASH), undefined, 'removed PSK row must not be resurrected from the previous list'); + }); + + for (const skew of [HOUR, -HOUR]) { + const label = skew > 0 ? 'browser clock 1h ahead' : 'browser clock 1h behind'; + + await test('WS update during an in-flight request wins (' + label + ')', async () => { + const h = makeHarness({ skewMs: skew }); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public', { messageCount: 10, lastMessage: 'old snapshot' })] }); + await h.init(); + const pending = deferred(); + h.respondChannels = () => pending.promise; + const load = h.w._channelsLoadChannelsForTest(true); + await flush(); + h.liveMessage('public', 'Carol', 'live during flight'); + const liveRow = Object.assign({}, h.row('public')); + // The snapshot was taken on the server before the live packet arrived. + pending.resolve({ channels: [serverChannel('public', { + messageCount: 10, + lastActivity: new RealDate(RealDate.now() - 1000).toISOString(), + lastSender: 'Server', + lastMessage: 'snapshot', + })] }); + await load; + const row = h.row('public'); + assert.strictEqual(row.lastMessage, 'live during flight', 'live message kept (got ' + row.lastMessage + ')'); + assert.strictEqual(row.lastSender, 'Carol', 'live sender kept with its message'); + assert.strictEqual(row.lastActivityMs, liveRow.lastActivityMs, 'live activity time kept with its message'); + assert.strictEqual(row.messageCount, 11, 'live messageCount kept with its message'); + }); + + await test('a stale client row does not override a newer snapshot (' + label + ')', async () => { + const h = makeHarness({ skewMs: skew }); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public', { messageCount: 10 })] }); + await h.init(); + // Live update BEFORE the refresh starts: the snapshot already has it + // plus a newer message. + h.liveMessage('public', 'Carol', 'older live'); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public', { + messageCount: 12, + lastActivity: new RealDate(RealDate.now() - 1000).toISOString(), + lastSender: 'Dave', + lastMessage: 'newer snapshot', + })] }); + await h.w._channelsLoadChannelsForTest(true); + const row = h.row('public'); + assert.strictEqual(row.lastMessage, 'newer snapshot', 'snapshot message wins (got ' + row.lastMessage + ')'); + assert.strictEqual(row.lastSender, 'Dave', 'snapshot sender wins'); + assert.strictEqual(row.messageCount, 12, 'snapshot count wins'); + }); + } + + await test('loadChannels() does not mutate the api() payload (shared with the api cache)', async () => { + const h = makeHarness(); + h.storeKey('#ops', 'aa'.repeat(16), 'Ops room'); + const payload = { channels: [serverChannel('#ops', { name: '#ops' })] }; + const snapshot = JSON.stringify(payload); + h.respondChannels = () => Promise.resolve(payload); + await h.init(); + await h.w._channelsLoadChannelsForTest(true); + assert.strictEqual(JSON.stringify(payload), snapshot, 'api payload must be left untouched'); + assert.notStrictEqual(h.row('#ops'), payload.channels[0], 'channels must not alias the payload rows'); + }); + + await test('mergeClientChannelState: pure, carries by hash, never resurrects, never mutates inputs', async () => { + const h = makeHarness(); + const merge = h.w._channelsMergeClientChannelStateForTest; + assert.strictEqual(typeof merge, 'function', '_channelsMergeClientChannelStateForTest must be exported'); + const deepFreeze = (o) => { Object.values(o).forEach((v) => { if (v && typeof v === 'object') deepFreeze(v); }); return Object.freeze(o); }; + const fresh = deepFreeze([ + { hash: 'public', name: 'public', lastActivityMs: 100, lastMessage: 'fresh', lastSender: 'S', messageCount: 5 }, + { hash: PSK_HASH, name: PSK_NAME, lastActivityMs: 0, lastMessage: 'Encrypted — click to decrypt', lastSender: '', messageCount: 0, userAdded: true, userLabel: '' }, + ]); + const prev = deepFreeze([ + { hash: 'public', unread: 0, userAdded: true, userLabel: 'Pub', lastActivityMs: 999999, lastMessage: 'stale', lastSender: 'X', messageCount: 99, _wsSeq: 3 }, + { hash: PSK_HASH, unread: 2, userAdded: true, userLabel: 'Team', lastActivityMs: 50, lastMessage: 'preview', lastSender: 'B', messageCount: 1 }, + { hash: 'gone', unread: 9 }, + ]); + const freshCopy = JSON.stringify(fresh); + const prevCopy = JSON.stringify(prev); + const out = merge(fresh, prev, 3); + assert.strictEqual(JSON.stringify(fresh), freshCopy, 'fresh input untouched'); + assert.strictEqual(JSON.stringify(prev), prevCopy, 'prev input untouched'); + assert.notStrictEqual(out, fresh); + out.forEach((o, i) => { assert.notStrictEqual(o, fresh[i], 'rows must be copies'); assert.notStrictEqual(o, prev[i]); }); + assert.deepStrictEqual(out.map((o) => o.hash), ['public', PSK_HASH], 'no resurrected rows, fresh order kept'); + assert.strictEqual(out[0].unread, 0, 'explicit 0 carried'); + // F2 (#152 follow-up): userAdded/userLabel are NOT carried from prev — + // mergeUserChannels() (run before this, on the same fresh list) is the + // sole source now, so storage stays authoritative. A stale prev value + // (here simulating an in-memory row a remove-handler forgot to clear) + // must never leak onto the merged row. + assert.strictEqual(out[0].userAdded, undefined, 'userAdded must not be carried from prev'); + assert.strictEqual(out[0].userLabel, undefined, 'userLabel must not be carried from prev'); + assert.strictEqual(out[0].lastMessage, 'fresh', 'stamp not newer than request start → snapshot activity wins'); + assert.strictEqual(out[0].messageCount, 5); + assert.strictEqual(out[1].unread, 2); + assert.strictEqual(out[1].userLabel, '', 'fresh already set this (by mergeUserChannels in the real pipeline); merge must not override it from prev'); + assert.strictEqual(out[1].lastMessage, 'preview', 'client-only row keeps its preview'); + const newer = merge(fresh, prev, 2); + assert.strictEqual(newer[0].lastMessage, 'stale', 'stamp newer than request start → live activity wins'); + assert.strictEqual(newer[0].messageCount, 99, 'moved together with its message'); + assert.strictEqual(newer[0].lastSender, 'X'); + assert.strictEqual(newer[0].lastActivityMs, 999999); + const none = merge(null, prev, 0); + assert.ok(Array.isArray(none) && none.length === 0, 'non-array fresh → []'); + const noPrev = merge(fresh, null, 0); + assert.deepStrictEqual(JSON.parse(JSON.stringify(noPrev)), JSON.parse(freshCopy)); + assert.notStrictEqual(noPrev[0], fresh[0]); + }); + + // ── F2: storage must be authoritative for userAdded/userLabel. A removed + // key's label must not resurrect across refreshes (regression for the + // remove-handler leaving ch.userLabel set, and mergeClientChannelState + // carrying it forward from the in-memory previous list). + await test('F2: a removed key\'s label and My Channels row do not resurrect across two refreshes', async () => { + const h = makeHarness(); + h.storeKey('#ops', 'aa'.repeat(16), 'Ops room'); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('#ops', { name: '#ops' })] }); + await h.init(); + const ops = h.row('#ops'); + assert.ok(ops && ops.userAdded === true && ops.userLabel === 'Ops room', 'precondition: key+label present'); + + // Remove the key through the same handler the UI uses (server-known + // channel branch: unmark userAdded, keep the row). removeUserChannelKey + // itself calls ChannelDecrypt.removeKey(), so don't double-remove. + h.w._channelsRemoveKeyHandlerForTest('#ops'); + + const afterRemove = h.row('#ops'); + assert.strictEqual(afterRemove.userAdded, false, 'userAdded cleared immediately by the remove handler'); + assert.ok(!afterRemove.userLabel, 'userLabel must be cleared immediately by the remove handler (got ' + JSON.stringify(afterRemove.userLabel) + ')'); + + for (let i = 0; i < 2; i++) { + await h.w._channelsLoadChannelsForTest(true); + const row = h.row('#ops'); + assert.ok(!row.userLabel, 'refresh ' + (i + 1) + ': label must not resurrect (got ' + JSON.stringify(row.userLabel) + ')'); + assert.notStrictEqual(row.userAdded, true, 'refresh ' + (i + 1) + ': My Channels membership must not resurrect'); + } + assert.ok(!/ch-section-mychannels/.test(h.elements.chList.innerHTML) || !/Ops room/.test(h.elements.chList.innerHTML), 'My Channels must not show the removed label'); + }); + + // ── F3: a PSK whose name collides with a server-known or newly-approved + // shared channel is matched by name in mergeUserChannels() instead of + // getting its own user:* row, so the user:* hash the conversation opened + // under disappears from the fresh list even though the conversation is + // still live. reconcileSelectionAfterChannelRefresh() must remap to the + // row mergeUserChannels() annotated instead of closing the conversation. + await test('F3 (region variant): a PSK is remapped, not closed, when a region switch reveals a same-named server channel', async () => { + const h = makeHarness(); + const NAME = '#chat'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, 'aa'.repeat(16), 'Chat'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + h.setState({ selectedHash: HASH, messages: [{ sender: 'Alice', text: 'hello', timestamp: '2026-01-01T00:00:00Z', packetHash: 'm1' }] }); + assert.ok(h.row(HASH), 'precondition: user:* row exists before the refresh'); + + // The new region's snapshot already knows about #chat as a real server + // channel — mergeUserChannels() will name-match it instead of creating + // a user:#chat row. Drive loadChannels() directly (what the region + // handler calls first) rather than the full region-change handler: a + // same-named match isn't `encrypted`, so the handler's own follow-up + // would fall through to refreshMessages(), which legitimately clears + // messages when the new region has none over REST — a separate, + // unrelated behavior this test isn't about. + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + await h.w._channelsLoadChannelsForTest(true); + + assert.strictEqual(h.row(HASH), undefined, 'the user:* hash must no longer exist (name-matched instead)'); + const remapped = h.row(NAME); + assert.ok(remapped && remapped.userAdded === true, 'the name-matched row must carry userAdded'); + assert.strictEqual(h.state().selectedHash, NAME, 'selection must remap to the matched row, not close (got ' + h.state().selectedHash + ')'); + assert.strictEqual(h.state().messages.length, 1, 'messages must not be cleared by the remap'); + assert.ok(!h.historyCalls.includes('#/channels'), 'must not route back to #/channels (got ' + JSON.stringify(h.historyCalls) + ')'); + assert.ok(h.historyCalls[h.historyCalls.length - 1].indexOf(encodeURIComponent(NAME)) !== -1, 'URL must be updated to the matched row (got ' + JSON.stringify(h.historyCalls) + ')'); + }); + + await test('F3 (approval variant): a PSK is remapped, not closed, when approving a shared channel with the same name', async () => { + const h = makeHarness(); + const NAME = '#chat'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, 'bb'.repeat(16), 'Chat'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + h.setState({ messages: [{ sender: 'Alice', text: 'hello', timestamp: '2026-01-01T00:00:00Z', packetHash: 'm1' }] }); + + h.respondChannels = () => Promise.resolve({ channels: [], approvedChannels: [{ hash: NAME, name: NAME }] }); + assert.strictEqual(typeof h.approvedCallback, 'function', 'init() must mount ChannelProposals with onApproved'); + h.approvedCallback(); + await flush(300); + + assert.strictEqual(h.row(HASH), undefined, 'the user:* hash must no longer exist (name-matched instead)'); + const remapped = h.row(NAME); + assert.ok(remapped && remapped.userAdded === true, 'the name-matched row must carry userAdded'); + assert.strictEqual(h.state().selectedHash, NAME, 'selection must remap on approval too (got ' + h.state().selectedHash + ')'); + assert.strictEqual(h.state().messages.length, 1, 'messages must not be cleared by the remap'); + }); + + // init() keeps its own mergeUserChannels()/render after loadChannels(): + // a no-op after a successful load, but the only thing that lists My + // Channels when /channels fails. + await test('init(): My Channels still listed when /channels fails', async () => { + const h = makeHarness(); + h.storeKey(PSK_NAME, PSK_KEY, 'Team'); + h.respondChannels = () => Promise.reject(new Error('offline')); + await h.init(); + assert.ok(h.row(PSK_HASH) && h.row(PSK_HASH).userAdded === true, 'PSK row listed'); + assert.ok(/ch-section-mychannels/.test(h.elements.chList.innerHTML), 'My Channels rendered'); + }); + + await test('init(): a successful load lists each PSK row exactly once', async () => { + const h = makeHarness(); + h.storeKey(PSK_NAME, PSK_KEY, 'Team'); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + assert.strictEqual(h.state().channels.filter((c) => c.hash === PSK_HASH).length, 1); + }); + + // ── Nit: a brand-new WS-pushed channel row must be _wsSeq-stamped like an + // existing row is, or its freshly-live activity loses to a concurrent, + // older snapshot once mergeClientChannelState() can no longer tell it's + // newer than the request. + await test('a live message for a brand-new channel is _wsSeq-stamped and wins over a concurrent older snapshot', async () => { + const h = makeHarness(); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + const pending = deferred(); + h.respondChannels = () => pending.promise; + const load = h.w._channelsLoadChannelsForTest(true); + await flush(); + assert.strictEqual(h.row('brandnew'), undefined, 'precondition: channel does not exist yet'); + h.liveMessage('brandnew', 'Carol', 'hello'); + assert.ok(h.row('brandnew'), 'WS path must create the row immediately'); + pending.resolve({ channels: [serverChannel('brandnew', { + messageCount: 1, + lastActivity: new RealDate(RealDate.now() - 100000).toISOString(), + lastSender: 'Old', + lastMessage: 'stale snapshot', + })] }); + await load; + const row = h.row('brandnew'); + assert.strictEqual(row.lastMessage, 'hello', 'live message must win over an older concurrent snapshot (got ' + row.lastMessage + ')'); + assert.strictEqual(row.lastSender, 'Carol'); + assert.strictEqual(row.messageCount, 1); + }); + + // ── N1 (#153 review round 3, P3): a remap that happens while a decrypt is + // still in flight must restart loading for the remapped channel, not + // leave the pane stuck on "Decrypting messages…". The in-flight request's + // own staleness check (isStaleMessageRequest — selectedHash changed under + // it) silently discards its result once reconcile remaps the selection, + // and nothing else used to restart the fetch. + await test('N1: a remap mid-decrypt restarts loading for the remapped channel', async () => { + const h = makeHarness(); + const KEY = 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'; + const NAME = '#n1chat'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'N1 Chat'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const msg = encryptChannelMessage(KEY, 'Alice', 'remapped channel message'); + + const pending = deferred(); + h.respondPackets = () => pending.promise; + const select = h.w._channelsSelectChannelForTest(HASH); + await flush(300); + assert.ok(/Decrypting/.test(h.elements.chMessages.innerHTML), 'pane must show "Decrypting…" while the fetch is in flight'); + + // #n1chat becomes server-known (e.g. approved) in the next refresh — + // mergeUserChannels() name-matches it instead of keeping a user:* row, + // so reconcile remaps the open selection to it mid-decrypt. + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-remap', '2026-01-01T00:00:00Z', msg)] }); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + await h.w._channelsLoadChannelsForTest(true); + await flush(300); + + // The original (now-stale) fetch finally resolves too; it must not + // clobber the remapped channel's freshly-loaded view. + pending.resolve({ packets: [] }); + await select; + await flush(300); + + assert.strictEqual(h.state().selectedHash, NAME, 'selection must have remapped to the server-known hash'); + const msgEl = h.elements.chMessages; + assert.ok(!/Decrypting/.test(msgEl.innerHTML), 'pane must not be stuck on "Decrypting…" (got ' + msgEl.innerHTML.slice(0, 200) + ')'); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('remapped channel message') !== -1, 'the remapped channel must actually load its messages (got ' + JSON.stringify(texts) + ')'); + }); + + await test('N1: a quiet remap (no decrypt in flight) still leaves messages untouched', async () => { + // Regression guard for the F3 (round 2) invariant: only restart loading + // when a decrypt was genuinely pending at remap time. + const h = makeHarness(); + const NAME = '#n1quiet'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, 'cc'.repeat(16), 'Quiet'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + h.setState({ selectedHash: HASH, messages: [{ sender: 'Alice', text: 'already loaded', timestamp: '2026-01-01T00:00:00Z', packetHash: 'm1' }] }); + + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + await h.w._channelsLoadChannelsForTest(true); + + assert.strictEqual(h.state().selectedHash, NAME, 'selection must remap'); + assert.strictEqual(h.state().messages.length, 1, 'messages must not be cleared by a quiet remap (got ' + JSON.stringify(h.state().messages) + ')'); + }); + + // ── N2 (#153 review round 3, P3): the client-side decrypt cache must be + // region-scoped. The cache key was the channel name alone, so a cache + // entry primed under one region answered a fetch for a different region. + await test('N2: the decrypt cache is region-scoped — a different region never shows another region\'s cached messages', async () => { + const h = makeHarness(); + const KEY = 'a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2'; + const NAME = 'psk:n2test'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'N2 Team'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const sjcMsg = encryptChannelMessage(KEY, 'Alice', 'sjc message'); + const sfoMsg = encryptChannelMessage(KEY, 'Bob', 'sfo message'); + + // Region "All" (no filter): the decrypt fetch sees both packets. + h.respondPackets = () => Promise.resolve({ packets: [ + encryptedPacket('pkt-sjc', '2026-01-01T00:00:00Z', sjcMsg), + encryptedPacket('pkt-sfo', '2026-01-01T00:01:00Z', sfoMsg), + ] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + let texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('sjc message') !== -1 && texts.indexOf('sfo message') !== -1 && texts.length === 2, + 'All region must see both messages (got ' + JSON.stringify(texts) + ')'); + + // Switch to OAK: no observer there saw either packet (0 candidates). + h.regionParam = 'OAK'; + h.respondPackets = () => Promise.resolve({ packets: [] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + texts = h.state().messages.map((m) => m.text); + assert.strictEqual(texts.length, 0, 'OAK region (0 candidates) must show an empty list, not the All-region cache (got ' + JSON.stringify(texts) + ')'); + }); + + await test('N2: a same-candidate-count delta fetch in a different region does not show the old region\'s message', async () => { + const h = makeHarness(); + const KEY = 'b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2'; + const NAME = 'psk:n2delta'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'N2 Delta'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const sjcMsg = encryptChannelMessage(KEY, 'Alice', 'sjc-only message'); + const mryMsg = encryptChannelMessage(KEY, 'Carol', 'mry-only message'); + + h.regionParam = 'SJC'; + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-sjc', '2026-01-01T00:00:00Z', sjcMsg)] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + assert.ok(h.state().messages.some((m) => m.text === 'sjc-only message'), 'precondition: SJC message cached'); + + // MRY: exactly one candidate too (same count as the SJC cache), but an + // older timestamp — if the cache weren't region-scoped, the delta path + // would see "0 new candidates since lastTs" and return SJC's cached + // message instead of MRY's own. + h.regionParam = 'MRY'; + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-mry', '2025-01-01T00:00:00Z', mryMsg)] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('mry-only message') !== -1, 'MRY must show its own message (got ' + JSON.stringify(texts) + ')'); + assert.ok(texts.indexOf('sjc-only message') === -1, 'MRY must not show the SJC-region cached message (got ' + JSON.stringify(texts) + ')'); + }); + + await test('N2: a stale (superseded) decrypt does not write its evidence into the cache', async () => { + const h = makeHarness(); + const KEY = 'c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3'; + const NAME = 'psk:n2stale'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'N2 Stale'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const staleMsg = encryptChannelMessage(KEY, 'Alice', 'stale message'); + const realMsg = encryptChannelMessage(KEY, 'Bob', 'real message'); + + const pending = deferred(); + h.respondPackets = () => pending.promise; // first decrypt stays in flight + const select1 = h.w._channelsSelectChannelForTest(HASH); + await flush(300); + + // Supersede it before it resolves (e.g. a second region switch). Zero + // candidates — this leg never touches the cache either way, so it can't + // mask the thing under test. + h.respondPackets = () => Promise.resolve({ packets: [] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + + // The first (now-stale) fetch resolves with one real decryptable + // candidate. If the isStale guard were missing, this would write + // { messages: [stale message], count: 1, lastTimestamp: T } to the + // cache. + pending.resolve({ packets: [encryptedPacket('pkt-stale', '2020-01-01T00:00:00Z', staleMsg)] }); + await select1; + await flush(300); + + // A later select sees exactly ONE candidate too (same count a poisoned + // cache entry would hold) with the SAME timestamp as the stale one — + // not a different message rendered via onCacheHit, but specifically the + // delta path's own "0 candidates newer than lastTs" short-circuit + // (fetchAndDecryptChannel, the `newCandidates.length === 0` branch), + // which trusts the cache's count/lastTs and returns its cached messages + // outright. That only reaches the real candidate instead of the stale + // one if the cache was never poisoned in the first place (cachedCount + // stays 0, so this fetch takes the full-decrypt path instead of delta). + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-real', '2020-01-01T00:00:00Z', realMsg)] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('stale message') === -1, 'a stale decrypt must not have poisoned the cache (got ' + JSON.stringify(texts) + ')'); + assert.ok(texts.indexOf('real message') !== -1, 'the real candidate must be decrypted and shown (got ' + JSON.stringify(texts) + ')'); + }); + + // ── R4-1 (#153 review round 4, P2): N2 made the decrypt cache keys + // region-scoped ("|"), but removeKey() still only + // deleted cache[""], so removing a key left the channel's + // decrypted plaintext in localStorage under every region it was viewed in. + await test('R4-1: removeKey drops every region-scoped cache entry, keeping a prefix-sharing channel', async () => { + const h = makeHarness(); + const NAME = 'psk:r4rm'; + const OTHER = 'psk:r4rmx'; // "psk:r4rm" is a prefix of this name + h.storeKey(NAME, 'd4'.repeat(16), 'R4 Remove'); + h.storeKey(OTHER, 'e5'.repeat(16), 'R4 Other'); + // Entries of the current key format; the older formats are dropped by + // the version migration (see the #163 item 2 tests), not by removeKey. + h.ctx.localStorage.setItem('corescope_channel_cache_v', '3'); + const blob = {}; + blob[NAME + '|'] = cacheEntry('all-regions secret'); + blob[NAME + '|SJC'] = cacheEntry('sjc secret'); + blob[NAME + '|SFO,SJC'] = cacheEntry('sfo-sjc secret'); + blob[OTHER + '|SJC'] = cacheEntry('other channel text'); + h.ctx.localStorage.setItem(DECRYPT_CACHE_KEY, JSON.stringify(blob)); + + h.removeKey(NAME); + + const keys = decryptCacheKeys(h); + assert.deepStrictEqual(keys.filter((k) => k.indexOf(NAME + '|') === 0), [], + 'no cache entry for the removed channel may survive (got ' + JSON.stringify(keys) + ')'); + assert.ok(!/secret/.test(rawDecryptCache(h)), 'no plaintext of the removed channel may stay in localStorage'); + assert.ok(keys.indexOf(OTHER + '|SJC') !== -1, 'a channel whose name merely shares the prefix must keep its cache (got ' + JSON.stringify(keys) + ')'); + assert.strictEqual(h.w.ChannelDecrypt.getCache(OTHER + '|SJC').messages[0].text, 'other channel text'); + }); + + await test('R4-1: removing a PSK through the channel list clears the plaintext cached by real decrypts in several regions', async () => { + const h = makeHarness(); + const KEY = 'f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6'; + const NAME = 'psk:r4ui'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 UI'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + + const msg = encryptChannelMessage(KEY, 'Alice', 'plaintext-r4ui'); + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-r4ui', '2026-01-01T00:00:00Z', msg)] }); + for (const region of ['SJC', 'SFO,SJC', '']) { + h.regionParam = region; + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + } + assert.strictEqual(decryptCacheKeys(h).filter((k) => k.indexOf(NAME + '|') === 0).length, 3, + 'precondition: three region-scoped entries cached (got ' + JSON.stringify(decryptCacheKeys(h)) + ')'); + assert.ok(/plaintext-r4ui/.test(rawDecryptCache(h)), 'precondition: plaintext is cached'); + + h.w._channelsRemoveKeyHandlerForTest(HASH); + + assert.deepStrictEqual(decryptCacheKeys(h).filter((k) => k.indexOf(NAME) === 0), [], + 'Remove must clear every cached region of the channel (got ' + JSON.stringify(decryptCacheKeys(h)) + ')'); + assert.ok(!/plaintext-r4ui/.test(rawDecryptCache(h)), 'no decrypted plaintext may remain in localStorage after Remove'); + }); + + // ── R4-2 (#153 review round 4, P3): the decrypt cache was only capped by + // entry count. One entry can be ~365 KiB (1000 messages) against a + // ~5.2M-character localStorage quota, so a full cache made storeKey() and + // the labels fail silently. It is now kept under a fixed character budget, + // evicting the least recently used entries. + await test('R4-2: the decrypt cache stays under its 1.5M-character budget, evicting least recently written entries first', async () => { + const sb = makeDecryptSandbox(); + for (let i = 1; i <= 5; i++) sb.CD.setCache('budget|' + i, bigMessages(400000, 'b' + i), 'ts', 1); + assert.ok(sb.raw().length <= 1500000, 'cache blob must stay within 1.5M characters (got ' + sb.raw().length + ')'); + assert.deepStrictEqual(sb.keys().sort(), ['budget|3', 'budget|4', 'budget|5'], + 'the oldest entries must be evicted first, the newest kept (got ' + JSON.stringify(sb.keys()) + ')'); + assert.ok(/b5:/.test(sb.CD.getCache('budget|5').messages[0].text), 'the entry just written must be readable'); + }); + + await test('R4-2: a recently read entry outlives an older unread one', async () => { + const sb = makeDecryptSandbox(); + for (let i = 1; i <= 3; i++) sb.CD.setCache('lru|' + i, bigMessages(400000, 'l' + i), 'ts', 1); + assert.ok(sb.CD.getCache('lru|1'), 'precondition: lru|1 cached'); + sb.CD.setCache('lru|4', bigMessages(400000, 'l4'), 'ts', 1); + assert.deepStrictEqual(sb.keys().sort(), ['lru|1', 'lru|3', 'lru|4'], + 'lru|2 (oldest use) must go, not the just-read lru|1 (got ' + JSON.stringify(sb.keys()) + ')'); + }); + + await test('R4-2: a QuotaExceededError evicts and retries, and an entry that can never fit leaves a valid blob', async () => { + const sb = makeDecryptSandbox({ quota: 1000000 }); + for (let i = 1; i <= 3; i++) sb.CD.setCache('quota|' + i, bigMessages(300000, 'q' + i), 'ts', 1); + assert.deepStrictEqual(sb.keys().sort(), ['quota|1', 'quota|2', 'quota|3'], 'precondition: 900K fits the quota'); + sb.CD.setCache('quota|4', bigMessages(300000, 'q4'), 'ts', 1); + assert.deepStrictEqual(sb.keys().sort(), ['quota|2', 'quota|3', 'quota|4'], + 'a quota failure must evict the oldest entry and retry (got ' + JSON.stringify(sb.keys()) + ')'); + + sb.CD.setCache('quota|huge', bigMessages(1100000, 'qh'), 'ts', 1); + const raw = sb.storage[DECRYPT_CACHE_KEY]; + if (raw !== undefined) assert.doesNotThrow(() => JSON.parse(raw), 'the cache blob must never be left half-written'); + assert.strictEqual(sb.CD.getCache('quota|huge'), null, 'an entry larger than the quota is not cached'); + }); + + await test('R4-2: storeKey and labels still save when the decrypt cache has filled the quota', async () => { + const sb = makeDecryptSandbox(); + for (let i = 1; i <= 3; i++) sb.CD.setCache('full|' + i, bigMessages(300000, 'f' + i), 'ts', 1); + sb.quota = sb.used() + 10; // localStorage is now full + const KEY = '0123456789abcdef0123456789abcdef'; + sb.CD.storeKey('psk:quotakey', KEY, 'Quota Label'); + assert.strictEqual(sb.CD.getKeys()['psk:quotakey'], KEY, 'the key must be saved, evicting decrypt cache to make room'); + assert.strictEqual(sb.CD.getLabel('psk:quotakey'), 'Quota Label', 'the label must be saved too'); + sb.CD.saveLabel('psk:quotakey', 'Renamed ' + 'L'.repeat(200)); + assert.strictEqual(sb.CD.getLabel('psk:quotakey'), 'Renamed ' + 'L'.repeat(200), 'a later label change must also make room'); + assert.ok(sb.keys().length < 3, 'room must come from the decrypt cache (got ' + JSON.stringify(sb.keys()) + ')'); + }); + + // #163 item 2: cache keys are "|", but a channel name may + // contain '|' itself, so clearing "#a" also cleared "#a|b" and the two + // channels' entries could be confused. The channel part now has '|' (and + // '%', which escapes it) percent-encoded, so the first '|' of a key is + // always the separator. + await test('#163 item 2: removing channel "#a" leaves the cache of channel "#a|b" intact', async () => { + const sb = makeDecryptSandbox(); + const CD = sb.CD; + const keysOf = { a: [CD.channelCacheKey('#a', ''), CD.channelCacheKey('#a', 'SJC')], ab: [CD.channelCacheKey('#a|b', ''), CD.channelCacheKey('#a|b', 'SJC')] }; + keysOf.a.concat(keysOf.ab).forEach((k) => CD.setCache(k, [{ text: 'plain ' + k }], 'ts', 1)); + assert.strictEqual(new Set(keysOf.a.concat(keysOf.ab)).size, 4, 'every (channel, region) pair needs its own key'); + + CD.clearChannelCache('#a'); + keysOf.a.forEach((k) => assert.strictEqual(CD.getCache(k), null, '#a\'s entry ' + k + ' must be cleared')); + keysOf.ab.forEach((k) => assert.ok(CD.getCache(k), '#a|b\'s entry ' + k + ' must survive (left: ' + JSON.stringify(sb.keys()) + ')')); + + CD.clearChannelCache('#a|b'); + assert.deepStrictEqual(sb.keys(), [], 'clearing #a|b removes its own entries (left: ' + JSON.stringify(sb.keys()) + ')'); + }); + + await test('#163 item 2: removeKey("#a") clears only that channel\'s decrypt cache', async () => { + const sb = makeDecryptSandbox(); + const CD = sb.CD; + CD.storeKey('#a', '0123456789abcdef0123456789abcdef', 'A'); + CD.storeKey('#a|b', 'fedcba9876543210fedcba9876543210', 'AB'); + CD.setCache(CD.channelCacheKey('#a', 'SJC'), [{ text: 'a' }], 'ts', 1); + CD.setCache(CD.channelCacheKey('#a|b', ''), [{ text: 'ab' }], 'ts', 1); + CD.removeKey('#a'); + assert.strictEqual(CD.getCache(CD.channelCacheKey('#a', 'SJC')), null, '#a\'s cache goes with its key'); + assert.ok(CD.getCache(CD.channelCacheKey('#a|b', '')), '#a|b\'s cache must stay'); + assert.ok(CD.getKeys()['#a|b'], '#a|b\'s key must stay'); + }); + + await test('#163 item 2: the escaping is injective — "a|b" and "a%7Cb" are different channels', async () => { + const { CD } = makeDecryptSandbox(); + const names = ['a', 'a|b', 'a%7Cb', 'a%', 'a%25', 'a|', '|', '%7C', 'a|b|c']; + const keys = names.map((n) => CD.channelCacheKey(n, '')); + assert.strictEqual(new Set(keys).size, names.length, 'distinct channels must have distinct keys (got ' + JSON.stringify(keys) + ')'); + keys.forEach((k, i) => assert.strictEqual(k.indexOf('|'), k.length - 1, 'the only "|" of ' + JSON.stringify(names[i]) + '\'s key is the separator (got ' + k + ')')); + assert.strictEqual(CD.channelCacheKey('#a', 'SFO,SJC'), CD.channelCacheKey('#a', 'SJC,SFO'), 'region order still does not matter'); + assert.strictEqual(CD.channelCacheKey('psk:r4sort', 'SJC'), 'psk:r4sort|SJC', 'a name without "|" or "%" keeps its key'); + }); + + // #163 item 4: cacheMessages()/getCachedMessages() wrote and read bare + // "" keys, outside the "|" format. Nothing + // called them; any caller has to go through channelCacheKey(). + await test('#163 item 4: the unused bare-name cacheMessages/getCachedMessages API is gone', async () => { + const { CD } = makeDecryptSandbox(); + assert.strictEqual(CD.cacheMessages, undefined, 'cacheMessages must not be exported'); + assert.strictEqual(CD.getCachedMessages, undefined, 'getCachedMessages must not be exported'); + assert.strictEqual(typeof CD.channelCacheKey, 'function', 'channelCacheKey stays the way to build a key'); + assert.strictEqual(typeof CD.setCache, 'function'); + assert.strictEqual(typeof CD.getCache, 'function'); + }); + + // Every cache blob written before the key format settled is dropped once: + // pre-#153 entries are keyed by the bare channel name, #153's by the + // unescaped "|", and the two can't be told apart for a name + // containing '|'. It is only a cache, and none of them may keep plaintext. + for (const marker of [undefined, '2']) { + await test('#163 item 2: cache entries of the pre-#153 and #153 key formats are dropped once (version marker ' + marker + ')', async () => { + const old = {}; + old['#oldchan'] = cacheEntry('PLAIN pre-153 bare name'); + old['#a|b'] = cacheEntry('PLAIN pre-153 name with pipe'); + old['psk:legacy|SJC'] = cacheEntry('PLAIN 153 region key'); + old['#a|b|'] = cacheEntry('PLAIN 153 pipe name'); + old['#a|b|SJC'] = cacheEntry('PLAIN 153 pipe name region'); + const storage = { [DECRYPT_CACHE_KEY]: JSON.stringify(old) }; + if (marker !== undefined) storage.corescope_channel_cache_v = marker; + const sb = makeDecryptSandbox({ storage }); + assert.strictEqual(sb.CD.getCache('psk:legacy|SJC'), null, 'an old-format entry must not be served'); + assert.deepStrictEqual(sb.keys(), [], 'old-format entries must be dropped (got ' + JSON.stringify(sb.keys()) + ')'); + assert.ok(!/PLAIN/.test(sb.raw()), 'no old plaintext may remain'); + + // Only once: an entry written after the migration survives a reload. + sb.CD.setCache(sb.CD.channelCacheKey('written-later', ''), [{ text: 'kept' }], 'ts', 1); + const reloaded = makeDecryptSandbox({ storage: sb.storage }); + assert.ok(reloaded.CD.getCache(sb.CD.channelCacheKey('written-later', '')), 'the migration must not run again on the next page load'); + }); + } + + // #163 item 5: the cleanup ran lazily, on the first cache read or write, + // so old-format plaintext stayed in localStorage until the channels page + // happened to use the cache. It now runs when ChannelDecrypt initialises. + const OLD_BLOB = () => JSON.stringify({ + '#oldchan': cacheEntry('PLAIN pre-153'), + 'psk:legacy|SJC': cacheEntry('PLAIN 153'), + }); + + for (const marker of [undefined, '2']) { + await test('#163 item 5: loading the module drops old-format cache entries without any cache use (version marker ' + marker + ')', async () => { + const storage = { [DECRYPT_CACHE_KEY]: OLD_BLOB() }; + if (marker !== undefined) storage.corescope_channel_cache_v = marker; + const sb = makeDecryptSandbox({ storage }); + // No getCache()/setCache()/clearChannelCache() call: only the load. + assert.ok(!/PLAIN/.test(sb.raw()), 'old plaintext must be gone right after load (got ' + sb.raw().slice(0, 100) + ')'); + assert.strictEqual(sb.storage.corescope_channel_cache_v, '3', 'the version marker must be set'); + }); + } + + await test('#163 item 5: a current-format cache survives the load', async () => { + const key = makeDecryptSandbox().CD.channelCacheKey('psk:keep', 'SJC'); + const sb = makeDecryptSandbox({ storage: { corescope_channel_cache_v: '3', [DECRYPT_CACHE_KEY]: JSON.stringify({ [key]: cacheEntry('KEEP') }) } }); + assert.deepStrictEqual(sb.keys(), [key], 'a version-3 cache must be left alone by the load'); + assert.strictEqual(sb.CD.getCache(key).messages[0].text, 'KEEP'); + }); + + await test('#163 item 5: the lazy call stays as a fallback when localStorage was not available at load', async () => { + const sb = makeDecryptSandbox({ lateStorage: true, storage: { [DECRYPT_CACHE_KEY]: OLD_BLOB() } }); + assert.ok(/PLAIN/.test(sb.raw()), 'precondition: nothing could be cleaned at load'); + sb.attachStorage(); + assert.strictEqual(sb.CD.getCache('psk:legacy|SJC'), null, 'the first cache use must not serve an old-format entry'); + assert.ok(!/PLAIN/.test(sb.raw()), 'the first cache use must drop the old plaintext (got ' + sb.raw().slice(0, 100) + ')'); + }); + + // ── R4-3 (#153 review round 4, P3): the N1 "a load is pending" flag was + // one shared boolean, set only inside decryptAndRender(). A superseded + // decrypt cleared it in its finally while a newer one was still running + // (S1), and a remap during selectChannel()'s first await (S2) saw no + // flag at all. A superseded selectChannel() also still painted + // "Decrypting messages…" over a newer request's view (S3). + await test('R4-3 S1: decrypt A, region switch starts decrypt B, A ends, then a remap — the pane must not hang', async () => { + const h = makeHarness(); + const KEY = 'a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7'; + const NAME = '#r4s1'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 S1'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + const msg = encryptChannelMessage(KEY, 'Alice', 's1 remapped message'); + + const decryptA = deferred(); + h.respondPackets = () => decryptA.promise; + const selectA = h.w._channelsSelectChannelForTest(HASH); + assert.ok(await settle(() => h.packetsRequests.length === 1), 'precondition: decrypt A is in flight'); + + const decryptB = deferred(); + h.respondPackets = () => decryptB.promise; + h.regionParam = 'SJC'; + h.regionChange(); // re-runs selectChannel for the encrypted selection (F1) + assert.ok(await settle(() => h.packetsRequests.length === 2), 'precondition: decrypt B is in flight (got ' + h.packetsRequests.length + ' fetches)'); + + decryptA.resolve({ packets: [] }); // A is superseded and finishes first + await selectA; + await flush(300); + + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-s1', '2026-01-01T00:00:00Z', msg)] }); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + await h.w._channelsLoadChannelsForTest(true); + await flush(300); + decryptB.resolve({ packets: [] }); + await settle(() => h.state().messages.length > 0); + await flush(300); + + assert.strictEqual(h.state().selectedHash, NAME, 'selection must have remapped'); + assert.ok(!/Decrypting/.test(h.elements.chMessages.innerHTML), 'pane must not hang on "Decrypting…" (got ' + h.elements.chMessages.innerHTML.slice(0, 120) + ')'); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('s1 remapped message') !== -1, 'the remapped channel must load (got ' + JSON.stringify(texts) + ')'); + }); + + await test('R4-3 S2: a remap before computeChannelHash resolves restarts loading', async () => { + const h = makeHarness(); + const KEY = 'b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8'; + const NAME = '#r4s2'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 S2'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + const msg = encryptChannelMessage(KEY, 'Bob', 's2 remapped message'); + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-s2', '2026-01-01T00:00:00Z', msg)] }); + + const hashReady = deferred(); + h.w.ChannelDecrypt.computeChannelHash = () => hashReady.promise; + const select = h.w._channelsSelectChannelForTest(HASH); + await flush(50); + assert.strictEqual(h.packetsRequests.length, 0, 'precondition: still waiting for computeChannelHash'); + + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + await h.w._channelsLoadChannelsForTest(true); + hashReady.resolve(msg.channelHash); + await select; + await flush(300); + + assert.strictEqual(h.state().selectedHash, NAME, 'selection must have remapped'); + assert.ok(!/Decrypting/.test(h.elements.chMessages.innerHTML), 'pane must not hang on "Decrypting…" (got ' + h.elements.chMessages.innerHTML.slice(0, 120) + ')'); + const texts = h.state().messages.map((m) => m.text); + assert.ok(texts.indexOf('s2 remapped message') !== -1, 'the remapped channel must load (got ' + JSON.stringify(texts) + ')'); + }); + + await test('R4-3 S3: a region switch that reveals a same-named channel mid-decrypt is not overwritten by a late "Decrypting…"', async () => { + const h = makeHarness(); + const KEY = 'c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9c9'; + const NAME = '#r4s3'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 S3'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + const realApi = h.ctx.api; + h.ctx.api = (p, o) => (p.indexOf('/channels/' + encodeURIComponent(NAME) + '/messages') === 0 + ? Promise.resolve({ messages: [{ id: 1, sender: 'Srv', text: 's3 server message', timestamp: '2026-01-01T00:00:00Z', packetHash: 'srv1' }] }) + : realApi(p, o)); + + const hashReady = deferred(); + h.w.ChannelDecrypt.computeChannelHash = () => hashReady.promise; + const select = h.w._channelsSelectChannelForTest(HASH); + await flush(50); + + // SJC lists #r4s3 as a server channel: reconcile remaps the selection, + // and the region handler refreshes it over REST. + h.regionParam = 'SJC'; + h.respondChannels = () => Promise.resolve({ channels: [serverChannel(NAME, { name: NAME })] }); + h.regionChange(); + await flush(300); + assert.strictEqual(h.state().selectedHash, NAME, 'precondition: selection remapped'); + assert.ok(h.state().messages.some((m) => m.text === 's3 server message'), 'precondition: REST refresh rendered the server channel'); + + hashReady.resolve(encryptChannelMessage(KEY, 'x', 'y').channelHash); + await select; + await flush(300); + assert.ok(!/Decrypting/.test(h.elements.chMessages.innerHTML), + 'a superseded selectChannel must not paint "Decrypting…" over the current view (got ' + h.elements.chMessages.innerHTML.slice(0, 120) + ')'); + assert.ok(h.state().messages.some((m) => m.text === 's3 server message'), 'the current view must keep its messages'); + }); + + // ── #163 item 1: selectChannel() walks every stored key for an encrypted + // channel, awaiting computeChannelHash once per key, and then paints the + // #781 "no decryption key" lock text. Nothing checked whether the request + // was still current, so the lock text landed on top of a newer request's + // view — the same late-write pattern R4-3 S3 fixed for "Decrypting…". + const LOCK_TEXT = /no decryption key is configured/; + const msgBelongsTo = (h, text) => h.state().messages.some((m) => m.text === text); + + async function encryptedChannelHarness(storedKeys) { + const h = makeHarness(); + for (let i = 0; i < storedKeys; i++) h.storeKey('psk:i163k' + i, (i + 1).toString(16).repeat(32), 'K' + i); + h.respondChannels = () => Promise.resolve({ channels: [serverChannel('public')] }); + await h.init(); + h.setState({ + channels: [ + Object.assign({}, h.row('public')), + serverChannel('42', { encrypted: true, name: 'Encrypted 42' }), + ], + }); + const realApi = h.ctx.api; + h.ctx.api = (p, o) => (p.indexOf('/channels/public/messages') === 0 + ? Promise.resolve({ messages: [{ id: 1, sender: 'Srv', text: 'i163 public message', timestamp: '2026-01-01T00:00:00Z', packetHash: 'srv-public' }] }) + : realApi(p, o)); + // computeChannelHash is slow: each stored key's call stays pending + // until the test releases it, and none of them matches channel 42. + h.hashCalls = []; + h.w.ChannelDecrypt.computeChannelHash = () => { const d = deferred(); h.hashCalls.push(d); return d.promise; }; + return h; + } + + // Releases A's computeChannelHash calls as they appear (none matches), + // until A's selectChannel() has returned. + async function releaseKeyLoop(h, selection) { + let done = false; + selection.then(() => { done = true; }, () => { done = true; }); + let released = 0; + for (let i = 0; i < 200 && !done; i++) { + while (released < h.hashCalls.length) h.hashCalls[released++].resolve(250); + await flush(5); + } + assert.ok(done, 'the superseded selectChannel() must return'); + await flush(100); + } + + await test('#163 item 1: the lock text of a superseded encrypted selection does not overwrite the newer channel\'s view', async () => { + const h = await encryptedChannelHarness(3); + const selectA = h.w._channelsSelectChannelForTest('42'); + assert.ok(await settle(() => h.hashCalls.length === 1), 'precondition: A is awaiting its first computeChannelHash'); + + await h.w._channelsSelectChannelForTest('public'); + await flush(50); + assert.strictEqual(h.state().selectedHash, 'public', 'precondition: B is selected'); + assert.ok(msgBelongsTo(h, 'i163 public message'), 'precondition: B\'s messages are rendered'); + assert.ok(!LOCK_TEXT.test(h.elements.chMessages.innerHTML), 'precondition: no lock text yet'); + + await releaseKeyLoop(h, selectA); + + assert.ok(!LOCK_TEXT.test(h.elements.chMessages.innerHTML), + 'the lock text of the superseded request must not be painted over B (got ' + h.elements.chMessages.innerHTML.slice(0, 160) + ')'); + assert.strictEqual(h.state().selectedHash, 'public', 'B must stay selected'); + assert.ok(msgBelongsTo(h, 'i163 public message'), 'B\'s messages must be intact'); + }); + + await test('#163 item 1: a superseded encrypted selection stops walking the stored keys', async () => { + const h = await encryptedChannelHarness(4); + // Only the first call is slow; any further call is counted and answered. + let calls = 0; + const first = deferred(); + h.w.ChannelDecrypt.computeChannelHash = () => (++calls === 1 ? first.promise : Promise.resolve(250)); + const selectA = h.w._channelsSelectChannelForTest('42'); + assert.ok(await settle(() => calls === 1), 'precondition: A awaits the first key'); + await h.w._channelsSelectChannelForTest('public'); + first.resolve(250); + await selectA; + await flush(100); + assert.strictEqual(calls, 1, 'A must not hash the remaining keys once it is superseded (got ' + calls + ' calls)'); + }); + + await test('#163 item 1: a region change while the key loop runs keeps the lock text off the pane', async () => { + const h = await encryptedChannelHarness(2); + const selectA = h.w._channelsSelectChannelForTest('42'); + assert.ok(await settle(() => h.hashCalls.length === 1), 'precondition: A awaits the first key'); + // The region changes under A, which makes A stale; the pane now shows + // something else. + h.regionParam = 'SJC'; + h.elements.chMessages.innerHTML = '
region view
'; + await releaseKeyLoop(h, selectA); + assert.ok(/i163-region-view/.test(h.elements.chMessages.innerHTML), + 'a request superseded by a region change must not touch the pane (got ' + h.elements.chMessages.innerHTML.slice(0, 160) + ')'); + }); + + await test('#163 item 1: a failed encrypted-ness lookup of a superseded deep link does not paint "Loading messages…" over the newer view', async () => { + const h = await encryptedChannelHarness(0); + // A deep link to a '#'-named channel that is not in the loaded list + // starts a /channels?includeEncrypted lookup; it fails after the user + // has moved on to another channel. + let failLookup; + const lookup = new Promise((_resolve, reject) => { failLookup = reject; }); + h.respondChannels = () => lookup; + const selectA = h.w._channelsSelectChannelForTest('#i163deep'); + assert.ok(await settle(() => h.channelRequests.some((p) => p.indexOf('includeEncrypted=true') !== -1)), 'precondition: the lookup is in flight'); + + await h.w._channelsSelectChannelForTest('public'); + await flush(50); + assert.ok(msgBelongsTo(h, 'i163 public message'), 'precondition: B\'s messages are rendered'); + + h.elements.chMessages.innerHTML = '
B view
'; + failLookup(new Error('lookup failed')); + await selectA; + await flush(100); + assert.ok(/i163-b-view/.test(h.elements.chMessages.innerHTML), + 'a superseded request must not paint over B after its lookup fails (got ' + h.elements.chMessages.innerHTML.slice(0, 160) + ')'); + }); + + // ── R4-4 (#153 review round 4, P3): when the current region's fetch finds + // zero candidates, N2 rendered an empty pane but left that region's cache + // entry in place, so the next visit flashed the outdated history (via + // onCacheHit) before its own fetch answered. + await test('R4-4: zero candidates in the same region drop that region\'s cache entry (a stale request leaves it)', async () => { + const h = makeHarness(); + const KEY = 'dadadadadadadadadadadadadadadada'; + const NAME = 'psk:r4zero'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 Zero'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + h.regionParam = 'SJC'; + const msg = encryptChannelMessage(KEY, 'Alice', 'aged-out message'); + + const withMessage = () => Promise.resolve({ packets: [encryptedPacket('pkt-zero', '2026-01-01T00:00:00Z', msg)] }); + h.respondPackets = withMessage; + await h.w._channelsSelectChannelForTest(HASH); + assert.ok(h.w.ChannelDecrypt.getCache(NAME + '|SJC'), 'precondition: SJC entry cached'); + + // A superseded request answering "zero candidates" must not drop the + // entry the current request owns. + const staleFetch = deferred(); + h.respondPackets = () => staleFetch.promise; + const staleSelect = h.w._channelsSelectChannelForTest(HASH); + assert.ok(await settle(() => h.packetsRequests.length === 2), 'precondition: the soon-stale fetch is in flight'); + h.respondPackets = withMessage; + await h.w._channelsSelectChannelForTest(HASH); + staleFetch.resolve({ packets: [] }); + await staleSelect; + await flush(300); + assert.ok(h.w.ChannelDecrypt.getCache(NAME + '|SJC'), 'a stale zero-candidate response must leave the cache alone'); + + // The current request finds nothing any more (the packets aged out). + h.respondPackets = () => Promise.resolve({ packets: [] }); + await h.w._channelsSelectChannelForTest(HASH); + await flush(300); + assert.strictEqual(h.state().messages.length, 0, 'zero candidates render an empty pane (got ' + JSON.stringify(h.state().messages.map((m) => m.text)) + ')'); + assert.strictEqual(h.w.ChannelDecrypt.getCache(NAME + '|SJC'), null, 'the region\'s cache entry must be dropped'); + + // Next visit: nothing outdated may flash while the fetch is in flight. + const nextFetch = deferred(); + h.respondPackets = () => nextFetch.promise; + const nextSelect = h.w._channelsSelectChannelForTest(HASH); + await flush(300); + assert.ok(!/aged-out message/.test(h.elements.chMessages.innerHTML) && h.state().messages.length === 0, + 'the next visit must not flash the outdated history (got ' + JSON.stringify(h.state().messages.map((m) => m.text)) + ')'); + nextFetch.resolve({ packets: [] }); + await nextSelect; + }); + + // ── R4-5 (#153 review round 4, nits). + await test('R4-5: region order does not change the decrypt cache key (SJC,SFO and SFO,SJC share one entry)', async () => { + const h = makeHarness(); + const KEY = 'ebebebebebebebebebebebebebebebeb'; + const NAME = 'psk:r4sort'; + const HASH = 'user:' + NAME; + h.storeKey(NAME, KEY, 'R4 Sort'); + h.respondChannels = () => Promise.resolve({ channels: [] }); + await h.init(); + const msg = encryptChannelMessage(KEY, 'Alice', 'sorted message'); + h.respondPackets = () => Promise.resolve({ packets: [encryptedPacket('pkt-sort', '2026-01-01T00:00:00Z', msg)] }); + for (const region of ['SJC,SFO', 'SFO,SJC']) { + h.regionParam = region; + await h.w._channelsSelectChannelForTest(HASH); + } + assert.deepStrictEqual(decryptCacheKeys(h).filter((k) => k.indexOf(NAME + '|') === 0), [NAME + '|SFO,SJC'], + 'both orders must map to the one sorted key (got ' + JSON.stringify(decryptCacheKeys(h)) + ')'); + }); + + // The decrypt E2E's process helpers: a child killed by a signal has + // exitCode null and signalCode set, so checking exitCode alone missed a + // SIGKILL/SIGSEGV — waitFor() polled out its whole timeout and + // stopProcess() waited forever for an 'exit' that had already fired. + const e2eHarness = require('./test-channels-client-state-152-decrypt-e2e.js'); + const os = require('os'); + function spawnSleeper(dir) { + return e2eHarness.startProcess('sleeper', process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], dir); + } + function within(ms, p, what) { + let t; + return Promise.race([p, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(what + ' did not settle within ' + ms + 'ms')), ms); })]) + .finally(() => clearTimeout(t)); + } + async function killedBySignal(dir, sig) { + const proc = spawnSleeper(dir); + const exited = new Promise((r) => proc.once('exit', r)); + proc.kill(sig); + await exited; + assert.strictEqual(proc.exitCode, null, 'precondition: a signal-killed child has no exit code'); + assert.strictEqual(proc.signalCode, sig, 'precondition: signalCode is set'); + return proc; + } + + await test('R4-5: the decrypt E2E\'s waitFor() stops at once when its process dies from SIGKILL or SIGSEGV', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'r4-waitfor-')); + try { + for (const sig of ['SIGKILL', 'SIGSEGV']) { + const proc = await killedBySignal(dir, sig); + const started = RealDate.now(); + await assert.rejects(within(3000, e2eHarness.waitFor('never', () => false, 10000, proc), 'waitFor'), + (e) => /exited early/.test(e.message) && e.message.indexOf(sig) !== -1, + 'waitFor must reject naming ' + sig + ' instead of polling out its timeout'); + assert.ok(RealDate.now() - started < 1000, 'waitFor must notice the dead process at once'); + } + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + await test('R4-5: the decrypt E2E\'s stopProcess() returns at once for a process a signal already killed', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'r4-stop-')); + try { + const proc = await killedBySignal(dir, 'SIGKILL'); + await within(3000, e2eHarness.stopProcess(proc), 'stopProcess'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + process.exit(failed > 0 ? 1 : 0); +})().catch((e) => { console.error('FATAL:', e); process.exit(1); }); diff --git a/test-channels-observed-path-hash-size.js b/test-channels-observed-path-hash-size.js index 7d759ec71..96ffe2d56 100644 --- a/test-channels-observed-path-hash-size.js +++ b/test-channels-observed-path-hash-size.js @@ -201,6 +201,7 @@ test('delta cache absorbs later evidence for the same packet without re-decrypti ctx.ChannelDecrypt = { hexToBytes: () => new Uint8Array(16), + channelCacheKey: (name, regionParam) => name + '|' + (regionParam || ''), getCache: () => ({ messages: [legacyMessage], lastTimestamp: timestamp, count: 1 }), setCache: (key, messages, lastTimestamp, count) => { stored = { key, messages: plain(messages), lastTimestamp, count }; @@ -239,6 +240,7 @@ test('delta cache unions richer evidence without replacing cached plaintext', as ctx.ChannelDecrypt = { hexToBytes: () => new Uint8Array(16), + channelCacheKey: (name, regionParam) => name + '|' + (regionParam || ''), getCache: () => ({ messages: [cachedMessage], lastTimestamp: timestamp, count: 1 }), setCache: (key, messages, lastTimestamp, count) => { stored = { key, messages: plain(messages), lastTimestamp, count };