Bug 249801 - Add a module to export logins to a CSV file. r=MattN,fluent-reviewers
authorAndrei Cristian Petcu <andrei@ceata.org>
Fri, 29 May 2020 07:14:03 +0000
changeset 532908 863025bf6b1c9ca170ce0079b3fc9dfd64fb5069
parent 532907 9dae371f2b336893faea10148a86fc1fca325584
child 532909 c1820d13f7d232c8e3e1a7c8fe7fe76c61da2924
push id37460
push userbtara@mozilla.com
push dateFri, 29 May 2020 15:59:09 +0000
treeherdermozilla-central@60a406d3b53a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersMattN, fluent-reviewers
bugs249801
milestone78.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 249801 - Add a module to export logins to a CSV file. r=MattN,fluent-reviewers The menu item is hidden until it requests re-authentication via Master Password. Differential Revision: https://phabricator.services.mozilla.com/D75716
browser/components/BrowserGlue.jsm
browser/components/aboutlogins/AboutLoginsChild.jsm
browser/components/aboutlogins/AboutLoginsParent.jsm
browser/components/aboutlogins/content/aboutLogins.html
browser/components/aboutlogins/content/aboutLoginsUtils.js
browser/components/aboutlogins/content/components/menu-button.css
browser/components/aboutlogins/content/components/menu-button.js
browser/components/aboutlogins/tests/browser/browser.ini
browser/components/aboutlogins/tests/browser/browser_openExport.js
browser/locales/en-US/browser/aboutLogins.ftl
testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.jsm
toolkit/components/passwordmgr/LoginExport.jsm
toolkit/components/passwordmgr/moz.build
toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
toolkit/components/passwordmgr/test/unit/xpcshell.ini
--- a/browser/components/BrowserGlue.jsm
+++ b/browser/components/BrowserGlue.jsm
@@ -97,16 +97,17 @@ let JSWINDOWACTORS = {
         AboutLoginsOpenMobileIos: { wantUntrusted: true },
         AboutLoginsOpenPreferences: { wantUntrusted: true },
         AboutLoginsOpenSite: { wantUntrusted: true },
         AboutLoginsRecordTelemetryEvent: { wantUntrusted: true },
         AboutLoginsSortChanged: { wantUntrusted: true },
         AboutLoginsSyncEnable: { wantUntrusted: true },
         AboutLoginsSyncOptions: { wantUntrusted: true },
         AboutLoginsUpdateLogin: { wantUntrusted: true },
+        AboutLoginsExportPasswords: { wantUntrusted: true },
       },
     },
     matches: ["about:logins", "about:logins?*"],
   },
 
   AboutNewInstall: {
     parent: {
       moduleURI: "resource:///actors/AboutNewInstallParent.jsm",
--- a/browser/components/aboutlogins/AboutLoginsChild.jsm
+++ b/browser/components/aboutlogins/AboutLoginsChild.jsm
@@ -186,16 +186,20 @@ class AboutLoginsChild extends JSWindowA
         break;
       }
       case "AboutLoginsUpdateLogin": {
         this.sendAsyncMessage("AboutLogins:UpdateLogin", {
           login: event.detail,
         });
         break;
       }
+      case "AboutLoginsExportPasswords": {
+        this.sendAsyncMessage("AboutLogins:ExportPasswords");
+        break;
+      }
     }
   }
 
   receiveMessage(message) {
     switch (message.name) {
       case "AboutLogins:MasterPasswordResponse":
         if (masterPasswordPromise) {
           masterPasswordPromise.resolve(message.data.result);
--- a/browser/components/aboutlogins/AboutLoginsParent.jsm
+++ b/browser/components/aboutlogins/AboutLoginsParent.jsm
@@ -11,16 +11,17 @@ const { XPCOMUtils } = ChromeUtils.impor
   "resource://gre/modules/XPCOMUtils.jsm"
 );
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   E10SUtils: "resource://gre/modules/E10SUtils.jsm",
   LoginBreaches: "resource:///modules/LoginBreaches.jsm",
   LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+  LoginExport: "resource://gre/modules/LoginExport.jsm",
   MigrationUtils: "resource:///modules/MigrationUtils.jsm",
   OSKeyStore: "resource://gre/modules/OSKeyStore.jsm",
   Services: "resource://gre/modules/Services.jsm",
   UIState: "resource://services-sync/UIState.jsm",
   PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
 });
 
 XPCOMUtils.defineLazyGetter(this, "log", () => {
@@ -524,16 +525,54 @@ class AboutLoginsParent extends JSWindow
         }
         try {
           Services.logins.modifyLogin(logins[0], modifiedLogin);
         } catch (error) {
           this.handleLoginStorageErrors(modifiedLogin, error, message);
         }
         break;
       }
