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.ts104
1 files changed, 55 insertions, 49 deletions
diff --git a/crates/chat-server/tests/browser.ts b/crates/chat-server/tests/browser.ts
index 74a9df2..468a5f5 100644
--- a/crates/chat-server/tests/browser.ts
+++ b/crates/chat-server/tests/browser.ts
@@ -19,20 +19,20 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir
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 { spawn, execFileSync, spawnSync, execFile, type SpawnSyncReturns } 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';
+import { closeBrowser, 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-'));
+let root = testRoot !== undefined && testRoot.length > 0 ? 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) || ''
+ ? ((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'}`;
+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);
@@ -53,32 +53,32 @@ if (packaged) {
await mkdir(join(root,'var'), {recursive:true});
await symlink(resolve('var/build'),join(root,'var/build'));
}
-async function stack(...args: string[]) {
+async function stack(...args: string[]): Promise<{ stdout: string; stderr: string; }> {
return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000});
}
-async function restorePackage() {
+async function restorePackage(): Promise<void> {
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);
+ const archive = (/Encrypted export: (.+)/.exec(backup.stdout))?.[1];
+ assert.ok(archive !== undefined && archive.length > 0,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}`;
+ const destination = `${initialRoot}-restored-${String(++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}.`);
+ console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${String(restorations)}.`);
}
let server: ReturnType<typeof spawn> | undefined;
let serverErrors = '';
-async function verifyGateway() {
+async function verifyGateway(): Promise<void> {
const url = new URL(origin);
const args = ['--noproxy','*','--silent','--show-error','--fail'];
if (url.hostname.endsWith('.localhost')) {
@@ -90,7 +90,7 @@ async function verifyGateway() {
const health = await run('curl',[...args,origin + '/healthz']);
assert.equal(health.stdout,'ok\n');
}
-async function launchServer() {
+async function launchServer(): Promise<void> {
if (packaged) {
await stack('up','--production');
let failure;
@@ -101,17 +101,20 @@ async function launchServer() {
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(); });
+ server.stderr?.on('data', (bytes: unknown) => {
+ if (!Buffer.isBuffer(bytes)) throw new Error('Expected server diagnostics as 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()),
+ new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => { ready(); }); server?.once('exit', () => { reject(new Error((serverErrors.length > 0 ? serverErrors : 'Server exited'))); }); }),
+ new Promise((_, reject) => setTimeout(() => { reject(new Error('Server did not start')); }, 20000).unref()),
]);
}
-async function stopServer() {
+async function stopServer(): Promise<void> {
if (packaged) { await stack('down'); return; }
- if (!server) return;
+ if (server === undefined) return;
if (server.exitCode !== null || server.signalCode !== null) return;
- const stopped = new Promise<void>(done => server?.once('exit', () => done()));
+ const stopped = new Promise<void>(done => server?.once('exit', () => { done(); }));
server.kill('SIGINT');
await stopped;
}
@@ -128,21 +131,21 @@ if (libre) {
'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() };
+ browser = { close: (): Promise<void> => required(persistent, 'Missing Firefox context').close() };
} else { browser = await chromium.launch({headless:true,channel:'chromium'}); }
-const deadline = setTimeout(() => browser.close(), 300000);
+const deadline = setTimeout(() => { closeBrowser(browser); }, 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');
+async function page(): Promise<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']>) => {
+ page.goto = async (...args: Parameters<Page['goto']>): ReturnType<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; }
@@ -155,15 +158,15 @@ async function page() {
});
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) {
+async function waitEntry(page: Page): Promise<void> { await page.locator('#create-choice').waitFor({state:'visible'}); }
+async function waitStatus(page: Page, text: string): Promise<void> { await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value), text); }
+async function create(page: Page, name: string, owner = false): Promise<{ name: string; mimeType: string; buffer: Buffer; }> {
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'); })]);
+ 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'));
@@ -180,18 +183,18 @@ async function create(page: Page, name: string, owner = 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) {
+async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password): Promise<void> {
await page.setInputFiles('#file', file);
await page.fill('#password', phrase);
await page.click('#unlock-form button');
}
-async function storageEmpty(page: Page) {
+async function storageEmpty(page: Page): Promise<void> {
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() {
+async function verifyLibreJS(): Promise<void> {
let executed = false;
const controlServer = createServer((request,response) => {
response.setHeader('Cache-Control','no-store');
@@ -221,12 +224,15 @@ try {
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.locator('#owner-choice').waitFor({state:'visible'}).catch(async (error: unknown) => {
+ const currentStatus = await owner.locator('#status').textContent();
+ throw new Error(`${String(error)}; status=${currentStatus ?? ''}`, { cause: error });
+ });
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'/);
+ 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.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);
@@ -234,7 +240,7 @@ try {
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/);
+ assert.match((await owner.locator('#who').textContent() ?? ''), /OWNER/);
const visitor = await page();
await visitor.goto(origin);
await waitEntry(visitor);
@@ -246,7 +252,7 @@ try {
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/);
+ 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');
@@ -254,7 +260,7 @@ try {
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.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'));
@@ -280,7 +286,7 @@ try {
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/);
+ 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'));
@@ -290,10 +296,10 @@ try {
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.waitForFunction(() => !(document.querySelector<HTMLButtonElement>('#retry')?.disabled === true));
await visitor.click('#retry');
await waitStatus(visitor,'Encrypted message stored');
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/);
await visitor.reload();
await waitEntry(visitor);
assert.equal(await visitor.locator('#messages li').count(),0);
@@ -302,12 +308,12 @@ try {
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.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/);
+ 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);
@@ -329,7 +335,7 @@ try {
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.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');
@@ -342,7 +348,7 @@ try {
await owner.click('#owner-open');
await unlock(owner,ownerFile);
await waitStatus(owner,'Unlocked in memory only');
- assert.match(await owner.locator('#who').textContent() || '',/OWNER/);
+ assert.match((await owner.locator('#who').textContent() ?? ''),/OWNER/);
await visitor.fill('#history-count','3');
await visitor.click('#history-form button');
await visitor.click('#lock');
@@ -358,7 +364,7 @@ try {
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.fill('#text',`Batch fixture ${String(index)}`);
await visitor.click('#send-form button');
await waitStatus(visitor,'Encrypted message stored');
}
@@ -392,7 +398,7 @@ try {
}
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
+ const administrative = (...args: string[]): SpawnSyncReturns<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');
@@ -426,10 +432,10 @@ try {
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 (((process.env['CHAT_SCREENSHOT'] ?? '').length > 0)) 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');
+ 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.');