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.mjs135
1 files changed, 112 insertions, 23 deletions
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
index e12c3da..b0a9041 100644
--- a/crates/chat-server/tests/browser.mjs
+++ b/crates/chat-server/tests/browser.mjs
@@ -15,23 +15,88 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
-import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:fs/promises';
+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 } from 'node:child_process';
+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 root = await mkdtemp(join(tmpdir(), 'stack-chat-e2e-'));
-const token = randomBytes(32).toString('hex');
-const origin = `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`;
+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';
-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});
+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([
@@ -40,6 +105,7 @@ async function launchServer() {
]);
}
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');
@@ -50,10 +116,10 @@ const libre = process.env.CHAT_LIBREJS === '1';
let persistent;
let browser;
if (libre) {
- const profile = join(root,'firefox-profile');
+ 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,firefoxUserPrefs:{
+ 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,
@@ -66,11 +132,18 @@ const external = [];
const errors = [];
const bodies = [];
async function page() {
- const context = persistent || await browser.newContext({acceptDownloads:true});
+ 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());
@@ -90,6 +163,13 @@ async function create(page, name, owner = false) {
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);
@@ -129,7 +209,7 @@ try {
if (libre) await verifyLibreJS();
const owner = await page();
const response = await owner.goto(origin);
- await owner.locator('#owner-choice').waitFor({state:'visible'});
+ 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'/);
@@ -211,12 +291,15 @@ try {
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();
+ 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');
@@ -279,11 +362,15 @@ try {
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','Batch fixture 51',password]) assert.ok(!database.includes(Buffer.from(plaintext)),`Plaintext persisted: ${plaintext}`);
+ 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) => spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'});
+ 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();
@@ -317,7 +404,7 @@ try {
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 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.`);
@@ -327,5 +414,7 @@ try {
clearTimeout(deadline);
await browser.close();
await stopServer();
- await rm(root,{recursive:true,force:true});
+ if (!packaged) await rm(root,{recursive:true,force:true});
+ else await run('gpgconf',['--homedir',gpgHome,'--kill','all']);
+ await rm(testHome,{recursive:true,force:true});
}