Files
webmail/lib/__tests__/plugin-host-set-settings.test.ts
T

141 lines
8.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useLocaleStore } from '@/stores/locale-store';
import { useCalendarStore } from '@/stores/calendar-store';
import { settingsHooks } from '../plugin-hooks';
import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(id: string, perms: string[]): InstalledPlugin {
return {
id, name: id, version: '1.0.0', author: 'test', description: '',
type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const ASSISTANT = plugin('assistant', ['settings:read', 'settings:change']);
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
type Change = { applied: Record<string, { old: unknown; new: unknown }>; refused: Record<string, string>; undoToken: string | null };
const policy = usePolicyStore.getState().policy;
function restrict(restrictions: Record<string, unknown>, features: Record<string, boolean> = {}) {
usePolicyStore.setState({ policy: { ...policy, restrictions: { ...policy.restrictions, ...restrictions }, features: { ...policy.features, ...features } } } as never);
}
beforeEach(() => {
clearUndoTokens();
useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as unknown as IJMAPClient });
useLocaleStore.setState({ locale: 'en' });
useSettingsStore.setState({ timeFormat: '24h', deleteAction: 'trash', showPreview: true } as never);
usePolicyStore.setState({ policy } as never);
});
afterEach(() => {
useAuthStore.setState({ client: null });
usePolicyStore.setState({ policy } as never);
});
describe('api.app.describeSettings', () => {
it('says what a change would do in the user\'s words', async () => {
const out = await call(ASSISTANT, 'app.describeSettings', { timeFormat: '12h' }) as Record<string, Record<string, unknown>>;
expect(out.timeFormat).toMatchObject({
label: 'Time format', page: 'general', current: '24h', next: '12h', allowed: true,
});
expect(out.timeFormat.currentLabel).toMatch(/24/);
expect(out.timeFormat.nextLabel).toMatch(/12/);
expect(out.timeFormat.options).toHaveLength(2);
});
});
describe('api.app.setSettings', () => {
it('changes a setting through the store, reads it back and puts it back on undo', async () => {
const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
expect(result.applied).toEqual({ timeFormat: { old: '24h', new: '12h' } });
expect(useSettingsStore.getState().timeFormat).toBe('12h');
// The settings page counts it as the user's own choice.
expect(useSettingsStore.getState().explicitSettings).toContain('timeFormat');
const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[] };
expect(undone.restored).toEqual(['timeFormat']);
expect(useSettingsStore.getState().timeFormat).toBe('24h');
});
it('refuses what is not on the allow-list, values it rules out, and unknown keys', async () => {
const result = await call(ASSISTANT, 'app.setSettings', {
externalContentPolicy: 'allow', deleteAction: 'permanent', readReceiptResponse: 'always', confirmBulkDelete: false, fooBar: 1,
}) as Change;
expect(result.refused).toEqual({
externalContentPolicy: 'not_allowed', deleteAction: 'bad_value', readReceiptResponse: 'bad_value', confirmBulkDelete: 'bad_value', fooBar: 'unknown',
});
expect(result.undoToken).toBeNull();
expect(useSettingsStore.getState().deleteAction).toBe('trash');
});
it('refuses settings the admin locked or hid, rows a gate hides, and values outside a restriction', async () => {
restrict({ timeFormat: { locked: true }, showPreview: { hidden: true }, markAsReadDelay: { allowedValues: [0] } }, { unifiedCrossAccountEnabled: false });
const result = await call(ASSISTANT, 'app.setSettings', {
timeFormat: '12h', showPreview: false, unifiedCrossAccount: true, markAsReadDelay: 3000,
}) as Change;
expect(result.refused).toEqual({ timeFormat: 'locked', showPreview: 'hidden', unifiedCrossAccount: 'unavailable', markAsReadDelay: 'bad_value' });
expect(useSettingsStore.getState().timeFormat).toBe('24h');
});
it('says so when another plugin stops the change', async () => {
const off = settingsHooks.onBeforeSettingChange.register('guard', (async () => false) as never);
try {
const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
expect(result.refused).toEqual({ timeFormat: 'stopped' });
expect(useSettingsStore.getState().timeFormat).toBe('24h');
} finally {
off.dispose();
}
}, 10_000);
it('undo writes the exact old value, but not where the admin has locked it since', async () => {
useSettingsStore.setState({ deleteAction: 'permanent' } as never);
const result = await call(ASSISTANT, 'app.setSettings', { deleteAction: 'trash', timeFormat: '12h' }) as Change;
restrict({ timeFormat: { locked: true } });
const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[]; refused: Record<string, string> };
expect(undone).toEqual({ restored: ['deleteAction'], refused: { timeFormat: 'locked' } });
// A value the assistant may not set, put back because the user had it.
expect(useSettingsStore.getState().deleteAction).toBe('permanent');
});
it('takes the names app.getSettings uses for language and the calendar view', async () => {
const result = await call(ASSISTANT, 'app.setSettings', { language: 'fr', calendarView: 'week' }) as Change;
expect(Object.keys(result.applied).sort()).toEqual(['calendarView', 'language']);
expect(useLocaleStore.getState().locale).toBe('fr');
expect(useCalendarStore.getState().viewMode).toBe('week');
const described = await call(ASSISTANT, 'app.describeSettings', ['language']) as Record<string, { allowed: boolean; current: unknown }>;
expect(described.language).toMatchObject({ allowed: true, current: 'fr' });
await call(ASSISTANT, 'app.undoSettings', result.undoToken);
expect(useLocaleStore.getState().locale).toBe('en');
});
it('undo checks what a change checks: hidden, a gate and the restriction now', async () => {
useSettingsStore.setState({ markAsReadDelay: 0, unifiedCrossAccount: false } as never);
restrict({}, { unifiedCrossAccountEnabled: true });
const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h', markAsReadDelay: 3000, unifiedCrossAccount: true }) as Change;
expect(Object.keys(result.applied).sort()).toEqual(['markAsReadDelay', 'timeFormat', 'unifiedCrossAccount']);
restrict({ timeFormat: { hidden: true }, markAsReadDelay: { allowedValues: [3000] } }, { unifiedCrossAccountEnabled: false });
const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[]; refused: Record<string, string> };
expect(undone).toEqual({ restored: [], refused: { timeFormat: 'hidden', markAsReadDelay: 'bad_value', unifiedCrossAccount: 'unavailable' } });
expect(useSettingsStore.getState().timeFormat).toBe('12h');
});
it('needs settings:change, and keeps the undo to its plugin', async () => {
await expect(call(plugin('reader', ['settings:read']), 'app.setSettings', { timeFormat: '12h' })).rejects.toThrow(/lacks permission "settings:change"/);
const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
await expect(call(plugin('other', ['settings:change']), 'app.undoSettings', result.undoToken)).rejects.toThrow(/can't be undone/);
expect(useSettingsStore.getState().timeFormat).toBe('12h');
});
});