test: cover shared tags in group mailboxes against the test servers

This commit is contained in:
Linus Rath
2026-10-28 07:10:42 +01:00
parent 528450db61
commit a19d95c4bb
+295
View File
@@ -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, unknown>, 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<NonNullable<typeof api>> {
if (!api) {
const session = await (await fetch(`${JMAP_URL}/.well-known/jmap`, { headers: { Authorization: auth() } })).json();
const team = Object.entries(session.accounts as Record<string, { name: string }>).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<unknown> {
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<void> {
await jmap([['Mailbox/set', { accountId: api!.team, update: { [api!.inbox]: { 'metadata/org.bulwarkmail.tags': null } } }, 's']], META_USING);
}
async function messageKeywords(): Promise<Record<string, boolean>> {
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<void> {
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<string, unknown>;
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<string, any> }>).find((s) => s.id === 'org-team')!;
expect(team.policy.restrictions.sharedTags).toEqual({ locked: true });
expect((policy.restrictions as Record<string, unknown>).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();
});
});