import { test, expect, _electron as electron } from '@playwright/test'; import type { ElectronApplication, Page } from '@playwright/test'; import { spawn, type ChildProcess } from 'node:child_process'; import { createServer } from 'node:net'; import { get as httpGet } from 'node:http'; import { createHash, randomBytes } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { ACCOUNTS, JMAP_URL } from './helpers/config'; import { sendMail } from './helpers/smtp'; import { JmapClient } from './helpers/jmap'; import { expectFolderUnread } from './helpers/app'; /** * The encrypted local search index (lib/mail-index/**) against the real * Stalwart fixture. THREE tests, because no single configuration can cover the * whole feature - the reasons are specific and worth reading before changing * any of them. * * Constraint 1 - the renderer cannot reach this fixture from a production * build. The renderer talks JMAP DIRECTLY to Stalwart, and this fixture's * Stalwart is deliberately plain HTTP (integration/webmail.Dockerfile explains * why). The production CSP pins `connect-src` to `'self' https: wss:`. Setting * NODE_ENV=development at RUNTIME does not help: `next build` INLINES * process.env.NODE_ENV into the compiled middleware, so proxy.ts's `isDev` is * frozen at build time. Verified by watching a standalone server started with * NODE_ENV=development still serve the production CSP, and the login fail with * "Refused to connect ... violates connect-src 'self' https: wss:". * * Constraint 2 - the fd-3 key channel cannot survive `next dev`. `next dev` * forks its server process with an IPC channel that claims fd 3, so adopting it * fails with EEXIST; fd 4 in that process is not a pipe either (ENOTTY). Both * were observed, not assumed. Extra file descriptors simply are not plumbed * through `npx -> next dev -> forked server`. The real standalone server is a * single process and has no such problem (test 3 proves it). * * So each test takes the configuration that lets it prove its own half: * * 1. PIPELINE - drives the REAL standalone server over HTTP from Node, with a * real fd-3 key channel. CSP is irrelevant here because there is no * browser: a Node client with a real session cookie exercises the real * routes. This is the test that proves a real delivery becomes searchable * by a word from its BODY, and that the file on disk is really encrypted. * * 2. TRIGGER - proves the EVENT-DRIVEN wiring: a real SMTP delivery makes the * renderer POST /api/offline/reindex off the back of its live JMAP push. * Runs against `next dev` (constraint 1), and asserts the request is made - * the indexing itself is test 1's job. * * 3. WIRING - launches the REAL shell with no ELECTRON_LOAD_URL, so * electron/main.ts boots the real standalone artifact and stands up the real * fd-3 key service on real safeStorage. Asserts the index routes are * REACHABLE in a real build (401 "sign in", not 404 "feature absent", not * 503 "no native binding / no key channel"). * * Nothing is mocked anywhere: real SMTP, real Stalwart, real Electron, real * SQLCipher, real safeStorage. */ const alice = ACCOUNTS.alice; const projectRoot = path.resolve(__dirname, '../..'); /** Mirrors lib/mail-index/paths.ts's accountFileToken(). */ function accountFileToken(accountId: string): string { return createHash('sha256').update(accountId, 'utf8').digest('hex').slice(0, 32); } function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); if (address && typeof address === 'object') { const { port } = address; server.close(() => resolve(port)); } else { server.close(() => reject(new Error('Could not allocate a free localhost port'))); } }); }); } function waitForServerReady(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const attempt = () => { const req = httpGet(url, (res) => { res.resume(); resolve(); }); req.on('error', () => { if (Date.now() > deadline) { reject(new Error(`Server never became reachable at ${url}`)); return; } setTimeout(attempt, 300); }); }; attempt(); }); } /** * Serves the key protocol of electron/key-service.ts over the child's inherited * fd. The key and the encryption are real; only safeStorage's wrapping of it is * out of the picture here, which is what test 3 covers. */ function serveKeyChannel(child: ChildProcess, fdIndex: number, key: Buffer): void { const channel = child.stdio[fdIndex] as NodeJS.ReadWriteStream | null; if (!channel) throw new Error(`child has no fd ${fdIndex} - key channel missing`); let buffer = ''; channel.on('data', (chunk: Buffer) => { buffer += chunk.toString('utf8'); let newline: number; while ((newline = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (!line.trim()) continue; const req = JSON.parse(line) as { id?: number; op?: string }; const reply = req.op === 'getIndexKey' ? { id: req.id, ok: true, key: key.toString('hex') } : req.op === 'deleteIndexKey' ? { id: req.id, ok: true } : { id: req.id, ok: false, code: 'bad-request', error: 'unknown op' }; channel.write(`${JSON.stringify(reply)}\n`); } }); } /** Minimal cookie jar - the index routes are cookie-authenticated. */ class Jar { private cookies = new Map(); absorb(response: Response): void { for (const raw of response.headers.getSetCookie()) { const [pair] = raw.split(';'); const eq = pair.indexOf('='); if (eq <= 0) continue; this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); } } header(): string { return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; '); } } interface SearchHit { contentType: string; id: string; title: string; snippet: string; } interface SearchResponse { ok?: boolean; count?: number; hits?: SearchHit[]; contextBlock?: string; stats?: Array<{ contentType: string; count: number }>; error?: string; } test.describe('Electron desktop shell - encrypted local search index', () => { test('pipeline: a real delivery becomes searchable by a body word, and the file is encrypted', async () => { const jmap = await JmapClient.connect(alice.email, alice.password); await jmap.reset(); const stamp = Date.now(); const subject = `IT index subject ${stamp}`; // Appears ONLY in the body, so a hit proves the body was actually fetched // and indexed - not merely the subject, which any list view already holds. const bodyPhrase = `zurichlease${stamp}`; // Deliver BEFORE indexing, so the catch-up path has something real to find. await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: `Please review the ${bodyPhrase} renewal before September.`, }); const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-index-it-')); const key = randomBytes(32); const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const serverEntry = path.join(projectRoot, '.next', 'standalone', 'server.js'); expect( fs.existsSync(serverEntry), `missing ${serverEntry} - run "npm run build:standalone" first`, ).toBe(true); // The REAL standalone artifact, spawned exactly as electron/main.ts spawns // it (including the fd-3 key channel), just with plain node rather than // ELECTRON_RUN_AS_NODE - the server code is identical either way. const server = spawn(process.execPath, [serverEntry], { cwd: path.dirname(serverEntry), env: { ...process.env, PORT: String(port), HOSTNAME: '127.0.0.1', NODE_ENV: 'production', JMAP_SERVER_URL: JMAP_URL, SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: '3', }, stdio: ['pipe', 'pipe', 'pipe', 'pipe'], }); server.stderr?.on('data', (chunk) => process.stderr.write(`[standalone] ${chunk}`)); serveKeyChannel(server, 3, key); const jar = new Jar(); const call = async (url: string, init?: RequestInit): Promise => { const response = await fetch(`${baseUrl}${url}`, { ...init, headers: { ...(init?.headers ?? {}), cookie: jar.header() }, }); jar.absorb(response); return response; }; const search = async (query: string, types?: string): Promise => { const params = new URLSearchParams({ q: query, stats: 'true' }); if (types) params.set('types', types); const response = await call(`/api/offline/search?${params.toString()}`); if (!response.ok) return { error: `HTTP ${response.status}: ${await response.text()}` }; return (await response.json()) as SearchResponse; }; try { await waitForServerReady(baseUrl, 60000); // Server-side login. This route verifies the credentials against Stalwart // from Node and writes BOTH the session cookie and the jmap_stalwart_ctx // auth context the index routes read (app/api/auth/session/route.ts:94). const login = await call('/api/auth/session?slot=0', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl: JMAP_URL, username: alice.email, password: alice.password, slot: 0, }), }); expect(login.status, `login failed: ${await login.text()}`).toBe(200); // The gate must be open and the native binding loaded, or every assertion // below would fail for an unrelated reason. const reachable = await call('/api/offline/search?stats=true&q='); expect( reachable.status, `index routes unreachable: ${(await reachable.text()).slice(0, 300)}`, ).toBe(200); // Index it. This is the catch-up shape (no ids), which is what the app // runs at launch. const reindex = await call('/api/offline/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catchUp: true }), }); const reindexBody = await reindex.json(); expect(reindex.status, JSON.stringify(reindexBody)).toBe(200); expect( reindexBody.written?.mail, `no mail indexed: ${JSON.stringify(reindexBody)}`, ).toBeGreaterThan(0); // THE assertion: found by a word that exists only in the message body. const hit = await search(bodyPhrase); expect(hit.error).toBeUndefined(); expect(hit.count, `search for a body word found nothing: ${JSON.stringify(hit)}`) .toBeGreaterThan(0); expect(hit.hits?.[0].contentType).toBe('mail'); expect(hit.hits?.[0].title).toBe(subject); expect(hit.hits?.[0].snippet).toContain(bodyPhrase); // The prompt-ready retrieval surface an AI feature would consume. expect(hit.contextBlock).toContain('[EMAIL]'); expect(hit.contextBlock).toContain(subject); // Also findable by sender address, which lives in the `people` column. expect((await search(alice.email)).count).toBeGreaterThan(0); // Type filtering must filter, and a word in no message must not match - // otherwise the hit above proves nothing about relevance. expect((await search(bodyPhrase, 'calendar')).count).toBe(0); expect((await search(bodyPhrase, 'mail')).count).toBeGreaterThan(0); expect((await search(`absent${stamp}`)).count).toBe(0); // Catch-up must be idempotent: a second pass must not duplicate rows. const before = ((await search(bodyPhrase)).stats ?? []) .find((s) => s.contentType === 'mail')?.count ?? 0; const second = await call('/api/offline/reindex', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catchUp: true }), }); expect(second.status).toBe(200); const after = ((await search(bodyPhrase)).stats ?? []) .find((s) => s.contentType === 'mail')?.count ?? 0; expect(after).toBe(before); expect((await search(bodyPhrase)).count).toBe(1); // Calendar/contacts/files: assert they were ATTEMPTED and did not error, // rather than asserting counts - this fixture provisions mailboxes only, // so an empty calendar is the correct result and a count assertion would // be testing the fixture rather than the code. const errors = (reindexBody.errors ?? []) as Array<{ contentType: string; message: string }>; expect(errors, `per-type failures during reindex: ${JSON.stringify(errors)}`).toEqual([]); const attempted = Object.keys(reindexBody.written ?? {}); const skipped = (reindexBody.skipped ?? []) as string[]; expect( [...attempted, ...skipped].sort(), 'every content type must be either attempted or explicitly skipped', ).toEqual(['calendar', 'contact', 'file', 'mail']); } finally { server.kill(); // Let the process release its WAL files before reading them. await new Promise((r) => setTimeout(r, 500)); } // ── the file on disk is genuinely encrypted ────────────────────────────── const accountId = `${alice.email}@${new URL(JMAP_URL).hostname}`; const dbPath = path.join(storeDir, 'index', `${accountFileToken(accountId)}.db`); expect(fs.existsSync(dbPath), `no index database at ${dbPath}`).toBe(true); // Read every file the store wrote, WAL included: the newest rows can still // be sitting in the -wal, so checking only the main database could miss // plaintext that is genuinely on disk. const onDisk = Buffer.concat( ['', '-wal', '-shm'] .map((suffix) => `${dbPath}${suffix}`) .filter((f) => fs.existsSync(f)) .map((f) => fs.readFileSync(f)), ); expect(onDisk.length).toBeGreaterThan(0); // The assertions that catch a silently-UNENCRYPTED store. `PRAGMA key` is a // no-op on a non-SQLCipher binding - no error, working database, mailbox in // cleartext - so every functional assertion above would pass either way. expect( fs.readFileSync(dbPath).subarray(0, 15).toString('latin1'), 'the index file has a plain SQLite header - it is NOT encrypted', ).not.toBe('SQLite format 3'); expect( onDisk.includes(bodyPhrase), 'the message body is recoverable from the raw database bytes - not encrypted', ).toBe(false); expect( onDisk.includes(subject), 'the subject is recoverable from the raw database bytes - not encrypted', ).toBe(false); fs.rmSync(storeDir, { recursive: true, force: true }); }); test('trigger: a real delivery makes the renderer ask the index to update', async () => { const jmap = await JmapClient.connect(alice.email, alice.password); await jmap.reset(); const devPort = await getFreePort(); const devUrl = `http://127.0.0.1:${devPort}`; const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-trigger-profile-')); // `next dev` for the CSP reason in the header comment. No key channel here: // this test asserts the REQUEST is made, which is the wiring it owns; the // indexing itself is test 1's job. (Extra fds don't survive next dev // anyway - constraint 2 above.) const devServer = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(devPort)], { cwd: projectRoot, env: { ...process.env, JMAP_SERVER_URL: JMAP_URL, SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', NODE_ENV: 'development', // Enough for the route to exist and pass its gate; it fails later on the // absent key channel, which this test deliberately does not assert on. VNCMAIL_DESKTOP_STORE_DIR: path.join(userDataDir, 'offline'), VNCMAIL_DESKTOP_KEY_FD: '3', }, stdio: 'pipe', }); devServer.stderr?.on('data', (chunk) => process.stderr.write(`[next dev] ${chunk}`)); let electronApp: ElectronApplication | undefined; try { await waitForServerReady(devUrl, 90000); electronApp = await electron.launch({ args: [projectRoot, `--user-data-dir=${userDataDir}`], env: { ...process.env, ELECTRON_LOAD_URL: devUrl }, }); const appWindow: Page = await electronApp.firstWindow(); await appWindow.waitForLoadState('domcontentloaded'); await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); await appWindow.fill('#username', alice.email); await appWindow.fill('#password', alice.password); await appWindow.click('button[type="submit"]'); await appWindow .locator('[data-testid="account-switcher"]') .first() .waitFor({ state: 'visible', timeout: 60000 }); // An actively-selected inbox is a precondition for the push handler's // refresh, which is what schedules the index update - the same reason // 11-electron-notification.spec.ts waits here. await expectFolderUnread(appWindow, { role: 'inbox' }, 0); const reindexCalls: string[] = []; appWindow.on('request', (request) => { if (request.method() === 'POST' && request.url().includes('/api/offline/reindex')) { reindexCalls.push(request.postData() ?? ''); } }); // Let the launch-time catch-up land first so it is not mistaken for the // delivery-driven call below. await appWindow.waitForTimeout(8000); const baseline = reindexCalls.length; await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: `IT index trigger ${Date.now()}`, body: 'a delivery should make the renderer ask the index to update', }); await expect .poll(() => reindexCalls.length, { timeout: 60000, message: 'a real delivery did not make the renderer POST /api/offline/reindex - ' + 'the push -> handleStateChange -> indexOnStateChange wiring is broken', }) .toBeGreaterThan(baseline); // The delivery-driven call must name the mail type, rather than being an // unconditional full catch-up. const triggered = reindexCalls.slice(baseline); expect( triggered.some((body) => body.includes('"mail"')), `no reindex call mentioned the mail type: ${JSON.stringify(triggered)}`, ).toBe(true); } finally { await electronApp?.close(); devServer.kill(); fs.rmSync(userDataDir, { recursive: true, force: true }); } }); test('wiring: the real standalone boot reaches the index with a real safeStorage key', async () => { // A FRESH profile is load-bearing, not hygiene: the 401 this test asserts is // "nobody is signed in", and a leftover jmap_stalwart_ctx cookie from any // previous run turns it into a 200. That actually happened while writing this. const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vncmail-wiring-profile-')); const electronApp = await electron.launch({ args: [projectRoot, `--user-data-dir=${userDataDir}`], env: { ...process.env, JMAP_SERVER_URL: JMAP_URL, SESSION_SECRET: 'integration-not-a-real-secret-32-chars!!', }, }); try { const appWindow: Page = await electronApp.firstWindow(); await appWindow.waitForLoadState('domcontentloaded'); await appWindow.locator('#username').waitFor({ state: 'visible', timeout: 60000 }); // safeStorage must be usable, or main.ts deliberately refuses to enable // the feature at all (electron/key-service.ts's checkEncryptionAvailable). const encryptionAvailable = await electronApp.evaluate(({ safeStorage }) => safeStorage.isEncryptionAvailable(), ); expect( encryptionAvailable, 'safeStorage reports no encryption available on this host, so main.ts ' + 'correctly disabled the index - this assertion cannot pass here', ).toBe(true); const probe = await appWindow.evaluate(async () => { const response = await fetch('/api/offline/search?q=anything'); return { status: response.status, body: (await response.text()).slice(0, 300) }; }); // 401 = the gate opened, the native binding loaded and the fd-3 key // channel is present; it refuses only because nobody is signed in (this // build cannot log in against a plain-HTTP Stalwart - constraint 1). // 404 => VNCMAIL_DESKTOP_STORE_DIR was never set (gate closed, or // main.ts refused because no OS keyring is available) // 503 => the native binding or the key channel is missing from the real // artifact - the class of failure only a real build reveals expect( probe.status, `expected 401 (reachable, unauthenticated) but got ${probe.status}: ${probe.body}`, ).toBe(401); } finally { await electronApp.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); } }); });