Files
webmail/integration/tests/07-shared-folders.spec.ts
T
KazNIISA IT c516ba98d0 fix(email-store): route shared-folder management to the owner account
Folder management performed on a shared/group account was sent to the
logged-in user's primary JMAP account instead of the mailbox owner's
account. Subfolder creation therefore failed with invalidProperties for
parentId, root folders could be created silently in the personal account,
and rename, delete, role, and reorder targeted the wrong mailbox tree.

Shared mailboxes use namespaced store ids to avoid cross-account
collisions, while Mailbox/set requires the owner's accountId and the bare
server mailbox id. Resolve that mutation context centrally: recover the
mailbox originalId and owner account, use a directly connected owner
client when one exists, otherwise reach delegated/group accounts through
the active client with an explicit accountId.

Thread the account target through create, update, and delete JMAP methods
and all folder-management actions. Scope role replacement to the same
owner account, and expose explicit root-folder creation from shared
account headers without making personal root creation depend on the
currently selected folder.

Add unit coverage for JMAP request scoping, shared store routing, role
isolation, direct-client handling, and reorder. Add a Stalwart integration
scenario that creates root and nested group folders, renames them, and
deletes them through the UI.
2026-08-12 18:11:43 +05:00

201 lines
8.5 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { ACCOUNTS, GROUP } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expandSharedFolders,
folderRow,
openFolder,
expectFolderCountsSynced,
expectEmailVisible,
expectEmailUnread,
emailContextAction,
emailItem,
forceSync,
} from './helpers/app';
/**
* Shared (delegated) folders. Alice shares a custom folder — plus her Trash and
* Junk so delete/spam can route to the owner's system folders — with carol, who
* then acts on the mail from her own session and checks the shared counters.
*
* carol is the grantee (not asserted on by other specs), so the shared-account
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
*/
const { alice, carol } = ACCOUNTS;
const SHARED = 'TeamShared';
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
test.describe('Shared folder actions', () => {
let ja: JmapClient; // owner (alice)
let jc: JmapClient; // grantee + member of the team group account
let sharedId: string;
let teamAccountId: string;
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
jc = await JmapClient.connect(carol.email, carol.password);
teamAccountId = jc.accountIdByName(GROUP.team.email);
await ja.reset();
await jc.reset();
await jc.reset(teamAccountId);
// Delegate a custom folder + Trash + Junk to carol.
sharedId = await ja.createSharedFolder(SHARED, carol.email);
await ja.shareMailboxByRole('trash', carol.email);
await ja.shareMailboxByRole('junk', carol.email);
});
async function seedIntoShared(subject: string): Promise<void> {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
const m = await ja.waitForEmail(subject);
await ja.moveEmail(m.id, sharedId);
}
test('shared folder appears with its counter and message', async ({ page }) => {
const s = subj('sh-show');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await openFolder(page, { name: SHARED, shared: true });
await forceSync(page);
await expectEmailVisible(page, s);
});
test('group folder create, rename, and delete stay in the group account', async ({ page }) => {
const rootName = `GroupRoot-${Date.now()}`;
const childName = `${rootName}-child`;
const renamed = `${childName}-renamed`;
await login(page, carol);
await expandSharedFolders(page, GROUP.team.email);
const groupHeader = page.locator(
`[data-testid="section-shared-account"][data-section-name="${GROUP.team.email}"]`,
);
await groupHeader.click({ button: 'right' });
await page.getByTestId('mailbox-new-folder').click();
await page.getByRole('dialog').getByRole('textbox').fill(rootName);
await page.getByRole('dialog').getByRole('textbox').press('Enter');
const rootRow = folderRow(page, { name: rootName, shared: true }).first();
await expect(rootRow).toBeVisible();
await expect.poll(
async () => await jc.mailboxByName(rootName, teamAccountId),
{ timeout: 30000 },
).toMatchObject({ parentId: null });
const rootId = (await jc.mailboxByName(rootName, teamAccountId))!.id;
await rootRow.click({ button: 'right' });
await page.getByTestId('mailbox-new-subfolder').click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('textbox').fill(childName);
await dialog.getByRole('textbox').press('Enter');
await expect(rootRow.getByTestId('folder-expand-toggle')).toBeVisible();
const childRow = folderRow(page, { name: childName, shared: true }).first();
// The prompt closes before its async submit handler finishes. The mailbox
// refetch can therefore replace the row just as the first expand click
// lands; retry the user gesture until the freshly fetched child is visible.
await expect(async () => {
if (!(await childRow.isVisible())) {
await rootRow.getByTestId('folder-expand-toggle').click();
}
await expect(childRow).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 20000 });
await expect.poll(async () => await jc.mailboxByName(childName, teamAccountId), { timeout: 30000 })
.toMatchObject({ parentId: rootId });
await childRow.click({ button: 'right' });
await page.getByTestId('mailbox-rename').click();
await page.getByRole('dialog').getByRole('textbox').fill(renamed);
await page.getByRole('dialog').getByRole('textbox').press('Enter');
const renamedRow = folderRow(page, { name: renamed, shared: true }).first();
await expect(renamedRow).toBeVisible();
await expect.poll(async () => await jc.mailboxByName(renamed, teamAccountId), { timeout: 30000 })
.toMatchObject({ parentId: rootId });
await renamedRow.click({ button: 'right' });
await page.getByTestId('mailbox-delete').click();
await page.getByRole('alertdialog').getByRole('button').last().click();
await expect(renamedRow).toHaveCount(0);
await expect.poll(async () => await jc.mailboxByName(renamed, teamAccountId), { timeout: 30000 })
.toBeUndefined();
await rootRow.click({ button: 'right' });
await page.getByTestId('mailbox-delete').click();
await page.getByRole('alertdialog').getByRole('button').last().click();
await expect(rootRow).toHaveCount(0);
await expect.poll(async () => await jc.mailboxByName(rootName, teamAccountId), { timeout: 30000 })
.toBeUndefined();
});
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
const s = subj('sh-read');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await emailContextAction(page, s, 'ctx-mark-read');
await expectEmailUnread(page, s, false);
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
await emailContextAction(page, s, 'ctx-mark-unread');
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
// Owner sees the same state on the server.
const found = await ja.findEmailBySubject(s, sharedId);
expect(found.keywords?.$seen).toBeFalsy();
});
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
const s = subj('sh-del');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-delete');
// Source shared folder drains, and the message really is in the owner's
// Trash on the server. (We assert the destination server-side rather than
// the shared Trash badge to keep the check independent of sidebar layout.)
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
const trash = await ja.mailboxByRole('trash');
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
});
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
const s = subj('sh-spam');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-spam');
// The message leaves the shared folder's list, and on the server it has
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
// spam doesn't optimistically drain the source *counter*, and forceSync
// can't reconcile a shared account — so we assert list + server state.)
await expect(emailItem(page, s)).toHaveCount(0);
const junk = await ja.mailboxByRole('junk');
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
});
});