diff --git a/public/channels.js b/public/channels.js index c0739370..a996d172 100644 --- a/public/channels.js +++ b/public/channels.js @@ -1160,6 +1160,9 @@ return; } } + // #781: No matching key found — show lock message instead of fetching gibberish + msgEl.innerHTML = '
🔒 This channel is encrypted and no decryption key is configured
'; + return; } msgEl.innerHTML = '
Loading messages…
'; diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js index 458f75b3..46fdb7f1 100644 --- a/test-frontend-helpers.js +++ b/test-frontend-helpers.js @@ -2700,6 +2700,114 @@ console.log('\n=== channels.js: WS batch + region snapshot integration ==='); assert.ok(historyCalls.includes('#/channels'), 'should route back to channels root'); }); } +// ===== CHANNELS.JS: #781 encrypted channel without key shows lock message ===== +console.log('\n=== channels.js: encrypted channel without key shows lock message (#781) ==='); +{ + test('selectChannel shows lock message for encrypted channel with no matching key', async () => { + const ctx = makeSandbox(); + const dom = {}; + function makeEl(id) { + if (dom[id]) return dom[id]; + dom[id] = { + id, + innerHTML: '', + textContent: '', + value: '', + scrollTop: 0, + scrollHeight: 100, + clientHeight: 80, + style: {}, + dataset: {}, + classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } }, + addEventListener() {}, + removeEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + getBoundingClientRect() { return { left: 0, bottom: 0, width: 0 }; }, + setAttribute() {}, + removeAttribute() {}, + focus() {}, + }; + return dom[id]; + } + const headerText = { textContent: '' }; + makeEl('chHeader').querySelector = (sel) => (sel === '.ch-header-text' ? headerText : null); + makeEl('chMessages'); + makeEl('chList'); + makeEl('chScrollBtn'); + makeEl('chAriaLive'); + makeEl('chBackBtn'); + makeEl('chRegionFilter'); + const appEl = { + innerHTML: '', + querySelector(sel) { + if (sel === '.ch-sidebar' || sel === '.ch-sidebar-resize' || sel === '.ch-main') return makeEl(sel); + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return makeEl(sel); + }, + addEventListener() {}, + }; + let apiCallPaths = []; + ctx.document.getElementById = makeEl; + ctx.document.querySelector = (sel) => { + if (sel === '.ch-layout') return { classList: { add() {}, remove() {}, contains() { return false; } } }; + return null; + }; + ctx.document.querySelectorAll = () => []; + ctx.document.addEventListener = () => {}; + ctx.document.removeEventListener = () => {}; + ctx.document.documentElement = { getAttribute: () => null, setAttribute: () => {} }; + ctx.document.body = { appendChild() {}, removeChild() {}, contains() { return false; } }; + ctx.history = { replaceState() {} }; + ctx.matchMedia = () => ({ matches: false }); + ctx.window.matchMedia = ctx.matchMedia; + ctx.MutationObserver = function () { this.observe = () => {}; this.disconnect = () => {}; }; + ctx.RegionFilter = { init() {}, onChange() { return () => {}; }, offChange() {}, getRegionParam() { return ''; } }; + ctx.debouncedOnWS = (fn) => fn; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.api = (path) => { + apiCallPaths.push(path); + if (path.indexOf('/observers') === 0) return Promise.resolve({ observers: [] }); + // Return an encrypted channel in the list + if (path.indexOf('/channels') === 0 && path.indexOf('/messages') === -1) { + return Promise.resolve({ channels: [ + { hash: '42', name: 'secret-chan', messageCount: 5, lastActivity: null, encrypted: true } + ] }); + } + // This should NOT be called for encrypted channels without a key + if (path.indexOf('/messages') !== -1) { + return Promise.resolve({ messages: [{ sender: 'X', text: 'gibberish', timestamp: '2025-01-01T00:00:00Z' }] }); + } + return Promise.resolve({}); + }; + ctx.CLIENT_TTL = { observers: 120000, channels: 15000, channelMessages: 10000, nodeDetail: 10000 }; + ctx.ROLE_EMOJI = {}; + ctx.ROLE_LABELS = {}; + ctx.timeAgo = () => '1m ago'; + ctx.registerPage = (name, handlers) => { ctx._pageHandlers = handlers; }; + ctx.btoa = (s) => Buffer.from(String(s), 'utf8').toString('base64'); + ctx.atob = (s) => Buffer.from(String(s), 'base64').toString('utf8'); + + ctx.crypto = { subtle: require('crypto').webcrypto.subtle }; ctx.TextEncoder = TextEncoder; ctx.TextDecoder = TextDecoder; ctx.Uint8Array = Uint8Array; + loadInCtx(ctx, 'public/channel-decrypt.js'); + loadInCtx(ctx, 'public/channels.js'); + ctx._pageHandlers.init(appEl); + // Wait for loadChannels() to resolve (async in init) + for (let i = 0; i < 10; i++) await Promise.resolve(); + + // Select the encrypted channel — no stored keys exist + apiCallPaths = []; + await ctx.window._channelsSelectChannelForTest('42'); + + // Should show lock message, NOT fetch messages API + const msgEl = dom['chMessages']; + assert.ok(msgEl.innerHTML.includes('🔒'), 'should show lock emoji for encrypted channel without key'); + assert.ok(msgEl.innerHTML.includes('no decryption key'), 'should mention no decryption key'); + const messageApiFetched = apiCallPaths.some(p => p.indexOf('/messages') !== -1); + assert.ok(!messageApiFetched, 'should NOT fetch messages API for encrypted channel without key'); + }); +} // ===== PACKETS.JS: savedTimeWindowMin default guard ===== console.log('\n=== packets.js: savedTimeWindowMin defaults ==='); {