diff options
Diffstat (limited to 'crates/chat-server/tests')
| -rw-r--r-- | crates/chat-server/tests/browser.mjs | 94 |
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(); |
