From a19d95c4bb172fb7cd3d4f0950fb2c9a466631f0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:10:42 +0100 Subject: [PATCH] test: cover shared tags in group mailboxes against the test servers --- integration/tests/19-org-tags.spec.ts | 295 ++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 integration/tests/19-org-tags.spec.ts diff --git a/integration/tests/19-org-tags.spec.ts b/integration/tests/19-org-tags.spec.ts new file mode 100644 index 000000000..c8da119e0 --- /dev/null +++ b/integration/tests/19-org-tags.spec.ts @@ -0,0 +1,295 @@ +import { test, expect, type Page } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { expandSharedFolders, emailItem, folderRow } from './helpers/app'; +import { + ORG, + ORG_PASSWORD, + ORG_SERVER, + adminContext, + getPolicy, + putPolicy, + requireOrgEnv, + shot, + userPage, +} from './helpers/org'; + +/** + * Shared tags in group mailboxes (organisation feature 6), through the real + * UI. On the v1.0.0 release (label 1.0.0) the definitions live in the + * metadata of org-team's Inbox: org-alice defines "Urgent" in red and tags a + * message, org-bob sees the same name and colour, and a group scope that + * locks `sharedTags` makes the editor read-only. On servers without working + * metadata (0.16, the 1.0 test build) there is no shared editor, the tags on + * group messages still show, and the app stays online. + * + * Cleanup: the test message (and with it its tags) is destroyed, the + * `org.bulwarkmail.tags` metadata entry is set back to null, the policy is + * put back. Other `org.bulwarkmail.*` entries (templates) are never touched. + */ + +test.describe.configure({ mode: 'serial' }); + +const SUBJECT = `Shared tags ${Date.now()}`; +const HAS_METADATA = ORG_SERVER === '1.0.0'; + +// --- JMAP, as org-alice, straight to the server -------------------------- + +type Call = [string, Record, string]; +let api: { url: string; team: string; inbox: string } | null = null; +let messageId: string | null = null; + +const auth = () => `Basic ${Buffer.from(`${ORG.alice}:${ORG_PASSWORD}`).toString('base64')}`; + +/** The API url, org-team's account id and its Inbox id, looked up once. */ +async function ensureApi(): Promise> { + if (!api) { + const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json(); + const team = Object.entries(session.accounts as Record).find(([, a]) => a.name === ORG.team)![0]; + api = { url: `${JMAP_URL}${new URL(session.apiUrl).pathname}`, team, inbox: '' }; + const res = await jmapRaw([['Mailbox/query', { accountId: team, filter: { role: 'inbox' } }, 'q']]); + api.inbox = res[0][1].ids[0]; + } + return api; +} + +async function jmap(calls: Call[], using?: string[]) { + await ensureApi(); + return jmapRaw(calls, using); +} + +async function jmapRaw(calls: Call[], using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']) { + const res = await fetch(api!.url, { + method: 'POST', + headers: { Authorization: auth(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }); + expect(res.ok, `JMAP ${res.status}`).toBe(true); + return (await res.json()).methodResponses as Array<[string, any, string]>; +} + +const META_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:metadata']; + +async function sharedTagsOnServer(): Promise { + const res = await jmap([['Mailbox/get', { accountId: api!.team, ids: [api!.inbox], properties: ['metadata'] }, 'g']], META_USING); + return res[0][1].list[0].metadata?.['org.bulwarkmail.tags'] ?? null; +} + +async function clearSharedTags(): Promise { + await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING); +} + +async function messageKeywords(): Promise> { + const res = await jmap([['Email/get', { accountId: api!.team, ids: [messageId], properties: ['keywords'] }, 'g']]); + return res[0][1].list[0].keywords; +} + +// --- UI helpers ---------------------------------------------------------- + +async function openTeamInbox(page: Page): Promise { + await expandSharedFolders(page, ORG.team); + await folderRow(page, { role: 'inbox', shared: true }).first().click(); + await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 }); +} + +/** Settings > Folders & tags, scrolled to the shared tags of org-team. */ +async function openSharedTagSettings(page: Page, phone = false) { + if (phone) { + await page.goto('/settings'); + await page.getByRole('button', { name: 'Folders & tags' }).or(page.getByRole('link', { name: 'Folders & tags' })).first().click(); + } else { + await page.goto('/settings/organize/shared-tags'); + } + // The block is drawn again once the tags have loaded. + const account = page.locator('[data-testid="shared-tags-account"]:not([data-status="loading"])').filter({ hasText: ORG.team }); + await expect(account).toBeVisible({ timeout: 30000 }); + await account.scrollIntoViewIfNeeded(); + return account; +} + +let original: Record; + +test.beforeAll(async ({ browser }) => { + requireOrgEnv(); + const admin = await adminContext(browser); + original = await getPolicy(admin); + await putPolicy(admin, { ...original, restrictions: {}, scopes: [] }); + await admin.close(); + + // A message in org-team's Inbox; on servers without metadata it carries a + // tag set by another client, which must still show. + const keywords = HAS_METADATA ? {} : { '$label:from-another-client': true }; + const { team, inbox } = await ensureApi(); + const res = await jmap([['Email/set', { + accountId: team, + create: { + m: { + mailboxIds: { [inbox]: true }, + from: [{ name: 'Org Alice', email: ORG.alice }], + to: [{ email: ORG.team }], + subject: SUBJECT, + keywords, + bodyValues: { b: { value: 'A message for the shared tags spec.' } }, + textBody: [{ partId: 'b', type: 'text/plain' }], + }, + }, + }, 'c']]); + messageId = res[0][1].created.m.id; + if (HAS_METADATA) await clearSharedTags(); +}); + +test.afterAll(async ({ browser }) => { + if (messageId) await jmap([['Email/set', { accountId: api!.team, destroy: [messageId] }, 'd']]); + if (HAS_METADATA && api) { + await clearSharedTags(); + expect(await sharedTagsOnServer()).toBeNull(); + } + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.close(); +}); + +test.describe('on a server with metadata', () => { + test.skip(!HAS_METADATA, 'shared definitions need Stalwart v1.0.0 metadata'); + + test('org-alice defines "Urgent" in red for org-team and tags a message with it', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice); + const account = await openSharedTagSettings(alice); + await account.getByRole('button', { name: 'Add shared tag' }).click(); + const form = account.getByTestId('shared-tag-form'); + await form.getByLabel('Name').fill('Urgent'); + await form.getByRole('button', { name: 'red', exact: true }).click(); + await form.getByRole('button', { name: 'Save' }).click(); + await expect(account.locator('[data-testid="shared-tag-row"][data-tag-id="urgent"]')).toBeVisible(); + await shot(alice, 'tags-settings-editor'); + await expect.poll(sharedTagsOnServer).toEqual({ list: [{ id: 'urgent', label: 'Urgent', color: 'red' }] }); + + await alice.goto('/'); + await openTeamInbox(alice); + // The group's tags are listed under its folders. + await expect(alice.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible(); + + const row = emailItem(alice, SUBJECT).first(); + await expect(async () => { + await row.click({ button: 'right' }); + await alice.getByTestId('ctx-tag').waitFor({ state: 'visible', timeout: 2000 }); + }).toPass({ timeout: 15000 }); + await alice.getByTestId('ctx-tag').hover(); + await expect(alice.getByTestId('tag-picker-shared')).toBeVisible(); + await shot(alice, 'tags-picker'); + await alice.getByRole('menuitemcheckbox', { name: 'Urgent' }).click(); + await expect.poll(async () => (await messageKeywords())['$label:urgent']).toBe(true); + await alice.keyboard.press('Escape'); + await expect(row.getByTitle('Urgent')).toBeVisible(); + await alice.context().close(); + }); + + test('org-bob sees the same name and colour on that message', async ({ browser }) => { + const bob = await userPage(browser, ORG.bob); + await openTeamInbox(bob); + const chip = emailItem(bob, SUBJECT).first().getByTitle('Urgent'); + await expect(chip).toBeVisible(); + await expect(chip).toHaveClass(/bg-red-500\/10/); + await expect(bob.getByTestId('shared-account-tags').getByText('Urgent')).toBeVisible(); + await shot(bob, 'tags-member-view'); + await bob.context().close(); + }); + + test('a lock applies only to the scope that sets it, read-only with the reason in place', async ({ browser }) => { + const admin = await adminContext(browser); + let policy = await getPolicy(admin); + await putPolicy(admin, { + ...policy, + scopes: [ + { id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { restrictions: { sharedTags: { locked: true } } } }, + { id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} }, + ], + }); + + // A domain scope for another domain leaves org-team's members alone. + const alice = await userPage(browser, ORG.alice); + const aliceTags = await openSharedTagSettings(alice); + await expect(aliceTags.getByRole('button', { name: 'Add shared tag' })).toBeVisible(); + await alice.context().close(); + + // The admin locks shared tags for the org-team group on Organisation > Sharing. + const page = await admin.newPage(); + await page.goto('/admin/org-sharing'); + await expect(page.getByRole('heading', { name: 'Sharing' }).first()).toBeVisible(); + await page.getByRole('button', { name: 'Org team' }).click(); + await page.waitForLoadState('networkidle'); + const section = page.getByTestId('org-shared-tags-section'); + await section.getByLabel('Lock').check(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await section.scrollIntoViewIfNeeded(); + await shot(page, 'tags-admin-lock'); + policy = await getPolicy(admin); + const team = (policy.scopes as Array<{ id: string; policy: Record }>).find((s) => s.id === 'org-team')!; + expect(team.policy.restrictions.sharedTags).toEqual({ locked: true }); + expect((policy.restrictions as Record).sharedTags).toBeUndefined(); + await admin.close(); + + const bob = await userPage(browser, ORG.bob); + const bobTags = await openSharedTagSettings(bob); + await expect(bobTags.getByText('Set by your organisation')).toBeVisible(); + await expect(bobTags.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0); + await expect(bobTags.getByRole('button', { name: 'Edit tag' })).toHaveCount(0); + await expect(bobTags.locator('[data-tag-id="urgent"]')).toBeVisible(); + await shot(bob, 'tags-settings-locked'); + // Tagging with the shared tags still works. + await bob.goto('/'); + await openTeamInbox(bob); + await expect(emailItem(bob, SUBJECT).first().getByTitle('Urgent')).toBeVisible(); + await bob.context().close(); + }); + + test('the shared tags work at phone width', async ({ browser }) => { + const bob = await userPage(browser, ORG.bob, true); + const account = await openSharedTagSettings(bob, true); + await expect(account.getByText('Set by your organisation')).toBeVisible(); + await expect(account.locator('[data-tag-id="urgent"]')).toBeVisible(); + const overflow = await bob.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + await shot(bob, 'tags-settings-phone'); + await bob.context().close(); + + const admin = await adminContext(browser, true); + const page = await admin.newPage(); + await page.goto('/admin/org-sharing'); + await expect(page.getByTestId('org-shared-tags-section')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + await page.getByTestId('org-shared-tags-section').scrollIntoViewIfNeeded(); + await shot(page, 'tags-admin-phone'); + await admin.close(); + }); +}); + +test.describe('on a server without metadata', () => { + test.skip(HAS_METADATA, 'covered above'); + + test('no shared editor, the tags on group messages still show, and the app stays online', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice); + const requests: string[] = []; + alice.on('request', (request) => { + const body = request.postData() ?? ''; + if (body.includes('urn:ietf:params:jmap:metadata')) requests.push(body); + }); + await openTeamInbox(alice); + await expect(emailItem(alice, SUBJECT).first().getByTitle('from-another-client')).toBeVisible(); + await expect(alice.getByTestId('shared-account-tags')).toHaveCount(0); + + const account = await openSharedTagSettings(alice); + await expect(account.getByTestId('shared-tags-unsupported')).toBeVisible(); + await expect(account.getByRole('button', { name: 'Add shared tag' })).toHaveCount(0); + await shot(alice, 'tags-settings-unsupported'); + + // The 1.0 test build drops the connection on Mailbox/get with metadata; + // that must not leave the app reconnecting. 0.16 gets no such request. + await alice.goto('/'); + await openTeamInbox(alice); + await alice.waitForTimeout(3000); + await expect(alice.getByText('Connection lost. Attempting to reconnect')).toHaveCount(0); + if (ORG_SERVER === '0.16') expect(requests).toEqual([]); + await alice.context().close(); + }); +});