mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-26 13:57:21 +00:00
## Summary Implements milestones M3, M4, and M5 from #725 — all client-side, zero server changes. ### M3: PSK channel support The channel input field now accepts both `#channelname` (hashtag derivation) and raw 32-char hex keys (PSK). Auto-detection: if input starts with `#`, derive key via SHA-256; otherwise validate as hex and store directly. Same decrypt pipeline — `ChannelDecrypt.decrypt()` takes key bytes regardless of source. Input placeholder updated to: `#LongFast or paste hex key` ### M4: Channel removal User-added channels now show a ✕ button on hover. Click → confirm dialog → removes: - Key from localStorage (`ChannelDecrypt.removeKey()`) - Cached messages from localStorage (`ChannelDecrypt.clearChannelCache()`) - Channel entry from sidebar If the removed channel was selected, the view resets to the empty state. ### M5: localStorage message caching with delta fetch After client-side decryption, results are cached in localStorage keyed by channel name: ``` { messages: [...], lastTimestamp: "...", count: N, ts: Date.now() } ``` On subsequent visits: 1. **Instant render** — cached messages displayed immediately via `onCacheHit` callback 2. **Delta fetch** — only packets newer than `lastTimestamp` are fetched and decrypted 3. **Merge** — new messages merged with cache, deduplicated by `packetHash` 4. **Cache invalidation** — if total candidate count changes, full re-decrypt triggered 5. **Size limit** — max 1000 messages cached per channel (most recent kept) ### Performance - Delta fetch avoids re-decrypting the full history on every page load - Cache-first rendering provides instant UI response - `deduplicateAndMerge()` uses a hash set for O(n) dedup - 1000-message cap prevents localStorage quota issues ### Tests (24 new) - M3: hex key detection (valid/invalid patterns) - M3: key derivation round-trip, channel hash computation - M3: PSK key storage and retrieval - M4: channel removal clears both key and cache - M5: cache size limit enforcement (1200 → 1000 stored) - M5: cache stores count and lastTimestamp - M5: clearChannelCache works independently - All existing tests pass (523 frontend helpers, 62 packet filter) ### Files changed | File | Change | |------|--------| | `public/channel-decrypt.js` | `removeKey()` now clears cache; `clearChannelCache()`; `setCache()` with count + size limit | | `public/channels.js` | Extracted `decryptCandidates()`, `deduplicateAndMerge()`; delta fetch logic; remove button handler; cache-first rendering | | `public/style.css` | `.ch-remove-btn` styles (hover-reveal ✕) | | `test-channel-decrypt-m345.js` | 24 new tests | Implements #725 Co-authored-by: you <you@example.com>
158 lines
5.9 KiB
JavaScript
158 lines
5.9 KiB
JavaScript
/**
|
|
* Tests for #725 M3 (PSK hex key), M4 (channel removal), M5 (message caching).
|
|
* Runs in Node.js via vm.createContext to simulate browser environment.
|
|
*/
|
|
'use strict';
|
|
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const { subtle } = require('crypto').webcrypto;
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function assert(cond, msg) {
|
|
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
|
else { failed++; console.error(' ✗ ' + msg); }
|
|
}
|
|
|
|
// Build a minimal browser-like sandbox
|
|
function createSandbox() {
|
|
const storage = {};
|
|
const localStorage = {
|
|
getItem: (k) => storage[k] !== undefined ? storage[k] : null,
|
|
setItem: (k, v) => { storage[k] = String(v); },
|
|
removeItem: (k) => { delete storage[k]; },
|
|
_data: storage
|
|
};
|
|
|
|
const ctx = {
|
|
window: {},
|
|
crypto: { subtle },
|
|
TextEncoder: TextEncoder,
|
|
TextDecoder: TextDecoder,
|
|
Uint8Array,
|
|
localStorage,
|
|
console,
|
|
Date,
|
|
JSON,
|
|
parseInt,
|
|
Math,
|
|
String,
|
|
Number,
|
|
Object,
|
|
Array,
|
|
RegExp,
|
|
Error,
|
|
Promise,
|
|
setTimeout,
|
|
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
|
|
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
|
|
};
|
|
ctx.window = ctx;
|
|
ctx.self = ctx;
|
|
return ctx;
|
|
}
|
|
|
|
async function runTests() {
|
|
console.log('\n=== M3: PSK hex key detection ===');
|
|
|
|
// Load channel-decrypt.js in sandbox
|
|
const cdSrc = fs.readFileSync(__dirname + '/public/channel-decrypt.js', 'utf8');
|
|
const sandbox = createSandbox();
|
|
const context = vm.createContext(sandbox);
|
|
vm.runInContext(cdSrc, context);
|
|
const CD = sandbox.window.ChannelDecrypt;
|
|
|
|
// Test: isHexKey detection (via channels.js logic)
|
|
// We test the pattern directly since isHexKey is inside channels.js IIFE
|
|
const isHexKey = (val) => /^[0-9a-fA-F]{32}$/.test(val);
|
|
|
|
assert(isHexKey('0123456789abcdef0123456789abcdef'), 'Valid 32-char hex detected');
|
|
assert(isHexKey('AABBCCDD11223344AABBCCDD11223344'), 'Valid uppercase hex detected');
|
|
assert(!isHexKey('#LongFast'), 'Hashtag name NOT detected as hex');
|
|
assert(!isHexKey('0123456789abcdef'), 'Short hex (16 chars) NOT detected');
|
|
assert(!isHexKey('0123456789abcdef0123456789abcdefXX'), 'Too long NOT detected');
|
|
assert(!isHexKey('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'), 'Non-hex chars NOT detected');
|
|
|
|
// Test: PSK decrypt with known key bytes
|
|
console.log('\n=== M3: PSK decrypt produces correct plaintext ===');
|
|
|
|
// Derive a key from #LongFast for testing
|
|
const keyBytes = await CD.deriveKey('#LongFast');
|
|
assert(keyBytes.length === 16, 'Derived key is 16 bytes');
|
|
|
|
const keyHex = CD.bytesToHex(keyBytes);
|
|
assert(keyHex.length === 32, 'Key hex is 32 chars');
|
|
|
|
// Round-trip: hex → bytes → hex
|
|
const roundTrip = CD.bytesToHex(CD.hexToBytes(keyHex));
|
|
assert(roundTrip === keyHex, 'Hex round-trip preserves key');
|
|
|
|
// Channel hash computation works
|
|
const hashByte = await CD.computeChannelHash(keyBytes);
|
|
assert(typeof hashByte === 'number' && hashByte >= 0 && hashByte <= 255, 'Channel hash byte is valid (0-255)');
|
|
|
|
// PSK key (raw hex) stored and retrieved correctly
|
|
const pskHex = 'aabbccdd11223344aabbccdd11223344';
|
|
CD.storeKey('psk:aabbccdd', pskHex);
|
|
const keys = CD.getStoredKeys();
|
|
assert(keys['psk:aabbccdd'] === pskHex, 'PSK key stored and retrieved correctly');
|
|
|
|
console.log('\n=== M4: Channel removal clears key + cache ===');
|
|
|
|
// 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);
|
|
|
|
// Verify they exist
|
|
var storedKeys = CD.getStoredKeys();
|
|
assert(storedKeys['#TestChannel'] === 'deadbeefdeadbeefdeadbeefdeadbeef', 'Key exists before removal');
|
|
var cachedBefore = CD.getCache('#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');
|
|
assert(!cachedAfter, 'Cache cleared after removal');
|
|
|
|
console.log('\n=== M5: Cache operations ===');
|
|
|
|
// Test: setCache with count and size limit
|
|
var bigMessages = [];
|
|
for (var i = 0; i < 1200; i++) {
|
|
bigMessages.push({ sender: 'S', text: 'msg' + i, timestamp: '2026-01-01T00:00:' + String(i).padStart(2, '0') + 'Z', packetHash: 'h' + i });
|
|
}
|
|
CD.setCache('bigchannel', bigMessages, '2026-01-01T00:20:00Z', 1200);
|
|
var bigCached = CD.getCache('bigchannel');
|
|
assert(bigCached.messages.length <= 1000, 'Cache enforces 1000 message limit (got ' + bigCached.messages.length + ')');
|
|
assert(bigCached.count === 1200, 'Cache stores total count');
|
|
assert(bigCached.lastTimestamp === '2026-01-01T00:20:00Z', 'Cache stores lastTimestamp');
|
|
// Should keep most recent 1000
|
|
assert(bigCached.messages[0].packetHash === 'h200', 'Cache keeps most recent 1000 (first is h200)');
|
|
|
|
// Test: cache hit (delta fetch scenario)
|
|
CD.setCache('deltatest', [
|
|
{ sender: 'A', text: 'old', timestamp: '2026-01-01T00:00:00Z', packetHash: 'p1' }
|
|
], '2026-01-01T00:00:00Z', 1);
|
|
|
|
var deltaCache = CD.getCache('deltatest');
|
|
assert(deltaCache.messages.length === 1, 'Delta cache has 1 message');
|
|
assert(deltaCache.lastTimestamp === '2026-01-01T00:00:00Z', 'Delta cache lastTimestamp correct');
|
|
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');
|
|
CD.clearChannelCache('clearthis');
|
|
assert(CD.getCache('clearthis') === null, 'Cache cleared by clearChannelCache');
|
|
|
|
console.log('\n=== Results ===');
|
|
console.log('Passed: ' + passed + ', Failed: ' + failed);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
runTests().catch(e => { console.error(e); process.exit(1); });
|