Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Tests recovery from an "accept before sync" UID conflict (bug 1717401).
*
* A CalDAV server doing server-side scheduling (RFC 6638) writes an event into
* the invitee's calendar under its own object URI as soon as the invitation
* arrives. If the user accepts the invitation before that copy has synced, the
* client does not know the server's URI and tries to create the event itself.
* The server rejects the create because the UID already exists. The provider
* reports calIErrors.UID_CONFLICT and the invitation code recovers by syncing
* and applying the response to the existing event with modifyItem, which also
* replies to the organizer - the same path as an ordinary accept-after-sync.
*/
var { CalDAVServer } = ChromeUtils.importESModule(
);
var { CalEvent } = ChromeUtils.importESModule("resource:///modules/CalEvent.sys.mjs");
var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
// Keep the UID free of characters the test server rejects in object paths
// (it only allows [\w-]+.ics); the provider derives the PUT path from the UID.
const UID = "accept-before-sync-1717401";
// The test calendar authenticates as "alice"; the CalDAV provider derives the
// user's calendar address as "mailto:alice@invalid", so the invitee uses that.
const USER = "mailto:alice@invalid";
const ORGANIZER = "mailto:organizer@example.com";
// Where the server stored its own copy of the event.
const SERVER_PATH = "/calendars/alice/test/server-scheduled.ics";
// A fixed DTSTAMP so the invitation and the server's copy are the same revision.
const DTSTAMP = "20220317T100000Z";
// REPLY messages the client tried to send to the organizer, recorded by the
// stubbed iMIP transport.
let sentReplies = [];
// How often processItipItem handed us an action while the last accept ran.
let optionsFuncCalls = 0;
/**
* Build a serialized calendar object for the invitation, with the invitee
* (USER) as an attendee.
*
* @param {object} options
* @param {string} options.partStat - The invitee's participation status.
* @param {number} [options.sequence] - The SEQUENCE of the event.
* @param {string} [options.method] - The iTIP METHOD, if any.
* @param {string} [options.uid] - The UID of the event.
* @param {string} [options.dtstamp] - The DTSTAMP of the event.
* @returns {string} The serialized VCALENDAR.
*/
function buildIcs({ partStat, sequence = 0, method, uid = UID, dtstamp = DTSTAMP }) {
const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Test//Test//EN"];
if (method) {
lines.push("METHOD:" + method);
}
lines.push(
"BEGIN:VEVENT",
"UID:" + uid,
"SUMMARY:Team meeting",
"DTSTART:20220317T120000Z",
"DTEND:20220317T130000Z",
"DTSTAMP:" + dtstamp,
"SEQUENCE:" + sequence,
"ORGANIZER;CN=Organizer:" + ORGANIZER,
"ATTENDEE;CN=Alice;PARTSTAT=" + partStat + ";RSVP=TRUE:" + USER,
"END:VEVENT",
"END:VCALENDAR"
);
return lines.join("\r\n");
}
/**
* Register a CalDAV calendar for the test collection and wait for its first
* sync to finish.
*
* @param {boolean} [cached=true] - Whether to enable the offline cache.
* @returns {Promise<calICalendar>} The registered calendar.
*/
function registerTestCalendar(cached = true) {
const calendar = cal.manager.createCalendar(
"caldav",
Services.io.newURI(`${CalDAVServer.origin}/calendars/alice/test/`)
);
calendar.name = "uidCollision";
calendar.id = cal.getUUID();
calendar.setProperty("cache.enabled", cached);
calendar.setProperty("username", "alice");
cal.manager.registerCalendar(calendar);
const registered = cal.manager.getCalendarById(calendar.id);
// Unregister the calendar if the task failed before doing it itself.
registerCleanupFunction(() => {
if (cal.manager.getCalendarById(registered.id)) {
cal.manager.unregisterCalendar(registered);
}
});
return new Promise(resolve => {
/** @implements {calIObserver} */
const observer = {
QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
onStartBatch() {},
onEndBatch() {},
onAddItem() {},
onModifyItem() {},
onDeleteItem() {},
onError() {},
onPropertyChanged() {},
onPropertyDeleting() {},
onLoad() {
registered.removeObserver(observer);
registered.setProperty("refreshInterval", "0");
resolve(registered);
},
};
registered.addObserver(observer);
});
}
/**
* Drive processItipItem for the invitation against the given calendar and
* return the create action (the cache is stale, so no item is found).
*
* @param {calICalendar} calendar - The (stale) target calendar.
* @returns {Promise<Function>} The action function to run to accept/decline.
*/
async function getCreateAction(calendar) {
const itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
itipItem.init(buildIcs({ partStat: "NEEDS-ACTION", method: "REQUEST" }));
itipItem.receivedMethod = "REQUEST";
itipItem.responseMethod = "REPLY";
itipItem.autoResponse = Ci.calIItipItem.AUTO;
itipItem.targetCalendar = calendar;
optionsFuncCalls = 0;
const processed = await new Promise(resolve => {
cal.itip.processItipItem(itipItem, (item, rc, actionFunc, foundItems) => {
optionsFuncCalls++;
resolve({ rc, actionFunc, foundItems });
});
});
Assert.ok(Components.isSuccessCode(processed.rc), "processItipItem succeeded");
Assert.equal(processed.foundItems.length, 0, "the event was not found in the stale cache");
Assert.equal(processed.actionFunc.method, "REQUEST", "the create path was chosen");
return processed.actionFunc;
}
/**
* Run an action function and resolve with its final {status, opType}.
*
* @param {Function} actionFunc - The action returned by getCreateAction.
* @param {string} partStat - The participation status to apply.
* @returns {Promise<{status: number, opType: number}>} The operation result.
*/
function runAction(actionFunc, partStat) {
return new Promise(resolve => {
const opListener = {
QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
onOperationComplete(_calendar, status, opType) {
resolve({ status, opType });
},
onGetResult() {},
};
actionFunc(opListener, partStat, null);
});
}
/**
* Wait for the reconciliation to reach the server's resource and our cache.
* The offline tests depend on this having happened; `calendar.refresh()`
* returns before the synchronisation it starts has finished.
*
* @param {calICalendar} calendar - The calendar holding the cached copy.
* @param {string} partStat - The status both copies must end up with.
*/
async function waitForReconciliation(calendar, partStat) {
await TestUtils.waitForCondition(
() => CalDAVServer.items.get(SERVER_PATH)?.ics.includes(`PARTSTAT=${partStat}`),
`the server's resource carries PARTSTAT=${partStat}`
);
await TestUtils.waitForCondition(
async () =>
(await calendar.getItem(UID))?.getAttendeeById(USER).participationStatus == partStat,
`the cached copy carries PARTSTAT=${partStat}`
);
}
/**
* Wait until the server has not received a request for a short period, so
* background requests settle before the calendar is torn down. Nothing is
* asserted after this, use waitForReconciliation() for that.
*/
async function waitForServerQuiescence() {
let last = -1;
while (last != CalDAVServer.requestCount) {
last = CalDAVServer.requestCount;
await new Promise(resolve => do_timeout(250, resolve));
}
}
/** Count the resources on the server whose UID matches the test event. */
function serverItemCountForUid() {
let count = 0;
for (const item of CalDAVServer.items.values()) {
if (CalDAVServer._extractUid(item.ics) == UID) {
count++;
}
}
return count;
}
add_setup(async function () {
do_get_profile();
CalDAVServer.open("alice", "alice");
const loginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(Ci.nsILoginInfo);
loginInfo.init(CalDAVServer.origin, null, "test", "alice", "alice", "", "");
await Services.logins.addLoginAsync(loginInfo);
// Record the REPLY the client sends to the organizer instead of mailing it.
const originalTransport = cal.itip.getImipTransport;
cal.itip.getImipTransport = () => ({
QueryInterface: ChromeUtils.generateQI(["calIItipTransport"]),
scheme: "mailto",
type: "email",
senderAddress: USER,
sendItems(recipients, itipItem) {
sentReplies.push({
method: itipItem.responseMethod,
recipients: Array.from(recipients, attendee => attendee.id),
});
return true;
},
});
registerCleanupFunction(() => {
cal.itip.getImipTransport = originalTransport;
CalDAVServer.close();
});
});
/**
* Accept before the server's copy has synced and check that the recovery
* updates the existing event (no duplicate) and replies to the organizer.
*
* @param {string} style - The server's rejection shape.
* @param {boolean} cached - Whether the calendar uses the offline cache.
* @param {object} [buildIcsArgs] - Extra arguments for the copy the server
* deposited in the calendar.
*/
async function runAcceptBeforeSync(style, cached, buildIcsArgs = {}) {
const serverIcs = buildIcs({ partStat: "NEEDS-ACTION", ...buildIcsArgs });
const label = `"${style}"${cached ? "" : " (uncached)"}`;
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar(cached);
// The server schedules the event into the calendar after our initial
// sync, so our local copy of the collection does not know about it yet.
CalDAVServer.conflictResponseStyle = style;
await CalDAVServer.putItemInternal(SERVER_PATH, serverIcs);
Assert.equal(await calendar.getItem(UID), null, `stale local data for ${label}`);
const action = await getCreateAction(calendar);
const result = await runAction(action, "ACCEPTED");
Assert.ok(
Components.isSuccessCode(result.status),
`accepting before sync succeeds for ${label} (status=${result.status.toString(16)})`
);
Assert.equal(
result.opType,
Ci.calIOperationListener.MODIFY,
`recovery updates the existing event rather than creating one for ${label}`
);
// Once for the invitation and once for the event the recovery leaves behind.
// Without the guard in processFoundItems() the sync during the recovery
// offers a third one, which replaces the action the user is running - in the
// invitation bar the buttons reappear for a moment.
Assert.equal(
optionsFuncCalls,
2,
`the invitation is not offered again during the recovery for ${label}`
);
Assert.equal(serverItemCountForUid(), 1, `no duplicate on the server for ${label}`);
Assert.ok(
CalDAVServer.items.get(SERVER_PATH).ics.includes("PARTSTAT=ACCEPTED"),
`the server's resource was updated in place for ${label}`
);
const stored = await calendar.getItem(UID);
Assert.equal(
stored.getAttendeeById(USER).participationStatus,
"ACCEPTED",
`the local copy carries the acceptance for ${label}`
);
Assert.ok(
sentReplies.some(m => m.method == "REPLY" && m.recipients.includes(ORGANIZER)),
`a REPLY was sent to the organizer for ${label}`
);
await waitForServerQuiescence();
cal.manager.unregisterCalendar(calendar);
}
/**
* Accepting before the server's copy has synced must succeed by updating the
* existing event (not creating a duplicate) and reply to the organizer, for
* every real-world rejection shape: Nextcloud's bare 400, Radicale's and
* SOGo's 409, the conformant 409 with the CALDAV:no-uid-conflict element and a
* plain 412 for a copy stored at the requested URI itself.
*/
add_task(async function testAcceptBeforeSyncSabre400() {
await runAcceptBeforeSync("sabre400", true);
});
add_task(async function testAcceptBeforeSyncRadicale409() {
await runAcceptBeforeSync("radicale409", true);
});
add_task(async function testAcceptBeforeSyncSogo409() {
await runAcceptBeforeSync("sogo409", true);
});
add_task(async function testAcceptBeforeSyncPrecondition409Href() {
await runAcceptBeforeSync("precondition409href", true);
});
add_task(async function testAcceptBeforeSyncPlain412() {
await runAcceptBeforeSync("plain412", true);
});
/**
* The recovery must also work without the offline cache, where the provider's
* rejection reaches the invitation code directly.
*/
add_task(async function testAcceptBeforeSyncResolvesUncached() {
await runAcceptBeforeSync("sabre400", false);
});
/**
* The recovery must not apply the response to a different revision of the
* event, e.g. when the organizer sent an update after this invitation. The add
* fails the way an unresolved conflict always has, and the server copy is left
* untouched for the user to review after the sync.
*/
add_task(async function testNewerRevisionNotResolved() {
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar();
CalDAVServer.conflictResponseStyle = "sabre400";
const newerIcs = buildIcs({ partStat: "NEEDS-ACTION", sequence: 2 });
await CalDAVServer.putItemInternal(SERVER_PATH, newerIcs);
const action = await getCreateAction(calendar);
const result = await runAction(action, "ACCEPTED");
Assert.ok(
!Components.isSuccessCode(result.status),
"accepting an outdated revision fails instead of updating the newer copy"
);
Assert.equal(CalDAVServer.items.get(SERVER_PATH).ics, newerIcs, "the server copy is untouched");
Assert.ok(
!sentReplies.some(m => m.method == "REPLY"),
"no REPLY is sent for the outdated revision"
);
await waitForServerQuiescence();
cal.manager.unregisterCalendar(calendar);
});
/**
* A calendar with "itip.disableRevisionChecks" waives the revision check in the
* found NEEDS-ACTION path, so the recovery has to waive it too: the response is
* applied even though the server's copy carries a newer SEQUENCE.
*/
add_task(async function testNewerRevisionResolvedWithoutRevisionChecks() {
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar();
calendar.setProperty("itip.disableRevisionChecks", true);
CalDAVServer.conflictResponseStyle = "sabre400";
await CalDAVServer.putItemInternal(
SERVER_PATH,
buildIcs({ partStat: "NEEDS-ACTION", sequence: 2 })
);
const action = await getCreateAction(calendar);
const result = await runAction(action, "ACCEPTED");
Assert.ok(
Components.isSuccessCode(result.status),
`accepting succeeds without revision checks (status=${result.status.toString(16)})`
);
Assert.ok(
CalDAVServer.items.get(SERVER_PATH).ics.includes("PARTSTAT=ACCEPTED"),
"the response was applied to the newer server copy"
);
await waitForServerQuiescence();
cal.manager.unregisterCalendar(calendar);
});
/**
* A newer DTSTAMP alone is not a different revision: the server rewrites its
* copy whenever another attendee replies. Like the found NEEDS-ACTION path, the
* recovery compares the SEQUENCE only, so the acceptance is still applied.
*/
add_task(async function testNewerStampResolved() {
await runAcceptBeforeSync("sabre400", true, { dtstamp: "20220317T110000Z" });
});
/**
* The recovery must not act on a copy that already carries a response (e.g. the
* invitation was answered on another client). Like the found path, a copy that
* is no longer NEEDS-ACTION is left untouched and no second reply is sent.
*/
add_task(async function testAlreadyRespondedNotResolved() {
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar();
CalDAVServer.conflictResponseStyle = "sabre400";
const respondedIcs = buildIcs({ partStat: "ACCEPTED" });
await CalDAVServer.putItemInternal(SERVER_PATH, respondedIcs);
const action = await getCreateAction(calendar);
const result = await runAction(action, "ACCEPTED");
Assert.ok(
!Components.isSuccessCode(result.status),
"accepting an already-answered copy fails instead of replying twice"
);
Assert.equal(
CalDAVServer.items.get(SERVER_PATH).ics,
respondedIcs,
"the already-answered server copy is untouched"
);
Assert.ok(!sentReplies.some(m => m.method == "REPLY"), "no second REPLY is sent");
await waitForServerQuiescence();
cal.manager.unregisterCalendar(calendar);
});
/**
* Accept while offline and check that the reconnect applies the response to the
* server's own copy.
*
* @param {object} [buildIcsArgs] - Extra arguments for the copy the server
* deposited in the calendar.
*/
async function runAcceptWhileOffline(buildIcsArgs = {}) {
const serverIcs = buildIcs({ partStat: "NEEDS-ACTION", ...buildIcsArgs });
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar();
CalDAVServer.conflictResponseStyle = "sabre400";
await CalDAVServer.putItemInternal(SERVER_PATH, serverIcs);
Assert.equal(await calendar.getItem(UID), null, "the event is not yet in our cache");
// Go offline and accept; the response is queued as an offline create.
const wasManaged = Services.io.manageOfflineStatus;
Services.io.manageOfflineStatus = false;
Services.io.offline = true;
try {
await calendar.addItem(new CalEvent(buildIcs({ partStat: "ACCEPTED" })));
const offlineCopy = await calendar.getItem(UID);
Assert.equal(
offlineCopy.getAttendeeById(USER).participationStatus,
"ACCEPTED",
"the accepted event is stored locally while offline"
);
} finally {
Services.io.offline = false;
Services.io.manageOfflineStatus = wasManaged;
}
// Reconnect and sync: the queued create collides and must be reconciled.
await calendar.refresh();
await waitForReconciliation(calendar, "ACCEPTED");
await waitForServerQuiescence();
const stored = await calendar.getItem(UID);
Assert.equal(
stored.getAttendeeById(USER).participationStatus,
"ACCEPTED",
"the acceptance survived the reconnect"
);
Assert.equal(serverItemCountForUid(), 1, "no duplicate event on the server");
Assert.ok(
CalDAVServer.items.get(SERVER_PATH).ics.includes("PARTSTAT=ACCEPTED"),
"the acceptance was written to the server's resource"
);
// This task stores the acceptance with addItem instead of driving the
// invitation flow, so no REPLY is due here. Where the flow does run, the
// REPLY goes out when the user accepts, not when the reconciliation writes
// the response to the server - see testOfflineAcceptSecondDepositResync.
Assert.equal(sentReplies.length, 0, "no client REPLY for the offline reconciliation");
cal.manager.unregisterCalendar(calendar);
}
/**
* Accepting an invitation while offline must not lose the response. The local
* "Accept" is queued offline; on reconnect the queued create collides with the
* server's own copy, and the reconciliation applies the response to it so the
* next sync no longer overwrites the acceptance with the server's NEEDS-ACTION
* copy.
*/
add_task(async function testAcceptWhileOfflineRecovers() {
await runAcceptWhileOffline();
});
/**
* The reconciliation compares the SEQUENCE only, like the online recovery, so a
* server copy that merely got a newer DTSTAMP while we were offline still
* receives the queued response.
*/
add_task(async function testOfflineNewerStampRecovers() {
await runAcceptWhileOffline({ dtstamp: "20220317T110000Z" });
});
/**
* Two invitations are deposited by the server; one is accepted through the
* invitation flow while offline. After the reconnect sync reconciles it, the
* event must remain locally, also after another offline/online cycle.
*/
add_task(async function testOfflineAcceptSecondDepositResync() {
const UID2 = "second-deposit-1717401";
const SERVER_PATH2 = "/calendars/alice/test/server-scheduled-2.ics";
CalDAVServer.reset();
sentReplies = [];
const calendar = await registerTestCalendar();
CalDAVServer.conflictResponseStyle = "sabre400";
await CalDAVServer.putItemInternal(SERVER_PATH, buildIcs({ partStat: "NEEDS-ACTION" }));
await CalDAVServer.putItemInternal(
SERVER_PATH2,
buildIcs({ partStat: "NEEDS-ACTION", uid: UID2 })
);
Assert.equal(await calendar.getItem(UID), null, "the events are not yet in our cache");
// Accept the first invitation through the iTIP flow while offline.
const wasManaged = Services.io.manageOfflineStatus;
Services.io.manageOfflineStatus = false;
Services.io.offline = true;
try {
const action = await getCreateAction(calendar);
const result = await runAction(action, "ACCEPTED");
Assert.ok(Components.isSuccessCode(result.status), "the offline accept is stored locally");
} finally {
Services.io.offline = false;
Services.io.manageOfflineStatus = wasManaged;
}
await calendar.refresh();
await waitForReconciliation(calendar, "ACCEPTED");
await waitForServerQuiescence();
Assert.ok(
CalDAVServer.items.get(SERVER_PATH).ics.includes("PARTSTAT=ACCEPTED"),
"the acceptance was written to the server's resource"
);
let stored = await calendar.getItem(UID);
Assert.ok(stored, "the event is present after the reconciliation");
// The user-reported trigger: another offline/online cycle and a manual sync.
Services.io.manageOfflineStatus = false;
Services.io.offline = true;
Services.io.offline = false;
Services.io.manageOfflineStatus = wasManaged;
await calendar.refresh();
await waitForServerQuiescence();
stored = await calendar.getItem(UID);
Assert.ok(stored, "the event survives another offline/online cycle");
const occurrences = await calendar.getItemsAsArray(
Ci.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES | Ci.calICalendar.ITEM_FILTER_TYPE_ALL,
0,
cal.createDateTime("20220316T000000Z"),
cal.createDateTime("20220319T000000Z")
);
const uids = occurrences.map(o => o.id);
Assert.ok(uids.includes(UID), "the view query returns the accepted event");
Assert.ok(uids.includes(UID2), "the view query returns the second deposit");
Assert.equal(
stored.getAttendeeById(USER).participationStatus,
"ACCEPTED",
"the acceptance is intact"
);
cal.manager.unregisterCalendar(calendar);
});