// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; const run = promisify(execFile); const packaged = Boolean(process.env.CHAT_TEST_ROOT); let root = packaged ? resolve(process.env.CHAT_TEST_ROOT) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-')); const initialRoot = root; const token = packaged ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN=')).slice('CHAT_SETUP_TOKEN='.length) : randomBytes(32).toString('hex'); const origin = process.env.CHAT_TEST_ORIGIN || `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`; if (packaged) { const fixture = await readFile(join(root,'.env'),'utf8'); const address = new URL(origin); assert.equal(address.protocol,'https:'); assert.ok(address.hostname.endsWith('.localhost'),'Packaged tests require a disposable .localhost origin'); assert.ok(fixture.split('\n').includes('WEB_BIND=127.0.0.1'),'Packaged tests require loopback-only publishing'); } const password = 'browser-test-only-unique-passphrase'; const testHome = await mkdtemp(join(tmpdir(),'stack-chat-test-')); const gpgHome = join(testHome,'gnupg'); let restorations = 0; if (packaged) { await mkdir(gpgHome,{mode:0o700}); await run('gpg',['--no-options','--homedir',gpgHome,'--batch','--pinentry-mode','loopback','--passphrase','','--quick-generate-key','Stack disposable recovery fixture','rsa2048','encr','1d']); } else { await writeFile(join(root, '.env'), `CHAT_ORIGIN=${origin}\nCHAT_SETUP_TOKEN=${token}\n`, {mode:0o600}); for (const path of ['crates','target','docs','web','LICENSE']) await symlink(resolve(path), join(root,path)); await mkdir(join(root,'var/build'), {recursive:true}); } async function stack(...args) { return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000}); } async function restorePackage() { const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']); const fingerprint = listed.stdout.split('\n').find(line => line.startsWith('fpr:')).split(':')[9]; const key = join(testHome,'recipient.asc'); const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]); await writeFile(key,exported.stdout); const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key); const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1]; assert.ok(archive,backup.stdout); assert.equal((await stat(archive)).mode & 0o777,0o600); assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600); await stopServer(); const destination = `${initialRoot}-restored-${++restorations}`; await stack('restore',archive,destination); root = destination; assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8')); for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1); await launchServer(); console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`); } let server; let serverErrors = ''; async function verifyGateway() { const url = new URL(origin); const args = ['--noproxy','*','--silent','--show-error','--fail']; if (url.hostname.endsWith('.localhost')) { const state = JSON.parse(await readFile(join(root,'var/state.json'),'utf8')); const certificate = join(testHome,'gateway-ca.crt'); await run('podman',['--root',join(root,'var/podman/storage'),'--runroot',join(root,'var/podman/run'),'cp',`${state.project}-caddy:/data/caddy/pki/authorities/local/root.crt`,certificate]); args.push('--cacert',certificate,'--resolve',`${url.hostname}:${url.port}:127.0.0.1`); } const health = await run('curl',[...args,origin + '/healthz']); assert.equal(health.stdout,'ok\n'); } async function launchServer() { if (packaged) { await stack('up','--production'); let failure; for (let attempt = 0; attempt < 20; attempt += 1) { try { await verifyGateway(); return; } catch (error) { failure = error; await new Promise(done => setTimeout(done,500)); } } throw failure; } server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']}); server.stderr.on('data', bytes => { serverErrors += bytes.toString(); }); await Promise.race([ new Promise((ready, reject) => { server.stdout.once('data', ready); server.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }), new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()), ]); } async function stopServer() { if (packaged) { await stack('down'); return; } if (server.exitCode !== null || server.signalCode !== null) return; const stopped = new Promise(done => server.once('exit',done)); server.kill('SIGINT'); await stopped; } await launchServer(); const libre = process.env.CHAT_LIBREJS === '1'; let persistent; let browser; if (libre) { const profile = join(testHome,'firefox-profile'); await mkdir(join(profile,'extensions'),{recursive:true}); await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),await readFile(resolve('.tools/librejs.xpi'))); persistent = await firefox.launchPersistentContext(profile, {headless:true,acceptDownloads:true,ignoreHTTPSErrors:packaged,firefoxUserPrefs:{ 'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15, 'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false, 'browser.sessionstore.resume_from_crash':false, }}); browser = { close: () => persistent.close() }; } else { browser = await chromium.launch({headless:true,channel:'chromium'}); } const deadline = setTimeout(() => browser.close(), 300000); const contexts = []; const external = []; const errors = []; const bodies = []; async function page() { const context = persistent || await browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}); contexts.push(context); const page = await context.newPage(); page.setDefaultTimeout(90000); page.on('pageerror', error => errors.push(error.message)); const navigate = page.goto.bind(page); page.goto = async (...args) => { for (let attempt = 0; ; attempt += 1) { try { return await navigate(...args); } catch (error) { if (attempt >= 2 || !error.message.includes('ERR_NETWORK_CHANGED')) throw error; } } }; page.on('request', request => { if (!request.url().startsWith(origin + '/')) external.push(request.url()); if (request.url() === origin + '/api') bodies.push(JSON.parse(request.postData())); }); return page; } async function waitEntry(page) { await page.locator('#create-choice').waitFor({state:'visible'}); } async function waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); } async function create(page, name, owner = false) { const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length; if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice'); await page.fill('#name', name); await page.fill('#new-password', password); await page.fill('#confirm-password', password); const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status').textContent)).then(async () => { throw new Error(await page.locator('#status').textContent()); })]); await page.click('#create-form button'); const download = await downloaded; const bytes = await readFile(await download.path()); await waitStatus(page,'Reopen your downloaded'); assert.equal(await page.locator('#chat').isVisible(), false); assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications); return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes}; } async function unlock(page, file, phrase = password) { await page.setInputFiles('#file', file); await page.fill('#password', phrase); await page.click('#unlock-form button'); } async function storageEmpty(page) { assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0); assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]); assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0); assert.deepEqual(await page.context().cookies(),[]); } async function verifyLibreJS() { let executed = false; const controlServer = createServer((request,response) => { response.setHeader('Cache-Control','no-store'); if (request.url === '/control.js') { response.setHeader('Content-Type','text/javascript'); response.end("window.unlicensedControl = true; fetch('/executed');"); } else if (request.url === '/executed') { executed = true; response.end('unexpected'); } else { response.setHeader('Content-Type','text/html'); response.end('

