mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
api.tasks.create and update take alerts by id: a trigger from the task's due (or start) as an ISO 8601 duration, or at an instant in UTC; display or email; at most five. An update merges by id, each alert written by its JMAP patch path: an alert given is set, one given null is removed, and alerts the plugin did not name, such as one another app wrote, stay as the server holds them; null for the whole map removes every alert. Whether the map is patched or written whole is decided on the task as the server holds it, not an older copy. An alert kept with the same trigger and action stays acknowledged, so a snooze is an update of its trigger, which then fires again. While a task uses its calendar's default alerts, a change makes them the task's own under new ids, as the task form does, an alert under a default's id is refused rather than dropped, and removing every alert turns the defaults off too. TaskView returns the alerts, each with when it was acknowledged, and useDefaultAlerts. api.tasks.acknowledgeAlert dismisses one alert (or undoes that with null) by writing only its acknowledged. A plugin's writes to one task go one after another, since Stalwart refuses a set while another is under way on the same object. The task store applies patch paths to the task the caller read, so a task the list does not hold is shown and reported whole.
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types';
|
|
import { HOOK_CATALOG } from '../plugin-hook-catalog';
|
|
import { SLOT_CATALOG } from '../plugin-slot-catalog';
|
|
|
|
vi.mock('@/stores/toast-store', () => ({
|
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
|
|
}));
|
|
|
|
import { PERM_PER_METHOD } from '../plugin-sandbox/host-api';
|
|
|
|
const root = path.resolve(__dirname, '../..');
|
|
|
|
/**
|
|
* Gates that live outside the three tables, each with the call that
|
|
* enforces it. The test reads the file, so a gate that is removed there
|
|
* no longer counts here.
|
|
*/
|
|
const OTHER_GATES: Record<string, { file: string; call: string }> = {
|
|
'ui:keyboard': { file: 'lib/plugin-sandbox/loader.ts', call: "pluginHasPermission(plugin, 'ui:keyboard')" },
|
|
};
|
|
|
|
/**
|
|
* Permissions in ALL_PERMISSIONS that nothing checks. A plugin could ask
|
|
* for one and the user would be asked to allow it, but it would grant
|
|
* nothing. None today; one added without a gate shows up here.
|
|
*/
|
|
const GATES_NOTHING: string[] = [];
|
|
|
|
function gatedPermissions(): Map<string, string[]> {
|
|
const where = new Map<string, string[]>();
|
|
const add = (perm: string | null | undefined, by: string) => {
|
|
if (!perm) return;
|
|
where.set(perm, [...(where.get(perm) ?? []), by]);
|
|
};
|
|
for (const [method, perm] of Object.entries(PERM_PER_METHOD)) add(perm, `method ${method}`);
|
|
for (const [hook, spec] of Object.entries(HOOK_CATALOG)) add(spec.permission, `hook ${hook}`);
|
|
for (const [slot, spec] of Object.entries(SLOT_CATALOG)) {
|
|
for (const perm of spec.permissions) add(perm, `slot ${slot}`);
|
|
if (spec.props === '*') continue;
|
|
for (const [prop, perm] of Object.entries(spec.props)) add(perm, `slot prop ${slot}.${prop}`);
|
|
}
|
|
for (const perm of IMPLICIT_PERMISSIONS) add(perm, 'implicit');
|
|
for (const [perm, gate] of Object.entries(OTHER_GATES)) {
|
|
if (readFileSync(path.join(root, gate.file), 'utf8').includes(gate.call)) add(perm, gate.file);
|
|
}
|
|
return where;
|
|
}
|
|
|
|
describe('plugin permissions', () => {
|
|
it('every permission gates a method, hook or slot (or is a known gap)', () => {
|
|
const gated = gatedPermissions();
|
|
const ungated = ALL_PERMISSIONS.filter((perm) => !gated.has(perm));
|
|
expect(ungated).toEqual(GATES_NOTHING);
|
|
});
|
|
|
|
it('the tables only use permissions that exist', () => {
|
|
const known = new Set<string>(ALL_PERMISSIONS);
|
|
expect([...gatedPermissions().keys()].filter((perm) => !known.has(perm))).toEqual([]);
|
|
});
|
|
|
|
it('the new host APIs are behind the permissions they were given', () => {
|
|
expect(PERM_PER_METHOD).toMatchObject({
|
|
'calendar.getEvents': 'calendar:read',
|
|
'calendar.createEvent': 'calendar:write',
|
|
'tasks.list': 'tasks:read',
|
|
'tasks.setComplete': 'tasks:write',
|
|
'tasks.acknowledgeAlert': 'tasks:write',
|
|
'templates.list': 'templates:read',
|
|
'templates.create': 'templates:write',
|
|
'identities.update': 'identity:write',
|
|
'vacation.get': 'vacation:read',
|
|
'vacation.set': 'vacation:write',
|
|
'email.get': 'email:read',
|
|
'email.list': 'email:read',
|
|
'mailboxes.list': 'email:read',
|
|
'email.getAttachment': 'email:blob-read',
|
|
'email.getRaw': 'email:blob-read',
|
|
'compose.open': 'compose:open',
|
|
'drafts.create': 'email:write',
|
|
'email.forward': 'email:forward',
|
|
'notifications.show': 'ui:notifications',
|
|
});
|
|
});
|
|
});
|