aboutsummaryrefslogtreecommitdiff
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.mjs94
1 files changed, 80 insertions, 14 deletions
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
index a56c7cc..e12c3da 100644
--- a/crates/chat-server/tests/browser.mjs
+++ b/crates/chat-server/tests/browser.mjs
@@ -19,12 +19,12 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename } from 'node:f
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { randomBytes } from 'node:crypto';
-import { spawn, execFileSync } from 'node:child_process';
+import { spawn, execFileSync, spawnSync } 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 origin = `http://127.0.0.1:${process.env.CHAT_TEST_PORT || '9081'}`;
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));
@@ -32,7 +32,7 @@ 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 = 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'))); }),
@@ -78,8 +78,10 @@ async function page() {
});
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);
@@ -90,7 +92,7 @@ async function create(page, name, owner = false) {
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);
+ 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) {
@@ -126,17 +128,24 @@ async function verifyLibreJS() {
try {
if (libre) await verifyLibreJS();
const owner = await page();
- const response = await owner.goto(origin + '/#setup');
+ const response = await owner.goto(origin);
+ await owner.locator('#owner-choice').waitFor({state:'visible'});
+ await owner.click('#owner-choice');
assert.match(response.headers()['cache-control'], /no-store/);
assert.match(response.headers()['content-security-policy'], /frame-ancestors 'none'/);
- await waitStatus(owner,'Owner setup.');
+ 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 waitStatus(visitor,'Choose CREATE');
+ 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');
@@ -182,7 +191,7 @@ try {
await waitStatus(visitor,'Encrypted message stored');
assert.match(await visitor.locator('#counts').textContent(),/Total: 3/);
await visitor.reload();
- await waitStatus(visitor,'Choose CREATE');
+ 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(),'');
@@ -209,7 +218,7 @@ try {
await rename(snapshot,join(root,'var/data/chat/chat.sqlite'));
await launchServer();
await visitor.reload();
- await waitStatus(visitor,'Choose CREATE');
+ await waitEntry(visitor);
await visitor.click('#open-choice');
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
@@ -219,9 +228,11 @@ try {
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 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/);
@@ -236,6 +247,30 @@ try {
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,[]);
@@ -245,9 +280,40 @@ try {
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}`);
+ 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}`);
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 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) {
@@ -256,7 +322,7 @@ try {
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.');
+ 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();