Files
webmail/lib/__tests__/device-policy.test.ts
T

204 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
canDownload,
canExport,
canPrint,
devicesForAccount,
getStrictestDevices,
watermarkAddress,
watermarkText,
} from '@/lib/device-policy';
import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, saveBlobAs } from '@/lib/download-guard';
import { openPreviewInNewWindow } from '@/lib/standalone-display';
import { saveBlob } from '@/lib/files-zip';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useEmailStore } from '@/stores/email-store';
import { canCopyBetween } from '@/lib/device-policy';
// The device policies (Organisation > Devices) follow the account a message
// belongs to; what isn't about one account follows the strictest signed-in
// account. The anchor and new-tab helpers refuse as a backstop.
const ALICE = { email: 'alice@example.org', groups: ['team@example.org'] };
const CAROL = { email: 'carol@example.org', groups: [] };
// Whatever works the subject out from a client gets the same answer.
const clientA = {
getUsername: () => ALICE.email,
getSharedAccounts: () => [{ id: 'g', name: 'team@example.org', isPrimary: false }],
getAccountInfo: () => ({ isPersonal: false }),
getPrincipalTypes: async () => ({ g: 'group' }),
};
const clientC = { getUsername: () => CAROL.email, getSharedAccounts: () => [] };
const RAW: SettingsPolicy = {
...DEFAULT_POLICY,
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noDownloads: true, noPrint: true } } } }],
};
const initialAuth = useAuthStore.getState();
const initialPolicy = usePolicyStore.getState();
beforeEach(() => {
resetAccountSubjects();
useAuthStore.setState({
activeAccountId: 'acc-a',
client: clientA as never,
username: ALICE.email,
getClientForAccount: ((id: string) => (id === 'acc-a' ? clientA : id === 'acc-c' ? clientC : undefined)) as never,
getAllConnectedClients: (() => new Map<string, unknown>([['acc-a', clientA], ['acc-c', clientC]])) as never,
});
usePolicyStore.setState({ rawPolicy: RAW, loaded: true });
usePolicyStore.getState().setPolicySubject(ALICE);
useAccountSubjects.setState({ subjects: { 'acc-c': CAROL }, settled: { 'acc-c': true } });
});
afterEach(() => {
useAuthStore.setState(initialAuth, true);
usePolicyStore.setState(initialPolicy, true);
resetAccountSubjects();
});
describe('which account decides', () => {
it('a message follows its own account', () => {
expect(canDownload({ sourceClientAccountId: 'acc-a' })).toBe(false);
expect(canDownload({ sourceClientAccountId: 'acc-c' })).toBe(true);
expect(canPrint('acc-c')).toBe(true);
expect(canExport('acc-a')).toBe(true);
});
it('the active account when nothing names one', () => {
expect(canDownload(null)).toBe(false);
expect(devicesForAccount(undefined).noPrint).toBe(true);
});
it('a JMAP client stands for its account', () => {
expect(canDownload({ client: clientC })).toBe(true);
expect(canDownload({ client: clientA })).toBe(false);
});
it('the strictest signed-in account where no message decides', () => {
usePolicyStore.getState().setPolicySubject(CAROL);
useAuthStore.setState({ activeAccountId: 'acc-c', client: clientC as never });
useAccountSubjects.setState({ subjects: { 'acc-a': ALICE }, settled: { 'acc-a': true } });
expect(canDownload(null)).toBe(true);
expect(getStrictestDevices()).toMatchObject({ noDownloads: true, noPrint: true, noExport: false });
});
});
describe('offline caps', () => {
const scoped = (id: string, match: object, devices: Record<string, unknown>) => ({ id, label: id, match, policy: { organisation: { devices } } });
function withPolicy(organisationDevices: Record<string, unknown>, scopes: unknown[]) {
usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { devices: organisationDevices }, scopes } as never, loaded: true });
usePolicyStore.getState().setPolicySubject(null);
usePolicyStore.getState().setPolicySubject(ALICE);
}
it('stay numbers across signed-in accounts, the lowest one winning', () => {
withPolicy({}, [
scoped('a', { groups: ['team@example.org'] }, { offlineMaxDays: 90, offlineMaxTotalMb: 500 }),
]);
const strictest = getStrictestDevices();
expect(strictest.offlineMaxDays).toBe(90);
expect(strictest.offlineMaxTotalMb).toBe(500);
// Carol has no cap: 0 means none, and must not read as the lowest.
expect(devicesForAccount('acc-c').offlineMaxDays).toBe(0);
});
it('a scope can lower a cap but not raise it', () => {
withPolicy({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 }, [
scoped('up', { groups: ['team@example.org'] }, { offlineMaxDays: 365, offlineMaxAttachmentMb: 0 }),
]);
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 });
withPolicy({ offlineMaxDays: 60 }, [
scoped('down', { groups: ['team@example.org'] }, { offlineMaxDays: 7 }),
]);
expect(devicesForAccount('acc-a').offlineMaxDays).toBe(7);
});
it('a scope cannot allow attachments that another one forbids', () => {
withPolicy({ noOfflineAttachments: true }, [
scoped('up', { groups: ['team@example.org'] }, { noOfflineAttachments: false }),
]);
expect(devicesForAccount('acc-a').noOfflineAttachments).toBe(true);
});
it('noOffline stays last value wins, as before', () => {
withPolicy({ noOffline: true }, [
scoped('allow', { groups: ['team@example.org'] }, { noOffline: false }),
]);
expect(devicesForAccount('acc-a').noOffline).toBe(false);
});
it('drops what is not a number, and negative caps mean none', () => {
withPolicy({ offlineMaxDays: 'lots', offlineMaxTotalMb: -5 }, []);
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 0, offlineMaxTotalMb: 0 });
});
});
describe('the backstop', () => {
it('refuses a download, says so, and never builds the anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const blocked = vi.fn();
window.addEventListener(DOWNLOAD_BLOCKED_EVENT, blocked);
expect(saveBlobAs(new Blob(['x']), 'a.pdf', { kind: 'attachment', subject: 'acc-a' })).toBe(false);
expect(saveBlob(new Blob(['x']), 'b.zip')).toBe(false);
expect(click).not.toHaveBeenCalled();
expect(blocked).toHaveBeenCalledTimes(2);
expect(saveBlobAs(new Blob(['x']), 'a.pdf', { kind: 'attachment', subject: 'acc-c' })).toBe(true);
expect(click).toHaveBeenCalledTimes(1);
window.removeEventListener(DOWNLOAD_BLOCKED_EVENT, blocked);
click.mockRestore();
});
it('keeps exports and downloads apart', () => {
expect(downloadAllowed('export', 'acc-a')).toBe(true);
expect(downloadAllowed('attachment', 'acc-a')).toBe(false);
});
it('opens no new tab under the policy, so the caller previews in the app', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
expect(openPreviewInNewWindow('https://example.org/x.pdf', { sourceClientAccountId: 'acc-a' })).toBe(false);
expect(open).not.toHaveBeenCalled();
expect(openPreviewInNewWindow('https://example.org/x.pdf', { sourceClientAccountId: 'acc-c' })).toBe(true);
open.mockRestore();
});
});
describe('another account opened from the sidebar', () => {
afterEach(() => useEmailStore.setState({ viewingAccountId: null }));
it('a message without a stamp follows the account the sidebar shows', () => {
// ent-carol active, ent-alice's Inbox opened from the multi-account sidebar.
usePolicyStore.getState().setPolicySubject(CAROL);
useAuthStore.setState({ activeAccountId: 'acc-c', client: clientC as never });
useAccountSubjects.setState({ subjects: { 'acc-a': ALICE }, settled: { 'acc-a': true } });
useEmailStore.setState({ viewingAccountId: 'acc-a' });
expect(canDownload({ sourceClientAccountId: undefined })).toBe(false);
expect(canPrint({})).toBe(false);
expect(watermarkAddress({})).toBe(ALICE.email);
useEmailStore.setState({ viewingAccountId: null });
expect(canDownload({})).toBe(true);
});
});
describe('Copy to another account', () => {
it('never copies out of an account into a less strict one', () => {
expect(canCopyBetween('acc-a', 'acc-c')).toBe(false);
expect(canCopyBetween('acc-c', 'acc-a')).toBe(true);
expect(canCopyBetween('acc-a', 'acc-a')).toBe(true);
});
});
describe('watermark', () => {
it('names the address of the message’s account and the date', () => {
expect(watermarkAddress({ sourceClientAccountId: 'acc-c' })).toBe(CAROL.email);
expect(watermarkAddress(null)).toBe(ALICE.email);
expect(watermarkText('alice@example.org', new Date('2026-11-01T12:00:00Z'), 'en-GB')).toBe('alice@example.org · 1 Nov 2026');
});
});