Files
webmail/lib/__tests__/plugin-api-gates.test.ts
T
Linus Rath 5a553a315b fix: report refused read changes and re-register push at its limit
Follow-ups to the review of the push, keyword and identity work:

- A send that names no sender, like the List-Unsubscribe mailto: of the
  unified view, fell back to the first identity in server order, which
  on Stalwart 1.0 is a group's. It now takes the login's own identity,
  with addresses compared regardless of case.
- Re-register creates the replacement subscription before it destroys
  the old one, so at Stalwart's limit (15 per account by default) it
  failed with `overQuota`. It now frees the old record's place and
  tries once more.
- On 1.0 the push settings note goes by the emailpush capability alone
  and ignores refusals remembered under 0.16. On 0.16 a group the user
  belongs to is recognised by its identity, so a member without
  calendars, contacts or files keeps the group's junk filter and is no
  longer told the group sends no notifications.
- Plugin imports can no longer set Stalwart 1.0's attachment keywords,
  and `keywords.discover` leaves them out.
- Batch mark-as-read showed messages the server refused as read. It now
  updates only what was written and reports the rest, and thread
  expansion and category-tab moves keep what a partly refused batch
  did write.
- Refused read and star changes from shortcuts, the list, the viewer
  and Pro tabs show "Failed to update email" instead of an unhandled
  rejection. The automatic mark-as-read on opening stays quiet.
- A tag whose id predates the 128-byte limit can have its colour
  changed again.
- The state-regression guard notes that a multi-node 1.0 cluster can
  trip it harmlessly, at the cost of one extra full fetch.
2026-09-25 21:57:11 +02:00

