mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: send an answer to one occurrence in an RSVP-only calendar #34
In a calendar the user may only answer (mayRSVP without write rights), an answer to the whole series was uploaded, but an answer to a single occurrence goes into the occurrence's override and counted as an edit: it was undone on the next sync without a word. A change that holds nothing but the user's own answer, to the series or to occurrences, now counts as an RSVP and is uploaded. Stalwart 0.16.23 refuses every update in a shared calendar without write rights, answers included; the engine then puts the rows back from the server and reports the refusal, as it already did for an answer to the series, so the answer is no longer lost silently. Checked with a planner test (a new and an existing override, and an extra edit that is still reverted) and a run through the engine and the fake server.
This commit is contained in:
@@ -94,6 +94,43 @@ describe('calendar device edits through the engine', () => {
|
||||
expect(h.batches.log.slice(before).filter((ops) => ops.some((op) => op.op !== 'syncState' && op.op !== 'assert'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('sends an answer to one occurrence in a calendar that grants only RSVP, and reports a refusal', async () => {
|
||||
const h = real({ accounts: 'withShared' });
|
||||
const team = h.server.addCalendar('team', {
|
||||
name: 'Team events',
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: true, mayShare: false, mayDelete: false },
|
||||
});
|
||||
h.prefs.calendarSelection[`team/${team}`] = true;
|
||||
const id = h.server.addEvent('team', {
|
||||
uid: 'team-weekly',
|
||||
title: 'Team weekly',
|
||||
start: '2026-10-05T09:00:00',
|
||||
duration: 'PT1H',
|
||||
timeZone: 'Europe/Berlin',
|
||||
recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly', count: 5 },
|
||||
organizerCalendarAddress: 'mailto:boss@example.net',
|
||||
participants: {
|
||||
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { '@type': 'Participant', calendarAddress: 'mailto:alice@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
},
|
||||
calendarIds: { [team]: true },
|
||||
});
|
||||
await h.run(CALENDAR_AUTHORITY);
|
||||
const master = h.events().find((e) => e[Events._SYNC_ID] === `team/${id}`)!;
|
||||
|
||||
// Etar answers the Oct 12 occurrence through CONTENT_EXCEPTION_URI.
|
||||
h.device.user.exceptionViaUri(Number(master._id), Date.UTC(2026, 9, 12, 7), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
const sets = () => h.server.requests.filter((r) => r.methods.includes('CalendarEvent/set')).length;
|
||||
const before = sets();
|
||||
const report = await h.run(CALENDAR_AUTHORITY);
|
||||
|
||||
// The answer was sent (this server takes no RSVP without write rights), the refusal is reported and the row put back.
|
||||
expect(sets()).toBeGreaterThan(before);
|
||||
expect(report.itemErrors).toEqual([expect.objectContaining({ ref: `team/${id}`, side: 'upload', type: 'forbidden' })]);
|
||||
expect(h.events().filter((e) => e[Events.ORIGINAL_SYNC_ID] === `team/${id}`)).toEqual([]);
|
||||
expect(h.events().every((e) => Number(e[Events.DIRTY] ?? 0) === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a description and location cleared on one occurrence cleared', async () => {
|
||||
const h = real();
|
||||
const id = h.server.addEvent('a', {
|
||||
|
||||
@@ -225,6 +225,33 @@ describe('calendar planner: recurrence edits made on the device', () => {
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an answer to one occurrence through in a calendar that grants only RSVP, and nothing more', async () => {
|
||||
const invite = weekly();
|
||||
invite.calendarIds = { r: true };
|
||||
invite.organizerCalendarAddress = 'mailto:boss@example.net';
|
||||
invite.participants = {
|
||||
boss: { calendarAddress: 'mailto:boss@example.net', roles: { owner: true }, participationStatus: 'accepted' },
|
||||
me: { calendarAddress: 'mailto:usera@example.org', roles: { attendee: true }, participationStatus: 'needs-action' },
|
||||
};
|
||||
const { h, id } = await synced(invite);
|
||||
// Etar answers Oct 19 through CONTENT_EXCEPTION_URI (a new override)...
|
||||
const added = h.fake.user.exceptionViaUri(id, utc('2026-10-19T13:00:00Z'), { [Events.SELF_ATTENDEE_STATUS]: 2, [Events.STATUS]: 1 });
|
||||
// ... and another app answers the existing override of Oct 7 on its row.
|
||||
const existing = (await h.local('bl'))!.exceptions.find((x) => x.recurrenceId === '2026-10-07T09:00:00')!;
|
||||
h.fake.table('attendees').forEach((a) => {
|
||||
if (a.event_id === existing.eventId && a[Attendees.ATTENDEE_EMAIL] === ME) a[Attendees.ATTENDEE_STATUS] = 1;
|
||||
});
|
||||
h.fake.user.updateEvent(existing.eventId, {});
|
||||
const action = patchOf(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx));
|
||||
type Answer = { participants: Record<string, { participationStatus?: string }> };
|
||||
expect((action.patch['recurrenceOverrides/2026-10-19T09:00:00'] as Answer).participants.me.participationStatus).toBe('declined');
|
||||
expect((action.patch['recurrenceOverrides/2026-10-07T09:00:00/participants'] as Answer['participants']).me.participationStatus).toBe('accepted');
|
||||
expect(action.sendSchedulingMessages).toBe(true);
|
||||
// Anything more than the answer is still the calendar owner's to change.
|
||||
h.fake.user.updateEvent(added, { [Events.TITLE]: 'Mine now' });
|
||||
expect(calendarPlanner.planUpload((await h.local('bl'))!, h.ctx).kind).toBe('revert');
|
||||
});
|
||||
|
||||
it('turns EXDATE entries an app adds or removes into exclusions and re-inclusions', async () => {
|
||||
const { h, id } = await synced(weekly());
|
||||
h.fake.user.updateEvent(id, { [Events.EXDATE]: '20261012T130000Z\nAmerica/New_York;20261021T090000' });
|
||||
|
||||
@@ -308,9 +308,8 @@ function planUpload(local: LocalEvent, ctx: CalendarContext): Plan {
|
||||
return m && computation.patch[k] === true && ctx.isReadOnly(m[1]);
|
||||
});
|
||||
if ((readOnly && keys.length) || movedToReadOnly) {
|
||||
// RSVP is the one change a calendar without write rights may take.
|
||||
const rsvpOnly = keys.length > 0 && keys.every((k) => /^participants\/[^/]+\/participationStatus$/.test(k));
|
||||
if (!(rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) {
|
||||
// RSVP, to the series or to occurrences, is the one change a calendar without write rights may take.
|
||||
if (!(computation.rsvpOnly && ctx.calendar(calendar.calendarId)?.myRights?.mayRSVP && !movedToReadOnly)) {
|
||||
const ops = revertGroup(local, ctx);
|
||||
return ops ? { kind: 'revert', ops } : { kind: 'skip', reason: 'readOnly' };
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface UploadComputation {
|
||||
/** Exception rows whose instance is excluded by the patch, or already was. */
|
||||
removedExceptions: Set<number>;
|
||||
sendSchedulingMessages: boolean;
|
||||
/** The patch holds nothing but the user's own answer, to the series or to occurrences (what `mayRSVP` allows). */
|
||||
rsvpOnly: boolean;
|
||||
/** Units changed on the device that can't be uploaded (reported when nothing else is). */
|
||||
dropped: string[];
|
||||
}
|
||||
@@ -362,6 +364,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo
|
||||
}
|
||||
|
||||
// Exception rows.
|
||||
let answersOnly = true;
|
||||
if (!ruleRemoved && shadow.recurrenceRule) {
|
||||
for (const x of local.exceptions) {
|
||||
const units = exceptionChanges(x, local, shadow, calendarId, zone, overrides, removedExceptions, ctx, lossy);
|
||||
@@ -369,6 +372,7 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo
|
||||
exceptionUnits.set(x.eventId, units.units);
|
||||
overridesChanged = true;
|
||||
rsvp = rsvp || units.rsvp;
|
||||
answersOnly &&= units.rsvp && !units.others && units.units.size === 1 && units.units.has('attendees');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,8 +381,9 @@ export function computeUpdate(local: LocalEvent, ctx: CalendarContext): UploadCo
|
||||
|
||||
const notifying = NOTIFYING_UNITS.some((u) => masterUnits.has(u)) || overridesChanged || attendeeOthers;
|
||||
const sendSchedulingMessages = sendsSchedulingMessages(shadow, { kind: 'update', notifying, rsvp, addsAttendees: attendeeOthers }, ctx);
|
||||
const rsvpOnly = rsvp && answersOnly && !attendeeOthers && [...masterUnits].every((u) => u.startsWith('attendee:'));
|
||||
|
||||
return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped };
|
||||
return { patch, masterUnits, exceptionUnits, removedExceptions, sendSchedulingMessages, dropped, rsvpOnly };
|
||||
}
|
||||
|
||||
/** Timing properties that differ from the server's. */
|
||||
@@ -413,7 +418,7 @@ function exceptionChanges(
|
||||
removed: Set<number>,
|
||||
ctx: CalendarContext,
|
||||
lossy: boolean,
|
||||
): { units: Set<string>; rsvp: boolean } {
|
||||
): { units: Set<string>; rsvp: boolean; others?: boolean } {
|
||||
const units = new Set<string>();
|
||||
const key = x.recurrenceId;
|
||||
if (!key) return { units, rsvp: false };
|
||||
@@ -439,6 +444,7 @@ function exceptionChanges(
|
||||
const cur = sideOfRow(x);
|
||||
const values: Record<string, unknown> = {};
|
||||
let rsvp = false;
|
||||
let others = false;
|
||||
const organizer = userIsOrganizer(shadow, ctx);
|
||||
|
||||
for (const unit of EXCEPTION_COLUMN_UNITS) {
|
||||
@@ -504,6 +510,7 @@ function exceptionChanges(
|
||||
if (result.changes.length) {
|
||||
values.participants = result.participants;
|
||||
rsvp = result.rsvp;
|
||||
others = result.others;
|
||||
units.add('attendees');
|
||||
}
|
||||
}
|
||||
@@ -517,12 +524,12 @@ function exceptionChanges(
|
||||
|
||||
if (existing) {
|
||||
if (units.size) work.props.set(key, { ...(work.props.get(key) ?? {}), ...values });
|
||||
return { units, rsvp };
|
||||
return { units, rsvp, others };
|
||||
}
|
||||
// A new override: nothing but its pinned time is still an override (an app saving the instance unchanged).
|
||||
if (!units.size) units.add('new');
|
||||
work.whole.set(key, newOverride(shadow, key, values));
|
||||
return { units, rsvp };
|
||||
return { units, rsvp, others };
|
||||
}
|
||||
|
||||
/** Units a new exception row inherits from its series when it leaves them empty. */
|
||||
|
||||
Reference in New Issue
Block a user