mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
Stalwart refuses a push subscription's whole emailPush map as a `forbidden` SetError when it names a mailbox shared by ACL (0.16.16 and later, and 1.0), and 1.0 stops listing emailpush on such accounts. The app narrowed the map on any `forbidden`, including a refused method call, and put every account with folders in the first attempt, so 1.0 cost one refused call per install. Worse, a failed listing counted as "the server has no subscription" and dropped the key of a working registration, and any failed refresh - a network error, a timeout, rate limiting - started a replacement. - `jmap-result` gains `JMAPSetError` (a `JMAPMethodError` with the same name, carrying `properties`) and `isJMAPSetError`. `assertSetResult` throws it, so every refused object can be told apart from a refused call; existing `instanceof` checks and the outbox's name-based error classification are unchanged. - The emailPush map holds the primary account and the accounts whose `accountCapabilities` list `urn:ietf:params:jmap:emailpush`, which leaves ACL shares out up front on 1.0. On 0.16.16-0.16.23 the refused map still narrows to the accounts that look owned, then to the primary account, but only on a `forbidden` SetError. - A subscription ends only with a verified replacement, a user disable or the server's verdict: a failed listing aborts the run, and only a SetError on the refresh starts a replacement. Other failures leave the subscription and its key alone and the next run tries again. - `forbidden` and `overQuota` read as plain text in settings and the onboarding prompt, and settings says that mailboxes others share don't send background notifications. New strings in all 27 languages.
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
vi.mock('../jmap-client', () => ({
|
|
jmapClient: {
|
|
accountId: 'acc-1',
|
|
currentSession: {
|
|
eventSourceUrl: 'https://mail.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}',
|
|
},
|
|
authHeader: 'Basic dXNlcjpwYXNz',
|
|
request: vi.fn(),
|
|
hasCapability: vi.fn(() => false),
|
|
},
|
|
}));
|
|
|
|
import { jmapClient } from '../jmap-client';
|
|
import {
|
|
createPushSubscription,
|
|
destroyPushSubscription,
|
|
listPushSubscriptions,
|
|
startPolling,
|
|
updatePushSubscription,
|
|
verifyPushSubscription,
|
|
} from '../push';
|
|
import { isJMAPSetError } from '../jmap-result';
|
|
|
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
|
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
|
const EMAIL_PUSH = 'urn:ietf:params:jmap:emailpush';
|
|
|
|
describe('push operations', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('emailPush read-back', () => {
|
|
const FILTER = {
|
|
filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] },
|
|
properties: ['id', 'threadId'],
|
|
urgency: 'high' as const,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockHasCapability.mockImplementation((urn: string) => urn === EMAIL_PUSH);
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
});
|
|
|
|
it('asks for emailPush explicitly under the emailpush capability', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/get', { list: [{ id: 's1', emailPush: { a: FILTER } }] }, '0']],
|
|
});
|
|
const list = await listPushSubscriptions();
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1].properties).toContain('emailPush');
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
|
|
expect(list[0].emailPush).toEqual({ a: FILTER });
|
|
});
|
|
|
|
it('keeps the default properties on servers without emailpush', async () => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/get', { list: [] }, '0']] });
|
|
await listPushSubscriptions();
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1]).toEqual({ ids: null });
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
|
|
it('sends the capability with an emailPush create and update', async () => {
|
|
mockRequest.mockResolvedValueOnce({
|
|
methodResponses: [['PushSubscription/set', { created: { new: { id: 's1' } } }, '0']],
|
|
});
|
|
await createPushSubscription({
|
|
deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'], emailPush: { a: FILTER },
|
|
});
|
|
expect(mockRequest.mock.calls[0][1]).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
|
|
|
|
mockRequest.mockResolvedValueOnce({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' });
|
|
// An update that leaves the filter alone doesn't need the capability.
|
|
expect(mockRequest.mock.calls[1][1]).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
|
|
it('drops emailPush from writes on servers without the capability', async () => {
|
|
mockHasCapability.mockImplementation(() => false);
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z', emailPush: { a: FILTER } });
|
|
const [calls, using] = mockRequest.mock.calls[0];
|
|
expect(calls[0][1].update.s1).toEqual({ expires: '2026-10-01T00:00:00Z' });
|
|
expect(using).toEqual(['urn:ietf:params:jmap:core']);
|
|
});
|
|
});
|
|
|
|
describe('createPushSubscription', () => {
|
|
const params = {
|
|
deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'], expires: '2026-12-01T00:00:00Z',
|
|
};
|
|
|
|
it('returns the expiry the server clamped the subscription to', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [[
|
|
'PushSubscription/set',
|
|
{ created: { new: { id: 's1', keys: null, expires: '2026-10-01T00:00:00Z' } } },
|
|
'0',
|
|
]],
|
|
});
|
|
await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-10-01T00:00:00Z' });
|
|
});
|
|
|
|
it('keeps the requested expiry when the server does not echo one', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { created: { new: { id: 's1' } } }, '0']],
|
|
});
|
|
await expect(createPushSubscription(params)).resolves.toEqual({ id: 's1', expires: '2026-12-01T00:00:00Z' });
|
|
});
|
|
});
|
|
|
|
describe('PushSubscription/set refusals', () => {
|
|
const forbidden = {
|
|
type: 'forbidden',
|
|
description: 'No access to one of the accounts in the emailPush map.',
|
|
};
|
|
|
|
it('surfaces the SetError type when a create is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
|
});
|
|
await expect(
|
|
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }),
|
|
).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
|
});
|
|
|
|
it('surfaces the SetError type when an update is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notUpdated: { s1: forbidden } }, '0']],
|
|
});
|
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }))
|
|
.rejects.toMatchObject({ type: 'forbidden' });
|
|
});
|
|
|
|
it('throws when the verification code is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [[
|
|
'PushSubscription/set',
|
|
{ notUpdated: { s1: { type: 'invalidProperties', description: 'Verification code does not match.' } } },
|
|
'0',
|
|
]],
|
|
});
|
|
await expect(verifyPushSubscription('s1', 'CODE')).rejects.toThrow('Verification code does not match.');
|
|
});
|
|
|
|
it('throws when a destroy is refused', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notDestroyed: { s1: forbidden } }, '0']],
|
|
});
|
|
await expect(destroyPushSubscription('s1')).rejects.toMatchObject({ type: 'forbidden' });
|
|
});
|
|
|
|
it('treats destroying a subscription that is already gone as done', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notDestroyed: { s1: { type: 'notFound' } } }, '0']],
|
|
});
|
|
await expect(destroyPushSubscription('s1')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['list', () => listPushSubscriptions()],
|
|
['create', () => createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] })],
|
|
['update', () => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })],
|
|
['verify', () => verifyPushSubscription('s1', 'CODE')],
|
|
['destroy', () => destroyPushSubscription('s1')],
|
|
])('%s throws on a method-level error response', async (_name, call) => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['error', { type: 'serverFail', description: 'Store unavailable' }, '0']],
|
|
});
|
|
await expect(call()).rejects.toMatchObject({ name: 'JMAPMethodError', type: 'serverFail' });
|
|
});
|
|
|
|
it('resolves when the update is applied', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { updated: { s1: null } }, '0']],
|
|
});
|
|
await expect(updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' })).resolves.toBeUndefined();
|
|
});
|
|
|
|
// The push setup narrows the emailPush map, or replaces a subscription,
|
|
// only on the server's verdict about the subscription itself.
|
|
const rejection = (call: () => Promise<unknown>) => call().then(() => null, (err: unknown) => err);
|
|
|
|
it('throws a refused create as a SetError (Stalwart 0.16.16+ and 1.0)', async () => {
|
|
// As Stalwart answers a map naming a mailbox shared by ACL: no
|
|
// accountId, state or `created` key (jmap-proto method/set.rs).
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notCreated: { new: forbidden } }, '0']],
|
|
});
|
|
const err = await rejection(() =>
|
|
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
|
expect(isJMAPSetError(err)).toBe(true);
|
|
expect(err).toMatchObject({ type: 'forbidden' });
|
|
});
|
|
|
|
it('throws an update of a vanished subscription as a notFound SetError', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['PushSubscription/set', { notUpdated: { s1: { type: 'notFound' } } }, '0']],
|
|
});
|
|
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
|
expect(isJMAPSetError(err)).toBe(true);
|
|
expect(err).toMatchObject({ type: 'notFound' });
|
|
});
|
|
|
|
it('throws a refused method call as a plain method error, even when forbidden', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [['error', { type: 'forbidden', description: 'Permission denied' }, '0']],
|
|
});
|
|
const create = await rejection(() =>
|
|
createPushSubscription({ deviceClientId: 'd', url: 'https://relay/x', types: ['EmailDelivery'] }));
|
|
const update = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
|
for (const err of [create, update]) {
|
|
expect(err).toMatchObject({ name: 'JMAPMethodError', type: 'forbidden' });
|
|
expect(isJMAPSetError(err)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('does not read an update that is neither applied nor refused as a SetError', async () => {
|
|
mockRequest.mockResolvedValue({ methodResponses: [['PushSubscription/set', {}, '0']] });
|
|
const err = await rejection(() => updatePushSubscription('s1', { expires: '2026-10-01T00:00:00Z' }));
|
|
expect(err).toMatchObject({ name: 'JMAPMethodError' });
|
|
expect(isJMAPSetError(err)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('startPolling', () => {
|
|
it('should poll at the specified interval', async () => {
|
|
mockRequest.mockResolvedValue({
|
|
methodResponses: [
|
|
['Mailbox/get', { state: 'state-1' }, 'm'],
|
|
['Email/get', { state: 'state-1' }, 'e'],
|
|
],
|
|
});
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// First poll
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
|
|
|
// Second poll with same state - no notification
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
stop();
|
|
});
|
|
|
|
it('should notify on state change', async () => {
|
|
let callCount = 0;
|
|
mockRequest.mockImplementation(async () => {
|
|
callCount++;
|
|
return {
|
|
methodResponses: [
|
|
['Mailbox/get', { state: callCount === 1 ? 'state-1' : 'state-2' }, 'm'],
|
|
['Email/get', { state: 'state-1' }, 'e'],
|
|
],
|
|
};
|
|
});
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// First poll (establishes baseline)
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
// Second poll (state changed for Mailbox)
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler).toHaveBeenCalledWith({
|
|
'@type': 'StateChange',
|
|
changed: {
|
|
'acc-1': { Mailbox: 'state-2' },
|
|
},
|
|
});
|
|
|
|
stop();
|
|
});
|
|
|
|
it('should handle errors silently', async () => {
|
|
mockRequest.mockRejectedValue(new Error('Network error'));
|
|
|
|
const handler = vi.fn();
|
|
const stop = startPolling(handler, 1000);
|
|
|
|
// Should not throw
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
stop();
|
|
});
|
|
});
|
|
});
|