aboutsummaryrefslogtreecommitdiff
path: root/crates/chat-server/tests
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 22:12:54 -0500
commitbe16d00a8a4750028073e018ab2c58d54af192c4 (patch)
tree3eeabb31508f98a6753a2492238581f888769b4a /crates/chat-server/tests
parentb7374fe09306c495131cf3673addb76a1dcf017d (diff)
Refine chat controls and add explicit owner recovery reset
Simplify the ASCII interface, expose owner setup, and load any selected number of messages using bounded history batches. Add backend-only owner reset with a matching public fingerprint and stopped-service guards, and document the loss of old history and key continuity.
Diffstat (limited to 'crates/chat-server/tests')
-rw-r--r--crates/chat-server/tests/browser.mjs90
1 files changed, 78 insertions, 12 deletions
diff --git a/crates/chat-server/tests/browser.mjs b/crates/chat-server/tests/browser.mjs
index 08c5c88..e12c3da 100644
--- a/crates/chat-server/tests/browser.mjs
+++ b/crates/chat-server/tests/browser.mjs
@@ -19,7 +19,7 @@ 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-'));
@@ -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();