fix(mail-index): real end-to-end verification, and the three bugs it found
Adds integration/tests/12-electron-mail-index.spec.ts (3 tests, all passing
against the real Stalwart fixture) and fixes what running it exposed. None of
these were visible from reading the code.
1. JMAP session fetch never followed a redirect. Stalwart 307-redirects
/.well-known/jmap to /jmap/session, and fetchJmapSession used
`redirect: 'manual'` and treated any non-2xx as failure - so every reindex
died with "JMAP session fetch failed (307)". Now follows up to 3 hops and
REFUSES to follow off-origin, because the user's credentials ride on every
hop; a blind `redirect: 'follow'` would hand the Authorization header to
whatever host a misconfigured session pointed at. Same bound and same
reasoning as lib/auth/verify-jmap-auth.ts.
2. The fd-3 key channel could only be adopted once per process, but its state
was module-scoped. Next re-evaluates route modules, so a second instance hit
`Could not open fd 3: Error: open EEXIST` from libuv. State moved to a
Symbol on globalThis - the one place in a Node process that survives module
re-evaluation.
3. Next's output file tracing does NOT carry @signalapp/sqlcipher's prebuilds/
into .next/standalone. It traced the package's JS and its node-gyp-build
dependency, but node-gyp-build resolves the .node binary by scanning a
directory at runtime, which no static tracer can follow - so `require()`
would have failed in every packaged build. scripts/assemble-standalone.mjs
now copies it, alongside the public/ and .next/static copies it already does
for the same "standalone output omits things" reason. All six platform/arch
prebuilds are copied, not just this host's, because electron-builder
cross-builds the x64 and arm64 macOS targets from one runner.
The three tests, and why it takes three - two constraints made a single
configuration impossible, and both were measured rather than assumed:
* The renderer cannot reach this fixture from a production build. Its CSP
pins connect-src to `'self' https: wss:` and the fixture's Stalwart is
plain HTTP. 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 (observed: a standalone server started with
NODE_ENV=development still served the production CSP).
* The fd-3 channel cannot survive `next dev`, which forks its server with an
IPC channel that claims fd 3 (EEXIST); fd 4 there is not a pipe either
(ENOTTY).
So: PIPELINE drives the real standalone server over HTTP from Node with a
real fd-3 key channel (no browser, so no CSP) and asserts a real SMTP
delivery is findable by a word from its BODY, with a real snippet and
contextBlock, idempotent catch-up, working type filters, and - reading the
raw bytes of the .db AND its -wal - that nothing is recoverable in cleartext.
TRIGGER proves the event-driven wiring: a real delivery makes the renderer
POST /api/offline/reindex off its live push. WIRING launches the real shell
with no ELECTRON_LOAD_URL and asserts the routes are reachable (401, not 404
or 503) with real safeStorage behind them.
Each test now gets its own --user-data-dir. That is load-bearing, not hygiene:
Electron reuses one profile across launches, and a leftover jmap_stalwart_ctx
cookie from an earlier run made the WIRING test's 401 assertion pass as a 200.
Verified: typecheck clean; unit suite 2379 tests with the SAME 3 pre-existing
failures as the base commit b15098a6 (2 builtin-themes, 1 jmap-client-
resilience) and 48 net new passing; both `docker build`s succeed; the
hosted-deployment gate returns 404 with an empty body and materialises no file
in the production image; e2e/electron-smoke 4/4; 11-electron-notification
still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7e9aefcfa1
commit
0271df4338
@@ -0,0 +1,508 @@
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<string, string>();
|
||||
|
||||
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<Response> => {
|
||||
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<SearchResponse> => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+32
-1
@@ -75,14 +75,45 @@ async function fetchWithTimeout(url: string, init: RequestInit): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
/** Stalwart 307-redirects /.well-known/jmap to /jmap/session. */
|
||||
const MAX_REDIRECTS = 3;
|
||||
|
||||
export async function fetchJmapSession(serverUrl: string, authHeader: string): Promise<JmapSessionInfo> {
|
||||
const response = await fetchWithTimeout(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, {
|
||||
const base = serverUrl.replace(/\/+$/, '');
|
||||
const origin = new URL(base).origin;
|
||||
let currentUrl = `${base}/.well-known/jmap`;
|
||||
let response: Response | undefined;
|
||||
|
||||
// Redirects must be followed EXPLICITLY, not with `redirect: 'follow'`: we
|
||||
// attach the user's credentials to every hop, so each one has to be checked to
|
||||
// still be on the origin we authenticated against. A blind follow would hand
|
||||
// the Authorization header to whatever host a misconfigured or hostile session
|
||||
// pointed at. Same reasoning (and same bound) as lib/auth/verify-jmap-auth.ts.
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
response = await fetchWithTimeout(currentUrl, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: authHeader },
|
||||
});
|
||||
if (response.status < 300 || response.status >= 400) break;
|
||||
|
||||
const location = response.headers.get('location');
|
||||
if (!location) throw new JmapIndexError('JMAP session redirect had no Location header');
|
||||
const next = new URL(location, currentUrl);
|
||||
if (next.origin !== origin) {
|
||||
throw new JmapIndexError(
|
||||
`JMAP session redirected off-origin (${next.origin}); refusing to send credentials there`,
|
||||
);
|
||||
}
|
||||
currentUrl = next.toString();
|
||||
}
|
||||
|
||||
if (!response) throw new JmapIndexError('JMAP session fetch produced no response');
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new JmapIndexError('JMAP authentication failed', 401);
|
||||
}
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new JmapIndexError('Too many redirects fetching the JMAP session');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new JmapIndexError(`JMAP session fetch failed (${response.status})`);
|
||||
}
|
||||
|
||||
+52
-23
@@ -36,21 +36,49 @@ interface Pending {
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
let socket: net.Socket | null = null;
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
let readBuffer = '';
|
||||
/**
|
||||
* Channel state lives on `globalThis`, NOT in module scope.
|
||||
*
|
||||
* A file descriptor can be adopted as a socket exactly ONCE per process: a
|
||||
* second `new net.Socket({ fd })` for an fd this process already owns throws
|
||||
* `EEXIST` from libuv's uv_pipe_open. Module scope is not once-per-process -
|
||||
* Next re-evaluates route modules (dev HMR, and separate module instances
|
||||
* across route bundles), so a module-scoped `let socket` produced exactly that
|
||||
* crash: `Could not open fd 3: Error: open EEXIST`, found by the integration
|
||||
* test rather than by reading the code.
|
||||
*
|
||||
* A Symbol key on globalThis is the one place in a Node process that survives
|
||||
* module re-evaluation, so adoption genuinely happens once.
|
||||
*/
|
||||
interface ChannelState {
|
||||
socket: net.Socket | null;
|
||||
nextId: number;
|
||||
pending: Map<number, Pending>;
|
||||
readBuffer: string;
|
||||
}
|
||||
|
||||
function failAll(error: Error): void {
|
||||
for (const [, p] of pending) {
|
||||
const STATE_KEY = Symbol.for('vncmail.mailIndex.keyChannel');
|
||||
|
||||
function state(): ChannelState {
|
||||
const holder = globalThis as unknown as Record<symbol, ChannelState | undefined>;
|
||||
const existing = holder[STATE_KEY];
|
||||
if (existing) return existing;
|
||||
const created: ChannelState = { socket: null, nextId: 1, pending: new Map(), readBuffer: '' };
|
||||
holder[STATE_KEY] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function failAll(s: ChannelState, error: Error): void {
|
||||
for (const [, p] of s.pending) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
s.pending.clear();
|
||||
}
|
||||
|
||||
function getSocket(): net.Socket {
|
||||
if (socket && !socket.destroyed) return socket;
|
||||
const s = state();
|
||||
if (s.socket && !s.socket.destroyed) return s.socket;
|
||||
|
||||
const raw = process.env[KEY_FD_ENV]?.trim();
|
||||
const fd = raw ? Number(raw) : NaN;
|
||||
@@ -73,12 +101,12 @@ function getSocket(): net.Socket {
|
||||
created.unref();
|
||||
|
||||
created.on('data', (chunk: Buffer) => {
|
||||
readBuffer += chunk.toString('utf8');
|
||||
if (readBuffer.length > 64 * 1024) readBuffer = '';
|
||||
s.readBuffer += chunk.toString('utf8');
|
||||
if (s.readBuffer.length > 64 * 1024) s.readBuffer = '';
|
||||
let newline: number;
|
||||
while ((newline = readBuffer.indexOf('\n')) >= 0) {
|
||||
const line = readBuffer.slice(0, newline);
|
||||
readBuffer = readBuffer.slice(newline + 1);
|
||||
while ((newline = s.readBuffer.indexOf('\n')) >= 0) {
|
||||
const line = s.readBuffer.slice(0, newline);
|
||||
s.readBuffer = s.readBuffer.slice(newline + 1);
|
||||
if (!line.trim()) continue;
|
||||
let msg: { id?: unknown; ok?: unknown; key?: unknown; code?: unknown; error?: unknown };
|
||||
try {
|
||||
@@ -88,9 +116,9 @@ function getSocket(): net.Socket {
|
||||
}
|
||||
const id = typeof msg.id === 'number' ? msg.id : null;
|
||||
if (id === null) continue;
|
||||
const p = pending.get(id);
|
||||
const p = s.pending.get(id);
|
||||
if (!p) continue;
|
||||
pending.delete(id);
|
||||
s.pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
if (msg.ok === true) {
|
||||
p.resolve({ key: typeof msg.key === 'string' ? msg.key : undefined });
|
||||
@@ -102,32 +130,33 @@ function getSocket(): net.Socket {
|
||||
});
|
||||
|
||||
const onGone = (error?: Error) => {
|
||||
socket = null;
|
||||
readBuffer = '';
|
||||
failAll(error ?? new IndexKeyError('no-channel', 'Key service channel closed'));
|
||||
s.socket = null;
|
||||
s.readBuffer = '';
|
||||
failAll(s, error ?? new IndexKeyError('no-channel', 'Key service channel closed'));
|
||||
};
|
||||
created.on('close', () => onGone());
|
||||
created.on('error', (error) => onGone(new IndexKeyError('no-channel', String(error))));
|
||||
|
||||
socket = created;
|
||||
s.socket = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function request(op: 'getIndexKey' | 'deleteIndexKey', accountId: string): Promise<{ key?: string }> {
|
||||
const sock = getSocket();
|
||||
const id = nextId++;
|
||||
const s = state();
|
||||
const id = s.nextId++;
|
||||
return new Promise<{ key?: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
s.pending.delete(id);
|
||||
reject(new IndexKeyError('timeout', `Key service did not answer within ${REQUEST_TIMEOUT_MS}ms`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
// Don't let a pending key request keep the process alive either.
|
||||
timer.unref?.();
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
s.pending.set(id, { resolve, reject, timer });
|
||||
try {
|
||||
sock.write(`${JSON.stringify({ id, op, accountId })}\n`);
|
||||
} catch (error) {
|
||||
pending.delete(id);
|
||||
s.pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(new IndexKeyError('no-channel', `Could not write to the key service: ${String(error)}`));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ import { defineConfig } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './integration/tests',
|
||||
testMatch: '11-electron-notification.spec.ts',
|
||||
// 11 asserts the native notification bridge fires from a real push; 12
|
||||
// asserts a real delivery reaches the encrypted local search index. 12 runs
|
||||
// the REAL standalone-server boot (no ELECTRON_LOAD_URL), because that boot
|
||||
// is what wires the index's store directory and its fd-3 key channel.
|
||||
testMatch: /1[12]-electron-.*\.spec\.ts/,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 20_000 },
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -24,13 +24,13 @@ const VIDEO: VideoMode = VIDEO_MODES.includes(process.env.IT_VIDEO as VideoMode)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './integration/tests',
|
||||
// Electron's own spec runs under playwright.integration-electron.config.ts
|
||||
// The Electron specs run under playwright.integration-electron.config.ts
|
||||
// instead (see that file's header comment for why): the dockerized run
|
||||
// this config drives (integration/run-tests.sh, inside the official
|
||||
// Playwright image) has no Electron binary compatible with that
|
||||
// container's platform, so it must never be swept in by this config's
|
||||
// container's platform, so they must never be swept in by this config's
|
||||
// default testDir glob.
|
||||
testIgnore: '11-electron-notification.spec.ts',
|
||||
testIgnore: ['11-electron-notification.spec.ts', '12-electron-mail-index.spec.ts'],
|
||||
// next dev compiles routes lazily and each test logs in fresh, so give
|
||||
// individual tests and their polling assertions generous headroom.
|
||||
timeout: 90_000,
|
||||
|
||||
@@ -26,4 +26,36 @@ const staticDest = path.join(standaloneDir, ".next", "static");
|
||||
rmSync(staticDest, { recursive: true, force: true });
|
||||
cpSync(staticSrc, staticDest, { recursive: true });
|
||||
|
||||
// The native SQLCipher prebuilds for the local search index (lib/mail-index/**).
|
||||
//
|
||||
// Next's output file tracing DOES pick up @signalapp/sqlcipher's JS
|
||||
// (package.json + dist/index.cjs) and its node-gyp-build dependency, but NOT
|
||||
// the prebuilds/ directory holding the actual .node binaries - node-gyp-build
|
||||
// resolves those by scanning the directory at runtime, which no static tracer
|
||||
// can follow. Verified by inspecting a real `build:standalone` output: the
|
||||
// package was present, `prebuilds/` was absent, so `require()` would have
|
||||
// failed at runtime in every packaged build.
|
||||
//
|
||||
// Copying the WHOLE prebuilds directory (all six platform/arch pairs, ~11 MB)
|
||||
// rather than just this host's is deliberate: electron-builder cross-builds the
|
||||
// x64 and arm64 macOS targets from one runner (electron-builder.config.js), so
|
||||
// the artifact has to contain a prebuild for an arch this machine isn't.
|
||||
//
|
||||
// Skipped silently when absent - the package is an OPTIONAL dependency and is
|
||||
// legitimately missing on musl/Alpine, where both Dockerfiles build.
|
||||
const sqlcipherSrc = path.join(rootDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds");
|
||||
if (existsSync(sqlcipherSrc)) {
|
||||
const sqlcipherDest = path.join(
|
||||
standaloneDir, "node_modules", "@signalapp", "sqlcipher", "prebuilds",
|
||||
);
|
||||
rmSync(sqlcipherDest, { recursive: true, force: true });
|
||||
cpSync(sqlcipherSrc, sqlcipherDest, { recursive: true });
|
||||
console.log("Copied @signalapp/sqlcipher prebuilds into the standalone output");
|
||||
} else {
|
||||
console.log(
|
||||
"@signalapp/sqlcipher not installed (optional dependency) - " +
|
||||
"the encrypted local index will be disabled at runtime",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Assembled standalone server at", standaloneDir);
|
||||
|
||||
Reference in New Issue
Block a user