summaryrefslogtreecommitdiff
path: root/crates/chat-server/tests/browser.ts
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/tests/browser.ts')
-rw-r--r--crates/chat-server/tests/browser.ts443
1 files changed, 443 insertions, 0 deletions
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 <doan@luuduct.ai>
+//
+// 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 <https://www.gnu.org/licenses/>.
+
+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<typeof spawn> | 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<void>((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<void>(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<void> };
+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<typeof requestBody>[] = [];
+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<Page['goto']>) => {
+ 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('<!doctype html><script src="/control.js"></script><p>Unlicensed external-script rejection control.</p>'); }
+ });
+ await new Promise<void>(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 = '<script>window.chatInjection = true</script> 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<HTMLButtonElement>('#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});
+}