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 that an ICS calendar whose file does not parse as iCalendar is treated
* as read-only, so that its contents are never overwritten, and that it
* recovers once the file parses again.
*/
var { CalEvent } = ChromeUtils.importESModule("resource:///modules/CalEvent.sys.mjs");
var { TestUtils } = ChromeUtils.importESModule("resource://testing-common/TestUtils.sys.mjs");
do_get_profile();
const VALID_ICS = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"BEGIN:VEVENT",
"UID:valid-file-event",
"SUMMARY:Existing event",
"DTSTART:20260301T120000Z",
"DTEND:20260301T130000Z",
"END:VEVENT",
"END:VCALENDAR",
"",
].join("\r\n");
/** Changes of the readOnly property reported to calendar observers. */
const readOnlyChanges = [];
const observer = {
QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
onStartBatch() {},
onEndBatch() {},
onLoad(calendar) {
this._onLoad?.(calendar);
},
onAddItem() {},
onModifyItem() {},
onDeleteItem() {},
onError() {},
onPropertyChanged(calendar, name, value) {
if (name == "readOnly") {
readOnlyChanges.push(value);
}
},
onPropertyDeleting() {},
};
add_setup(function () {
cal.manager.addCalendarObserver(observer);
registerCleanupFunction(() => cal.manager.removeCalendarObserver(observer));
});
/**
* Run a function that makes the calendar load, and wait for the load to end.
*
* @param {string} id - The calendar ID.
* @param {Function} func - Function that starts the load.
*/
async function loadAndWait(id, func) {
const { promise, resolve } = Promise.withResolvers();
observer._onLoad = calendar => {
if (calendar.id == id) {
resolve();
}
};
func();
await promise;
observer._onLoad = null;
}
/**
* Register an ICS calendar for a local file and wait for it to load.
*
* @param {string|Uint8Array} contents - The initial contents of the file.
* @returns {{calendar: calICalendar, path: string}}
*/
async function loadCalendarFromFile(contents) {
const path = PathUtils.join(PathUtils.tempDir, `${cal.getUUID()}.ics`);
await (typeof contents == "string"
? IOUtils.writeUTF8(path, contents)
: IOUtils.write(path, contents));
const calendar = cal.manager.createCalendar("ics", Services.io.newURI(PathUtils.toFileURI(path)));
calendar.name = "invalid file test";
calendar.id = cal.getUUID();
readOnlyChanges.length = 0;
await loadAndWait(calendar.id, () => cal.manager.registerCalendar(calendar));
registerCleanupFunction(async () => {
cal.manager.unregisterCalendar(calendar);
await IOUtils.remove(path);
});
return { calendar: cal.manager.getCalendarById(calendar.id), path };
}
/**
* Replace the contents of a calendar file. The modification time is moved
* forward, as the calendar treats a file whose modification time did not
* change as unchanged, and two writes in quick succession can get the same
* modification time on some file systems.
*
* @param {string} path - The path of the file.
* @param {string} contents - The new contents of the file.
*/
async function rewriteFile(path, contents) {
const { lastModified } = await IOUtils.stat(path);
await IOUtils.writeUTF8(path, contents);
await IOUtils.setModificationTime(path, lastModified + 60 * 1000);
}
function createEvent() {
const event = new CalEvent();
event.id = cal.getUUID();
event.title = "New event";
event.startDate = cal.createDateTime("20260303T205500Z");
event.endDate = cal.createDateTime("20260303T210200Z");
return event;
}
add_task(async function test_invalidFileIsReadOnly() {
const contents = "Important notes\nthat are not a calendar\n";
const { calendar, path } = await loadCalendarFromFile(contents);
Assert.ok(calendar.readOnly, "calendar with an invalid file should be read-only");
Assert.deepEqual(readOnlyChanges, [true], "observers should be told it became read-only");
Assert.equal(
calendar.getProperty("currentStatus"),
Ci.calIErrors.ICS_MALFORMEDDATA,
"calendar should be in the error state"
);
Assert.ok(
!Services.prefs.prefHasUserValue(`calendar.registry.${calendar.id}.readOnly`),
"read-only state should not be persisted"
);
await Assert.rejects(
calendar.addItem(createEvent()),
e => e.result == Ci.calIErrors.CAL_IS_READONLY,
"adding an item should be refused"
);
Assert.equal(await IOUtils.readUTF8(path), contents, "file should be unchanged");
});
add_task(async function test_fileBecomesInvalid() {
const { calendar, path } = await loadCalendarFromFile(VALID_ICS);
Assert.ok(!calendar.readOnly, "calendar with a valid file should be writable");
const contents = "Important notes\nthat are not a calendar\n";
await rewriteFile(path, contents);
await loadAndWait(calendar.id, () => calendar.refresh());
Assert.ok(calendar.readOnly, "calendar should be read-only once the file no longer parses");
await Assert.rejects(
calendar.addItem(createEvent()),
e => e.result == Ci.calIErrors.CAL_IS_READONLY,
"adding an item should be refused"
);
Assert.equal(await IOUtils.readUTF8(path), contents, "file should be unchanged");
});
add_task(async function test_invalidFileRecovers() {
const { calendar, path } = await loadCalendarFromFile("Not a calendar\n");
Assert.ok(calendar.readOnly, "calendar with an invalid file should be read-only");
await rewriteFile(path, VALID_ICS);
await loadAndWait(calendar.id, () => calendar.refresh());
Assert.ok(!calendar.readOnly, "calendar should be writable once the file parses");
Assert.deepEqual(
readOnlyChanges,
[true, false],
"observers should be told it became read-only and then writable"
);
Assert.ok(
Components.isSuccessCode(calendar.getProperty("currentStatus")),
"calendar should no longer be in the error state"
);
Assert.ok(await calendar.getItem("valid-file-event"), "event from the file should be loaded");
await calendar.addItem(createEvent());
await TestUtils.waitForCondition(
async () => (await IOUtils.readUTF8(path)).includes("New event"),
"waiting for the new event to be written"
);
Assert.ok(
(await IOUtils.readUTF8(path)).includes("valid-file-event"),
"existing event should be kept when writing"
);
});
add_task(async function test_persistedReadOnlyIsKept() {
const { calendar, path } = await loadCalendarFromFile("Not a calendar\n");
calendar.readOnly = true;
readOnlyChanges.length = 0;
await rewriteFile(path, VALID_ICS);
await loadAndWait(calendar.id, () => calendar.refresh());
Assert.ok(calendar.readOnly, "a calendar set read-only should stay read-only");
Assert.deepEqual(readOnlyChanges, [], "observers should not be told of a change");
});
add_task(async function test_invalidUTF8IsReadOnly() {
// "Café" in ISO-8859-1, which is not valid UTF-8.
const contents = new TextEncoder().encode(VALID_ICS.replace("Existing event", "Caf\x00"));
contents[contents.indexOf(0)] = 0xe9;
const { calendar, path } = await loadCalendarFromFile(contents);
Assert.ok(calendar.readOnly, "calendar with invalid UTF-8 data should be read-only");
Assert.equal(
calendar.getProperty("currentStatus"),
Ci.calIErrors.CAL_UTF8_DECODING_FAILED,
"calendar should be in the error state"
);
Assert.ok(
!Services.prefs.prefHasUserValue(`calendar.registry.${calendar.id}.readOnly`),
"read-only state should not be persisted"
);
Assert.equal(
await calendar.getItem("valid-file-event"),
null,
"event should not be loaded from data that could not be decoded"
);
await Assert.rejects(
calendar.addItem(createEvent()),
e => e.result == Ci.calIErrors.CAL_IS_READONLY,
"adding an item should be refused"
);
Assert.deepEqual(
Array.from(await IOUtils.read(path)),
Array.from(contents),
"file should be unchanged"
);
await rewriteFile(path, VALID_ICS.replace("Existing event", "Caf\u00e9"));
await loadAndWait(calendar.id, () => calendar.refresh());
Assert.ok(!calendar.readOnly, "calendar should be writable once the data is valid UTF-8");
Assert.equal(
(await calendar.getItem("valid-file-event"))?.title,
"Caf\u00e9",
"event should be decoded correctly"
);
});
add_task(async function test_emptyFileIsWritable() {
const { calendar, path } = await loadCalendarFromFile("");
Assert.ok(!calendar.readOnly, "calendar with an empty file should be writable");
await calendar.addItem(createEvent());
await TestUtils.waitForCondition(
async () => (await IOUtils.readUTF8(path)).includes("BEGIN:VEVENT"),
"waiting for the event to be written"
);
});
add_task(async function test_whitespaceFileIsWritable() {
for (const contents of ["\n", "\r\n", " \n\n"]) {
const { calendar, path } = await loadCalendarFromFile(contents);
Assert.ok(
!calendar.readOnly,
`calendar with a file containing ${JSON.stringify(contents)} should be writable`
);
await calendar.addItem(createEvent());
await TestUtils.waitForCondition(
async () => (await IOUtils.readUTF8(path)).includes("BEGIN:VEVENT"),
"waiting for the event to be written"
);
}
});
add_task(async function test_validFileIsWritable() {
const { calendar } = await loadCalendarFromFile(VALID_ICS);
Assert.ok(!calendar.readOnly, "calendar with a valid file should be writable");
Assert.deepEqual(readOnlyChanges, [], "observers should not be told of a change");
Assert.ok(await calendar.getItem("valid-file-event"), "existing event should be loaded");
});