Files
webmail/components/settings/delegates/__tests__/delegates-settings.test.tsx
T
Linus Rath 49c9ec6a45 fix: start a delegate's Change from what they have and keep what fits no choice as it is
Change used to pre-fill the closest choice and then replace everything
of that type, so a folder added after "all", a single non-Inbox folder,
a non-default calendar, free/busy only or manager rights were narrowed
or widened on Save. Now a type gets a choice only when the grants match
it exactly; anything else starts at "Keep as it is", which leaves that
type out of the patch. Mixed levels keep each type's rights.

Also in the Delegates page:
- Change and Remove wait, with Try again, while a type couldn't be read
- an answer from the account before an account switch is dropped
- calendars, contacts and files follow their feature gates
- Remove says it covers what was shared from the user's own account
- typing your own address says "That's you"
- groups named only by the session get the group badge
- on Stalwart 1.0 the read hint says read delegates can flag mail
- the person list is plain buttons, not a listbox without arrow keys
- the rights follow sharingRights
2026-10-28 06:28:02 +01:00

362 lines
19 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DelegatesSettings } from '../delegates-settings';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '@/lib/jmap/share-rights';
import type { Principal, PrincipalListing } from '@/lib/jmap/types';
const ALICE: Principal = { id: 'ci', type: 'individual', name: 'org-alice@example.org', email: 'org-alice@example.org' };
const TEAM: Principal = { id: 'ch', type: 'group', name: 'org-team@example.org', email: 'org-team@example.org' };
const CAROL: Principal = { id: 'ck', type: 'individual', name: 'org-carol@example.org', email: 'org-carol@example.org' };
// Stalwart 1.0 with directory queries off: only the principals alice knows.
const DIRECTORY_OFF: PrincipalListing = { principals: [ALICE, TEAM, CAROL], status: 'restricted', directoryQueriesOff: true };
function targets(withCarol = false): ShareTarget[] {
const carol = (rights: object) => (withCarol ? { ck: rights } : {});
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: carol({ ...MAILBOX_PRESETS.read, maySetKeywords: true }) as never },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Sent Items', role: 'sent', shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c', name: 'Calendar', isDefault: true, shareWith: carol(CALENDAR_PRESETS.read) as never },
{ kind: 'addressBook', accountId: 'ci', id: 'd', name: 'Contacts', isDefault: true, shareWith: {} },
...(withCarol ? [{ kind: 'file' as const, accountId: 'ci', id: 'n', name: 'plan.pdf', shareWith: { ck: FILE_PRESETS.read } }] : []),
];
}
const ok = (changes: ShareChange[]): ShareChangeResult[] => changes.map((change) => ({ change, ok: true }));
function makeClient(options: {
withCarol?: boolean;
directory?: PrincipalListing;
apply?: (c: ShareChange[]) => ShareChangeResult[];
sharedAccounts?: Array<{ id: string; name: string }>;
targets?: ShareTarget[];
failed?: string[];
seenSetsKeywords?: boolean;
} = {}) {
return {
getAccountId: () => 'ci',
getUsername: () => 'org-alice@example.org',
getSharedAccounts: () => options.sharedAccounts ?? [],
supportsPrincipals: () => true,
sharesSeenWithKeywords: () => options.seenSetsKeywords ?? false,
getPrincipals: vi.fn(async () => (options.directory ?? DIRECTORY_OFF).principals),
listPrincipals: vi.fn(async () => options.directory ?? DIRECTORY_OFF),
getShareTargets: vi.fn(async (_include?: object) => ({ targets: options.targets ?? targets(options.withCarol), failed: (options.failed ?? []) as never[] })),
applyShareChanges: vi.fn(async (changes: ShareChange[]) => (options.apply ?? ok)(changes)),
findPrincipalByEmail: vi.fn(async () => ({ principal: null, status: 'forbidden' as const, directoryQueriesOff: true })),
};
}
const initialPolicy = usePolicyStore.getState();
const initialAuth = useAuthStore.getState();
function restrict(restrictions: Record<string, object>) {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: { ...state.policy.restrictions, ...restrictions } } }));
}
function renderWith(client: ReturnType<typeof makeClient>) {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc-alice' });
return render(<DelegatesSettings />);
}
beforeEach(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
afterEach(() => {
cleanup();
usePolicyStore.setState(initialPolicy, true);
useAuthStore.setState(initialAuth, true);
});
describe('DelegatesSettings', () => {
it('shows nothing and reads nothing when the admin hides delegation', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
const { container } = renderWith(client);
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(client.getShareTargets).not.toHaveBeenCalled();
});
it('appears once a late policy stops hiding it', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
renderWith(client);
await act(async () => {});
act(() => usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: {} } })));
expect(await screen.findByText('give_access')).toBeInTheDocument();
expect(client.getShareTargets).toHaveBeenCalled();
});
it('lists everyone with access, grouped by person, with what they have', async () => {
renderWith(makeClient({ withCarol: true }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-carol@example.org')).toBeInTheDocument();
expect(within(row).getByText('summary.mail_inbox · summary.calendars · summary.files · level.read')).toBeInTheDocument();
expect(screen.getAllByTestId('delegate-row')).toHaveLength(1);
});
it('names someone the directory does not list from the accounts they share with the user', async () => {
renderWith(makeClient({
withCarol: true,
directory: { principals: [ALICE], status: 'restricted', directoryQueriesOff: true },
sharedAccounts: [{ id: 'ck', name: 'org-carol@example.org' }],
}));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-carol@example.org')).toBeInTheDocument();
expect(within(row).queryByText('unknown_person')).toBeNull();
});
it('marks a group named only by the session as a group', async () => {
const list = targets();
list[0].shareWith.ch = MAILBOX_PRESETS.read;
renderWith(makeClient({
targets: list,
directory: { principals: [], status: 'error' },
sharedAccounts: [{ id: 'ch', name: 'org-team@example.org' }],
}));
// The auth store sets the policy subject once the session is in; it knows the user's groups.
act(() => usePolicyStore.setState({ subject: { email: ALICE.email!, groups: ['org-team@example.org'] } }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-team@example.org')).toBeInTheDocument();
expect(await within(row).findByText('group')).toBeInTheDocument();
});
it('asks only for the types the admin left on', async () => {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, calendarEnabled: false, filesEnabled: false } } }));
const client = makeClient();
renderWith(client);
await screen.findByText('nobody');
expect(client.getShareTargets).toHaveBeenLastCalledWith({ calendars: false, contacts: true, files: false });
});
it('holds Change and Remove while a type is unread, and reads again on Try again', async () => {
const client = makeClient({ withCarol: true, failed: ['calendar'] });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByRole('button', { name: 'change' })).toBeDisabled();
expect(within(row).getByRole('button', { name: 'remove' })).toBeDisabled();
const notice = screen.getByTestId('delegates-read-failed');
expect(notice).toHaveTextContent('read_failed');
client.getShareTargets.mockResolvedValue({ targets: targets(true), failed: [] });
fireEvent.click(within(notice).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(within(screen.getByTestId('delegate-row')).getByRole('button', { name: 'remove' })).toBeEnabled());
expect(client.getShareTargets).toHaveBeenCalledTimes(2);
});
it('shows the answer of the current account, not a slow one of the account before', async () => {
let releaseFirst: (v: { targets: ShareTarget[]; failed: never[] }) => void = () => {};
const first = makeClient({ withCarol: true });
first.getShareTargets.mockImplementation(() => new Promise((resolve) => { releaseFirst = resolve; }));
renderWith(first);
await act(async () => {});
const second = makeClient();
act(() => useAuthStore.setState({ client: second as never, activeAccountId: 'acc-other' }));
expect(await screen.findByText('nobody')).toBeInTheDocument();
await act(async () => { releaseFirst({ targets: targets(true), failed: [] }); });
expect(screen.queryByTestId('delegate-row')).toBeNull();
expect(screen.getByText('nobody')).toBeInTheDocument();
});
it('remembers the names of people with access, never the rest of the directory', async () => {
renderWith(makeClient({ withCarol: true }));
await screen.findByTestId('delegate-row');
await waitFor(() => expect(localStorage.getItem('bulwark-delegate-names')).toContain('ck'));
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('removes a person from every item, files included', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'remove' }));
const confirm = await screen.findByRole('alertdialog');
fireEvent.click(within(confirm).getByRole('button', { name: 'remove' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId, c.rights])).toEqual([
['mailbox', 'a', 'ck', null],
['calendar', 'c', 'ck', null],
['file', 'n', 'ck', null],
]);
// The overview is read again afterwards.
await waitFor(() => expect(client.getShareTargets).toHaveBeenCalledTimes(2));
});
});
function candidate(dialog: HTMLElement, text: string): HTMLElement {
return within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').find((b) => b.textContent?.includes(text))!;
}
describe('DelegateDialog', () => {
async function openDialog(client: ReturnType<typeof makeClient>) {
renderWith(client);
fireEvent.click(await screen.findByText('give_access'));
return screen.findByRole('dialog');
}
it('starts in the search field', async () => {
const dialog = await openDialog(makeClient());
await waitFor(() => expect(within(dialog).getByRole('textbox')).toHaveFocus());
});
it("says \"That's you\" for the user's own address", async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'ORG-alice@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
expect(await within(dialog).findByText('is_you')).toBeInTheDocument();
expect(client.findPrincipalByEmail).not.toHaveBeenCalled();
});
it('remembers the person just picked', async () => {
const client = makeClient({ directory: { principals: [ALICE, CAROL, TEAM], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('says read delegates can flag mail where the server ties the two rights', async () => {
const dialog = await openDialog(makeClient({ seenSetsKeywords: true }));
expect(within(dialog).getByText('rights_read_hint_flags')).toBeInTheDocument();
expect(within(dialog).queryByText('rights_read_hint')).toBeNull();
});
it('starts Change of manager rights at "Keep as it is" and has nothing to save', async () => {
const list = targets();
for (const t of list) if (t.kind === 'mailbox') t.shareWith.ck = MAILBOX_PRESETS.manager;
const client = makeClient({ targets: list });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'change' }));
const dialog = await screen.findByRole('dialog');
const checked = within(dialog).getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked).map((r) => (r as HTMLInputElement).value);
expect(checked).toEqual(['keep', 'none', 'none', 'keep']);
expect(within(dialog).getByRole('button', { name: 'apply_change' })).toBeDisabled();
expect(within(dialog).getByText('nothing_to_change')).toBeInTheDocument();
});
it('says why only known people are listed when directory queries are off', async () => {
const dialog = await openDialog(makeClient());
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
// The user's own principal isn't offered; their group and carol are.
const options = within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').map((o) => o.textContent);
expect(options.some((o) => o?.includes('org-alice'))).toBe(false);
expect(options.some((o) => o?.includes('org-carol'))).toBe(true);
});
it('shows the note for a typed address the server cannot look up', async () => {
// A listing refused for another reason: the note comes from the lookup.
const client = makeClient({ directory: { principals: [ALICE], status: 'restricted' } });
const dialog = await openDialog(client);
expect(within(dialog).queryByTestId('delegate-directory-off')).toBeNull();
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'org-carol@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
await waitFor(() => expect(client.findPrincipalByEmail).toHaveBeenCalledWith('org-carol@example.org'));
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
});
it('takes a typed address the server resolves', async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
client.findPrincipalByEmail.mockResolvedValue({ principal: CAROL, status: 'ok' } as never);
const dialog = await openDialog(client);
const input = within(dialog).getByRole('textbox');
fireEvent.change(input, { target: { value: 'org-carol@example.org' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await within(dialog).findByText('org-carol@example.org')).toBeInTheDocument();
expect(within(dialog).queryByRole('textbox')).toBeNull();
});
it('gives read access to all folders and the default calendar and address book in one batch', async () => {
const client = makeClient();
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId])).toEqual([
['mailbox', 'a', 'ck'], ['mailbox', 'b', 'ck'], ['calendar', 'c', 'ck'], ['addressBook', 'd', 'ck'],
]);
expect(changes[0].rights).toEqual(MAILBOX_PRESETS.read);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('reports the items that failed and tries only those again', async () => {
let calls = 0;
const client = makeClient({
apply: (changes) => {
calls += 1;
return changes.map((change) => ({ change, ok: calls > 1 || change.kind !== 'calendar', error: calls > 1 ? undefined : 'You may not share this calendar' }));
},
});
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
const report = await within(dialog).findByRole('alert');
expect(report).toHaveTextContent('partial_title');
expect(report).toHaveTextContent('Calendar');
expect(report).toHaveTextContent('You may not share this calendar');
expect(report).not.toHaveTextContent('Inbox');
fireEvent.click(within(dialog).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(2));
expect(client.applyShareChanges.mock.calls[1][0].map((c) => c.id)).toEqual(['c']);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('offers only read when the admin allows only read', async () => {
restrict({ sharingRights: { allowedValues: ['read'] } });
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read']);
expect(within(rights).getByText('org_locked')).toBeInTheDocument();
});
it('offers both without a restriction, and no lock note', async () => {
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read', 'write']);
expect(within(rights).queryByText('org_locked')).toBeNull();
});
it('keeps keys from the shortcuts behind it and closes on Escape', async () => {
const onWindowKey = vi.fn();
window.addEventListener('keydown', onWindowKey);
try {
const dialog = await openDialog(makeClient());
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'e' });
expect(onWindowKey).not.toHaveBeenCalled();
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(onWindowKey).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKey);
}
});
it('starts "Change" from what the person has, and replaces it', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'change' }));
const dialog = await screen.findByRole('dialog');
const checked = within(dialog).getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked).map((r) => (r as HTMLInputElement).value);
expect(checked).toEqual(['inbox', 'default', 'none', 'read']);
fireEvent.click(within(dialog).getAllByRole('radio').find((r) => (r as HTMLInputElement).value === 'write')!);
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_change' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.id, (c.rights as Record<string, boolean> | null)?.mayAddItems ?? (c.rights as Record<string, boolean> | null)?.mayWriteAll])).toEqual([
['a', true], ['c', true],
]);
});
});