Unlicensed external-script rejection control.

'); } }); await new Promise(done => controlServer.listen(0,'127.0.0.1',done)); const controlPage = await persistent.newPage(); try { await controlPage.goto(`http://127.0.0.1:${controlServer.address().port}/`); await controlPage.waitForTimeout(1500); assert.equal(await controlPage.evaluate(() => window.unlicensedControl),undefined,'LibreJS must reject the unlicensed control'); assert.equal(executed,false); } finally { await controlPage.close(); await new Promise(done => controlServer.close(done)); } } try { if (libre) await verifyLibreJS(); const owner = await page(); const response = await owner.goto(origin); await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); }); await owner.click('#owner-choice'); assert.match(response.headers()['cache-control'], /no-store/); assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/); await owner.locator('#setup-token').waitFor({state:'visible'}); assert.match(await owner.locator('#create-title').textContent(),/owner only/); assert.equal(await owner.locator('.introduction h1').count(),0); assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1); assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0); assert.equal(await owner.evaluate(() => document.scrollingElement === document.documentElement),true); const ownerFile = await create(owner,'Doan',true); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); assert.match(await owner.locator('#who').textContent(), /OWNER/); const visitor = await page(); await visitor.goto(origin); await waitEntry(visitor); const visitorFile = await create(visitor,'A friend'); assert.ok(!visitorFile.buffer.toString().includes('A friend')); await unlock(visitor,visitorFile,'wrong-but-long-password'); await waitStatus(visitor,'Cryptographic operation failed'); assert.equal(await visitor.locator('#chat').isVisible(),false); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.equal(await visitor.locator('#messages li').count(),0); assert.match(await visitor.locator('#counts').textContent(),/Total: 0/); const malicious = ' hello λ'; await visitor.fill('#text',malicious); await visitor.click('#send-form button'); await waitStatus(visitor,'Encrypted message stored'); await owner.click('#refresh-inbox'); await owner.locator('#conversations button').first().click(); await waitStatus(owner,'Conversation opened'); assert.match(await owner.locator('#thread-name').textContent(),/A friend/); assert.equal(await owner.locator('#messages li').count(),0); await owner.fill('#history-count','1'); await owner.click('#history-form button'); await waitStatus(owner,'Selected history decrypted'); assert.equal(await owner.locator('#messages p').textContent(),malicious); assert.equal(await owner.evaluate(() => window.chatInjection),undefined); await owner.fill('#text','Reply from owner'); await owner.click('#send-form button'); await waitStatus(owner,'Encrypted message stored'); await visitor.fill('#history-count','2'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); assert.match(await visitor.locator('#counts').textContent(),/Total: 2 \| Sent: 1 \| Received: 1/); let interrupted = false; await visitor.route('**/api',async route => { const body = route.request().postDataJSON(); if (body.op === 'send' && !interrupted) { interrupted = true; await route.fetch(); await route.abort(); } else await route.continue(); }); await visitor.fill('#text','Safe retry after a lost response'); await visitor.click('#send-form button'); await visitor.locator('#retry').waitFor({state:'visible'}); await visitor.waitForFunction(() => !document.getElementById('retry').disabled); await visitor.click('#retry'); await waitStatus(visitor,'Encrypted message stored'); assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); await visitor.reload(); await waitEntry(visitor); assert.equal(await visitor.locator('#messages li').count(),0); assert.equal(await visitor.locator('#file').inputValue(),''); assert.equal(await visitor.locator('#password').inputValue(),''); await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.fill('#history-count','1'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.match(await visitor.locator('#messages p').textContent(),/Safe retry/); await visitor.click('#older'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),2); await visitor.fill('#history-count','0'); await visitor.click('#history-form button'); await waitStatus(visitor,'No message history loaded'); assert.equal(await visitor.locator('#messages li').count(),0); if (packaged) await restorePackage(); else { const snapshot = join(root,'snapshot.sqlite'); execFileSync(resolve('target/debug/stack-chat-server'),['--root',root,'--snapshot',snapshot],{stdio:'pipe'}); await stopServer(); await rm(join(root,'var/data/chat/chat.sqlite')); await rename(snapshot,join(root,'var/data/chat/chat.sqlite')); await launchServer(); } await visitor.reload(); await waitEntry(visitor); await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); assert.match(await visitor.locator('#counts').textContent(),/Total: 3/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),3); assert.ok((await visitor.locator('#messages p').allTextContents()).includes(malicious)); await owner.goto(origin); await waitEntry(owner); await owner.click('#owner-choice'); await owner.locator('#owner-access').waitFor({state:'visible'}); await owner.click('#owner-open'); await unlock(owner,ownerFile); await waitStatus(owner,'Unlocked in memory only'); assert.match(await owner.locator('#who').textContent(),/OWNER/); await visitor.fill('#history-count','3'); await visitor.click('#history-form button'); await visitor.click('#lock'); await visitor.waitForTimeout(500); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.goto(origin + '/LIMITATIONS.txt'); await visitor.goBack(); await visitor.waitForFunction(() => !document.getElementById('entry').hidden); assert.equal(await visitor.locator('#messages li').count(),0); assert.equal(await visitor.locator('#password').inputValue(),''); assert.equal(await visitor.locator('#chat').isVisible(),false); await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Unlocked in memory only'); for (let index = 1; index <= 51; index += 1) { await visitor.fill('#text',`Batch fixture ${index}`); await visitor.click('#send-form button'); await waitStatus(visitor,'Encrypted message stored'); } assert.equal(await visitor.locator('#history-count').getAttribute('max'),'54'); assert.equal(await visitor.locator('#history-range').textContent(),'Latest messages (0-54)'); const historyStart = bodies.length; await visitor.fill('#history-count','54'); await visitor.click('#history-form button'); await waitStatus(visitor,'Selected history decrypted'); assert.equal(await visitor.locator('#messages li').count(),54); const batches = bodies.slice(historyStart).filter(body => body.op === 'history' && body.count > 0).map(body => body.count); assert.deepEqual(batches,[50,4]); assert.equal(await visitor.locator('#messages p').last().textContent(),'Batch fixture 51'); assert.equal(await visitor.locator('#older').isVisible(),false); await visitor.fill('#history-count','0'); await visitor.click('#history-form button'); await waitStatus(visitor,'No message history loaded'); assert.equal(await visitor.locator('#messages li').count(),0); await visitor.click('#lock'); await storageEmpty(visitor); await storageEmpty(owner); assert.deepEqual(external,[]); assert.deepEqual(errors,[]); for (const body of bodies) { for (const forbidden of ['file','password','secret','name','text']) assert.ok(!(forbidden in body),`Leaked ${forbidden}`); assert.ok(!JSON.stringify(body).includes(malicious)); } for (const filename of (await readdir(join(root,'var/data/chat'))).filter(name => name.startsWith('chat.sqlite'))) { const database = await readFile(join(root,'var/data/chat',filename)); for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted in ${filename}`); } const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}}); assert.equal(blocked.status(),403); const administrative = (...args) => packaged ? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'}) : spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'}); const registered = administrative('--owner-fingerprint'); assert.equal(registered.status,0); const fingerprint = registered.stdout.trim(); assert.match(fingerprint,/^[0-9A-F]{64}$/); const runningReset = administrative('--reset-owner',fingerprint); assert.notEqual(runningReset.status,0,'Reset must refuse while the server runs'); await stopServer(); const mismatch = administrative('--reset-owner','0'.repeat(64)); assert.notEqual(mismatch.status,0,'Wrong fingerprint must not reset the owner'); assert.equal(administrative('--owner-fingerprint').stdout.trim(),fingerprint); const preservedEnv = await readFile(join(root,'.env')); const reset = administrative('--reset-owner',fingerprint); assert.equal(reset.status,0); assert.deepEqual(await readFile(join(root,'.env')),preservedEnv); assert.notEqual(administrative('--owner-fingerprint').status,0); await launchServer(); await owner.goto(origin); await owner.locator('#owner-choice').waitFor({state:'visible'}); await owner.click('#owner-choice'); const replacement = await create(owner,'Replacement owner',true); await unlock(owner,replacement); await waitStatus(owner,'Unlocked in memory only'); assert.equal(await owner.locator('#conversations button').count(),0,'Reset must clear conversations'); assert.notEqual(administrative('--owner-fingerprint').stdout.trim(),fingerprint); await visitor.goto(origin); await waitEntry(visitor); await visitor.click('#open-choice'); await unlock(visitor,visitorFile); await waitStatus(visitor,'Pinned owner key differs'); assert.equal(await visitor.locator('#chat').isVisible(),false); assert.equal(serverErrors,''); if (process.env.CHAT_SCREENSHOT) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true}); if (libre) { const extensions = JSON.parse(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); const addon = extensions.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack'); assert.ok(addon?.active,'LibreJS must actually be active'); console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`); } console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, no browser storage or external requests, no private uploads or database plaintext.'); } finally { clearTimeout(deadline); await browser.close(); await stopServer(); if (!packaged) await rm(root,{recursive:true,force:true}); else await run('gpgconf',['--homedir',gpgHome,'--kill','all']); await rm(testHome,{recursive:true,force:true}); }