+      case "AboutLogins:ExportPasswords": {
+        let fp = Cc["@mozilla.org/filepicker;1"].createInstance(
+          Ci.nsIFilePicker
+        );
+        let fpCallback = function fpCallback_done(aResult) {
+          if (aResult != Ci.nsIFilePicker.returnCancel) {
+            LoginExport.exportAsCSV(fp.file.path);
+          }
+        };
+        let [
+          title,
+          defaultFilename,
+          okButtonLabel,
+          csvFilterTitle,
+        ] = await AboutLoginsL10n.formatValues([
+          {
+            id: "about-logins-export-file-picker-title",
+          },
+          {
+            id: "about-logins-export-file-picker-default-filename",
+          },
+          {
+            id: "about-logins-export-file-picker-export-button",
+          },
+          {
+            id: "about-logins-export-file-picker-csv-filter-title",
+          },
+        ]);
+
+        fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeSave);
+        fp.appendFilter(csvFilterTitle, "*.csv");
+        fp.appendFilters(Ci.nsIFilePicker.filterAll);
+        fp.defaultString = defaultFilename;
+        fp.defaultExtension = "csv";
+        fp.okButtonLabel = okButtonLabel;
+        fp.open(fpCallback);
+        break;
+      }
     }
   }
 
   handleLoginStorageErrors(login, error) {
     let messageObject = {
       login: augmentVanillaLoginObject(LoginHelper.loginToVanillaObject(login)),
       errorMessage: error.message,
     };
--- a/browser/components/aboutlogins/content/aboutLogins.html
+++ b/browser/components/aboutlogins/content/aboutLogins.html
@@ -266,16 +266,18 @@
 
     <template id="menu-button-template">
       <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
       <link rel="stylesheet" href="chrome://browser/content/aboutlogins/common.css">
       <link rel="stylesheet" href="chrome://browser/content/aboutlogins/components/menu-button.css">
       <button class="menu-button ghost-button" data-l10n-id="menu"></button>
       <ul class="menu" role="menu" hidden>
         <button role="menuitem" class="menuitem-button menuitem-import ghost-button" hidden data-supported-platforms="Win32,MacIntel" data-event-name="AboutLoginsImport" data-l10n-id="about-logins-menu-menuitem-import-from-another-browser"></button>
+        <button role="menuitem" class="menuitem-button menuitem-export ghost-button" data-event-name="AboutLoginsExportPasswords" data-l10n-id="about-logins-menu-menuitem-export-logins" hidden></button>
+        <hr role="separator" class="menuitem-separator"></hr>
         <button role="menuitem" class="menuitem-button menuitem-preferences ghost-button" data-event-name="AboutLoginsOpenPreferences" data-l10n-id="menu-menuitem-preferences"></button>
         <hr role="separator" class="menuitem-separator"></hr>
         <button role="menuitem" class="menuitem-button menuitem-help ghost-button" data-event-name="AboutLoginsGetHelp" data-l10n-id="about-logins-menu-menuitem-help"></button>
         <hr role="separator" class="menuitem-separator"></hr>
         <button role="menuitem" class="menuitem-button menuitem-mobile menuitem-mobile-android ghost-button" data-event-name="AboutLoginsOpenMobileAndroid" data-l10n-id="menu-menuitem-android-app"></button>
         <button role="menuitem" class="menuitem-button menuitem-mobile menuitem-mobile-ios ghost-button" data-event-name="AboutLoginsOpenMobileIos" data-l10n-id="menu-menuitem-iphone-app"></button>
       </ul>
     </template>
--- a/browser/components/aboutlogins/content/aboutLoginsUtils.js
+++ b/browser/components/aboutlogins/content/aboutLoginsUtils.js
@@ -1,18 +1,18 @@
 /* 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/. */
 
 /**
  * Dispatches a custom event to the AboutLoginsChild.jsm script which
  * will record the event.
- * @params {object} event.method The telemety event method
- * @params {object} event.object The telemety event object
- * @params {object} event.value [optional] The telemety event value
+ * @param {object} event.method The telemety event method
+ * @param {object} event.object The telemety event object
+ * @param {object} event.value [optional] The telemety event value
  */
 export function recordTelemetryEvent(event) {
   document.dispatchEvent(
     new CustomEvent("AboutLoginsRecordTelemetryEvent", {
       bubbles: true,
       detail: event,
     })
   );
--- a/browser/components/aboutlogins/content/components/menu-button.css
+++ b/browser/components/aboutlogins/content/components/menu-button.css
@@ -65,16 +65,20 @@
 .menuitem-help {
   background-image: url("chrome://global/skin/icons/help.svg");
 }
 
 .menuitem-import {
   background-image: url("chrome://browser/skin/import.svg");
 }
 
+.menuitem-export {
+  background-image: url("chrome://browser/skin/save.svg");
+}
+
 .menuitem-preferences {
   background-image: url("chrome://global/skin/icons/settings.svg");
 }
 
 .menuitem-mobile-ios {
   background-image: url("chrome://browser/skin/logo-ios.svg");
 }
 
--- a/browser/components/aboutlogins/content/components/menu-button.js
+++ b/browser/components/aboutlogins/content/components/menu-button.js
@@ -30,22 +30,25 @@ export default class MenuButton extends 
     this.addEventListener("blur", this);
     this._menuButton.addEventListener("click", this);
     this.addEventListener("keydown", this, true);
   }
 
   handleEvent(event) {
     switch (event.type) {
       case "blur": {
-        if (
-          event.explicitOriginalTarget &&
-          event.explicitOriginalTarget.closest(".menu") == this._menu
-        ) {
-          // Only hide the menu if focus has left the menu-button.
-          return;
+        if (event.explicitOriginalTarget) {
+          let node = event.explicitOriginalTarget;
+          if (node.nodeType == Node.TEXT_NODE) {
+            node = node.parentElement;
+          }
+          if (node.closest(".menu") == this._menu) {
+            // Only hide the menu if focus has left the menu-button.
+            return;
+          }
         }
         this._hideMenu();
         break;
       }
       case "click": {
         // Skip the catch-all event listener if it was the menu-button
         // that was clicked on.
         if (
--- a/browser/components/aboutlogins/tests/browser/browser.ini
+++ b/browser/components/aboutlogins/tests/browser/browser.ini
@@ -21,16 +21,17 @@ skip-if = asan || ccov || debug || (os =
 [browser_loginItemErrors.js]
 skip-if = debug # Bug 1577710
 [browser_loginListChanges.js]
 [browser_loginSortOrderRestored.js]
 skip-if = os == 'linux' && bits == 64 && os_version == '18.04' # Bug 1587625; Bug 1587626 for linux1804
 [browser_masterPassword.js]
 skip-if = (os == 'linux') # bug 1569789
 [browser_noLoginsView.js]
+[browser_openExport.js]
 [browser_openFiltered.js]
 [browser_openImport.js]
 skip-if = (os != "win" && os != "mac") # import is only available on Windows and macOS
 [browser_openPreferences.js]
 [browser_openPreferencesExternal.js]
 [browser_openSite.js]
 [browser_osAuthDialog.js]
 skip-if = (os == 'linux') # bug 1527745
new file mode 100644
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js
@@ -0,0 +1,68 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * Test the export logins file picker appears.
+ */
+
+let { MockFilePicker } = SpecialPowers;
+
+add_task(async function setup() {
+  MockFilePicker.init(window);
+  MockFilePicker.returnValue = MockFilePicker.returnCancel;
+  registerCleanupFunction(() => {
+    MockFilePicker.cleanup();
+  });
+});
+
+function waitForFilePicker() {
+  return new Promise(resolve => {
+    MockFilePicker.showCallback = () => {
+      MockFilePicker.showCallback = null;
+      ok(true, "Saw the file picker");
+      resolve();
+    };
+  });
+}
+
+add_task(async function test_open_export() {
+  await BrowserTestUtils.withNewTab(
+    { gBrowser, url: "about:logins" },
+    async function(browser) {
+      await BrowserTestUtils.synthesizeMouseAtCenter(
+        "menu-button",
+        {},
+        browser
+      );
+      await SpecialPowers.spawn(browser, [], async () => {
+        let menuButton = content.document.querySelector("menu-button");
+        return ContentTaskUtils.waitForCondition(function waitForMenu() {
+          return !menuButton.shadowRoot.querySelector(".menu").hidden;
+        }, "waiting for menu to open");
+      });
+
+      function getExportMenuItem() {
+        let menuButton = window.document.querySelector("menu-button");
+        let exportButton = menuButton.shadowRoot.querySelector(
+          ".menuitem-export"
+        );
+        // Force the menu item to be visible for the test.
+        exportButton.hidden = false;
+        return exportButton;
+      }
+
+      let filePicker = waitForFilePicker();
+      await BrowserTestUtils.synthesizeMouseAtCenter(
+        getExportMenuItem,
+        {},
+        browser
+      );
+      info("waiting for Export file picker to get opened");
+      await filePicker;
+      ok(true, "Export file picker opened");
+    }
+  );
+});
--- a/browser/locales/en-US/browser/aboutLogins.ftl
+++ b/browser/locales/en-US/browser/aboutLogins.ftl
@@ -1,11 +1,12 @@
 # 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/.
+# NOTE: New strings should use the about-logins- prefix.
 
 about-logins-page-title = Logins & Passwords
 
 # "Google Play" and "App Store" are both branding and should not be translated
 
 login-app-promo-title = Take your passwords everywhere
 login-app-promo-subtitle = Get the free { -lockwise-brand-name } app
 login-app-promo-android =
@@ -24,16 +25,17 @@ fxaccounts-avatar-button =
   .title = Manage account
 
 ## The ⋯ menu that is in the top corner of the page
 
 menu =
   .title = Open menu
 # This menuitem is only visible on Windows and macOS
 about-logins-menu-menuitem-import-from-another-browser = Import from Another Browser…
+about-logins-menu-menuitem-export-logins = Export Logins…
 menu-menuitem-preferences =
   { PLATFORM() ->
       [windows] Options
      *[other] Preferences
   }
 about-logins-menu-menuitem-help = Help
 menu-menuitem-android-app = { -lockwise-brand-short-name } for Android
 menu-menuitem-iphone-app = { -lockwise-brand-short-name } for iPhone and iPad
@@ -189,8 +191,25 @@ about-logins-vulnerable-alert-learn-more
 # This is an error message that appears when a user attempts to save
 # a new login that is identical to an existing saved login.
 # Variables:
 #   $loginTitle (String) - The title of the website associated with the login.
 about-logins-error-message-duplicate-login-with-link = An entry for { $loginTitle } with that username already exists. <a data-l10n-name="duplicate-link">Go to existing entry?</a>
 
 # This is a generic error message.
 about-logins-error-message-default = An error occurred while trying to save this password.
+
+
+## Login Export Dialog
+
+# Title of the file picker dialog
+about-logins-export-file-picker-title = Export Logins File
+# The default file name shown in the file picker when exporting saved logins.
+# This must end in .csv
+about-logins-export-file-picker-default-filename = logins.csv
+about-logins-export-file-picker-export-button = Export
+# A description for the .csv file format that may be shown as the file type
+# filter by the operating system.
+about-logins-export-file-picker-csv-filter-title =
+  { PLATFORM() ->
+      [macos] CSV Document
+     *[other] CSV File
+  }
--- a/testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.jsm
+++ b/testing/mochitest/BrowserTestUtils/BrowserTestUtilsChild.jsm
@@ -289,17 +289,18 @@ class BrowserTestUtilsChild extends JSWi
 
     let left = data.x;
     let top = data.y;
     if (target) {
       if (target.ownerDocument !== this.document) {
         // Account for nodes found in iframes.
         let cur = target;
         do {
-          let frame = cur.ownerGlobal.frameElement;
+          // eslint-disable-next-line mozilla/use-ownerGlobal
+          let frame = cur.ownerDocument.defaultView.frameElement;
           let rect = frame.getBoundingClientRect();
 
           left += rect.left;
           top += rect.top;
 
           cur = frame;
         } while (cur && cur.ownerDocument !== this.document);
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginExport.jsm
@@ -0,0 +1,85 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * Module to support exporting logins to a .csv file.
+ */
+
+const EXPORTED_SYMBOLS = ["LoginExport"];
+
+let { XPCOMUtils } = ChromeUtils.import(
+  "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  OS: "resource://gre/modules/osfile.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+class LoginExport {
+  /**
+   * Builds an array of strings representing a row in a CSV.
+   *
+   * @param {nsILoginInfo} login
+   *        The object that will be converted into a csv row.
+   * @param {string[]} columns
+   *        The CSV columns, used to find the properties from the login object.
+   * @returns {string[]} Representing a row.
+   */
+  static _buildCSVRow(login, columns) {
+    let row = [];
+    for (let columnName of columns) {
+      let columnValue = login[columnName];
+      if (typeof columnValue == "string") {
+        columnValue = columnValue.split('"').join('""');
+      }
+      if (columnValue !== null && columnValue != undefined) {
+        row.push(`"${columnValue}"`);
+      } else {
+        row.push("");
+      }
+    }
+    return row;
+  }
+
+  /**
+   * Given a path it saves all the logins as a CSV file.
+   *
+   * @param {string} path
+   *        The file path to save the login to.
+   */
+  static async exportAsCSV(path) {
+    let columns = [
+      "origin",
+      "username",
+      "password",
+      "httpRealm",
+      "formActionOrigin",
+      "guid",
+      "timeCreated",
+      "timeLastUsed",
+      "timePasswordChanged",
+    ];
+    let csvHeader = columns.map(name => {
+      if (name == "origin") {
+        return '"url"';
+      }
+      return `"${name}"`;
+    });
+
+    let rows = [];
+    rows.push(csvHeader);
+    let logins = await Services.logins.getAllLoginsAsync();
+    for (let login of logins) {
+      rows.push(LoginExport._buildCSVRow(login, columns));
+    }
+    // https://tools.ietf.org/html/rfc7111 suggests always using CRLF.
+    let csvAsString = rows.map(e => e.join(",")).join("\r\n");
+    await OS.File.writeAtomic(path, new TextEncoder().encode(csvAsString), {
+      tmpPath: path + ".tmp",
+    });
+  }
+}
--- a/toolkit/components/passwordmgr/moz.build
+++ b/toolkit/components/passwordmgr/moz.build
@@ -48,16 +48,17 @@ EXTRA_JS_MODULES += [
 ]
 
 if CONFIG['OS_TARGET'] == 'Android':
     EXTRA_JS_MODULES += [
         'storage-geckoview.js',
     ]
 else:
     EXTRA_JS_MODULES += [
+        'LoginExport.jsm',
         'LoginImport.jsm',
         'LoginStore.jsm',
     ]
 
 if CONFIG['OS_TARGET'] == 'WINNT':
     EXTRA_JS_MODULES += [
         'OSCrypto_win.js',
     ]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_module_LoginExport.js
@@ -0,0 +1,215 @@
+/* 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 the LoginExport module.
+ */
+
+"use strict";
+
+let { LoginExport } = ChromeUtils.import(
+  "resource://gre/modules/LoginExport.jsm"
+);
+let { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm");
+
+/**
+ * Saves the logins to a temporary CSV file, reads the lines and returns the CSV lines.
+ * After extracting the CSV lines, it deletes the tmp file.
+ */
+async function exportAsCSVInTmpFile() {
+  let tmpFilePath = FileTestUtils.getTempFile("logins.csv").path;
+  await LoginExport.exportAsCSV(tmpFilePath);
+  let csvContent = await OS.File.read(tmpFilePath);
+  let csvString = new TextDecoder().decode(csvContent);
+  await OS.File.remove(tmpFilePath);
+  // CSV uses CRLF
+  return csvString.split(/\r\n/);
+}
+
+const COMMON_LOGIN_MODS = {
+  guid: "{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}",
+  timeCreated: 1589617814635,
+  timeLastUsed: 1589710449871,
+  timePasswordChanged: 1589617846802,
+  timesUsed: 1,
+  username: "joe@example.com",
+  password: "qwerty",
+  origin: "https://example.com",
+};
+
+/**
+ * Generates a new login object with all the form login fields populated.
+ */
+function exportFormLogin(modifications) {
+  return LoginTestUtils.testData.formLogin({
+    ...COMMON_LOGIN_MODS,
+    formActionOrigin: "https://action.example.com",
+    ...modifications,
+  });
+}
+
+function exportAuthLogin(modifications) {
+  return LoginTestUtils.testData.authLogin({
+    ...COMMON_LOGIN_MODS,
+    httpRealm: "My realm",
+    ...modifications,
+  });
+}
+
+add_task(async function setup() {
+  let oldLogins = Services.logins;
+  Services.logins = { getAllLoginsAsync: sinon.stub() };
+  registerCleanupFunction(() => {
+    Services.logins = oldLogins;
+  });
+});
+
+add_task(async function test_buildCSVRow() {
+  let testObject = {
+    null: null,
+    emptyString: "",
+    number: 99,
+    string: "Foo",
+  };
+  Assert.deepEqual(
+    LoginExport._buildCSVRow(testObject, [
+      "null",
+      "emptyString",
+      "number",
+      "string",
+    ]),
+    ["", `""`, `"99"`, `"Foo"`],
+    "Check _buildCSVRow with different types"
+  );
+});
+
+add_task(async function test_no_new_properties_to_export() {
+  let login = exportFormLogin();
+  Assert.deepEqual(
+    Object.keys(login),
+    [
+      "QueryInterface",
+      "displayOrigin",
+      "origin",
+      "hostname",
+      "formActionOrigin",
+      "formSubmitURL",
+      "httpRealm",
+      "username",
+      "usernameField",
+      "password",
+      "passwordField",
+      "init",
+      "equals",
+      "matches",
+      "clone",
+      "guid",
+      "timeCreated",
+      "timeLastUsed",
+      "timePasswordChanged",
+      "timesUsed",
+    ],
+    "Check that no new properties were added to a login that should maybe be exported"
+  );
+});
+
+add_task(async function test_export_one_form_login() {
+  let login = exportFormLogin();
+  Services.logins.getAllLoginsAsync.returns([login]);
+
+  let rows = await exportAsCSVInTmpFile();
+
+  Assert.equal(
+    rows[0],
+    '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+    "checking csv headers"
+  );
+  Assert.equal(
+    rows[1],
+    '"https://example.com","joe@example.com","qwerty",,"https://action.example.com","{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+    `checking login is saved as CSV row\n${JSON.stringify(login)}\n`
+  );
+});
+
+add_task(async function test_export_one_auth_login() {
+  let login = exportAuthLogin();
+  Services.logins.getAllLoginsAsync.returns([login]);
+
+  let rows = await exportAsCSVInTmpFile();
+
+  Assert.equal(
+    rows[0],
+    '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+    "checking csv headers"
+  );
+  Assert.equal(
+    rows[1],
+    '"https://example.com","joe@example.com","qwerty","My realm",,"{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+    `checking login is saved as CSV row\n${JSON.stringify(login)}\n`
+  );
+});
+
+add_task(async function test_export_escapes_values() {
+  let login = exportFormLogin({
+    password: "!@#$%^&*()_+,'",
+  });
+  Services.logins.getAllLoginsAsync.returns([login]);
+
+  let rows = await exportAsCSVInTmpFile();
+
+  Assert.equal(
+    rows[1],
+    '"https://example.com","joe@example.com","!@#$%^&*()_+,\'",,"https://action.example.com","{5ec0d12f-e194-4279-ae1b-d7d281bb46f0}","1589617814635","1589710449871","1589617846802"',
+    `checking login correctly escapes CSV characters \n${JSON.stringify(login)}`
+  );
+});
+
+add_task(async function test_export_multiple_rows() {
+  let logins = await LoginTestUtils.testData.loginList();
+  Services.logins.getAllLoginsAsync.returns(logins);
+
+  let actualRows = await exportAsCSVInTmpFile();
+  let expectedRows = [
+    '"url","username","password","httpRealm","formActionOrigin","guid","timeCreated","timeLastUsed","timePasswordChanged"',
+    '"http://www.example.com","the username","the password for www.example.com",,"http://www.example.com",,,,',
+    '"https://www.example.com","the username","the password for https",,"https://www.example.com",,,,',
+    '"https://example.com","the username","the password for example.com",,"https://example.com",,,,',
+    '"http://www3.example.com","the username","the password",,"http://www.example.com",,,,',
+    '"http://www3.example.com","the username","the password",,"https://www.example.com",,,,',
+    '"http://www3.example.com","the username","the password",,"http://example.com",,,,',
+    '"http://www4.example.com","username one","password one",,"http://www4.example.com",,,,',
+    '"http://www4.example.com","username two","password two",,"http://www4.example.com",,,,',
+    '"http://www4.example.com","","password three",,"http://www4.example.com",,,,',
+    '"http://www5.example.com","multi username","multi password",,"http://www5.example.com",,,,',
+    '"http://www6.example.com","","12345",,"http://www6.example.com",,,,',
+    '"https://www7.example.com:8080","8080_username","8080_pass",,"https://www7.example.com:8080",,,,',
+    '"https://www7.example.com:8080","8080_username2","8080_pass2","My dev server",,,,,',
+    '"http://www.example.org","the username","the password","The HTTP Realm",,,,,',
+    '"ftp://ftp.example.org","the username","the password","ftp://ftp.example.org",,,,,',
+    '"http://www2.example.org","the username","the password","The HTTP Realm",,,,,',
+    '"http://www2.example.org","the username other","the password other","The HTTP Realm Other",,,,,',
+    '"http://example.net","the username","the password",,"http://example.net",,,,',
+    '"http://example.net","the username","the password",,"http://www.example.net",,,,',
+    '"http://example.net","username two","the password",,"http://www.example.net",,,,',
+    '"http://example.net","the username","the password","The HTTP Realm",,,,,',
+    '"http://example.net","username two","the password","The HTTP Realm Other",,,,,',
+    '"ftp://example.net","the username","the password","ftp://example.net",,,,,',
+    '"chrome://example_extension","the username","the password one","Example Login One",,,,,',
+    '"chrome://example_extension","the username","the password two","Example Login Two",,,,,',
+    '"file:///","file: username","file: password",,"file:///",,,,',
+    '"https://js.example.com","javascript: username","javascript: password",,"javascript:",,,,',
+  ];
+
+  Assert.equal(actualRows.length, expectedRows.length, "Check number of lines");
+  for (let i = 0; i < logins.length; i++) {
+    let login = logins[i];
+    Assert.equal(
+      actualRows[i],
+      expectedRows[i],
+      `checking CSV correctly writes row at index=${i} \n${JSON.stringify(
+        login
+      )}\n`
+    );
+  }
+});
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -39,16 +39,18 @@ skip-if = os == "android" # schemeUpgrad
 skip-if = os == "android"
 [test_logins_change.js]
 [test_logins_decrypt_failure.js]
 skip-if = os == "android" # Bug 1171687: Needs fixing on Android
 [test_logins_metainfo.js]
 [test_logins_search.js]
 [test_maybeImportLogin.js]
 skip-if = os == "android" # Only used by migrator, which isn't on Android
+[test_module_LoginExport.js]
+skip-if = os == "android" # there is no export for android
 [test_notifications.js]
 [test_OSCrypto_win.js]
 skip-if = os != "win"
 [test_PasswordGenerator.js]
 skip-if = os == "android" # Not packaged/used on Android
 [test_recipes_add.js]
 [test_recipes_content.js]
 [test_search_schemeUpgrades.js]