370 lines
16 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { DEFAULT_KEYWORDS, useSettingsStore } from '@/stores/settings-store';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
// host-api pulls in the whole store layer; nothing asserted here reaches a
// store, but the module graph has to resolve.
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'p1',
name: 'Test plugin',
version: '1.0.0',
author: 'test',
description: '',
type: 'hook',
permissions: [],
entrypoint: 'index.js',
enabled: true,
status: 'running',
settings: {},
...overrides,
};
}
/**
* Message of the rejection, or '' when the call got through. The gates are
* what's under test; what happens afterwards needs an IndexedDB and a JMAP
* session, so "did not fail at a gate" is the useful assertion for the
* allowed cases.
*/
async function gateError(...args: Parameters<typeof dispatchApiCall>): Promise<string> {
try {
await dispatchApiCall(...args);
return '';
} catch (err) {
return (err as Error).message;
}
}
const GATE_FAILURE = /lacks permission|requires the privileged plugin tier|Unknown API method/;
/**
* The staged-attachment API is the one place an untrusted plugin can touch the
* bytes of a file the user is sending, so the read/write asymmetry is worth
* pinning down: reading back a file it was just handed the id of is a normal
* permission, replacing that file is privileged-tier only.
*/
describe('upfiles tier + permission gates', () => {
it('refuses upfiles.get without email:blob-read', async () => {
expect(await gateError(plugin(), 'upfiles.get', ['some-id']))
.toMatch(/lacks permission "email:blob-read"/);
});
it('refuses upfiles.get when the permission is declared but not granted', async () => {
const p = plugin({ permissions: ['email:blob-read'], grantedPermissions: [] });
expect(await gateError(p, 'upfiles.get', ['some-id']))
.toMatch(/lacks permission "email:blob-read"/);
});
it('allows upfiles.get for an untrusted plugin granted email:blob-read', async () => {
const p = plugin({ permissions: ['email:blob-read'], grantedPermissions: ['email:blob-read'] });
expect(await gateError(p, 'upfiles.get', ['some-id'])).not.toMatch(GATE_FAILURE);
});
it('refuses upfiles.save from an untrusted plugin even with email:blob-write', async () => {
const p = plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] });
expect(await gateError(p, 'upfiles.save', ['old-id', new File(['x'], 'x.txt')]))
.toMatch(/requires the privileged plugin tier/);
});
it('lets a privileged plugin call upfiles.save', async () => {
const p = plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] });
const err = await gateError(p, 'upfiles.save', ['old-id', new File(['x'], 'x.txt')], { privileged: true });
expect(err).not.toMatch(GATE_FAILURE);
});
it('keeps stored message blobs privileged-only', async () => {
const p = plugin({ permissions: ['email:blob-read'], grantedPermissions: ['email:blob-read'] });
expect(await gateError(p, 'jmap.fetchBlob', ['blob-1']))
.toMatch(/requires the privileged plugin tier/);
});
});
describe('method-name typos', () => {
// Guards the class of bug this suite was added for: an entry in the
// privileged set naming a method the dispatcher never sees, which gates
// nothing while looking like it does.
it('rejects a method with no entry in the permission map', async () => {
expect(await gateError(plugin(), 'upfiles.set', ['x'])).toMatch(/Unknown API method/);
});
});
describe('native keyword extension API', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useEmailStore.setState({ tagCounts: {} });
});
it('uses the existing settings permissions for definition reads and writes', async () => {
expect(await gateError(plugin(), 'keywords.list', []))
.toMatch(/lacks permission "settings:read"/);
expect(await gateError(plugin(), 'keywords.add', [[]]))
.toMatch(/lacks permission "settings:write"/);
expect(await gateError(plugin(), 'keywords.reorder', [[]]))
.toMatch(/lacks permission "settings:write"/);
const reader = plugin({ permissions: ['settings:read'], grantedPermissions: ['settings:read'] });
expect(await dispatchApiCall(reader, 'keywords.list', []))
.toEqual(useSettingsStore.getState().emailKeywords);
});
it('only appends missing definitions and preserves existing user metadata', async () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' }],
});
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
const result = await dispatchApiCall(writer, 'keywords.add', [[
{ id: 'WORK', label: 'Plugin Work', color: 'red' },
{ id: 'gmail~shopping', label: 'Shopping', visibility: 'unread' },
]]) as { added: Array<{ id: string }>; skipped: string[] };
expect(result.added.map((keyword) => keyword.id)).toEqual(['gmail~shopping']);
expect(result.skipped).toEqual(['WORK']);
expect(useSettingsStore.getState().emailKeywords).toEqual([
{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' },
expect.objectContaining({
id: 'gmail~shopping',
label: 'Shopping',
visibility: 'unread',
color: expect.any(String),
}),
]);
});
it('rejects invalid definitions without partially updating settings', async () => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
const before = useSettingsStore.getState().emailKeywords;
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
await expect(dispatchApiCall(writer, 'keywords.add', [[
{ id: 'valid', label: 'Valid', color: 'blue' },
{ id: 'invalid label', label: 'Invalid', color: 'blue' },
]])).rejects.toThrow(/not a valid JMAP label id/);
expect(useSettingsStore.getState().emailKeywords).toEqual(before);
});
it('reorders complete label sets without changing user metadata', async () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' },
{ id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' },
{ id: 'travel', label: 'Travel', color: 'blue' },
],
});
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
const setState = vi.spyOn(useSettingsStore, 'setState');
const result = await dispatchApiCall(writer, 'keywords.reorder', [[
'TRAVEL', 'work', 'shopping',
]]);
expect(result).toEqual([
{ id: 'travel', label: 'Travel', color: 'blue' },
{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' },
{ id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' },
]);
expect(useSettingsStore.getState().emailKeywords).toEqual(result);
expect(setState).toHaveBeenCalledWith(expect.any(Function));
setState.mockRestore();
});
it('supports optional case-sensitive label id matching', async () => {
const original = [
{ id: 'Work', label: 'Work', color: 'purple' },
{ id: 'shopping', label: 'Shopping', color: 'green' },
];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['SHOPPING', 'work'],
{ caseSensitive: true },
])).rejects.toThrow(/unknown label id/);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['shopping', 'Work'],
{ caseSensitive: true },
])).resolves.toEqual([original[1], original[0]]);
});
it('rejects invalid reorder options without changing settings', async () => {
const original = [{ id: 'work', label: 'Work', color: 'purple' }];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
for (const options of [true, { caseSensitive: 'yes' }, { unknown: true }]) {
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['work'], options,
])).rejects.toThrow(/options/);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
}
});
it('rejects incomplete, duplicate, and unknown reorder ids atomically', async () => {
const original = [
{ id: 'work', label: 'Work', color: 'purple' },
{ id: 'shopping', label: 'Shopping', color: 'green' },
];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
for (const ids of [
['work'],
['work', 'WORK'],
['work', 'missing'],
]) {
await expect(dispatchApiCall(writer, 'keywords.reorder', [ids])).rejects.toThrow(
/every existing label|duplicate label id|unknown label id/,
);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
}
});
it('gates discovery and counts as email metadata', async () => {
for (const method of ['jmap.getKeywords', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) {
expect(await gateError(plugin(), method, [])).toMatch(/lacks permission "email:read"/);
}
useEmailStore.setState({ tagCounts: { shopping: { total: 9, unread: 2 } } });
const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] });
expect(await dispatchApiCall(reader, 'keywords.getCounts', [['shopping']]))
.toEqual({ shopping: { total: 9, unread: 2 } });
});
it('keeps jmap.getKeywords available to untrusted plugins with email:read', async () => {
const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] });
const error = await gateError(reader, 'jmap.getKeywords', []);
expect(error).not.toMatch(GATE_FAILURE);
expect(error).toMatch(/No active session/);
});
it('routes jmap.getKeywords through the client implementation', async () => {
const result = {
keywords: { '$label:work': 2 },
labels: [{
id: '$label:work', name: 'Work', color: null, total: 2, unread: 1,
isProviderLabel: true, source: 'provider' as const,
}],
scanned: 8,
total: 8,
complete: true,
};
const getKeywords = vi.fn().mockResolvedValue(result);
const discoverKeywords = vi.fn();
useAuthStore.setState({ client: { getKeywords, discoverKeywords } as never });
const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] });
await expect(dispatchApiCall(reader, 'jmap.getKeywords', [{ limit: 100 }])).resolves.toEqual(result);
expect(getKeywords).toHaveBeenCalledWith({ limit: 100 });
expect(discoverKeywords).not.toHaveBeenCalled();
});
it('gates jmap.setKeywords with email:write and validates complete maps', async () => {
expect(await gateError(plugin(), 'jmap.setKeywords', ['email-1', { '$seen': true }]))
.toMatch(/lacks permission "email:write"/);
const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] });
expect(await gateError(writer, 'jmap.setKeywords', ['email-1', { '$seen': false }]))
.toMatch(/must be true; omit it to remove it/);
expect(await gateError(writer, 'jmap.setKeywords', ['email-1', { 'invalid keyword': true }]))
.toMatch(/Invalid JMAP keyword/);
const error = await gateError(writer, 'jmap.setKeywords', ['email-1', { '$seen': true }]);
expect(error).not.toMatch(GATE_FAILURE);
expect(error).toMatch(/No active session/);
});
it('exposes incremental JMAP keyword aliases with email:write', async () => {
for (const method of ['jmap.setKeyword', 'jmap.removeKeyword']) {
expect(await gateError(plugin(), method, ['email-1', '$label:work']))
.toMatch(/lacks permission "email:write"/);
}
const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] });
for (const method of ['jmap.setKeyword', 'jmap.removeKeyword']) {
expect(await gateError(writer, method, ['', '$label:work']))
.toMatch(/emailId is required/);
expect(await gateError(writer, method, ['email-1', 'invalid keyword']))
.toMatch(/Invalid JMAP keyword/);
const error = await gateError(writer, method, ['email-1', '$label:work']);
expect(error).not.toMatch(GATE_FAILURE);
expect(error).toMatch(/No active session/);
}
});
// Stalwart 1.0 derives hasAttachment from these, so a plugin adding or
// removing one would misreport the message.
it('keeps plugins off the attachment keywords Stalwart 1.0 sets itself', async () => {
const setKeyword = vi.fn();
const removeKeyword = vi.fn();
const updateEmailKeywords = vi.fn();
useAuthStore.setState({ client: { setKeyword, removeKeyword, updateEmailKeywords } as never });
const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] });
for (const method of ['jmap.setKeyword', 'jmap.removeKeyword', 'email.setKeyword', 'email.removeKeyword']) {
for (const keyword of ['$hasattachment', '$hasnoattachment', '$HasAttachment']) {
expect(await gateError(writer, method, ['email-1', keyword])).toMatch(/is set by the server/);
}
}
expect(await gateError(writer, 'jmap.setKeywords', ['email-1', { '$seen': true, '$hasattachment': true }]))
.toMatch(/"\$hasattachment" is set by the server/);
expect(setKeyword).not.toHaveBeenCalled();
expect(removeKeyword).not.toHaveBeenCalled();
expect(updateEmailKeywords).not.toHaveBeenCalled();
// Other keywords still go through.
await dispatchApiCall(writer, 'jmap.setKeyword', ['email-1', '$label:work']);
expect(setKeyword).toHaveBeenCalledWith('email-1', '$label:work', undefined);
});
it('leaves the attachment keywords out of a plugin import', async () => {
// Stalwart 1.0 adds the right one itself and keeps whatever else the
// import names, so a stray `$hasattachment` would stick.
const importRawEmail = vi.fn().mockResolvedValue('email-9');
const getMailboxes = vi.fn().mockResolvedValue([{ id: 'mb-inbox', role: 'inbox' }]);
useAuthStore.setState({ client: { importRawEmail, getMailboxes } as never });
const importer = plugin({ permissions: ['email:raw-send'], grantedPermissions: ['email:raw-send'] });
await dispatchApiCall(
importer,
'jmap.importRaw',
[new Uint8Array([1, 2, 3]), ['inbox'], { keywords: { $seen: true, $hasattachment: true, $HasNoAttachment: true } }],
{ privileged: true },
);
expect(importRawEmail).toHaveBeenCalledWith(expect.any(Blob), { 'mb-inbox': true }, { $seen: true }, undefined);
});
it('hides the attachment keywords Stalwart 1.0 puts on every message from keywords.discover', async () => {
const discoverKeywords = vi.fn().mockResolvedValue({
keywords: { $seen: 5, $hasattachment: 2, $hasnoattachment: 3, '$label:work': 1 },
scanned: 5,
total: 5,
complete: true,
});
useAuthStore.setState({ client: { discoverKeywords } as never });
const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] });
await expect(dispatchApiCall(reader, 'keywords.discover', [])).resolves.toEqual({
keywords: { $seen: 5, '$label:work': 1 },
scanned: 5,
total: 5,
complete: true,
});
});
});