test: read the store's raw files without an owner, lose a key for real, and lose only the submission of a send

This commit is contained in:
Linus Rath
2026-11-03 09:25:08 +01:00
parent ebec09212e
commit 9dd6e61d35
+96 -4
View File
@@ -160,6 +160,46 @@ async function offlineDatabaseExists(page: Page): Promise<boolean> {
});
}
/**
* The store's files as bytes, with no worker holding them: which of `needles`
* occur in them. Goes to a same-origin file without scripts first, so that
* leaving the app ends the owner.
*/
async function rawStoreFiles(page: Page, needles: string[]): Promise<{ bytes: number; found: string[] }> {
await page.goto(new URL('/sw.js', page.url()).href);
return page.evaluate(async (wanted) => {
const read = async (dir: FileSystemDirectoryHandle): Promise<string[]> => {
const parts: string[] = [];
for await (const handle of (dir as unknown as { values(): AsyncIterable<FileSystemHandle> }).values()) {
if (handle.kind === 'directory') parts.push(...await read(handle as FileSystemDirectoryHandle));
else parts.push(new TextDecoder('latin1').decode(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer()));
}
return parts;
};
const root = await navigator.storage.getDirectory();
const files = await read(await root.getDirectoryHandle('.bulwark-store'));
return {
bytes: files.reduce((sum, file) => sum + file.length, 0),
found: wanted.filter((needle) => files.some((file) => file.includes(needle))),
};
}, needles);
}
/** The file of the account's store, from the key database. */
async function storeFileName(page: Page): Promise<string> {
return page.evaluate(() => new Promise<string>((resolve, reject) => {
const open = indexedDB.open('bulwark-store');
open.onerror = () => reject(open.error);
open.onsuccess = () => {
const all = open.result.transaction('accounts').objectStore('accounts').getAll();
all.onsuccess = () => {
open.result.close();
resolve((all.result as Array<{ file: string }>)[0].file);
};
};
}));
}
/** The accounts the local store holds a key for. Their mail is in encrypted files nothing here can read. */
async function offlineDatabase(page: Page): Promise<{ exists: boolean; accounts: number }> {
return page.evaluate(async () => {
@@ -238,6 +278,45 @@ test('with the browser offline, kept mail opens, also after a reload without a n
await page.context().close();
});
test('the files on the device hold no readable mail, and a store whose key went starts over', async ({ browser }) => {
const subject = `Offline encrypted ${RUN}`;
const secret = `Secret body ${RUN}`;
await deliverAndWait(subject, secret);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await toMail(page);
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
await openMessage(page, subject);
await expect(body(page)).toContainText(secret, { timeout: 20_000 });
// Long enough for the store to have the message and its text on disk.
await page.waitForTimeout(5_000);
const file = await storeFileName(page);
const raw = await rawStoreFiles(page, [subject, secret, 'SQLite format 3', file]);
expect(raw.bytes).toBeGreaterThan(100_000);
// The name of the file is the one thing here in the clear: the search works.
expect(raw.found).toEqual([file]);
// The browser clears the key database and leaves the files.
await page.evaluate(() => new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase('bulwark-store');
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
}));
await page.goto(new URL('/', page.url()).href);
await expect(page.getByText("couldn't be read", { exact: false })).toBeVisible({ timeout: 60_000 });
await toMail(page);
await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 });
await expect.poll(async () => (await offlineDatabase(page)).accounts, { timeout: 30_000 }).toBeGreaterThan(0);
const newFile = await storeFileName(page);
expect(newFile).not.toBe(file);
await expect(page.getByText("couldn't be read", { exact: false })).toBeVisible();
const after = await rawStoreFiles(page, [file, newFile]);
expect(after.found).toEqual([newFile]);
await page.context().close();
});
test('a mail written offline waits in the outbox, the required label still stops it, and it goes out labelled when back online', async ({ browser }) => {
const subject = `Outbox label ${RUN}`;
await setPolicy(browser, { organisation: { labels: { list: LABELS, defaultId: '', required: true, extraHeader: '' } } });
@@ -396,8 +475,8 @@ test('emptying the Trash offline shows at once and reaches the server once back
await page.context().close();
});
test('a send whose response got lost goes out once', async ({ browser }) => {
const subject = `Offline lost response ${RUN}`;
/** A send taken from the outbox gets no answer. `arrives` is what reached the server of it. */
async function loseResponse(browser: Browser, subject: string, arrives: (body: { methodCalls: Array<[string, unknown, string]> }) => void) {
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await toMail(page);
@@ -413,9 +492,12 @@ test('a send whose response got lost goes out once', async ({ browser }) => {
let lost = 0;
await context.unroute(origin);
await context.route(origin, async (route) => {
if (lost === 0 && (route.request().postData() ?? '').includes('EmailSubmission/set')) {
const sent = route.request().postData() ?? '';
if (lost === 0 && sent.includes('EmailSubmission/set')) {
lost += 1;
await route.fetch();
const body = JSON.parse(sent);
arrives(body);
await route.fetch({ postData: JSON.stringify(body) });
await route.abort('connectionreset');
return;
}
@@ -430,6 +512,16 @@ test('a send whose response got lost goes out once', async ({ browser }) => {
expect(copies).toHaveLength(1);
expect(copies[0].keywords.$draft).toBeFalsy();
await context.close();
}
test('a send whose response got lost goes out once', async ({ browser }) => {
await loseResponse(browser, `Offline lost response ${RUN}`, () => {});
});
test('a send of which only the draft arrived is submitted, and goes out once', async ({ browser }) => {
await loseResponse(browser, `Offline lost submission ${RUN}`, (body) => {
body.methodCalls = body.methodCalls.filter(([name]) => name === 'Email/set');
});
});
test('signing out asks first when unsent mail would be lost, then wipes the device', async ({ browser }) => {