From fcd9930e4a575caa5719fc1c59bd216924025599 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:08:34 +0100 Subject: [PATCH] test: cover recovering deleted items against the test servers #1319 --- integration/tests/19-org-recovery.spec.ts | 362 ++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 integration/tests/19-org-recovery.spec.ts diff --git a/integration/tests/19-org-recovery.spec.ts b/integration/tests/19-org-recovery.spec.ts new file mode 100644 index 000000000..34d8dc1af --- /dev/null +++ b/integration/tests/19-org-recovery.spec.ts @@ -0,0 +1,362 @@ +import { test, expect, type Page } from '@playwright/test'; +import { + ORG, + ORG_PASSWORD, + adminContext, + getPolicy, + putPolicy, + requireOrgEnv, + shot, + userPage, +} from './helpers/org'; +import { JmapClient } from './helpers/jmap'; +import { folderRow } from './helpers/app'; + +/** + * Recover deleted items (organisation feature 9, #1319) through the real UI + * on a VM 106 test server. + * + * No test server has a Stalwart Enterprise license yet, so x:ArchivedItem + * answers `forbidden` everywhere and the feature has to hide itself: Bulwark + * asks once, then shows no Settings entry and no Trash link. An admin scope + * that hides `deletedItemsRecovery` keeps Bulwark from asking at all. + * + * The licensed case (IT_RECOVERY_LICENSED=1, on a server with a license and + * `archiveDeletedItemsFor` set): org-alice deletes a message for good, finds + * it under Settings > Recover deleted items, restores it, and it is back in + * her Inbox over JMAP. The admin's policy is put back at the end. + */ + +// Sign-ins and first compiles of a dev server take a while. +test.describe.configure({ mode: 'serial', timeout: 180000 }); + +const LICENSED = process.env.IT_RECOVERY_LICENSED === '1'; +const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']; + +let original: Record; +let alice: JmapClient; + +const isArchiveQuery = (body: string | null) => !!body && body.includes('x:ArchivedItem/query'); + +/** Collects the bodies of the JMAP requests the page sends that ask the archive. */ +function watchArchiveQueries(page: Page): string[] { + const seen: string[] = []; + page.on('request', (request) => { + const body = request.postData(); + if (request.method() === 'POST' && isArchiveQuery(body)) seen.push(body!); + }); + return seen; +} + +async function openTrash(page: Page): Promise { + const trash = folderRow(page, { role: 'trash', shared: false }).first(); + await expect(trash).toBeVisible({ timeout: 30000 }); + await trash.click(); +} + +async function setPolicy(browser: import('@playwright/test').Browser, patch: Record): Promise { + const admin = await adminContext(browser); + await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {}, ...patch }); + await admin.close(); +} + +test.beforeAll(async ({ browser }) => { + requireOrgEnv(); + const admin = await adminContext(browser); + original = await getPolicy(admin); + await admin.close(); + await setPolicy(browser, {}); + alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD); +}); + +test.afterAll(async ({ browser }) => { + if (!original) return; + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.close(); +}); + +test.describe('without an Enterprise license', () => { + test.skip(LICENSED, 'the server has a license (IT_RECOVERY_LICENSED=1)'); + + test('the server refuses x:ArchivedItem/query', async () => { + const r = await alice.request([['x:ArchivedItem/query', { accountId: alice.accountId }, 'q']], STALWART_USING); + expect(r.methodResponses[0][0]).toBe('error'); + expect(r.methodResponses[0][1]).toMatchObject({ type: 'forbidden' }); + }); + + test('Bulwark asks once, then shows no Trash link and no Settings entry', async ({ browser }) => { + const page = await userPage(browser, ORG.alice); + const asked = watchArchiveQueries(page); + const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 }); + await openTrash(page); + await answer; + await expect(page.getByTestId('trash-recovery-link')).toHaveCount(0); + await shot(page, 'recovery-trash-hidden'); + + await page.goto('/settings/recovery'); + await expect(page.getByRole('heading', { name: 'Appearance' }).first()).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Recover deleted items' })).toHaveCount(0); + await expect(page.getByTestId('recover-deleted-list')).toHaveCount(0); + // One question per account and session, not one per page. + expect(asked.length).toBe(1); + await page.context().close(); + }); + + test('the same at phone width', async ({ browser }) => { + const page = await userPage(browser, ORG.carol, true); + const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 }); + await page.goto('/settings/recovery'); + await answer; + await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0); + await shot(page, 'recovery-settings-hidden-phone'); + await page.context().close(); + }); +}); + +test('the admin hides it for a group and limits the days for a domain on Organisation > Sharing', async ({ browser }) => { + const admin = await adminContext(browser); + await putPolicy(admin, { + ...original, + restrictions: {}, features: {}, + scopes: [ + { id: 'recovery-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} }, + { id: 'recovery-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} }, + ], + }); + const page = await admin.newPage(); + await page.goto('/admin/org-sharing'); + const section = page.locator('[data-settings-section="recovery"]'); + await expect(section.getByRole('heading', { name: 'Recover deleted items' })).toBeVisible({ timeout: 30000 }); + await expect(section.getByTestId('org-recovery-requirements')).toContainText('Stalwart Enterprise license'); + const scopeBar = page.getByTestId('org-scope-bar'); + await scopeBar.getByRole('button', { name: 'Example', exact: true }).click(); + await section.getByLabel('Show deleted mail of the last (days)').fill('14'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await scopeBar.getByRole('button', { name: 'Org team', exact: true }).click(); + await section.getByLabel('Hide', { exact: true }).check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(page, 'recovery-admin'); + + const saved = await getPolicy(admin); + const scopes = saved.scopes as Array<{ id: string; policy: { restrictions?: Record; organisation?: Record } }>; + expect(scopes.find((s) => s.id === 'recovery-team')!.policy.restrictions).toEqual({ deletedItemsRecovery: { hidden: true } }); + expect(scopes.find((s) => s.id === 'recovery-example')!.policy.organisation).toEqual({ recovery: { days: 14 } }); + await page.close(); + + const phone = await adminContext(browser, true); + const phonePage = await phone.newPage(); + await phonePage.goto('/admin/org-sharing'); + const phoneSection = phonePage.locator('[data-settings-section="recovery"]'); + await phoneSection.scrollIntoViewIfNeeded(); + await expect(phoneSection.getByLabel('Show deleted mail of the last (days)')).toBeVisible({ timeout: 30000 }); + const width = await phonePage.evaluate(() => document.documentElement.scrollWidth); + expect(width).toBeLessThanOrEqual(390); + await shot(phonePage, 'recovery-admin-phone'); + await phone.close(); + await admin.close(); +}); + +test('a member of the hidden group gets nothing and Bulwark never asks the server', async ({ browser }) => { + const page = await userPage(browser, ORG.alice); + const asked = watchArchiveQueries(page); + await openTrash(page); + await page.goto('/settings/recovery'); + await expect(page.getByRole('heading', { name: 'Appearance' }).first()).toBeVisible({ timeout: 30000 }); + await expect(page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' }))).toHaveCount(0); + expect(asked).toEqual([]); + await page.context().close(); +}); + +test('someone outside the group is still asked about', async ({ browser }) => { + // carol is in example.org but not in the group: Bulwark asks the server for her. + const page = await userPage(browser, ORG.carol); + const answer = page.waitForResponse((res) => isArchiveQuery(res.request().postData()), { timeout: 30000 }); + await openTrash(page); + await answer; + if (!LICENSED) await expect(page.getByTestId('trash-recovery-link')).toHaveCount(0); + else await expect(page.getByTestId('trash-recovery-link')).toBeVisible(); + await page.context().close(); +}); + +/** + * The page with archive answers put in by the browser test, not a server: + * DERIVED from the Stalwart source (x:ArchivedItem in + * crates/jmap/src/registry/mapping/archived_item.rs, ArchivedEmail in + * crates/registry/src/schema/structs_impl.rs), because no server can give + * them yet. Every other request goes to the real server. Restoring `b` is + * refused, to show the partial failure. + */ +type ArchiveMode = 'items' | 'empty' | 'getFails'; + +async function answerArchiveCalls(page: Page, mode: () => ArchiveMode): Promise { + const restored: string[] = []; + const day = 24 * 60 * 60_000; + const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'); + const now = Date.now(); + const items: Record> = { + a: { from: 'Org Bob ', subject: 'Signed contract', archivedAt: iso(now - 2 * 60 * 60_000) }, + b: { from: 'billing@supplier.example', subject: 'Invoice 2026-118', archivedAt: iso(now - 3 * day) }, + c: { from: 'Org Carol', subject: 'Team offsite plan', archivedAt: iso(now - 9 * day) }, + }; + await page.route('**/*', async (route) => { + const body = route.request().postData(); + if (route.request().method() !== 'POST' || !body || !body.includes('"x:ArchivedItem/')) return route.fallback(); + const request = JSON.parse(body) as { methodCalls: Array<[string, Record, string]> }; + const methodResponses = request.methodCalls.map(([method, args, id]) => { + const accountId = args.accountId; + if (method === 'x:ArchivedItem/query') { + const ids = mode() === 'empty' ? [] : Object.keys(items); + return [method, { accountId, ids, total: ids.length, position: 0, queryState: 's0', canCalculateChanges: false }, id]; + } + if (method === 'x:ArchivedItem/get') { + if (mode() === 'getFails') return ['error', { type: 'serverFail', description: 'Synthetic failure' }, id]; + const list = (args.ids as string[]).filter((i) => items[i]).map((i) => ({ + id: i, '@type': 'Email', receivedAt: iso(now - 30 * day), size: 4096, accountId, + archivedUntil: iso(now + 20 * day), blobId: `blob-${i}`, ...items[i], + })); + return [method, { accountId, list, notFound: [] }, id]; + } + if (method === 'x:ArchivedItem/set') { + const ids = Object.keys(args.update as object); + const ok = ids.filter((i) => i !== 'b'); + restored.push(...ok); + for (const i of ok) delete items[i]; + return [method, { + accountId, updated: Object.fromEntries(ok.map((i) => [i, null])), + notUpdated: ids.includes('b') ? { b: { type: 'notFound', description: 'Synthetic refusal' } } : {}, + }, id]; + } + return ['error', { type: 'unknownMethod' }, id]; + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ methodResponses, sessionState: 's' }) }); + }); + return restored; +} + +test.describe('the page with derived archive answers (no server has a license yet)', () => { + test.beforeAll(async ({ browser }) => { + await setPolicy(browser, {}); + }); + + test('Trash links to the list; restoring shows what failed and keeps it selected', async ({ browser }) => { + const page = await userPage(browser, ORG.alice); + let mode: ArchiveMode = 'items'; + const restored = await answerArchiveCalls(page, () => mode); + await openTrash(page); + const link = page.getByTestId('trash-recovery-link'); + await expect(link).toBeVisible({ timeout: 30000 }); + await shot(page, 'recovery-trash-link'); + await link.getByRole('button', { name: 'Recover deleted items' }).click(); + await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 }); + const rows = page.getByTestId('recover-deleted-row'); + await expect(rows).toHaveCount(3); + await expect(rows.nth(0)).toContainText('Signed contract'); + await expect(rows.nth(0)).toContainText('Org Bob'); + await expect(rows.nth(2)).toContainText('Team offsite plan'); + await shot(page, 'recovery-list'); + + await page.getByTestId('recover-deleted-select-all').check(); + await page.getByTestId('recover-deleted-restore').click(); + await expect(page.getByTestId('recover-deleted-partial')).toContainText("1 message couldn't be restored"); + await expect(rows).toHaveCount(1); + await expect(rows.nth(0)).toContainText('Invoice 2026-118'); + await expect(rows.nth(0).getByRole('checkbox')).toBeChecked(); + expect(restored.sort()).toEqual(['a', 'c']); + await shot(page, 'recovery-partial'); + + mode = 'getFails'; + await page.getByRole('button', { name: 'Refresh' }).click(); + await expect(page.getByTestId('recover-deleted-error')).toBeVisible(); + await shot(page, 'recovery-error'); + mode = 'empty'; + await page.getByTestId('recover-deleted-error').getByRole('button', { name: 'Try again' }).click(); + await expect(page.getByTestId('recover-deleted-empty')).toBeVisible(); + await shot(page, 'recovery-empty'); + await page.context().close(); + }); + + test('the list fits a phone and the days of a domain scope apply', async ({ browser }) => { + await setPolicy(browser, { scopes: [{ id: 'recovery-example', label: 'Example', match: { domains: ['example.org'] }, policy: { organisation: { recovery: { days: 7 } } } }] }); + const page = await userPage(browser, ORG.alice, true); + await answerArchiveCalls(page, () => 'items'); + await page.goto('/settings/recovery'); + await page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' })).first().click({ timeout: 30000 }); + await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 }); + // c was deleted nine days ago: outside the domain's seven days. + await expect(page.getByTestId('recover-deleted-row')).toHaveCount(2); + await expect(page.getByTestId('recover-deleted-range')).toHaveText('Showing mail deleted in the last 7 days.'); + const width = await page.evaluate(() => document.documentElement.scrollWidth); + expect(width).toBeLessThanOrEqual(390); + await shot(page, 'recovery-list-phone'); + await page.context().close(); + }); +}); + +test.describe('with an Enterprise license', () => { + test.skip(!LICENSED, 'set IT_RECOVERY_LICENSED=1 on a server with a license and archiveDeletedItemsFor'); + + test('alice deletes a message for good, finds it under Recover deleted items and restores it to her Inbox', async ({ browser }) => { + await setPolicy(browser, {}); + const subject = `Recovery ${Date.now()}`; + const inbox = (await alice.mailboxByRole('inbox'))!; + const created = await alice.request([ + ['Email/set', { + accountId: alice.accountId, + create: { + m: { + mailboxIds: { [inbox.id]: true }, + keywords: { $seen: true }, + from: [{ name: 'Org Bob', email: ORG.bob }], + to: [{ email: ORG.alice }], + subject, + bodyValues: { b: { value: 'Please bring this back.' } }, + textBody: [{ partId: 'b', type: 'text/plain' }], + }, + }, + }, '0'], + ]); + const emailId = created.methodResponses[0][1].created.m.id as string; + // Deleted for good, as emptying Trash does. + await alice.request([['Email/set', { accountId: alice.accountId, destroy: [emailId] }, '0']]); + expect(await alice.findEmailBySubject(subject)).toBeUndefined(); + + const page = await userPage(browser, ORG.alice); + await openTrash(page); + await page.getByTestId('trash-recovery-link').getByRole('button', { name: 'Recover deleted items' }).click(); + await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 }); + // The server archives in the background: refresh until the message is there. + const row = page.getByTestId('recover-deleted-row').filter({ hasText: subject }); + await expect(async () => { + await page.getByRole('button', { name: 'Refresh' }).click(); + await expect(row).toBeVisible({ timeout: 3000 }); + }).toPass({ timeout: 90000 }); + await expect(row).toContainText('Org Bob'); + await shot(page, 'recovery-list-licensed'); + + await row.getByRole('checkbox').check(); + await page.getByTestId('recover-deleted-restore').click(); + await expect(page.getByText(/on the way back to your Inbox/)).toBeVisible(); + await expect(row).toHaveCount(0); + + const restored = await alice.waitForEmail(subject, { mailboxId: inbox.id, timeoutMs: 90000 }); + expect(restored.keywords?.$seen).toBeFalsy(); + await alice.request([['Email/set', { accountId: alice.accountId, destroy: [restored.id] }, '0']]); + await page.context().close(); + }); + + test('the list and restore work at phone width', async ({ browser }) => { + const page = await userPage(browser, ORG.alice, true); + await page.goto('/settings/recovery'); + await page.getByRole('button', { name: 'Recover deleted items' }).or(page.getByRole('link', { name: 'Recover deleted items' })).first().click(); + await expect(page.getByRole('heading', { name: 'Recover deleted items' }).first()).toBeVisible({ timeout: 30000 }); + const width = await page.evaluate(() => document.documentElement.scrollWidth); + expect(width).toBeLessThanOrEqual(390); + await shot(page, 'recovery-list-phone-licensed'); + await page.context().close(); + }); +});