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/. */
const {
add_message_to_folder,
be_in_folder,
create_folder,
create_message,
get_about_message,
select_click_row,
} = ChromeUtils.importESModule(
);
const aboutMessage = get_about_message();
const mimeService = Cc["@mozilla.org/mime;1"].getService(Ci.nsIMIMEService);
const handlerService = Cc[
"@mozilla.org/uriloader/handler-service;1"
].getService(Ci.nsIHandlerService);
const { MockFilePicker } = SpecialPowers;
MockFilePicker.init(window.browsingContext);
// At the time of writing, this pref was set to true on nightly channels only.
// The behaviour is slightly different when it is false.
const IMPROVEMENTS_PREF_SET = Services.prefs.getBoolPref(
"browser.download.improvements_to_download_panel",
true
);
let tmpD;
let savePath;
let homeDirectory;
let folder;
let mockedHandlerApp;
const mockedHandlers = new Set();
function getNsIFileFromPath(path) {
const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
file.initWithPath(path);
return file;
}
add_setup(async function () {
folder = await create_folder("OpenAttachment");
await be_in_folder(folder);
// @see logic for tmpD in msgHdrView.js
tmpD = PathUtils.join(PathUtils.tempDir, "pid-" + Services.appinfo.processID);
savePath = await IOUtils.createUniqueDirectory(tmpD, "saveDestination");
Services.prefs.setStringPref("browser.download.dir", savePath);
homeDirectory = await IOUtils.createUniqueDirectory(tmpD, "homeDirectory");
Services.prefs.setIntPref("browser.download.folderList", 2);
Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
Services.prefs.setIntPref("security.dialog_enable_delay", 0);
const mockExePath = PathUtils.join(PathUtils.tempDir, "mockedExecutable");
await IOUtils.write(mockExePath, new Uint8Array(), {
mode: "appendOrCreate",
});
await IOUtils.setPermissions(mockExePath, 0o755);
const mockedExecutable = await IOUtils.getFile(mockExePath);
mockedHandlerApp = Cc[
"@mozilla.org/uriloader/local-handler-app;1"
].createInstance(Ci.nsILocalHandlerApp);
mockedHandlerApp.executable = mockedExecutable;
mockedHandlerApp.detailedDescription = "Mocked handler app";
registerCleanupFunction(() => {
if (mockedExecutable.exists()) {
mockedExecutable.remove(true);
}
});
});
registerCleanupFunction(async function () {
MockFilePicker.cleanup();
await IOUtils.remove(savePath, { recursive: true });
await IOUtils.remove(homeDirectory, { recursive: true });
Services.prefs.clearUserPref("browser.download.dir");
Services.prefs.clearUserPref("browser.download.folderList");
Services.prefs.clearUserPref("browser.download.useDownloadDir");
Services.prefs.clearUserPref("security.dialog.dialog_enable_delay");
for (const type of mockedHandlers) {
const handlerInfo = mimeService.getFromTypeAndExtension(type, null);
if (handlerService.exists(handlerInfo)) {
handlerService.remove(handlerInfo);
}
}
// Remove created folders.
folder.deleteSelf(null);
Services.focus.focusedWindow = window;
});
function createMockedHandler(type, preferredAction, alwaysAskBeforeHandling) {
info(`Creating handler for ${type}`);
const handlerInfo = mimeService.getFromTypeAndExtension(type, null);
handlerInfo.preferredAction = preferredAction;
handlerInfo.alwaysAskBeforeHandling = alwaysAskBeforeHandling;
handlerInfo.description = mockedHandlerApp.detailedDescription;
handlerInfo.possibleApplicationHandlers.appendElement(mockedHandlerApp);
handlerInfo.hasDefaultHandler = true;
handlerInfo.preferredApplicationHandler = mockedHandlerApp;
handlerService.store(handlerInfo);
mockedHandlers.add(type);
}
let messageIndex = -1;
async function createAndLoadMessage(
type,
{ filename, isDetached = false } = {}
) {
messageIndex++;
if (!filename) {
filename = `attachment${messageIndex}.test${messageIndex}`;
}
const attachment = {
contentType: type,
body: `${type}Attachment`,
filename,
};
// Allow for generation of messages with detached attachments.
if (isDetached) {
// Generate a file with content to represent the attachment.
const attachmentFile = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
attachmentFile.initWithPath(homeDirectory);
attachmentFile.append(filename);
if (!attachmentFile.exists()) {
attachmentFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o755);
await IOUtils.writeUTF8(attachmentFile.path, "some file content");
}
const fileHandler = Services.io
.getProtocolHandler("file")
.QueryInterface(Ci.nsIFileProtocolHandler);
// Append relevant Thunderbird headers to indicate a detached file.
attachment.extraHeaders = {
"X-Mozilla-External-Attachment-URL":
fileHandler.getURLSpecFromActualFile(attachmentFile),
"X-Mozilla-Altered":
'AttachmentDetached; date="Mon Apr 04 13:59:42 2022"',
};
}
await add_message_to_folder(
[folder],
create_message({
subject: `${type} attachment`,
body: {
body: "I'm an attached email!",
},
attachments: [attachment],
})
);
await select_click_row(messageIndex);
}
async function singleClickAttachmentAndWaitForDialog(
{ mode = "save", rememberExpected = true, remember } = {},
button = "cancel"
) {
const dialogPromise = BrowserTestUtils.promiseAlertDialog(
undefined,
{
async callback(dialogWindow) {
// Wait for the buttons to be enabled, which requires focus plus
// clearing the event queue.
await SimpleTest.promiseFocus(dialogWindow);
await TestUtils.waitForTick();
const dialogDocument = dialogWindow.document;
const rememberChoice = dialogDocument.getElementById("rememberChoice");
Assert.equal(
dialogDocument.getElementById("mode").selectedItem.id,
mode,
"correct action is selected"
);
Assert.equal(
rememberChoice.checked,
rememberExpected,
"remember choice checkbox checked/not checked as expected"
);
if (remember !== undefined && remember != rememberExpected) {
EventUtils.synthesizeMouseAtCenter(rememberChoice, {}, dialogWindow);
Assert.equal(
rememberChoice.checked,
remember,
"remember choice checkbox changed"
);
}
dialogDocument.querySelector("dialog").getButton(button).click();
},
}
);
info(aboutMessage.document.getElementById("attachmentName").value);
EventUtils.synthesizeMouseAtCenter(
aboutMessage.document.getElementById("attachmentName"),
{},
aboutMessage
);
await dialogPromise;
}
async function singleClickAttachment() {
info(aboutMessage.document.getElementById("attachmentName").value);
EventUtils.synthesizeMouseAtCenter(
aboutMessage.document.getElementById("attachmentName"),
{},
aboutMessage
);
}
// Other test boilerplate should initialize a message with attachment; here we
// verify that it was created and return an nsIFile handle to it.
async function verifyAndFetchSavedAttachment(parentPath = savePath, leafName) {
const expectedFile = getNsIFileFromPath(parentPath);
if (leafName) {
expectedFile.append(leafName);
} else {
expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
}
await TestUtils.waitForCondition(
() => expectedFile.exists(),
`attachment was not saved to ${expectedFile.path}`
);
Assert.ok(expectedFile.exists(), `${expectedFile.path} exists`);
return expectedFile;
}
/** @param {nsIFile} file - File to delete. */
async function deleteAttachmentFile(file) {
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) {
// Wait a moment in case the file is still locked for writing.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 250));
}
try {
await IOUtils.remove(file.path, { retryReadonly: true });
info(`File '${file.path}' removed`);
return;
} catch (e) {
if (attempt < 2) {
info(`Removing file attempt ${attempt} FAILED: ${e}`);
} else {
info(`All attempts to remove file attempt ${attempt} FAILED: ${e}`);
throw e;
}
}
}
}
function checkHandler(type, preferredAction, alwaysAskBeforeHandling) {
const handlerInfo = mimeService.getFromTypeAndExtension(type, null);
Assert.equal(
handlerInfo.preferredAction,
preferredAction,
`preferredAction of ${type}`
);
Assert.equal(
handlerInfo.alwaysAskBeforeHandling,
alwaysAskBeforeHandling,
`alwaysAskBeforeHandling of ${type}`
);
}
function promiseFileOpened() {
const __openFile = aboutMessage.AttachmentInfo.prototype._openFile;
return new Promise(resolve => {
aboutMessage.AttachmentInfo.prototype._openFile = function (
mimeInfo,
file
) {
aboutMessage.AttachmentInfo.prototype._openFile = __openFile;
resolve({ mimeInfo, file });
};
});
}
/**
* Check that the directory for saving is correct.
* If not, we're gonna have a bad time.
*/
add_task(async function sanityCheck() {
Assert.equal(
await Downloads.getPreferredDownloadsDirectory(),
savePath,
"sanity check: correct downloads directory"
);
});
// First, check content types we have no saved information about.
/**
* Open a content type we've never seen before. Save, and remember the action.
*/
add_task(async function noHandler() {
await createAndLoadMessage("test/foo");
await singleClickAttachmentAndWaitForDialog(
{ rememberExpected: false, remember: true },
"accept"
);
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
checkHandler("test/foo", Ci.nsIHandlerInfo.saveToDisk, false);
});
/**
* Open a content type we've never seen before. Save, and DON'T remember the
* action (except that we do remember it, but also remember to ask next time).
*/
add_task(async function noHandlerNoSave() {
await createAndLoadMessage("test/bar");
await singleClickAttachmentAndWaitForDialog(
{ rememberExpected: false, remember: false },
"accept"
);
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
checkHandler("test/bar", Ci.nsIHandlerInfo.saveToDisk, true);
});
/**
* The application/octet-stream type is handled weirdly. Check that opening it
* still behaves in a useful way.
*/
add_task(async function applicationOctetStream() {
await createAndLoadMessage("application/octet-stream");
await singleClickAttachmentAndWaitForDialog(
{ rememberExpected: false },
"accept"
);
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
});
// Now we'll test the various states that handler info objects might be in.
// There's two fields: preferredAction and alwaysAskBeforeHandling. If the
// latter is true, we MUST get a prompt. Check that first.
/**
* Open a content type set to save to disk, but always ask.
*/
add_task(async function saveToDiskAlwaysAsk() {
createMockedHandler(
"test/saveToDisk-true",
Ci.nsIHandlerInfo.saveToDisk,
true
);
await createAndLoadMessage("test/saveToDisk-true");
await singleClickAttachmentAndWaitForDialog(
{ rememberExpected: false },
"accept"
);
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
checkHandler("test/saveToDisk-true", Ci.nsIHandlerInfo.saveToDisk, true);
});
/**
* Open a content type set to save to disk, but always ask, and with no
* default download directory.
*/
add_task(async function saveToDiskAlwaysAskPromptLocation() {
Services.prefs.setBoolPref("browser.download.useDownloadDir", false);
createMockedHandler(
"test/saveToDisk-true",
Ci.nsIHandlerInfo.saveToDisk,
true
);
await createAndLoadMessage("test/saveToDisk-true");
const expectedFile = getNsIFileFromPath(tmpD);
expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
MockFilePicker.showCallback = function (instance) {
Assert.equal(instance.defaultString, expectedFile.leafName);
Assert.equal(instance.defaultExtension, `test${messageIndex}`);
};
MockFilePicker.setFiles([expectedFile]);
MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK;
await singleClickAttachmentAndWaitForDialog(
{ rememberExpected: false },
"accept"
);
const file = await verifyAndFetchSavedAttachment(tmpD);
await deleteAttachmentFile(file);
Assert.ok(MockFilePicker.shown, "file picker was shown");
MockFilePicker.reset();
Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
});
/**
* Open a content type set to always ask in both fields.
*/
add_task(async function alwaysAskAlwaysAsk() {
createMockedHandler("test/alwaysAsk-true", Ci.nsIHandlerInfo.alwaysAsk, true);
await createAndLoadMessage("test/alwaysAsk-true");
await singleClickAttachmentAndWaitForDialog({
mode: IMPROVEMENTS_PREF_SET ? "save" : "open",
rememberExpected: false,
});
});
/**
* Open a content type set to use helper app, but always ask.
*/
add_task(async function useHelperAppAlwaysAsk() {
createMockedHandler(
"test/useHelperApp-true",
Ci.nsIHandlerInfo.useHelperApp,
true
);
await createAndLoadMessage("test/useHelperApp-true");
await singleClickAttachmentAndWaitForDialog({
mode: "open",
rememberExpected: false,
});
});
/*
* Open a detached attachment with content type set to use helper app, but
* always ask.
*/
add_task(async function detachedUseHelperAppAlwaysAsk() {
const mimeType = "test/useHelperApp-true";
const openedPromise = promiseFileOpened();
createMockedHandler(mimeType, Ci.nsIHandlerInfo.useHelperApp, true);
// Generate an email with detached attachment.
await createAndLoadMessage(mimeType, { isDetached: true });
await singleClickAttachmentAndWaitForDialog(
{ mode: "open", rememberExpected: false },
"accept"
);
const expectedPath = PathUtils.join(
homeDirectory,
`attachment${messageIndex}.test${messageIndex}`
);
const { file } = await openedPromise;
Assert.equal(
file.path,
expectedPath,
"opened file should match attachment path"
);
await deleteAttachmentFile(file);
});
/**
* Open a content type set to use the system default app, but always ask.
*/
add_task(async function useSystemDefaultAlwaysAsk() {
createMockedHandler(
"test/useSystemDefault-true",
Ci.nsIHandlerInfo.useSystemDefault,
true
);
await createAndLoadMessage("test/useSystemDefault-true");
// Would be mode: "open" on all platforms except our handler isn't real.
await singleClickAttachmentAndWaitForDialog({
mode: AppConstants.platform == "win" ? "open" : "save",
rememberExpected: false,
});
});
// Check what happens with alwaysAskBeforeHandling set to false. We can't test
// the actions that would result in an external app opening the file.
/**
* Open a content type set to save to disk without asking.
*/
add_task(async function saveToDisk() {
createMockedHandler("test/saveToDisk-false", saveToDisk, false);
await createAndLoadMessage("test/saveToDisk-false");
await singleClickAttachment();
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
});
/**
* Open a content type set to save to disk without asking, and with no
* default download directory.
*/
add_task(async function saveToDiskPromptLocation() {
Services.prefs.setBoolPref("browser.download.useDownloadDir", false);
createMockedHandler(
"test/saveToDisk-true",
Ci.nsIHandlerInfo.saveToDisk,
false
);
await createAndLoadMessage("test/saveToDisk-false");
const expectedFile = getNsIFileFromPath(tmpD);
expectedFile.append(`attachment${messageIndex}.test${messageIndex}`);
MockFilePicker.showCallback = function (instance) {
Assert.equal(instance.defaultString, expectedFile.leafName);
Assert.equal(instance.defaultExtension, `test${messageIndex}`);
};
MockFilePicker.setFiles([expectedFile]);
MockFilePicker.returnValue = Ci.nsIFilePicker.returnOK;
await singleClickAttachment();
const file = await verifyAndFetchSavedAttachment(tmpD);
await deleteAttachmentFile(file);
Assert.ok(MockFilePicker.shown, "file picker was shown");
MockFilePicker.reset();
Services.prefs.setBoolPref("browser.download.useDownloadDir", true);
});
/**
* Open a content type set to always ask without asking (weird but plausible).
* Check the action is saved and the "do this automatically" checkbox works.
*/
add_task(async function alwaysAskRemember() {
createMockedHandler(
"test/alwaysAsk-false",
Ci.nsIHandlerInfo.alwaysAsk,
false
);
await createAndLoadMessage("test/alwaysAsk-false");
await singleClickAttachmentAndWaitForDialog(undefined, "accept");
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
checkHandler("test/alwaysAsk-false", Ci.nsIHandlerInfo.saveToDisk, false);
}).skip(!IMPROVEMENTS_PREF_SET);
/**
* Open a content type set to always ask without asking (weird but plausible).
* Check the action is saved and the unticked "do this automatically" leaves
* alwaysAskBeforeHandling set.
*/
add_task(async function alwaysAskForget() {
createMockedHandler(
"test/alwaysAsk-false",
Ci.nsIHandlerInfo.alwaysAsk,
false
);
await createAndLoadMessage("test/alwaysAsk-false");
await singleClickAttachmentAndWaitForDialog({ remember: false }, "accept");
const file = await verifyAndFetchSavedAttachment();
await deleteAttachmentFile(file);
checkHandler("test/alwaysAsk-false", Ci.nsIHandlerInfo.saveToDisk, true);
}).skip(!IMPROVEMENTS_PREF_SET);
/**
* Open a content type set to use helper app.
*/
add_task(async function useHelperApp() {
const openedPromise = promiseFileOpened();
createMockedHandler(
"test/useHelperApp-false",
Ci.nsIHandlerInfo.useHelperApp,
false
);
await createAndLoadMessage("test/useHelperApp-false");
await singleClickAttachment();
const attachmentFile = await verifyAndFetchSavedAttachment(tmpD);
const { file } = await openedPromise;
Assert.ok(file.path);
// In the temp dir, files should be read-only.
if (AppConstants.platform != "win") {
const fileInfo = await IOUtils.stat(file.path);
Assert.equal(
fileInfo.permissions,
0o400,
`file ${file.path} should be read-only`
);
}
attachmentFile.permissions = 0o755;
await deleteAttachmentFile(attachmentFile);
});
/*
* Open a detached attachment with content type set to use helper app.
*/
add_task(async function detachedUseHelperApp() {
const mimeType = "test/useHelperApp-false";
const openedPromise = promiseFileOpened();
createMockedHandler(mimeType, Ci.nsIHandlerInfo.useHelperApp, false);
// Generate an email with detached attachment.
await createAndLoadMessage(mimeType, { isDetached: true });
await singleClickAttachment();
const expectedPath = PathUtils.join(
homeDirectory,
`attachment${messageIndex}.test${messageIndex}`
);
const { file } = await openedPromise;
Assert.equal(
file.path,
expectedPath,
"opened file should match attachment path"
);
await deleteAttachmentFile(file);
});
/**
* Open a content type set to use the system default app.
*/
add_task(async function useSystemDefault() {
const openedPromise = promiseFileOpened();
createMockedHandler(
"test/useSystemDefault-false",
Ci.nsIHandlerInfo.useSystemDefault,
false
);
await createAndLoadMessage("test/useSystemDefault-false");
await singleClickAttachment();
const attachmentFile = await verifyAndFetchSavedAttachment(tmpD);
const { file } = await openedPromise;
Assert.ok(file.path);
// In the temp dir, files should be read-only.
if (AppConstants.platform != "win") {
const fileInfo = await IOUtils.stat(file.path);
Assert.equal(
fileInfo.permissions,
0o400,
`file ${file.path} should be read-only`
);
}
attachmentFile.permissions = 0o755;
await deleteAttachmentFile(attachmentFile);
});
/*
* Open a detached attachment with content type set to use the system default
* app.
*/
add_task(async function detachedUseSystemDefault() {
const mimeType = "test/useSystemDefault-false";
const openedPromise = promiseFileOpened();
createMockedHandler(mimeType, Ci.nsIHandlerInfo.useSystemDefault, false);
// Generate an email with detached attachment.
await createAndLoadMessage(mimeType, { isDetached: true });
await singleClickAttachment();
const expectedPath = PathUtils.join(
homeDirectory,
`attachment${messageIndex}.test${messageIndex}`
);
const { file } = await openedPromise;
Assert.equal(
file.path,
expectedPath,
"opened file should match attachment path"
);
await deleteAttachmentFile(file);
});
/**
* Save an attachment with characters that are illegal in a file name.
* Check the characters are sanitized.
*/
add_task(async function filenameSanitisedSave() {
createMockedHandler("test/bar", Ci.nsIHandlerInfo.saveToDisk, false);
// Colon, slash and backslash are escaped on all platforms.
// Backslash is double-escaped here because of the message generator.
await createAndLoadMessage("test/bar", { filename: "f:i\\\\le/123.bar" });
await singleClickAttachment();
let file = await verifyAndFetchSavedAttachment(undefined, "f_i_le_123.bar");
await deleteAttachmentFile(file);
// Asterisk, question mark, pipe and angle brackets are escaped on Windows.
await createAndLoadMessage("test/bar", { filename: "f*i?|le<123>.bar" });
await singleClickAttachment();
file = await verifyAndFetchSavedAttachment(undefined, "f_i__le_123_.bar");
await deleteAttachmentFile(file);
});
/**
* Open an attachment with characters that are illegal in a file name.
* Check the characters are sanitized.
*/
add_task(async function filenameSanitisedOpen() {
createMockedHandler("test/bar", Ci.nsIHandlerInfo.useHelperApp, false);
let openedPromise = promiseFileOpened();
// Colon, slash and backslash are escaped on all platforms.
// Backslash is double-escaped here because of the message generator.
await createAndLoadMessage("test/bar", { filename: "f:i\\\\le/123.bar" });
await singleClickAttachment();
let { file } = await openedPromise;
let attachmentFile = await verifyAndFetchSavedAttachment(
tmpD,
"f_i_le_123.bar"
);
Assert.equal(file.leafName, "f_i_le_123.bar");
// In the temp dir, files should be read-only.
if (AppConstants.platform != "win") {
const fileInfo = await IOUtils.stat(file.path);
Assert.equal(
fileInfo.permissions,
0o400,
`file ${file.path} should be read-only`
);
}
attachmentFile.permissions = 0o755;
await deleteAttachmentFile(attachmentFile);
openedPromise = promiseFileOpened();
// Asterisk, question mark, pipe and angle brackets are escaped on Windows.
await createAndLoadMessage("test/bar", { filename: "f*i?|le<123>.bar" });
await singleClickAttachment();
({ file } = await openedPromise);
attachmentFile = await verifyAndFetchSavedAttachment(
tmpD,
"f_i__le_123_.bar"
);
Assert.equal(file.leafName, "f_i__le_123_.bar");
attachmentFile.permissions = 0o755;
await deleteAttachmentFile(attachmentFile);
});