From 9139398d465f5899cc31bc4cdd54daa7392ca550 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Sat, 31 Oct 2026 12:25:30 -0500 Subject: Migrate browser code and tests to strict TypeScript --- crates/chat-server/tests/browser.mjs | 420 -------------------------------- crates/chat-server/tests/browser.ts | 443 ++++++++++++++++++++++++++++++++++ crates/chat-server/tests/progress.mjs | 127 ---------- crates/chat-server/tests/progress.ts | 127 ++++++++++ 4 files changed, 570 insertions(+), 547 deletions(-) delete mode 100644 crates/chat-server/tests/browser.mjs create mode 100644 crates/chat-server/tests/browser.ts delete mode 100644 crates/chat-server/tests/progress.mjs create mode 100644 crates/chat-server/tests/progress.ts (limited to 'crates/chat-server/tests') diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs deleted file mode 100644 index b0a9041..0000000 --- a/crates/chat-server/tests/browser.mjs +++ /dev/null @@ -1,420 +0,0 @@ -// 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()); - const envelope = JSON.parse(bytes.toString()); - assert.ok(envelope.format.startsWith('stack-chat-file/v2;')); - assert.ok(bytes.toString().startsWith('{\n "format"')); - assert.ok(Array.isArray(envelope.ciphertext)); - assert.ok(envelope.ciphertext.length > 1); - assert.ok(envelope.ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); - assert.ok(envelope.ciphertext.at(-1).length > 0 && envelope.ciphertext.at(-1).length <= 80); - 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}); -} diff --git a/crates/chat-server/tests/browser.ts b/crates/chat-server/tests/browser.ts new file mode 100644 index 0000000..74a9df2 --- /dev/null +++ b/crates/chat-server/tests/browser.ts @@ -0,0 +1,443 @@ +// 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, type Browser, type BrowserContext, type Page } from 'playwright'; +import { libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js'; + +const run = promisify(execFile); +const testRoot = process.env['CHAT_TEST_ROOT']; +const packaged = Boolean(testRoot); +let root = testRoot ? resolve(testRoot) : 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'), {recursive:true}); + await symlink(resolve('var/build'),join(root,'var/build')); +} +async function stack(...args: string[]) { + 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 = required(required(listed.stdout.split('\n').find(line => line.startsWith('fpr:')), 'Missing key fingerprint').split(':')[9], 'Missing key fingerprint field'); + 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: ReturnType | undefined; +let serverErrors = ''; +async function verifyGateway() { + const url = new URL(origin); + const args = ['--noproxy','*','--silent','--show-error','--fail']; + if (url.hostname.endsWith('.localhost')) { + const state = record(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',`${string(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) 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: BrowserContext | undefined; +let browser: Browser | { close(): Promise }; +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: () => required(persistent, 'Missing Firefox context').close() }; +} else { browser = await chromium.launch({headless:true,channel:'chromium'}); } +const deadline = setTimeout(() => browser.close(), 300000); +const contexts: BrowserContext[] = []; +const external: string[] = []; +const errors: string[] = []; +const bodies: ReturnType[] = []; +async function page() { + const context = required(persistent || await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context'); + 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: Parameters) => { + for (let attempt = 0; ; attempt += 1) { + try { return await navigate(...args); } + catch (error) { if (attempt >= 2 || !(error instanceof Error && 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(requestBody(required(request.postData(), 'Missing API body'))); + }); + return page; +} +async function waitEntry(page: Page) { await page.locator('#create-choice').waitFor({state:'visible'}); } +async function waitStatus(page: Page, text: string) { await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value), text); } +async function create(page: Page, name: string, 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() || 'Missing status'); })]); + await page.click('#create-form button'); + const download = await downloaded; + const bytes = await readFile(required(await download.path(), 'Missing download')); + const envelope = record(JSON.parse(bytes.toString())); + const ciphertext = list(envelope['ciphertext']).map(string); + assert.ok(string(envelope['format']).startsWith('stack-chat-file/v2;')); + assert.ok(bytes.toString().startsWith('{\n "format"')); + assert.ok(Array.isArray(ciphertext)); + assert.ok(ciphertext.length > 1); + assert.ok(ciphertext.slice(0,-1).every(chunk => chunk.length === 80)); + assert.ok(required(ciphertext.at(-1), 'Missing final chunk').length > 0 && required(ciphertext.at(-1), 'Missing final chunk').length <= 80); + 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: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password) { + await page.setInputFiles('#file', file); + await page.fill('#password', phrase); + await page.click('#unlock-form button'); +} +async function storageEmpty(page: 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 required(persistent, 'Missing Firefox context').newPage(); + try { + await controlPage.goto(serverOrigin(controlServer) + '/'); + 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 malformed = await page(); + await malformed.route('**/api', route => route.fulfill({ json: { owner: 42, origin, protocol: 'stack-chat/v1' } })); + await malformed.goto(origin); + await waitStatus(malformed, 'Invalid owner configuration'); + assert.equal(await malformed.locator('#create-choice').isVisible(), false); + await storageEmpty(malformed); + await malformed.close(); + 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(required(response, 'Missing response').headers()['cache-control'] || '', /no-store/); + assert.match(required(response, 'Missing 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.route('**/api', async route => { + const body = requestBody(required(route.request().postData(), 'Missing API body')); + if (body.op === 'history') { + const response = record(await (await route.fetch()).json()); + await route.fulfill({ json: { ...response, conversation: { ...record(response['conversation']), total: '1' } } }); + } else await route.continue(); + }); + await owner.fill('#history-count','1'); + await owner.click('#history-form button'); + await waitStatus(owner, 'Invalid response count: total'); + assert.equal(await owner.locator('#messages li').count(),0); + await owner.unroute('**/api'); + 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 = requestBody(required(route.request().postData(), 'Missing API request')); + 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.querySelector('#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 === false); + 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: string[]) => 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 addon = libreJSAddon(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8')); + 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, malformed configuration and history 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}); +} diff --git a/crates/chat-server/tests/progress.mjs b/crates/chat-server/tests/progress.mjs deleted file mode 100644 index 0ee5614..0000000 --- a/crates/chat-server/tests/progress.mjs +++ /dev/null @@ -1,127 +0,0 @@ -// 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 { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; -import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs'; - -const libre = process.env.CHAT_LIBREJS === '1'; -const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); -const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); -let mode = 'known'; -let transfer; -let configuration; -let configReady; -let signalConfig; -let origin; -const errors = []; -const server = createServer(async (request,response) => { - response.setHeader('Cache-Control','no-store'); - if (request.url === '/crypto.wasm') { - if (mode === 'failure') { response.writeHead(503); response.end(); return; } - response.setHeader('Content-Type','application/wasm'); - if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); - transfer = response; - response.write(bytes.subarray(0,1048576)); - return; - } - if (request.url === '/api') { configuration = response; signalConfig(); return; } - const assets = { '/':'crates/chat-server/web/index.html','/app.js':'crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; - const path = assets[request.url]; - if (!path) { response.writeHead(404); response.end(); return; } - const mime = request.url === '/' ? 'text/html' : request.url.endsWith('.js') ? 'text/javascript' : 'text/css'; - response.setHeader('Content-Type',mime); - let source = await readFile(resolve(path),'utf8'); - if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); - response.end(source); -}); -await new Promise(done => server.listen(0,'127.0.0.1',done)); -origin = `http://127.0.0.1:${server.address().port}`; -let browser; -let context; -if (libre) { - await mkdir(join(profile,'extensions')); - const addon = await readFile(resolve('.tools/librejs.xpi')); - await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); - context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); - browser = {close:() => context.close()}; -} else { - browser = await chromium.launch({headless:true,channel:'chromium'}); - context = await browser.newContext(); -} -async function open(selected) { - mode = selected; - configuration = undefined; - configReady = new Promise(done => { signalConfig = done; }); - const page = await context.newPage(); - page.on('pageerror',error => errors.push(error.message)); - await page.clock.install(); - await page.goto(origin); - return page; -} -async function waitStatus(page,part) { - await page.waitForFunction(value => document.getElementById('status').textContent.includes(value),part); - return page.locator('#status').textContent(); -} -async function complete(page,offset) { - transfer.end(bytes.subarray(offset)); - await waitStatus(page,'Connecting to chat'); - await configReady; - configuration.setHeader('Content-Type','application/json'); - configuration.end(JSON.stringify({origin,owner:''})); - await page.locator('#owner-choice').waitFor({state:'visible'}); - assert.equal(await page.locator('#status').textContent(),''); - assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); - await page.close(); -} -try { - const known = await open('known'); - assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); - await known.clock.fastForward(44000); - transfer.write(bytes.subarray(1048576,2097152)); - assert.match(await waitStatus(known,'2.0 /'),/39%/); - await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); - await known.clock.fastForward(44000); - assert.ok(!(await known.locator('#status').textContent()).includes('stalled')); - await complete(known,2097152); - const unknown = await open('unknown'); - assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); - await complete(unknown,1048576); - const failure = await open('failure'); - await waitStatus(failure,'HTTP 503'); - assert.equal(await failure.locator('#owner-choice').isVisible(),false); - await failure.close(); - const stalled = await open('known'); - await waitStatus(stalled,'1.0 /'); - await stalled.clock.fastForward(45001); - await waitStatus(stalled,'stalled for 45 seconds'); - assert.match(await stalled.locator('#status').textContent(),/reload/); - assert.equal(await stalled.locator('#owner-choice').isVisible(),false); - await stalled.close(); - assert.deepEqual(errors,[]); - if (libre) { - const state = JSON.parse(await readFile(join(profile,'extensions.json'),'utf8')); - assert.ok(state.addons.find(addon => addon.id === 'jid1-KtlZuoiikVfFew@jetpack')?.active); - } - console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); -} finally { - await browser.close(); - server.closeAllConnections(); - await new Promise(done => server.close(done)); - await rm(profile,{recursive:true,force:true}); -} diff --git a/crates/chat-server/tests/progress.ts b/crates/chat-server/tests/progress.ts new file mode 100644 index 0000000..59c939d --- /dev/null +++ b/crates/chat-server/tests/progress.ts @@ -0,0 +1,127 @@ +// 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, type ServerResponse } from 'node:http'; +import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, firefox, type BrowserContext, type Page } from 'playwright'; +import { libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js'; + +const libre = process.env['CHAT_LIBREJS'] === '1'; +const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-')); +const bytes = await readFile(resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm')); +let mode = 'known'; +let transfer: ServerResponse | undefined; +let configuration: ServerResponse | undefined; +let configReady: Promise | undefined; +let signalConfig: (() => void) | undefined; +let origin = ''; +const errors: string[] = []; +const server = createServer(async (request,response) => { + response.setHeader('Cache-Control','no-store'); + if (request.url === '/crypto.wasm') { + if (mode === 'failure') { response.writeHead(503); response.end(); return; } + response.setHeader('Content-Type','application/wasm'); + if (mode !== 'unknown') response.setHeader('Content-Length',bytes.length); + transfer = response; + response.write(bytes.subarray(0,1048576)); + return; + } + if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; } + const assets: Record = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' }; + const path = assets[request.url || '']; + if (!path) { response.writeHead(404); response.end(); return; } + const mime = request.url === '/' ? 'text/html' : request.url?.endsWith('.js') ? 'text/javascript' : 'text/css'; + response.setHeader('Content-Type',mime); + let source = await readFile(resolve(path),'utf8'); + if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin); + response.end(source); +}); +await new Promise(done => server.listen(0,'127.0.0.1',done)); +origin = serverOrigin(server); +let browser; +let context: BrowserContext; +if (libre) { + await mkdir(join(profile,'extensions')); + const addon = await readFile(resolve('.tools/librejs.xpi')); + await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon); + context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}}); + browser = {close:() => context.close()}; +} else { + browser = await chromium.launch({headless:true,channel:'chromium'}); + context = await browser.newContext(); +} +async function open(selected: 'known' | 'unknown' | 'failure') { + mode = selected; + configuration = undefined; + configReady = new Promise(done => { signalConfig = done; }); + const page = await context.newPage(); + page.on('pageerror',error => errors.push(error.message)); + await page.clock.install(); + await page.goto(origin); + return page; +} +async function waitStatus(page: Page, part: string) { + await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value),part); + return await page.locator('#status').textContent() || ''; +} +async function complete(page: Page, offset: number) { + required(transfer, 'Missing transfer').end(bytes.subarray(offset)); + await waitStatus(page,'Connecting to chat'); + await configReady; + required(configuration, 'Missing configuration').setHeader('Content-Type','application/json'); + required(configuration, 'Missing configuration').end(JSON.stringify({origin,owner:'',protocol:'stack-chat/v1'})); + await page.locator('#owner-choice').waitFor({state:'visible'}); + assert.equal(await page.locator('#status').textContent(),''); + assert.equal(await page.evaluate(async () => (await wasm).byteLength),bytes.length); + await page.close(); +} +try { + const known = await open('known'); + assert.match(await waitStatus(known,'1.0 /'),/\[#{3}-{17}\] 19%/); + await known.clock.fastForward(44000); + required(transfer, 'Missing transfer').write(bytes.subarray(1048576,2097152)); + assert.match(await waitStatus(known,'2.0 /'),/39%/); + await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true}); + await known.clock.fastForward(44000); + assert.ok(!(await known.locator('#status').textContent() || '').includes('stalled')); + await complete(known,2097152); + const unknown = await open('unknown'); + assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received'); + await complete(unknown,1048576); + const failure = await open('failure'); + await waitStatus(failure,'HTTP 503'); + assert.equal(await failure.locator('#owner-choice').isVisible(),false); + await failure.close(); + const stalled = await open('known'); + await waitStatus(stalled,'1.0 /'); + await stalled.clock.fastForward(45001); + await waitStatus(stalled,'stalled for 45 seconds'); + assert.match(await stalled.locator('#status').textContent() || '',/reload/); + assert.equal(await stalled.locator('#owner-choice').isVisible(),false); + await stalled.close(); + assert.deepEqual(errors,[]); + if (libre) { + assert.ok(libreJSAddon(await readFile(join(profile,'extensions.json'),'utf8')).active); + } + console.log(`PASS: ${libre ? 'Firefox/LibreJS' : 'Chromium'} streamed percentage/bar/MiB, unknown length, exact byte recovery, slow continuous transfer, idle timeout, HTTP failure, and connection phase.`); +} finally { + await browser.close(); + server.closeAllConnections(); + await new Promise(done => server.close(done)); + await rm(profile,{recursive:true,force:true}); +} -- cgit v1.2.3