diff options
| author | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 22:12:54 -0500 |
|---|---|---|
| committer | Doan Luu Duc Tai <doan@luuduct.ai> | 2026-10-30 22:12:54 -0500 |
| commit | be16d00a8a4750028073e018ab2c58d54af192c4 (patch) | |
| tree | 3eeabb31508f98a6753a2492238581f888769b4a /crates/chat-server/tests/browser.mjs | |
| parent | b7374fe09306c495131cf3673addb76a1dcf017d (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/browser.mjs')
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 90 |
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(); |
