diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index ff4dcd3cf..118302e51 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -160,6 +160,46 @@ async function offlineDatabaseExists(page: Page): Promise { }); } +/** + * 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 => { + const parts: string[] = []; + for await (const handle of (dir as unknown as { values(): AsyncIterable }).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 { + return page.evaluate(() => new Promise((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((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 }) => {