mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
* adds "canRSVP" in addition to the existing "getEventEditability" * wires calendar to leverage "canRSVP" before editability checks * adds tests for RSVP
348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import type { CalendarEvent, CalendarParticipant, CalendarRights } from '@/lib/jmap/types';
|
|
import {
|
|
getEventEditability,
|
|
canUserRsvp,
|
|
canCreateEventsIn,
|
|
eventHasNoOwner,
|
|
type EventEditability,
|
|
type EditabilityContext,
|
|
} from '@/lib/calendar-editability';
|
|
|
|
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
|
return {
|
|
'@type': 'Event',
|
|
id: 'ev1',
|
|
uid: 'uid-ev1',
|
|
calendarIds: { cal1: true },
|
|
title: 'Test Event',
|
|
description: '',
|
|
descriptionContentType: 'text/plain',
|
|
start: '2026-03-01T10:00:00',
|
|
duration: 'PT1H',
|
|
timeZone: 'UTC',
|
|
showWithoutTime: false,
|
|
status: 'confirmed',
|
|
freeBusyStatus: 'busy',
|
|
privacy: 'public',
|
|
keywords: null,
|
|
categories: null,
|
|
color: null,
|
|
recurrenceId: null,
|
|
recurrenceIdTimeZone: null,
|
|
recurrenceRules: null,
|
|
recurrenceOverrides: null,
|
|
excludedRecurrenceRules: null,
|
|
useDefaultAlerts: false,
|
|
alerts: null,
|
|
locations: null,
|
|
virtualLocations: null,
|
|
links: null,
|
|
relatedTo: null,
|
|
utcStart: null,
|
|
utcEnd: null,
|
|
isDraft: false,
|
|
isOrigin: true,
|
|
sequence: 0,
|
|
created: '2026-03-01T09:00:00Z',
|
|
updated: '2026-03-01T09:00:00Z',
|
|
locale: null,
|
|
replyTo: null,
|
|
organizerCalendarAddress: null,
|
|
participants: null,
|
|
mayInviteSelf: false,
|
|
mayInviteOthers: false,
|
|
hideAttendees: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRights(overrides: Partial<CalendarRights> = {}): CalendarRights {
|
|
return {
|
|
mayReadFreeBusy: false,
|
|
mayReadItems: false,
|
|
mayWriteAll: false,
|
|
mayWriteOwn: false,
|
|
mayUpdatePrivate: false,
|
|
mayRSVP: false,
|
|
mayShare: false,
|
|
mayDelete: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const ALL_RIGHTS = makeRights({
|
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
|
});
|
|
const READ_ONLY = makeRights({ mayReadFreeBusy: true, mayReadItems: true });
|
|
const WRITE_OWN = makeRights({
|
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true,
|
|
});
|
|
const RSVP_ONLY = makeRights({
|
|
mayReadFreeBusy: true, mayReadItems: true, mayUpdatePrivate: true, mayRSVP: true,
|
|
});
|
|
|
|
const SELF = 'alice@example.com';
|
|
const ALIAS = 'info@example.com';
|
|
const OTHER = 'carol@example.com';
|
|
|
|
const owner = (email: string): Partial<CalendarParticipant> => ({
|
|
'@type': 'Participant', name: email, email, roles: { owner: true, attendee: true },
|
|
sendTo: { imip: `mailto:${email}` }, participationStatus: 'accepted', kind: 'individual',
|
|
});
|
|
const attendee = (email: string): Partial<CalendarParticipant> => ({
|
|
'@type': 'Participant', name: email, email, roles: { attendee: true },
|
|
sendTo: { imip: `mailto:${email}` }, participationStatus: 'needs-action', kind: 'individual',
|
|
});
|
|
|
|
/** Build a context whose single calendar `cal1` carries the given rights. */
|
|
function ctx(
|
|
rights: CalendarRights | null,
|
|
addresses: string[] = [SELF],
|
|
subscriptions: string[] = [],
|
|
): EditabilityContext {
|
|
const calendarsById = new Map(rights ? [['cal1', { myRights: rights }]] : []);
|
|
return {
|
|
calendarsById,
|
|
userCalendarAddresses: addresses,
|
|
isSubscriptionCalendar: (id) => subscriptions.includes(id),
|
|
};
|
|
}
|
|
|
|
describe('canCreateEventsIn (issue #762)', () => {
|
|
const sub = (id: string) => id === 'sub';
|
|
it('allows a writable, non-subscription calendar', () => {
|
|
expect(canCreateEventsIn({ id: 'c', myRights: ALL_RIGHTS }, sub)).toBe(true);
|
|
expect(canCreateEventsIn({ id: 'c', myRights: WRITE_OWN }, sub)).toBe(true);
|
|
});
|
|
it('rejects a read-only calendar', () => {
|
|
expect(canCreateEventsIn({ id: 'c', myRights: READ_ONLY }, sub)).toBe(false);
|
|
expect(canCreateEventsIn({ id: 'c', myRights: RSVP_ONLY }, sub)).toBe(false);
|
|
});
|
|
it('rejects an iCal subscription even when its Stalwart rights are writable', () => {
|
|
expect(canCreateEventsIn({ id: 'sub', myRights: ALL_RIGHTS }, sub)).toBe(false);
|
|
});
|
|
it('is permissive when myRights is absent (local/birthday calendars)', () => {
|
|
expect(canCreateEventsIn({ id: 'c', myRights: undefined as never }, sub)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('eventHasNoOwner', () => {
|
|
it('is true for a plain event with no participants/organizer', () => {
|
|
expect(eventHasNoOwner(makeEvent())).toBe(true);
|
|
});
|
|
it('is false when an owner-role participant exists', () => {
|
|
expect(eventHasNoOwner(makeEvent({ participants: { a: owner(SELF) } as never }))).toBe(false);
|
|
});
|
|
it('is false when an event-level organizer address exists', () => {
|
|
expect(eventHasNoOwner(makeEvent({ organizerCalendarAddress: `mailto:${OTHER}` }))).toBe(false);
|
|
});
|
|
it('is false when a reply destination exists', () => {
|
|
expect(eventHasNoOwner(makeEvent({ replyTo: { imip: `mailto:${OTHER}` } }))).toBe(false);
|
|
});
|
|
it('is true when only non-owner participants exist', () => {
|
|
expect(eventHasNoOwner(makeEvent({ participants: { b: attendee(OTHER) } as never }))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - calendar rights gate', () => {
|
|
it('mayWriteAll -> editable even when the user is not the organizer', () => {
|
|
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(ALL_RIGHTS))).toBe('editable');
|
|
});
|
|
|
|
it('read-only calendar -> read-only even for an event the user organizes', () => {
|
|
const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
|
|
participants: { a: owner(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
|
|
});
|
|
|
|
it('mayWriteOwn + user is organizer -> editable', () => {
|
|
const ev = makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never });
|
|
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
|
|
});
|
|
|
|
it('mayWriteOwn + user is a mere attendee -> rsvp-only (cannot edit body)', () => {
|
|
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('rsvp-only');
|
|
});
|
|
|
|
it('mayWriteOwn + ownerless event -> editable', () => {
|
|
const ev = makeEvent({ participants: { b: attendee(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(WRITE_OWN))).toBe('editable');
|
|
});
|
|
|
|
it('RSVP-only rights + participant -> rsvp-only', () => {
|
|
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('rsvp-only');
|
|
});
|
|
|
|
it('RSVP-only rights + user is NOT a participant -> read-only', () => {
|
|
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: { a: owner(OTHER), b: attendee('dave@example.com') } as never });
|
|
expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('read-only');
|
|
});
|
|
|
|
it('no write / no rsvp rights -> read-only', () => {
|
|
const ev = makeEvent({ participants: { a: owner(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(READ_ONLY))).toBe('read-only');
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - subscription calendars', () => {
|
|
it('an event in a client-side iCal subscription is read-only regardless of rights/organizer', () => {
|
|
const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
|
|
participants: { a: owner(SELF) } as never });
|
|
// Even if the (irrelevant) rights map says all-writable, the subscription wins.
|
|
expect(getEventEditability(ev, ctx(ALL_RIGHTS, [SELF], ['cal1']))).toBe('read-only');
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - unknown calendar rights', () => {
|
|
it('own origin copy is editable when rights are not yet loaded', () => {
|
|
expect(getEventEditability(makeEvent({ isOrigin: true }), ctx(null))).toBe('editable');
|
|
});
|
|
it('a delivered (non-origin) copy stays read-only until rights load', () => {
|
|
expect(getEventEditability(makeEvent({ isOrigin: false }), ctx(null))).toBe('read-only');
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - alias over-permissiveness regression', () => {
|
|
it('organizer == account alias in a READ-ONLY shared calendar -> read-only (not editable)', () => {
|
|
// The alias branch would have matched isOrganizer and opened the editor; the
|
|
// rights gate must keep it read-only.
|
|
const ev = makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${ALIAS}`,
|
|
participants: { a: owner(ALIAS) } as never });
|
|
expect(getEventEditability(ev, ctx(READ_ONLY, [SELF, ALIAS]))).toBe('read-only');
|
|
});
|
|
|
|
it('organizer == account alias in a WRITE-OWN calendar -> editable (legitimate alias edit)', () => {
|
|
const ev = makeEvent({ organizerCalendarAddress: `mailto:${ALIAS}`,
|
|
participants: { a: owner(ALIAS) } as never });
|
|
expect(getEventEditability(ev, ctx(WRITE_OWN, [SELF, ALIAS]))).toBe('editable');
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - multi-calendar membership', () => {
|
|
it('requires the right on EVERY calendar (a read-only member blocks edit)', () => {
|
|
const ev = makeEvent({ calendarIds: { cal1: true, cal2: true },
|
|
participants: { a: owner(SELF) } as never });
|
|
const context: EditabilityContext = {
|
|
calendarsById: new Map([
|
|
['cal1', { myRights: ALL_RIGHTS }],
|
|
['cal2', { myRights: READ_ONLY }],
|
|
]),
|
|
userCalendarAddresses: [SELF],
|
|
isSubscriptionCalendar: () => false,
|
|
};
|
|
expect(getEventEditability(ev, context)).toBe('read-only');
|
|
});
|
|
});
|
|
|
|
describe('getEventEditability - matrix sanity', () => {
|
|
const cases: Array<[string, CalendarRights, boolean, EventEditability]> = [
|
|
['writeAll/organizer', ALL_RIGHTS, true, 'editable'],
|
|
['writeAll/attendee', ALL_RIGHTS, false, 'editable'],
|
|
['writeOwn/organizer', WRITE_OWN, true, 'editable'],
|
|
['writeOwn/attendee', WRITE_OWN, false, 'rsvp-only'],
|
|
['rsvpOnly/attendee', RSVP_ONLY, false, 'rsvp-only'],
|
|
['readOnly/organizer', READ_ONLY, true, 'read-only'],
|
|
['readOnly/attendee', READ_ONLY, false, 'read-only'],
|
|
];
|
|
it.each(cases)('%s -> %s', (_label, rights, userIsOrganizer, expected) => {
|
|
const ev = userIsOrganizer
|
|
? makeEvent({ participants: { a: owner(SELF), b: attendee(OTHER) } as never })
|
|
: makeEvent({ isOrigin: false, organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: { a: owner(OTHER), b: attendee(SELF) } as never });
|
|
expect(getEventEditability(ev, ctx(rights))).toBe(expected);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Stalwart shape: participants carry only `calendarAddress` (no email/sendTo)
|
|
* and the organizer is additionally named by `organizerCalendarAddress`.
|
|
*/
|
|
function receivedInvite(attendeeAddress = SELF): CalendarEvent {
|
|
return makeEvent({
|
|
isOrigin: false,
|
|
organizerCalendarAddress: `mailto:${OTHER}`,
|
|
participants: {
|
|
att: {
|
|
'@type': 'Participant', calendarAddress: `mailto:${attendeeAddress}`,
|
|
participationStatus: 'needs-action', expectReply: true,
|
|
roles: { attendee: true, required: true },
|
|
},
|
|
org: {
|
|
'@type': 'Participant', calendarAddress: `mailto:${OTHER}`,
|
|
participationStatus: 'accepted',
|
|
roles: { owner: true, required: true },
|
|
},
|
|
} as never,
|
|
});
|
|
}
|
|
|
|
describe('canUserRsvp (issue #937)', () => {
|
|
it('is true for an externally organized invite in the user\'s own calendar', () => {
|
|
expect(canUserRsvp(receivedInvite(), ctx(ALL_RIGHTS))).toBe(true);
|
|
});
|
|
|
|
it('leaves the 1.9 rights-first editability of that invite untouched', () => {
|
|
// Must not regress 1.9 functionality
|
|
expect(getEventEditability(receivedInvite(), ctx(ALL_RIGHTS))).toBe('editable');
|
|
});
|
|
|
|
it('is true when the invited address is an account alias', () => {
|
|
expect(canUserRsvp(receivedInvite(ALIAS), ctx(ALL_RIGHTS, [SELF, ALIAS]))).toBe(true);
|
|
});
|
|
|
|
it('agrees with the rsvp-only editability state', () => {
|
|
const ev = receivedInvite();
|
|
expect(getEventEditability(ev, ctx(RSVP_ONLY))).toBe('rsvp-only');
|
|
expect(canUserRsvp(ev, ctx(RSVP_ONLY))).toBe(true);
|
|
});
|
|
|
|
it('is false for the organizer of their own event', () => {
|
|
const ev = makeEvent({ organizerCalendarAddress: `mailto:${SELF}`,
|
|
participants: { a: owner(SELF), b: attendee(OTHER) } as never });
|
|
expect(canUserRsvp(ev, ctx(ALL_RIGHTS))).toBe(false);
|
|
});
|
|
|
|
it('is false when the user is not a participant at all', () => {
|
|
expect(canUserRsvp(receivedInvite(OTHER), ctx(ALL_RIGHTS, ['dave@example.com']))).toBe(false);
|
|
});
|
|
|
|
it('is false for a plain event with no organizer or participants', () => {
|
|
expect(canUserRsvp(makeEvent(), ctx(ALL_RIGHTS))).toBe(false);
|
|
});
|
|
|
|
it('is false without mayRSVP on the calendar', () => {
|
|
expect(canUserRsvp(receivedInvite(), ctx(READ_ONLY))).toBe(false);
|
|
});
|
|
|
|
it('is false in an iCal subscription', () => {
|
|
expect(canUserRsvp(receivedInvite(), ctx(ALL_RIGHTS, [SELF], ['cal1']))).toBe(false);
|
|
});
|
|
|
|
it('is false while the calendar rights are still unloaded', () => {
|
|
expect(canUserRsvp(receivedInvite(), ctx(null))).toBe(false);
|
|
});
|
|
|
|
it('requires mayRSVP on every calendar the event belongs to', () => {
|
|
const ev = receivedInvite();
|
|
ev.calendarIds = { cal1: true, cal2: true };
|
|
const context: EditabilityContext = {
|
|
calendarsById: new Map([
|
|
['cal1', { myRights: ALL_RIGHTS }],
|
|
['cal2', { myRights: READ_ONLY }],
|
|
]),
|
|
userCalendarAddresses: [SELF],
|
|
isSubscriptionCalendar: () => false,
|
|
};
|
|
expect(canUserRsvp(ev, context)).toBe(false);
|
|
});
|
|
});
|