Files
Linus Rath ffa909600c fix: keep web push on when someone shares a mailbox with you
Stalwart refuses a push subscription's whole emailPush map as
`forbidden` when it names a mailbox shared by ACL (0.16.16 and later,
and 1.0). Bulwark put every account with mailboxes in the map, so
enabling push showed raw JSON, and the background re-sync destroyed the
working subscription after its refresh was refused and dropped its key:
push stopped without a word and could not be turned back on while the
share existed. A network error, a timeout or rate limiting during the
refresh ended it the same way.

- The map holds the primary account and the accounts that list
  `urn:ietf:params:jmap:emailpush`, which 1.0 leaves off ACL shares.
  0.16 lists it everywhere, so a refused map narrows to the accounts
  that look owned, then to the primary account, as the mobile app does.
  Refused accounts are remembered under
  `bulwark.push.emailPushRefused.v1.<id>`; Re-register and disable
  clear the list.
- A subscription ends only when a verified replacement exists, the user
  turns push off, or the server no longer has it. The old record is
  destroyed after the new one is verified. A failed listing, a network
  error or a refused method call leave it and its key alone.
- PushSubscription/set failures throw typed errors (`jmapSetError` for
  a refused object, `jmapError` for a refused call) instead of JSON in
  the message or `false`, and the client exposes `getAccountInfo`.
- Settings says that mailboxes others share don't send background
  notifications, and `forbidden` and `overQuota` read as plain text in
  settings and the onboarding prompt.
2026-09-25 17:59:17 +02:00

61 lines
2.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { isJmapSetError, jmapError, jmapErrorProperties, jmapErrorType, jmapSetError } from '../jmap/jmap-error';
describe('jmapError', () => {
it('keeps the SetError type and properties on the thrown error', () => {
const error = jmapError('Could not save the contact', {
type: 'invalidProperties',
description: 'blobIds in media is not supported.',
properties: ['media'],
});
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Could not save the contact');
expect(jmapErrorType(error)).toBe('invalidProperties');
expect(jmapErrorProperties(error)).toEqual(['media']);
});
it('copies the properties so later edits to the response do not leak in', () => {
const properties = ['name'];
const error = jmapError('rejected', { type: 'invalidProperties', properties });
properties.push('parentId');
expect(jmapErrorProperties(error)).toEqual(['name']);
});
it('works without error details', () => {
const error = jmapError('failed', null);
expect(jmapErrorType(error)).toBeUndefined();
expect(jmapErrorProperties(error)).toEqual([]);
});
it('reads nothing from values that are not JMAP errors', () => {
expect(jmapErrorType(new Error('plain'))).toBeUndefined();
expect(jmapErrorType(undefined)).toBeUndefined();
expect(jmapErrorType('overQuota')).toBeUndefined();
expect(jmapErrorProperties(null)).toEqual([]);
});
});
describe('jmapSetError', () => {
it('tells a refused object apart from a refused method call of the same type', () => {
const setError = jmapSetError('PushSubscription/set create failed', {
type: 'forbidden',
description: 'No access to one of the accounts in the emailPush map.',
});
const methodError = jmapError('PushSubscription/set failed', { type: 'forbidden' });
expect(jmapErrorType(setError)).toBe('forbidden');
expect(isJmapSetError(setError)).toBe(true);
expect(jmapErrorType(methodError)).toBe('forbidden');
expect(isJmapSetError(methodError)).toBe(false);
});
it('is false for anything that is not a SetError', () => {
expect(isJmapSetError(new Error('Failed to fetch'))).toBe(false);
expect(isJmapSetError(undefined)).toBe(false);
expect(isJmapSetError({ jmapSetError: 'yes' })).toBe(false);
});
});