summaryrefslogtreecommitdiff
path: root/crates/chat-server/tests/browser.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/chat-server/tests/browser.mjs')
-rw-r--r--crates/chat-server/tests/browser.mjs265
1 files changed, 265 insertions, 0 deletions
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
new file mode 100644
index 0000000..a56c7cc
--- /dev/null
+++ b/crates/chat-server/tests/browser.mjs
@@ -0,0 +1,265 @@
+// 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 } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { resolve, join } from 'node:path';
+import { randomBytes } from 'node:crypto';
+import { spawn, execFileSync } from 'node:child_process';
+import { chromium, firefox } from '../../../.tools/browser/node_modules/playwright/index.mjs';
+
+const root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-'));
+const token = randomBytes(32).toString('hex');
+const origin = 'http://127.0.0.1:9080';
+const password = 'browser-test-only-unique-passphrase';
+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});
+let server;
+let serverErrors = '';
+async function launchServer() {
+ server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root], {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 (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(root,'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,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});
+ contexts.push(context);
+ const page = await context.newPage();
+ page.setDefaultTimeout(90000);
+ page.on('pageerror', error => errors.push(error.message));
+
+ 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 waitStatus(page, text) { await page.waitForFunction(value => document.getElementById('status').textContent.includes(value), text); }
+async function create(page, name, owner = false) {
+ 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.some(body => body.op === 'authenticate'), owner ? false : true);
+ 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('<!doctype html><script src="/control.js"></script><p>Unlicensed external-script rejection control.</p>'); }
+ });
+ 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 + '/#setup');
+ assert.match(response.headers()['cache-control'], /no-store/);
+ assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/);
+ await waitStatus(owner,'Owner setup.');
+ 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 waitStatus(visitor,'Choose CREATE');
+ 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.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 waitStatus(visitor,'Choose CREATE');
+ 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);
+ 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 waitStatus(visitor,'Choose CREATE');
+ 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.reload();
+ await waitStatus(owner,'Choose CREATE');
+ await owner.click('#open-choice');
+ 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 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));
+ }
+ const database = await readFile(join(root,'var/data/chat/chat.sqlite'));
+ for (const plaintext of [malicious,'A friend','Reply from owner','Safe retry after a lost response',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`);
+ const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}});
+ assert.equal(blocked.status(),403);
+ 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(root,'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, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, origin rejection, no browser storage or external requests, no private uploads or database plaintext.');
+} finally {
+ clearTimeout(deadline);
+ await browser.close();
+ await stopServer();
+ await rm(root,{recursive:true,force:true});
+}